Updated apps
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""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
@@ -0,0 +1,917 @@
|
||||
"""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],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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"
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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...')"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
"""The Alarmo Integration."""
|
||||
|
||||
import re
|
||||
import base64
|
||||
import logging
|
||||
import concurrent.futures
|
||||
|
||||
import bcrypt
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
asyncio,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE,
|
||||
ATTR_NAME,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.service import (
|
||||
async_register_admin_service,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
async_dispatcher_connect,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.components.alarm_control_panel import DOMAIN as PLATFORM
|
||||
|
||||
from . import const
|
||||
from .card import async_register_card
|
||||
from .mqtt import MqttHandler
|
||||
from .event import EventHandler
|
||||
from .panel import (
|
||||
async_register_panel,
|
||||
async_unregister_panel,
|
||||
)
|
||||
from .store import async_get_registry
|
||||
from .sensors import (
|
||||
ATTR_GROUP,
|
||||
ATTR_ENTITIES,
|
||||
ATTR_NEW_ENTITY_ID,
|
||||
SensorHandler,
|
||||
)
|
||||
from .websockets import async_register_websockets
|
||||
from .automations import AutomationHandler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Max number of threads to start when checking user codes.
|
||||
MAX_WORKERS = 4
|
||||
# Number of rounds of hashing when computing user hashes.
|
||||
BCRYPT_NUM_ROUNDS = 10
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Track states and offer events for sensors."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up Alarmo integration from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
store = await async_get_registry(hass)
|
||||
coordinator = AlarmoCoordinator(hass, session, entry, store)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(const.DOMAIN, coordinator.id)},
|
||||
name=const.NAME,
|
||||
model=const.NAME,
|
||||
sw_version=const.VERSION,
|
||||
manufacturer=const.MANUFACTURER,
|
||||
)
|
||||
|
||||
hass.data.setdefault(const.DOMAIN, {})
|
||||
hass.data[const.DOMAIN] = {"coordinator": coordinator, "areas": {}, "master": None}
|
||||
|
||||
if entry.unique_id is None:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=coordinator.id, data={})
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [PLATFORM])
|
||||
|
||||
# Register the panel (frontend)
|
||||
await async_register_panel(hass)
|
||||
await async_register_card(hass)
|
||||
|
||||
# Websocket support
|
||||
await async_register_websockets(hass)
|
||||
|
||||
# Register custom services
|
||||
register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload Alarmo config entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[hass.config_entries.async_forward_entry_unload(entry, PLATFORM)]
|
||||
)
|
||||
)
|
||||
if not unload_ok:
|
||||
return False
|
||||
|
||||
async_unregister_panel(hass)
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
await coordinator.async_unload()
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass, entry):
|
||||
"""Remove Alarmo config entry."""
|
||||
async_unregister_panel(hass)
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
await coordinator.async_delete_config()
|
||||
del hass.data[const.DOMAIN]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle migration of config entry."""
|
||||
return True
|
||||
|
||||
|
||||
class AlarmoCoordinator(DataUpdateCoordinator):
|
||||
"""Define an object to hold Alarmo device."""
|
||||
|
||||
def __init__(self, hass, session, entry, store):
|
||||
"""Initialize."""
|
||||
self.id = entry.unique_id
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
self._subscriptions = []
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_platform_loaded", self.setup_alarm_entities
|
||||
)
|
||||
)
|
||||
self.register_events()
|
||||
|
||||
super().__init__(hass, _LOGGER, config_entry=entry, name=const.DOMAIN)
|
||||
|
||||
@callback
|
||||
def setup_alarm_entities(self):
|
||||
"""Set up alarm_control_panel entities based on areas in storage."""
|
||||
self.hass.data[const.DOMAIN]["sensor_handler"] = SensorHandler(self.hass)
|
||||
self.hass.data[const.DOMAIN]["automation_handler"] = AutomationHandler(
|
||||
self.hass
|
||||
)
|
||||
self.hass.data[const.DOMAIN]["mqtt_handler"] = MqttHandler(self.hass)
|
||||
self.hass.data[const.DOMAIN]["event_handler"] = EventHandler(self.hass)
|
||||
|
||||
areas = self.store.async_get_areas()
|
||||
config = self.store.async_get_config()
|
||||
|
||||
for item in areas.values():
|
||||
async_dispatcher_send(self.hass, "alarmo_register_entity", item)
|
||||
|
||||
if len(areas) > 1 and config["master"]["enabled"]:
|
||||
async_dispatcher_send(self.hass, "alarmo_register_master", config["master"])
|
||||
|
||||
async def async_update_config(self, data):
|
||||
"""Update the main configuration."""
|
||||
if "master" in data:
|
||||
old_config = self.store.async_get_config()
|
||||
if old_config[const.ATTR_MASTER] != data["master"]:
|
||||
if self.hass.data[const.DOMAIN]["master"]:
|
||||
await self.async_remove_entity("master")
|
||||
if data["master"]["enabled"]:
|
||||
async_dispatcher_send(
|
||||
self.hass, "alarmo_register_master", data["master"]
|
||||
)
|
||||
else:
|
||||
automations = self.hass.data[const.DOMAIN][
|
||||
"automation_handler"
|
||||
].get_automations_by_area(None)
|
||||
if len(automations):
|
||||
for el in automations:
|
||||
self.store.async_delete_automation(el)
|
||||
async_dispatcher_send(self.hass, "alarmo_automations_updated")
|
||||
|
||||
self.store.async_update_config(data)
|
||||
async_dispatcher_send(self.hass, "alarmo_config_updated")
|
||||
|
||||
async def async_update_area_config(
|
||||
self,
|
||||
area_id: str | None = None,
|
||||
data: dict = {},
|
||||
):
|
||||
"""Update area configuration."""
|
||||
if const.ATTR_REMOVE in data:
|
||||
# delete an area
|
||||
res = self.store.async_get_area(area_id)
|
||||
if not res:
|
||||
return
|
||||
sensors = self.store.async_get_sensors()
|
||||
sensors = dict(filter(lambda el: el[1]["area"] == area_id, sensors.items()))
|
||||
if sensors:
|
||||
for el in sensors.keys():
|
||||
self.store.async_delete_sensor(el)
|
||||
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
||||
|
||||
automations = self.hass.data[const.DOMAIN][
|
||||
"automation_handler"
|
||||
].get_automations_by_area(area_id)
|
||||
if len(automations):
|
||||
for el in automations:
|
||||
self.store.async_delete_automation(el)
|
||||
async_dispatcher_send(self.hass, "alarmo_automations_updated")
|
||||
|
||||
self.store.async_delete_area(area_id)
|
||||
await self.async_remove_entity(area_id)
|
||||
|
||||
if (
|
||||
len(self.store.async_get_areas()) == 1
|
||||
and self.hass.data[const.DOMAIN]["master"]
|
||||
):
|
||||
await self.async_remove_entity("master")
|
||||
|
||||
elif self.store.async_get_area(area_id):
|
||||
# modify an area
|
||||
entry = self.store.async_update_area(area_id, data)
|
||||
if "name" not in data:
|
||||
async_dispatcher_send(self.hass, "alarmo_config_updated", area_id)
|
||||
else:
|
||||
await self.async_remove_entity(area_id)
|
||||
async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
|
||||
else:
|
||||
# create an area
|
||||
entry = self.store.async_create_area(data)
|
||||
async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
|
||||
|
||||
config = self.store.async_get_config()
|
||||
|
||||
if len(self.store.async_get_areas()) == 2 and config["master"]["enabled"]:
|
||||
async_dispatcher_send(
|
||||
self.hass, "alarmo_register_master", config["master"]
|
||||
)
|
||||
|
||||
def async_update_sensor_config(self, entity_id: str, data: dict):
|
||||
"""Update sensor configuration."""
|
||||
group = None
|
||||
if ATTR_GROUP in data:
|
||||
group = data[ATTR_GROUP]
|
||||
del data[ATTR_GROUP]
|
||||
|
||||
if ATTR_NEW_ENTITY_ID in data:
|
||||
# delete old sensor entry when changing the entity_id
|
||||
new_entity_id = data[ATTR_NEW_ENTITY_ID]
|
||||
del data[ATTR_NEW_ENTITY_ID]
|
||||
self.store.async_delete_sensor(entity_id)
|
||||
self.assign_sensor_to_group(new_entity_id, group)
|
||||
self.assign_sensor_to_group(entity_id, None)
|
||||
entity_id = new_entity_id
|
||||
|
||||
if const.ATTR_REMOVE in data:
|
||||
self.store.async_delete_sensor(entity_id)
|
||||
self.assign_sensor_to_group(entity_id, None)
|
||||
elif self.store.async_get_sensor(entity_id):
|
||||
self.store.async_update_sensor(entity_id, data)
|
||||
self.assign_sensor_to_group(entity_id, group)
|
||||
else:
|
||||
self.store.async_create_sensor(entity_id, data)
|
||||
self.assign_sensor_to_group(entity_id, group)
|
||||
|
||||
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
||||
|
||||
async def _validate_user_code(self, user_id: str, data: dict):
|
||||
user_with_code = await self.async_authenticate_user(data[ATTR_CODE])
|
||||
if user_id:
|
||||
if const.ATTR_OLD_CODE not in data:
|
||||
return "No code provided"
|
||||
if not await self.async_authenticate_user(
|
||||
data[const.ATTR_OLD_CODE], user_id
|
||||
):
|
||||
return "Wrong code provided"
|
||||
if user_with_code and user_with_code[const.ATTR_USER_ID] != user_id:
|
||||
return "User with same code already exists"
|
||||
elif user_with_code:
|
||||
return "User with same code already exists"
|
||||
return
|
||||
|
||||
def _validate_user_name(self, user_id: str, data: dict):
|
||||
if not data[ATTR_NAME]:
|
||||
return "User name must not be empty"
|
||||
for user in self.store.async_get_users().values():
|
||||
if (
|
||||
data[ATTR_NAME] == user[ATTR_NAME]
|
||||
and user_id != user[const.ATTR_USER_ID]
|
||||
):
|
||||
return "User with same name already exists"
|
||||
return
|
||||
|
||||
async def async_update_user_config(
|
||||
self, user_id: str | None = None, data: dict = {}
|
||||
):
|
||||
"""Update user configuration."""
|
||||
if const.ATTR_REMOVE in data:
|
||||
self.store.async_delete_user(user_id)
|
||||
return
|
||||
|
||||
if ATTR_NAME in data:
|
||||
err = self._validate_user_name(user_id, data)
|
||||
if err:
|
||||
_LOGGER.error(err)
|
||||
return err
|
||||
if ATTR_CODE in data:
|
||||
err = await self._validate_user_code(user_id, data)
|
||||
if err:
|
||||
_LOGGER.error(err)
|
||||
return err
|
||||
|
||||
if data.get(ATTR_CODE):
|
||||
data[const.ATTR_CODE_FORMAT] = (
|
||||
"number" if data[ATTR_CODE].isdigit() else "text"
|
||||
)
|
||||
data[const.ATTR_CODE_LENGTH] = len(data[ATTR_CODE])
|
||||
|
||||
def _hash_code(code):
|
||||
hashed = bcrypt.hashpw(
|
||||
code.encode("utf-8"),
|
||||
bcrypt.gensalt(rounds=BCRYPT_NUM_ROUNDS),
|
||||
)
|
||||
return base64.b64encode(hashed).decode()
|
||||
|
||||
data[ATTR_CODE] = await self.hass.async_add_executor_job(
|
||||
_hash_code, data[ATTR_CODE]
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
self.store.async_create_user(data)
|
||||
return
|
||||
else:
|
||||
if ATTR_CODE in data:
|
||||
del data[const.ATTR_OLD_CODE]
|
||||
self.store.async_update_user(user_id, data)
|
||||
return
|
||||
|
||||
async def async_authenticate_user(self, code, user_id=None):
|
||||
"""Run user authentication in the executor.
|
||||
|
||||
This wrapper ensures that the blocking bcrypt-based authentication
|
||||
is executed outside of the event loop, making it safe to call
|
||||
from async context.
|
||||
"""
|
||||
|
||||
def _authenticate_user_sync():
|
||||
"""Perform user authentication using blocking bcrypt operations."""
|
||||
|
||||
def check_user_code(user, code):
|
||||
"""Returns None or the supplied user object if the code matches."""
|
||||
if not user[const.ATTR_ENABLED]:
|
||||
return None
|
||||
elif not user[ATTR_CODE] and not code:
|
||||
return user
|
||||
elif user[ATTR_CODE]:
|
||||
try:
|
||||
hash = base64.b64decode(user[ATTR_CODE])
|
||||
except Exception:
|
||||
return None
|
||||
if bcrypt.checkpw((code or "").encode("utf-8"), hash):
|
||||
return user
|
||||
|
||||
if user_id:
|
||||
return check_user_code(self.store.async_get_user(user_id), code)
|
||||
|
||||
users = self.store.async_get_users()
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=MAX_WORKERS
|
||||
) as executor:
|
||||
futures = [
|
||||
executor.submit(check_user_code, user, code)
|
||||
for user in users.values()
|
||||
]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
if future.result():
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
return future.result()
|
||||
|
||||
return await self.hass.async_add_executor_job(_authenticate_user_sync)
|
||||
|
||||
def async_update_automation_config(
|
||||
self,
|
||||
automation_id: str | None = None,
|
||||
data: dict = {},
|
||||
):
|
||||
"""Update automation configuration."""
|
||||
if const.ATTR_REMOVE in data:
|
||||
self.store.async_delete_automation(automation_id)
|
||||
elif not automation_id:
|
||||
self.store.async_create_automation(data)
|
||||
else:
|
||||
self.store.async_update_automation(automation_id, data)
|
||||
|
||||
async_dispatcher_send(self.hass, "alarmo_automations_updated")
|
||||
|
||||
def register_events(self):
|
||||
"""Register event handlers."""
|
||||
|
||||
# handle push notifications with action buttons
|
||||
@callback
|
||||
async def async_handle_push_event(event):
|
||||
if not event.data:
|
||||
return
|
||||
action = (
|
||||
event.data.get("actionName")
|
||||
if "actionName" in event.data
|
||||
else event.data.get("action")
|
||||
)
|
||||
|
||||
if action not in const.EVENT_ACTIONS:
|
||||
return
|
||||
|
||||
if self.hass.data[const.DOMAIN]["master"]:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
|
||||
alarm_entity = next(
|
||||
iter(self.hass.data[const.DOMAIN]["areas"].values())
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Cannot process the push action, since there are multiple areas."
|
||||
)
|
||||
return
|
||||
|
||||
arm_mode = (
|
||||
alarm_entity._revert_state
|
||||
if alarm_entity._revert_state in const.ARM_MODES
|
||||
else alarm_entity._arm_mode
|
||||
)
|
||||
res = re.search(r"^ALARMO_ARM_", action)
|
||||
if res:
|
||||
arm_mode = action.replace("ALARMO_", "").lower().replace("arm", "armed")
|
||||
if not arm_mode:
|
||||
_LOGGER.info(
|
||||
"Cannot process the push action, since the arm mode is not known."
|
||||
)
|
||||
return
|
||||
|
||||
if action == const.EVENT_ACTION_FORCE_ARM:
|
||||
_LOGGER.info("Received request for force arming")
|
||||
await alarm_entity.async_handle_arm_request(
|
||||
arm_mode, skip_code=True, bypass_open_sensors=True
|
||||
)
|
||||
elif action == const.EVENT_ACTION_RETRY_ARM:
|
||||
_LOGGER.info("Received request for retry arming")
|
||||
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
||||
elif action == const.EVENT_ACTION_DISARM:
|
||||
_LOGGER.info("Received request for disarming")
|
||||
await alarm_entity.async_alarm_disarm(None, skip_code=True)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Received request for arming with mode %s",
|
||||
arm_mode,
|
||||
)
|
||||
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
||||
|
||||
self._subscriptions.append(
|
||||
self.hass.bus.async_listen(const.PUSH_EVENT, async_handle_push_event)
|
||||
)
|
||||
|
||||
async def async_remove_entity(self, area_id: str):
|
||||
"""Remove an alarm_control_panel entity."""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
if area_id == "master":
|
||||
entity = self.hass.data[const.DOMAIN]["master"]
|
||||
entity_registry.async_remove(entity.entity_id)
|
||||
self.hass.data[const.DOMAIN]["master"] = None
|
||||
else:
|
||||
entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
entity_registry.async_remove(entity.entity_id)
|
||||
self.hass.data[const.DOMAIN]["areas"].pop(area_id, None)
|
||||
|
||||
def async_get_sensor_groups(self):
|
||||
"""Fetch a list of sensor groups (websocket API hook)."""
|
||||
groups = self.store.async_get_sensor_groups()
|
||||
return list(groups.values())
|
||||
|
||||
def async_get_group_for_sensor(self, entity_id: str):
|
||||
"""Fetch the group ID for a given sensor."""
|
||||
groups = self.async_get_sensor_groups()
|
||||
result = next((el for el in groups if entity_id in el[ATTR_ENTITIES]), None)
|
||||
return result["group_id"] if result else None
|
||||
|
||||
def assign_sensor_to_group(self, entity_id: str, group_id: str):
|
||||
"""Assign a sensor to a group."""
|
||||
updated = False
|
||||
old_group = self.async_get_group_for_sensor(entity_id)
|
||||
if old_group and group_id != old_group:
|
||||
# remove sensor from group
|
||||
el = self.store.async_get_sensor_group(old_group)
|
||||
if len(el[ATTR_ENTITIES]) > 2:
|
||||
self.store.async_update_sensor_group(
|
||||
old_group,
|
||||
{ATTR_ENTITIES: [x for x in el[ATTR_ENTITIES] if x != entity_id]},
|
||||
)
|
||||
else:
|
||||
self.store.async_delete_sensor_group(old_group)
|
||||
updated = True
|
||||
if group_id:
|
||||
# add sensor to group
|
||||
group = self.store.async_get_sensor_group(group_id)
|
||||
if not group:
|
||||
_LOGGER.error(
|
||||
"Failed to assign entity %s to group %s",
|
||||
entity_id,
|
||||
group_id,
|
||||
)
|
||||
elif entity_id not in group[ATTR_ENTITIES]:
|
||||
self.store.async_update_sensor_group(
|
||||
group_id, {ATTR_ENTITIES: group[ATTR_ENTITIES] + [entity_id]}
|
||||
)
|
||||
updated = True
|
||||
if updated:
|
||||
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
||||
|
||||
def async_update_sensor_group_config(
|
||||
self,
|
||||
group_id: str | None = None,
|
||||
data: dict = {},
|
||||
):
|
||||
"""Update sensor group configuration."""
|
||||
if const.ATTR_REMOVE in data:
|
||||
self.store.async_delete_sensor_group(group_id)
|
||||
elif not group_id:
|
||||
self.store.async_create_sensor_group(data)
|
||||
else:
|
||||
self.store.async_update_sensor_group(group_id, data)
|
||||
|
||||
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
|
||||
|
||||
async def async_unload(self):
|
||||
"""Remove all alarmo objects."""
|
||||
# remove alarm_control_panel entities
|
||||
areas = list(self.hass.data[const.DOMAIN]["areas"].keys())
|
||||
for area in areas:
|
||||
await self.async_remove_entity(area)
|
||||
if self.hass.data[const.DOMAIN]["master"]:
|
||||
await self.async_remove_entity("master")
|
||||
|
||||
del self.hass.data[const.DOMAIN]["sensor_handler"]
|
||||
del self.hass.data[const.DOMAIN]["automation_handler"]
|
||||
del self.hass.data[const.DOMAIN]["mqtt_handler"]
|
||||
del self.hass.data[const.DOMAIN]["event_handler"]
|
||||
|
||||
# remove subscriptions for coordinator
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
async def async_delete_config(self):
|
||||
"""Wipe alarmo storage."""
|
||||
await self.store.async_delete()
|
||||
|
||||
|
||||
@callback
|
||||
def register_services(hass):
|
||||
"""Register services used by alarmo component."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
|
||||
async def async_srv_toggle_user(call):
|
||||
"""Enable a user by service call."""
|
||||
name = call.data.get(ATTR_NAME)
|
||||
enable = True if call.service == const.SERVICE_ENABLE_USER else False
|
||||
users = coordinator.store.async_get_users()
|
||||
user = next(
|
||||
(item for item in list(users.values()) if item[ATTR_NAME] == name), None
|
||||
)
|
||||
if user is None:
|
||||
_LOGGER.warning(
|
||||
"Failed to %s user, no match for name '%s'",
|
||||
"enable" if enable else "disable",
|
||||
name,
|
||||
)
|
||||
return
|
||||
|
||||
coordinator.store.async_update_user(
|
||||
user[const.ATTR_USER_ID], {const.ATTR_ENABLED: enable}
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"User user '%s' was %s", name, "enabled" if enable else "disabled"
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
const.DOMAIN,
|
||||
const.SERVICE_ENABLE_USER,
|
||||
async_srv_toggle_user,
|
||||
schema=const.SERVICE_TOGGLE_USER_SCHEMA,
|
||||
)
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
const.DOMAIN,
|
||||
const.SERVICE_DISABLE_USER,
|
||||
async_srv_toggle_user,
|
||||
schema=const.SERVICE_TOGGLE_USER_SCHEMA,
|
||||
)
|
||||
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.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
"""Automations."""
|
||||
|
||||
import re
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_TYPE,
|
||||
ATTR_SERVICE,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_SERVICE_DATA,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.template import Template, is_template_string
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.components.binary_sensor.device_condition import (
|
||||
ENTITY_CONDITIONS,
|
||||
)
|
||||
|
||||
from . import const
|
||||
from .helpers import (
|
||||
friendly_name_for_entity_id,
|
||||
)
|
||||
from .sensors import (
|
||||
STATE_OPEN,
|
||||
STATE_CLOSED,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from .alarm_control_panel import AlarmoBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EVENT_ARM_FAILURE = "arm_failure"
|
||||
|
||||
|
||||
def validate_area(trigger, area_id, hass):
|
||||
"""Validate area for trigger."""
|
||||
if const.ATTR_AREA not in trigger:
|
||||
return False
|
||||
elif trigger[const.ATTR_AREA]:
|
||||
return trigger[const.ATTR_AREA] == area_id
|
||||
elif len(hass.data[const.DOMAIN]["areas"]) == 1:
|
||||
return True
|
||||
else:
|
||||
return area_id is None
|
||||
|
||||
|
||||
def validate_modes(trigger, mode):
|
||||
"""Validate modes for trigger."""
|
||||
if const.ATTR_MODES not in trigger:
|
||||
return False
|
||||
elif not trigger[const.ATTR_MODES]:
|
||||
return True
|
||||
else:
|
||||
return mode in trigger[const.ATTR_MODES]
|
||||
|
||||
|
||||
def validate_trigger(trigger, to_state, from_state=None):
|
||||
"""Validate trigger condition."""
|
||||
if const.ATTR_EVENT not in trigger:
|
||||
return False
|
||||
elif trigger[const.ATTR_EVENT] == "untriggered" and from_state == "triggered":
|
||||
return True
|
||||
elif trigger[const.ATTR_EVENT] == to_state:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class AutomationHandler:
|
||||
"""Handle automations."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize automation handler."""
|
||||
self.hass = hass
|
||||
self._config = None
|
||||
self._subscriptions = []
|
||||
self._sensorTranslationCache = {}
|
||||
self._alarmTranslationCache = {}
|
||||
self._sensorTranslationLang = None
|
||||
self._alarmTranslationLang = None
|
||||
|
||||
def async_update_config():
|
||||
"""Automation config updated, reload the configuration."""
|
||||
self._config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_automations()
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_automations_updated", async_update_config
|
||||
)
|
||||
)
|
||||
async_update_config()
|
||||
|
||||
@callback
|
||||
async def async_alarm_state_changed(
|
||||
area_id: str, old_state: str, new_state: str
|
||||
):
|
||||
if not old_state:
|
||||
# ignore automations at startup/restoring
|
||||
return
|
||||
|
||||
if area_id:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
else:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
|
||||
if not alarm_entity:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"state of %s is updated from %s to %s",
|
||||
alarm_entity.entity_id,
|
||||
old_state,
|
||||
new_state,
|
||||
)
|
||||
|
||||
if new_state in const.ARM_MODES:
|
||||
# we don't distinguish between armed modes for automations
|
||||
# they are handled separately
|
||||
new_state = "armed"
|
||||
|
||||
for automation_id, config in self._config.items():
|
||||
if not config[const.ATTR_ENABLED]:
|
||||
continue
|
||||
for trigger in config[const.ATTR_TRIGGERS]:
|
||||
if (
|
||||
validate_area(trigger, area_id, self.hass)
|
||||
and validate_modes(trigger, alarm_entity._arm_mode)
|
||||
and validate_trigger(trigger, new_state, old_state)
|
||||
):
|
||||
await self.async_execute_automation(automation_id, alarm_entity)
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
self.hass, "alarmo_state_updated", async_alarm_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
async def async_handle_event(event: str, area_id: str, args: dict = {}):
|
||||
if event != const.EVENT_FAILED_TO_ARM:
|
||||
return
|
||||
if area_id:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
else:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s has failed to arm",
|
||||
alarm_entity.entity_id,
|
||||
)
|
||||
|
||||
for automation_id, config in self._config.items():
|
||||
if not config[const.ATTR_ENABLED]:
|
||||
continue
|
||||
for trigger in config[const.ATTR_TRIGGERS]:
|
||||
if (
|
||||
validate_area(trigger, area_id, self.hass)
|
||||
and validate_modes(trigger, alarm_entity._arm_mode)
|
||||
and validate_trigger(trigger, EVENT_ARM_FAILURE)
|
||||
):
|
||||
await self.async_execute_automation(automation_id, alarm_entity)
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event)
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""Prepare for removal."""
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
async def async_execute_automation(
|
||||
self, automation_id: str, alarm_entity: AlarmoBaseEntity
|
||||
):
|
||||
"""Execute the specified automation."""
|
||||
# automation is a dict of AutomationEntry
|
||||
_LOGGER.debug(
|
||||
"Executing automation %s",
|
||||
automation_id,
|
||||
)
|
||||
|
||||
actions = self._config[automation_id][const.ATTR_ACTIONS]
|
||||
for action in actions:
|
||||
try:
|
||||
service_data = copy.copy(action[CONF_SERVICE_DATA])
|
||||
|
||||
if action.get(ATTR_ENTITY_ID):
|
||||
service_data[ATTR_ENTITY_ID] = action[ATTR_ENTITY_ID]
|
||||
|
||||
if self._config[automation_id][CONF_TYPE] == const.ATTR_NOTIFICATION:
|
||||
# recursively process all service_data (supports deep nesting)
|
||||
service_data = await self._process_service_data(
|
||||
service_data,
|
||||
alarm_entity,
|
||||
)
|
||||
|
||||
domain, service = action[ATTR_SERVICE].split(".")
|
||||
|
||||
await self.hass.async_create_task(
|
||||
self.hass.services.async_call(
|
||||
domain,
|
||||
service,
|
||||
service_data,
|
||||
blocking=False,
|
||||
context={},
|
||||
)
|
||||
)
|
||||
except HomeAssistantError as e:
|
||||
_LOGGER.error(
|
||||
"Execution of action %s failed, reason: %s",
|
||||
automation_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def _process_service_data(
|
||||
self, obj, alarm_entity, depth: int = 0, max_depth: int = 10
|
||||
):
|
||||
"""Recursively process service_data replacing wildcards and templates safely."""
|
||||
if depth > max_depth:
|
||||
return obj
|
||||
|
||||
# Handle strings
|
||||
if isinstance(obj, str):
|
||||
return await self.replace_wildcards_in_string(obj, alarm_entity)
|
||||
|
||||
# Handle dictionaries
|
||||
if isinstance(obj, dict):
|
||||
processed = {}
|
||||
for k, v in obj.items():
|
||||
processed[k] = await self._process_service_data(
|
||||
v,
|
||||
alarm_entity,
|
||||
depth + 1,
|
||||
max_depth,
|
||||
)
|
||||
return processed
|
||||
|
||||
# Handle lists
|
||||
if isinstance(obj, list):
|
||||
return [
|
||||
await self._process_service_data(
|
||||
item,
|
||||
alarm_entity,
|
||||
depth + 1,
|
||||
max_depth,
|
||||
)
|
||||
for item in obj
|
||||
]
|
||||
# Other types (int, float, bool, None, etc.)
|
||||
return obj
|
||||
|
||||
def get_automations_by_area(self, area_id: str):
|
||||
"""Get automations for specified area."""
|
||||
result = []
|
||||
for automation_id, config in self._config.items():
|
||||
if any(
|
||||
el[const.ATTR_AREA] == area_id for el in config[const.ATTR_TRIGGERS]
|
||||
):
|
||||
result.append(automation_id)
|
||||
|
||||
return result
|
||||
|
||||
async def replace_wildcards_in_string(
|
||||
self, input: str, alarm_entity: AlarmoBaseEntity
|
||||
):
|
||||
"""Look for wildcards in string and replace them with content."""
|
||||
# process wildcard '{{open_sensors}}'
|
||||
res = re.search(r"{{open_sensors(\|lang=([^}]+))?(\|format=short)?}}", input)
|
||||
if res:
|
||||
lang = res.group(2) if res.group(2) else "en"
|
||||
names_only = True if res.group(3) else False
|
||||
|
||||
open_sensors = ""
|
||||
if alarm_entity.open_sensors:
|
||||
parts = []
|
||||
for entity_id, status in alarm_entity.open_sensors.items():
|
||||
if names_only:
|
||||
parts.append(friendly_name_for_entity_id(entity_id, self.hass))
|
||||
else:
|
||||
parts.append(
|
||||
await self.async_get_open_sensor_string(
|
||||
entity_id, status, lang
|
||||
)
|
||||
)
|
||||
open_sensors = ", ".join(parts)
|
||||
input = input.replace(res.group(0), open_sensors)
|
||||
|
||||
# process wildcard '{{bypassed_sensors}}'
|
||||
if "{{bypassed_sensors}}" in input:
|
||||
bypassed_sensors = ""
|
||||
if alarm_entity.bypassed_sensors and len(alarm_entity.bypassed_sensors):
|
||||
parts = []
|
||||
for entity_id in alarm_entity.bypassed_sensors:
|
||||
name = friendly_name_for_entity_id(entity_id, self.hass)
|
||||
parts.append(name)
|
||||
bypassed_sensors = ", ".join(parts)
|
||||
input = input.replace("{{bypassed_sensors}}", bypassed_sensors)
|
||||
|
||||
# process wildcard '{{arm_mode}}'
|
||||
res = re.search(r"{{arm_mode(\|lang=([^}]+))?}}", input)
|
||||
if res:
|
||||
lang = res.group(2) if res.group(2) else "en"
|
||||
arm_mode = await self.async_get_arm_mode_string(alarm_entity.arm_mode, lang)
|
||||
|
||||
input = input.replace(res.group(0), arm_mode)
|
||||
|
||||
# process wildcard '{{changed_by}}'
|
||||
if "{{changed_by}}" in input:
|
||||
changed_by = alarm_entity.changed_by if alarm_entity.changed_by else ""
|
||||
input = input.replace("{{changed_by}}", changed_by)
|
||||
|
||||
# process wildcard '{{delay}}'
|
||||
if "{{delay}}" in input:
|
||||
delay = str(alarm_entity.delay) if alarm_entity.delay else ""
|
||||
input = input.replace("{{delay}}", delay)
|
||||
|
||||
# process HA templates
|
||||
if is_template_string(input):
|
||||
input = Template(input, self.hass).async_render()
|
||||
_LOGGER.debug("Processed HA template, result:(%s) %s", type(input), input)
|
||||
|
||||
return input
|
||||
|
||||
async def async_get_open_sensor_string(
|
||||
self, entity_id: str, state: str, language: str
|
||||
):
|
||||
"""Get translation for sensor states."""
|
||||
if self._sensorTranslationCache and self._sensorTranslationLang == language:
|
||||
translations = self._sensorTranslationCache
|
||||
else:
|
||||
translations = await async_get_translations(
|
||||
self.hass, language, "device_automation", ["binary_sensor"]
|
||||
)
|
||||
|
||||
self._sensorTranslationCache = translations
|
||||
self._sensorTranslationLang = language
|
||||
|
||||
entity = self.hass.states.get(entity_id)
|
||||
|
||||
device_type = (
|
||||
entity.attributes["device_class"]
|
||||
if entity and "device_class" in entity.attributes
|
||||
else None
|
||||
)
|
||||
|
||||
if state == STATE_OPEN:
|
||||
translation_key = (
|
||||
f"component.binary_sensor.device_automation.condition_type.{ENTITY_CONDITIONS[device_type][0]['type']}"
|
||||
if device_type in ENTITY_CONDITIONS
|
||||
else None
|
||||
)
|
||||
if translation_key and translation_key in translations:
|
||||
string = translations[translation_key]
|
||||
else:
|
||||
string = "{entity_name} is open"
|
||||
elif state == STATE_CLOSED:
|
||||
translation_key = (
|
||||
f"component.binary_sensor.device_automation.condition_type.{ENTITY_CONDITIONS[device_type][1]['type']}"
|
||||
if device_type in ENTITY_CONDITIONS
|
||||
else None
|
||||
)
|
||||
if translation_key and translation_key in translations:
|
||||
string = translations[translation_key]
|
||||
else:
|
||||
string = "{entity_name} is closed"
|
||||
|
||||
elif state == STATE_UNAVAILABLE:
|
||||
string = "{entity_name} is unavailable"
|
||||
|
||||
else:
|
||||
string = "{entity_name} is unknown"
|
||||
|
||||
name = friendly_name_for_entity_id(entity_id, self.hass)
|
||||
string = string.replace("{entity_name}", name)
|
||||
|
||||
return string
|
||||
|
||||
async def async_get_arm_mode_string(self, arm_mode: str, language: str):
|
||||
"""Get translation for alarm arm mode."""
|
||||
if self._alarmTranslationCache and self._alarmTranslationLang == language:
|
||||
translations = self._alarmTranslationCache
|
||||
else:
|
||||
translations = await async_get_translations(
|
||||
self.hass, language, "entity_component", ["alarm_control_panel"]
|
||||
)
|
||||
|
||||
self._alarmTranslationCache = translations
|
||||
self._alarmTranslationLang = language
|
||||
|
||||
translation_key = (
|
||||
f"component.alarm_control_panel.entity_component._.state.{arm_mode}"
|
||||
if arm_mode
|
||||
else None
|
||||
)
|
||||
|
||||
if translation_key and translation_key in translations:
|
||||
return translations[translation_key]
|
||||
elif arm_mode:
|
||||
return " ".join(w.capitalize() for w in arm_mode.split("_"))
|
||||
else:
|
||||
return ""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""WebSocket handler and registration for Alarmo card update events."""
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.components.websocket_api import decorators, async_register_command
|
||||
|
||||
|
||||
@decorators.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "alarmo_updated",
|
||||
}
|
||||
)
|
||||
@decorators.async_response
|
||||
async def handle_subscribe_updates(hass, connection, msg):
|
||||
"""Handle subscribe updates."""
|
||||
|
||||
@callback
|
||||
def handle_event(event: str, area_id: str, args: dict = {}):
|
||||
"""Forward events to websocket."""
|
||||
data = dict(**args, **{"event": event, "area_id": area_id})
|
||||
connection.send_message(
|
||||
{"id": msg["id"], "type": "event", "event": {"data": data}}
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass, "alarmo_event", handle_event
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
async def async_register_card(hass):
|
||||
"""Publish event to lovelace when alarm changes."""
|
||||
async_register_command(hass, handle_subscribe_updates)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Config flow for the Alarmo component."""
|
||||
|
||||
import secrets
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
from .const import (
|
||||
NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class AlarmoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Alarmo."""
|
||||
|
||||
VERSION = "1.0.0"
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
# Only a single instance of the integration
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
id = secrets.token_hex(6)
|
||||
|
||||
await self.async_set_unique_id(id)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
|
||||
return self.async_create_entry(title=NAME, data={})
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Store constants."""
|
||||
|
||||
import datetime
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.const import (
|
||||
ATTR_NAME,
|
||||
CONF_CODE,
|
||||
CONF_MODE,
|
||||
ATTR_ENTITY_ID,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
AlarmControlPanelEntityFeature,
|
||||
)
|
||||
|
||||
VERSION = "1.10.18"
|
||||
NAME = "Alarmo"
|
||||
MANUFACTURER = "@nielsfaber"
|
||||
|
||||
DOMAIN = "alarmo"
|
||||
|
||||
CUSTOM_COMPONENTS = "custom_components"
|
||||
INTEGRATION_FOLDER = DOMAIN
|
||||
PANEL_FOLDER = "frontend"
|
||||
PANEL_FILENAME = "dist/alarm-panel.js"
|
||||
|
||||
PANEL_URL = "/api/panel_custom/alarmo"
|
||||
PANEL_TITLE = NAME
|
||||
PANEL_ICON = "mdi:shield-home"
|
||||
PANEL_NAME = "alarm-panel"
|
||||
|
||||
INITIALIZATION_TIME = datetime.timedelta(seconds=60)
|
||||
SENSOR_ARM_TIME = datetime.timedelta(seconds=5)
|
||||
|
||||
STATES = [
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelState.DISARMED,
|
||||
AlarmControlPanelState.TRIGGERED,
|
||||
AlarmControlPanelState.PENDING,
|
||||
AlarmControlPanelState.ARMING,
|
||||
]
|
||||
|
||||
ARM_MODES = [
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
]
|
||||
|
||||
ARM_MODE_TO_STATE = {
|
||||
"away": AlarmControlPanelState.ARMED_AWAY,
|
||||
"home": AlarmControlPanelState.ARMED_HOME,
|
||||
"night": AlarmControlPanelState.ARMED_NIGHT,
|
||||
"custom": AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
"vacation": AlarmControlPanelState.ARMED_VACATION,
|
||||
}
|
||||
|
||||
STATE_TO_ARM_MODE = {
|
||||
AlarmControlPanelState.ARMED_AWAY: "away",
|
||||
AlarmControlPanelState.ARMED_HOME: "home",
|
||||
AlarmControlPanelState.ARMED_NIGHT: "night",
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS: "custom",
|
||||
AlarmControlPanelState.ARMED_VACATION: "vacation",
|
||||
}
|
||||
|
||||
COMMAND_ARM_NIGHT = "arm_night"
|
||||
COMMAND_ARM_AWAY = "arm_away"
|
||||
COMMAND_ARM_HOME = "arm_home"
|
||||
COMMAND_ARM_CUSTOM_BYPASS = "arm_custom_bypass"
|
||||
COMMAND_ARM_VACATION = "arm_vacation"
|
||||
COMMAND_DISARM = "disarm"
|
||||
|
||||
COMMANDS = [
|
||||
COMMAND_DISARM,
|
||||
COMMAND_ARM_AWAY,
|
||||
COMMAND_ARM_NIGHT,
|
||||
COMMAND_ARM_HOME,
|
||||
COMMAND_ARM_CUSTOM_BYPASS,
|
||||
COMMAND_ARM_VACATION,
|
||||
]
|
||||
|
||||
EVENT_DISARM = "disarm"
|
||||
EVENT_LEAVE = "leave"
|
||||
EVENT_ARM = "arm"
|
||||
EVENT_ENTRY = "entry"
|
||||
EVENT_TRIGGER = "trigger"
|
||||
EVENT_FAILED_TO_ARM = "failed_to_arm"
|
||||
EVENT_COMMAND_NOT_ALLOWED = "command_not_allowed"
|
||||
EVENT_INVALID_CODE_PROVIDED = "invalid_code_provided"
|
||||
EVENT_NO_CODE_PROVIDED = "no_code_provided"
|
||||
EVENT_TRIGGER_TIME_EXPIRED = "trigger_time_expired"
|
||||
EVENT_READY_TO_ARM_MODES_CHANGED = "ready_to_arm_modes_changed"
|
||||
|
||||
ATTR_MODES = "modes"
|
||||
ATTR_ARM_MODE = "arm_mode"
|
||||
ATTR_CODE_DISARM_REQUIRED = "code_disarm_required"
|
||||
ATTR_CODE_MODE_CHANGE_REQUIRED = "code_mode_change_required"
|
||||
ATTR_REMOVE = "remove"
|
||||
ATTR_OLD_CODE = "old_code"
|
||||
|
||||
ATTR_TRIGGER_TIME = "trigger_time"
|
||||
ATTR_EXIT_TIME = "exit_time"
|
||||
ATTR_ENTRY_TIME = "entry_time"
|
||||
|
||||
ATTR_ENABLED = "enabled"
|
||||
ATTR_USER_ID = "user_id"
|
||||
|
||||
ATTR_CAN_ARM = "can_arm"
|
||||
ATTR_CAN_DISARM = "can_disarm"
|
||||
ATTR_DISARM_AFTER_TRIGGER = "disarm_after_trigger"
|
||||
ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER = "ignore_blocking_sensors_after_trigger"
|
||||
|
||||
ATTR_REMOVE = "remove"
|
||||
ATTR_IS_OVERRIDE_CODE = "is_override_code"
|
||||
ATTR_AREA_LIMIT = "area_limit"
|
||||
ATTR_CODE_FORMAT = "code_format"
|
||||
ATTR_CODE_LENGTH = "code_length"
|
||||
|
||||
ATTR_AUTOMATION_ID = "automation_id"
|
||||
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_AREA = "area"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
ATTR_TRIGGERS = "triggers"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_EVENT = "event"
|
||||
ATTR_REQUIRE_CODE = "require_code"
|
||||
|
||||
ATTR_NOTIFICATION = "notification"
|
||||
ATTR_VERSION = "version"
|
||||
ATTR_STATE_PAYLOAD = "state_payload"
|
||||
ATTR_COMMAND_PAYLOAD = "command_payload"
|
||||
|
||||
ATTR_FORCE = "force"
|
||||
ATTR_SKIP_DELAY = "skip_delay"
|
||||
ATTR_CONTEXT_ID = "context_id"
|
||||
|
||||
PUSH_EVENT = "mobile_app_notification_action"
|
||||
|
||||
EVENT_ACTION_FORCE_ARM = "ALARMO_FORCE_ARM"
|
||||
EVENT_ACTION_RETRY_ARM = "ALARMO_RETRY_ARM"
|
||||
EVENT_ACTION_DISARM = "ALARMO_DISARM"
|
||||
EVENT_ACTION_ARM_AWAY = "ALARMO_ARM_AWAY"
|
||||
EVENT_ACTION_ARM_HOME = "ALARMO_ARM_HOME"
|
||||
EVENT_ACTION_ARM_NIGHT = "ALARMO_ARM_NIGHT"
|
||||
EVENT_ACTION_ARM_VACATION = "ALARMO_ARM_VACATION"
|
||||
EVENT_ACTION_ARM_CUSTOM_BYPASS = "ALARMO_ARM_CUSTOM_BYPASS"
|
||||
|
||||
EVENT_ACTIONS = [
|
||||
EVENT_ACTION_FORCE_ARM,
|
||||
EVENT_ACTION_RETRY_ARM,
|
||||
EVENT_ACTION_DISARM,
|
||||
EVENT_ACTION_ARM_AWAY,
|
||||
EVENT_ACTION_ARM_HOME,
|
||||
EVENT_ACTION_ARM_NIGHT,
|
||||
EVENT_ACTION_ARM_VACATION,
|
||||
EVENT_ACTION_ARM_CUSTOM_BYPASS,
|
||||
]
|
||||
|
||||
MODES_TO_SUPPORTED_FEATURES = {
|
||||
AlarmControlPanelState.ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY,
|
||||
AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, # noqa: E501
|
||||
AlarmControlPanelState.ARMED_VACATION: AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
}
|
||||
|
||||
SERVICE_ARM = "arm"
|
||||
SERVICE_DISARM = "disarm"
|
||||
SERVICE_SKIP_DELAY = "skip_delay"
|
||||
|
||||
CONF_ALARM_ARMED_AWAY = "armed_away"
|
||||
CONF_ALARM_ARMED_CUSTOM_BYPASS = "armed_custom_bypass"
|
||||
CONF_ALARM_ARMED_HOME = "armed_home"
|
||||
CONF_ALARM_ARMED_NIGHT = "armed_night"
|
||||
CONF_ALARM_ARMED_VACATION = "armed_vacation"
|
||||
CONF_ALARM_ARMING = "arming"
|
||||
CONF_ALARM_DISARMED = "disarmed"
|
||||
CONF_ALARM_PENDING = "pending"
|
||||
CONF_ALARM_TRIGGERED = "triggered"
|
||||
|
||||
SERVICE_ARM_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_CODE, default=""): cv.string,
|
||||
vol.Optional(CONF_MODE, default=AlarmControlPanelState.ARMED_AWAY): vol.In(
|
||||
[
|
||||
"away",
|
||||
"home",
|
||||
"night",
|
||||
"custom",
|
||||
"vacation",
|
||||
CONF_ALARM_ARMED_AWAY,
|
||||
CONF_ALARM_ARMED_HOME,
|
||||
CONF_ALARM_ARMED_NIGHT,
|
||||
CONF_ALARM_ARMED_CUSTOM_BYPASS,
|
||||
CONF_ALARM_ARMED_VACATION,
|
||||
]
|
||||
),
|
||||
vol.Optional(ATTR_SKIP_DELAY, default=False): cv.boolean,
|
||||
vol.Optional(ATTR_FORCE, default=False): cv.boolean,
|
||||
vol.Optional(ATTR_CONTEXT_ID): int,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_DISARM_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_CODE, default=""): cv.string,
|
||||
vol.Optional(ATTR_CONTEXT_ID): int,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_SKIP_DELAY_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_ENABLE_USER = "enable_user"
|
||||
SERVICE_DISABLE_USER = "disable_user"
|
||||
SERVICE_TOGGLE_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_NAME, default=""): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""fire events in HA for use with automations."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from . import const
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventHandler:
|
||||
"""Class to handle events from Alarmo and fire HA events."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Class constructor."""
|
||||
self.hass = hass
|
||||
self._subscription = async_dispatcher_connect(
|
||||
self.hass, "alarmo_event", self.async_handle_event
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""Class destructor."""
|
||||
self._subscription()
|
||||
|
||||
@callback
|
||||
def async_handle_event(self, event: str, area_id: str, args: dict = {}):
|
||||
"""Handle event."""
|
||||
if area_id:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
else:
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["master"]
|
||||
|
||||
if alarm_entity is None:
|
||||
_LOGGER.warning(
|
||||
"Cannot resolve alarm_entity for area_id: %s (event: %s)",
|
||||
area_id,
|
||||
event,
|
||||
)
|
||||
return
|
||||
|
||||
if event in [
|
||||
const.EVENT_FAILED_TO_ARM,
|
||||
const.EVENT_COMMAND_NOT_ALLOWED,
|
||||
const.EVENT_INVALID_CODE_PROVIDED,
|
||||
const.EVENT_NO_CODE_PROVIDED,
|
||||
]:
|
||||
reasons = {
|
||||
const.EVENT_FAILED_TO_ARM: "open_sensors",
|
||||
const.EVENT_COMMAND_NOT_ALLOWED: "not_allowed",
|
||||
const.EVENT_INVALID_CODE_PROVIDED: "invalid_code",
|
||||
const.EVENT_NO_CODE_PROVIDED: "invalid_code",
|
||||
}
|
||||
|
||||
data = dict(
|
||||
**args,
|
||||
**{
|
||||
"area_id": area_id,
|
||||
"entity_id": alarm_entity.entity_id,
|
||||
"reason": reasons[event],
|
||||
},
|
||||
)
|
||||
if "open_sensors" in data:
|
||||
data["sensors"] = list(data["open_sensors"].keys())
|
||||
del data["open_sensors"]
|
||||
|
||||
self.hass.bus.async_fire("alarmo_failed_to_arm", data)
|
||||
|
||||
elif event in [const.EVENT_ARM, const.EVENT_DISARM]:
|
||||
data = dict(
|
||||
**args,
|
||||
**{
|
||||
"area_id": area_id,
|
||||
"entity_id": alarm_entity.entity_id,
|
||||
"action": event,
|
||||
},
|
||||
)
|
||||
if "arm_mode" in data:
|
||||
data["mode"] = const.STATE_TO_ARM_MODE[data["arm_mode"]]
|
||||
del data["arm_mode"]
|
||||
|
||||
self.hass.bus.async_fire("alarmo_command_success", data)
|
||||
|
||||
elif event == const.EVENT_READY_TO_ARM_MODES_CHANGED:
|
||||
supported_modes = dict(
|
||||
filter(
|
||||
lambda el: el[1] & alarm_entity.supported_features,
|
||||
const.MODES_TO_SUPPORTED_FEATURES.items(),
|
||||
)
|
||||
)
|
||||
modes = {
|
||||
k.value: (k.value in args["modes"]) for k in supported_modes.keys()
|
||||
}
|
||||
data = {
|
||||
"area_id": area_id,
|
||||
"entity_id": alarm_entity.entity_id,
|
||||
**modes,
|
||||
}
|
||||
|
||||
self.hass.bus.async_fire("alarmo_ready_to_arm_modes_updated", data)
|
||||
+3390
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
"""Helper functions for Alarmo integration."""
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
)
|
||||
|
||||
|
||||
def friendly_name_for_entity_id(entity_id: str, hass: HomeAssistant):
|
||||
"""Helper to get friendly name for entity."""
|
||||
state = hass.states.get(entity_id)
|
||||
if state and state.attributes.get("friendly_name"):
|
||||
return state.attributes["friendly_name"]
|
||||
|
||||
return entity_id
|
||||
|
||||
|
||||
def omit(obj: dict, blacklisted_keys: list):
|
||||
"""Helper to omit blacklisted keys from a dict."""
|
||||
return {key: val for key, val in obj.items() if key not in blacklisted_keys}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"arm": "mdi:shield-lock",
|
||||
"disarm": "mdi:shield-off",
|
||||
"enable_user": "mdi:account-lock-open",
|
||||
"disable_user": "mdi:account-lock-closed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"domain": "alarmo",
|
||||
"name": "Alarmo",
|
||||
"after_dependencies": [
|
||||
"mqtt",
|
||||
"notify"
|
||||
],
|
||||
"codeowners": [
|
||||
"@nielsfaber"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http",
|
||||
"panel_custom"
|
||||
],
|
||||
"documentation": "https://github.com/nielsfaber/alarmo",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/nielsfaber/alarmo/issues",
|
||||
"requirements": [],
|
||||
"version": "1.10.18"
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Class to handle MQTT integration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.components.mqtt import (
|
||||
DOMAIN as ATTR_MQTT,
|
||||
)
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from . import const
|
||||
from .helpers import (
|
||||
friendly_name_for_entity_id,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CONF_EVENT_TOPIC = "event_topic"
|
||||
|
||||
|
||||
class MqttHandler:
|
||||
"""Class to handle MQTT integration."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant): # noqa: PLR0915
|
||||
"""Class constructor."""
|
||||
self.hass = hass
|
||||
self._config = None
|
||||
self._subscribed_topics = []
|
||||
self._subscriptions = []
|
||||
|
||||
@callback
|
||||
def async_update_config(_args=None):
|
||||
"""Mqtt config updated, reload the configuration."""
|
||||
old_config = self._config
|
||||
new_config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_config()
|
||||
|
||||
if old_config and old_config[ATTR_MQTT] == new_config[ATTR_MQTT]:
|
||||
# only update MQTT config if some parameters are changed
|
||||
return
|
||||
|
||||
self._config = new_config
|
||||
|
||||
if (
|
||||
not old_config
|
||||
or old_config[ATTR_MQTT][CONF_COMMAND_TOPIC]
|
||||
!= new_config[ATTR_MQTT][CONF_COMMAND_TOPIC]
|
||||
):
|
||||
# re-subscribing is only needed if the command topic has changed
|
||||
self.hass.add_job(self._async_subscribe_topics())
|
||||
|
||||
_LOGGER.debug("MQTT config was (re)loaded")
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(hass, "alarmo_config_updated", async_update_config)
|
||||
)
|
||||
async_update_config()
|
||||
|
||||
@callback
|
||||
def async_alarm_state_changed(area_id: str, old_state: str, new_state: str):
|
||||
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
|
||||
return
|
||||
|
||||
topic = self._config[ATTR_MQTT][CONF_STATE_TOPIC]
|
||||
|
||||
if not topic: # do not publish if no topic is provided
|
||||
return
|
||||
|
||||
if area_id and len(self.hass.data[const.DOMAIN]["areas"]) > 1:
|
||||
# handle the sending of a state update for a specific area
|
||||
area = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
topic = topic.rsplit("/", 1)
|
||||
topic.insert(1, slugify(area.name))
|
||||
topic = "/".join(topic)
|
||||
|
||||
payload_config = self._config[ATTR_MQTT][const.ATTR_STATE_PAYLOAD]
|
||||
if payload_config.get(new_state):
|
||||
message = payload_config[new_state]
|
||||
else:
|
||||
message = new_state
|
||||
|
||||
hass.async_create_task(
|
||||
mqtt.async_publish(self.hass, topic, message, retain=True)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Published state '%s' on topic '%s'",
|
||||
message,
|
||||
topic,
|
||||
)
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
self.hass, "alarmo_state_updated", async_alarm_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_handle_event(event: str, area_id: str, args: dict = {}):
|
||||
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
|
||||
return
|
||||
|
||||
topic = self._config[ATTR_MQTT][CONF_EVENT_TOPIC]
|
||||
|
||||
if not topic: # do not publish if no topic is provided
|
||||
return
|
||||
|
||||
if area_id and len(self.hass.data[const.DOMAIN]["areas"]) > 1:
|
||||
# handle the sending of a state update for a specific area
|
||||
area = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
topic = topic.rsplit("/", 1)
|
||||
topic.insert(1, slugify(area.name))
|
||||
topic = "/".join(topic)
|
||||
|
||||
if event == const.EVENT_ARM:
|
||||
payload = {
|
||||
"event": f"{event.upper()}_{args['arm_mode'].split('_', 1).pop(1).upper()}", # noqa: E501
|
||||
"delay": args["delay"],
|
||||
}
|
||||
elif event == const.EVENT_TRIGGER:
|
||||
payload = {
|
||||
"event": event.upper(),
|
||||
"delay": args["delay"],
|
||||
"sensors": [
|
||||
{
|
||||
"entity_id": entity,
|
||||
"name": friendly_name_for_entity_id(entity, self.hass),
|
||||
}
|
||||
for (entity, state) in args["open_sensors"].items()
|
||||
],
|
||||
}
|
||||
elif event == const.EVENT_FAILED_TO_ARM:
|
||||
payload = {
|
||||
"event": event.upper(),
|
||||
"sensors": [
|
||||
{
|
||||
"entity_id": entity,
|
||||
"name": friendly_name_for_entity_id(entity, self.hass),
|
||||
}
|
||||
for (entity, state) in args["open_sensors"].items()
|
||||
],
|
||||
}
|
||||
elif event == const.EVENT_COMMAND_NOT_ALLOWED:
|
||||
payload = {
|
||||
"event": event.upper(),
|
||||
"state": args["state"],
|
||||
"command": args["command"].upper(),
|
||||
}
|
||||
elif event in [
|
||||
const.EVENT_INVALID_CODE_PROVIDED,
|
||||
const.EVENT_NO_CODE_PROVIDED,
|
||||
]:
|
||||
payload = {"event": event.upper()}
|
||||
else:
|
||||
return
|
||||
|
||||
payload = json.dumps(payload, cls=JSONEncoder)
|
||||
hass.async_create_task(mqtt.async_publish(self.hass, topic, payload))
|
||||
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event)
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
"""Prepare for removal."""
|
||||
while len(self._subscribed_topics):
|
||||
self._subscribed_topics.pop()()
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
async def _async_subscribe_topics(self):
|
||||
"""Install a listener for the command topic."""
|
||||
if len(self._subscribed_topics):
|
||||
while len(self._subscribed_topics):
|
||||
self._subscribed_topics.pop()()
|
||||
_LOGGER.debug("Removed subscribed topics")
|
||||
|
||||
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
|
||||
return
|
||||
|
||||
self._subscribed_topics.append(
|
||||
await mqtt.async_subscribe(
|
||||
self.hass,
|
||||
self._config[ATTR_MQTT][CONF_COMMAND_TOPIC],
|
||||
self.async_message_received,
|
||||
)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Subscribed to topic %s",
|
||||
self._config[ATTR_MQTT][CONF_COMMAND_TOPIC],
|
||||
)
|
||||
|
||||
@callback
|
||||
async def async_message_received(self, msg): # noqa: PLR0915, PLR0912
|
||||
"""Handle new MQTT messages."""
|
||||
command = None
|
||||
code = None
|
||||
area = None
|
||||
bypass_open_sensors = False
|
||||
skip_delay = False
|
||||
|
||||
try:
|
||||
payload = json.loads(msg.payload)
|
||||
payload = {k.lower(): v for k, v in payload.items()}
|
||||
|
||||
if "command" in payload:
|
||||
command = payload["command"]
|
||||
elif "cmd" in payload:
|
||||
command = payload["cmd"]
|
||||
elif "action" in payload:
|
||||
command = payload["action"]
|
||||
elif "state" in payload:
|
||||
command = payload["state"]
|
||||
|
||||
if "code" in payload:
|
||||
code = payload["code"]
|
||||
elif "pin" in payload:
|
||||
code = payload["pin"]
|
||||
elif "password" in payload:
|
||||
code = payload["password"]
|
||||
elif "pincode" in payload:
|
||||
code = payload["pincode"]
|
||||
|
||||
if payload.get("area"):
|
||||
area = payload["area"]
|
||||
|
||||
if (payload.get("bypass_open_sensors")) or (payload.get("force")):
|
||||
bypass_open_sensors = payload["bypass_open_sensors"]
|
||||
|
||||
if payload.get(const.ATTR_SKIP_DELAY):
|
||||
skip_delay = payload[const.ATTR_SKIP_DELAY]
|
||||
|
||||
except ValueError:
|
||||
# no JSON structure found
|
||||
command = msg.payload
|
||||
code = None
|
||||
|
||||
if type(command) is str:
|
||||
command = command.lower()
|
||||
else:
|
||||
_LOGGER.warning("Received unexpected command")
|
||||
return
|
||||
|
||||
payload_config = self._config[ATTR_MQTT][const.ATTR_COMMAND_PAYLOAD]
|
||||
skip_code = not self._config[ATTR_MQTT][const.ATTR_REQUIRE_CODE]
|
||||
|
||||
command_payloads = {}
|
||||
for item in const.COMMANDS:
|
||||
if payload_config.get(item):
|
||||
command_payloads[item] = payload_config[item].lower()
|
||||
else:
|
||||
command_payloads[item] = item.lower()
|
||||
|
||||
if command not in list(command_payloads.values()):
|
||||
_LOGGER.warning("Received unexpected command: %s", command)
|
||||
return
|
||||
|
||||
if area:
|
||||
res = list(
|
||||
filter(
|
||||
lambda el: slugify(el.name) == area,
|
||||
self.hass.data[const.DOMAIN]["areas"].values(),
|
||||
)
|
||||
)
|
||||
if not res:
|
||||
_LOGGER.warning(
|
||||
"Area %s does not exist",
|
||||
area,
|
||||
)
|
||||
return
|
||||
entity = res[0]
|
||||
elif (
|
||||
self._config[const.ATTR_MASTER][const.ATTR_ENABLED]
|
||||
and len(self.hass.data[const.DOMAIN]["areas"]) > 1
|
||||
):
|
||||
entity = self.hass.data[const.DOMAIN]["master"]
|
||||
elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
|
||||
entity = next(iter(self.hass.data[const.DOMAIN]["areas"].values()))
|
||||
else:
|
||||
_LOGGER.warning("No area specified")
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Received command %s",
|
||||
command,
|
||||
)
|
||||
|
||||
if command == command_payloads[const.COMMAND_DISARM]:
|
||||
entity.alarm_disarm(code, skip_code=skip_code)
|
||||
elif command == command_payloads[const.COMMAND_ARM_AWAY]:
|
||||
await entity.async_alarm_arm_away(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_NIGHT]:
|
||||
await entity.async_alarm_arm_night(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_HOME]:
|
||||
await entity.async_alarm_arm_home(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_CUSTOM_BYPASS]:
|
||||
await entity.async_alarm_arm_custom_bypass(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
elif command == command_payloads[const.COMMAND_ARM_VACATION]:
|
||||
await entity.async_alarm_arm_vacation(
|
||||
code, skip_code, bypass_open_sensors, skip_delay
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Panel registration for Alarmo integration."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
VERSION,
|
||||
PANEL_URL,
|
||||
PANEL_ICON,
|
||||
PANEL_NAME,
|
||||
PANEL_TITLE,
|
||||
PANEL_FOLDER,
|
||||
PANEL_FILENAME,
|
||||
CUSTOM_COMPONENTS,
|
||||
INTEGRATION_FOLDER,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_register_panel(hass):
|
||||
"""Register the panel."""
|
||||
root_dir = os.path.join(hass.config.path(CUSTOM_COMPONENTS), INTEGRATION_FOLDER)
|
||||
panel_dir = os.path.join(root_dir, PANEL_FOLDER)
|
||||
view_url = os.path.join(panel_dir, PANEL_FILENAME)
|
||||
|
||||
try:
|
||||
cache_bust = int(os.path.getmtime(view_url))
|
||||
except OSError:
|
||||
cache_bust = 0
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(PANEL_URL, view_url, cache_headers=False)]
|
||||
)
|
||||
|
||||
await panel_custom.async_register_panel(
|
||||
hass,
|
||||
webcomponent_name=PANEL_NAME,
|
||||
frontend_url_path=DOMAIN,
|
||||
module_url=f"{PANEL_URL}?v={VERSION}&m={cache_bust}",
|
||||
sidebar_title=PANEL_TITLE,
|
||||
sidebar_icon=PANEL_ICON,
|
||||
require_admin=True,
|
||||
config={},
|
||||
config_panel_domain=DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
def async_unregister_panel(hass):
|
||||
"""Unregister the panel."""
|
||||
frontend.async_remove_panel(hass, DOMAIN)
|
||||
_LOGGER.debug("Removing panel")
|
||||
@@ -0,0 +1,774 @@
|
||||
"""Sensor handling for Alarmo integration."""
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
STATE_ON,
|
||||
ATTR_NAME,
|
||||
STATE_OFF,
|
||||
ATTR_STATE,
|
||||
STATE_OPEN,
|
||||
STATE_CLOSED,
|
||||
STATE_UNKNOWN,
|
||||
STATE_UNAVAILABLE,
|
||||
ATTR_LAST_TRIP_TIME,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.components.lock import LockState
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
)
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
|
||||
|
||||
from . import const
|
||||
|
||||
ATTR_USE_EXIT_DELAY = "use_exit_delay"
|
||||
ATTR_USE_ENTRY_DELAY = "use_entry_delay"
|
||||
ATTR_ALWAYS_ON = "always_on"
|
||||
ATTR_ARM_ON_CLOSE = "arm_on_close"
|
||||
ATTR_ALLOW_OPEN = "allow_open"
|
||||
ATTR_TRIGGER_UNAVAILABLE = "trigger_unavailable"
|
||||
ATTR_AUTO_BYPASS = "auto_bypass"
|
||||
ATTR_AUTO_BYPASS_MODES = "auto_bypass_modes"
|
||||
ATTR_GROUP = "group"
|
||||
ATTR_GROUP_ID = "group_id"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
ATTR_EVENT_COUNT = "event_count"
|
||||
ATTR_ENTITIES = "entities"
|
||||
ATTR_NEW_ENTITY_ID = "new_entity_id"
|
||||
ATTR_ENTRY_DELAY = "entry_delay"
|
||||
ATTR_DELAY_ON = "delay_on"
|
||||
|
||||
SENSOR_STATES_OPEN = [STATE_ON, STATE_OPEN, LockState.UNLOCKED]
|
||||
SENSOR_STATES_CLOSED = [STATE_OFF, STATE_CLOSED, LockState.LOCKED]
|
||||
|
||||
|
||||
SENSOR_TYPE_DOOR = "door"
|
||||
SENSOR_TYPE_WINDOW = "window"
|
||||
SENSOR_TYPE_MOTION = "motion"
|
||||
SENSOR_TYPE_TAMPER = "tamper"
|
||||
SENSOR_TYPE_ENVIRONMENTAL = "environmental"
|
||||
SENSOR_TYPE_OTHER = "other"
|
||||
SENSOR_TYPES = [
|
||||
SENSOR_TYPE_DOOR,
|
||||
SENSOR_TYPE_WINDOW,
|
||||
SENSOR_TYPE_MOTION,
|
||||
SENSOR_TYPE_TAMPER,
|
||||
SENSOR_TYPE_ENVIRONMENTAL,
|
||||
SENSOR_TYPE_OTHER,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_sensor_state(state):
|
||||
"""Parse the state of a sensor into open/closed/unavailable/unknown."""
|
||||
if not state or not state.state:
|
||||
return STATE_UNAVAILABLE
|
||||
elif state.state == STATE_UNAVAILABLE:
|
||||
return STATE_UNAVAILABLE
|
||||
elif state.state in SENSOR_STATES_OPEN:
|
||||
return STATE_OPEN
|
||||
elif state.state in SENSOR_STATES_CLOSED:
|
||||
return STATE_CLOSED
|
||||
else:
|
||||
return STATE_UNKNOWN
|
||||
|
||||
|
||||
def sensor_state_allowed(state, sensor_config, alarm_state): # noqa: PLR0911
|
||||
"""Return whether the sensor state is permitted or a state change should occur."""
|
||||
if state != STATE_OPEN and (
|
||||
state != STATE_UNAVAILABLE or not sensor_config[ATTR_TRIGGER_UNAVAILABLE]
|
||||
):
|
||||
# sensor has the safe state
|
||||
return True
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.TRIGGERED:
|
||||
# alarm is already triggered
|
||||
return True
|
||||
|
||||
elif sensor_config[ATTR_ALWAYS_ON]:
|
||||
# alarm should always be triggered by always-on sensor
|
||||
return False
|
||||
|
||||
elif (
|
||||
alarm_state == AlarmControlPanelState.ARMING
|
||||
and not sensor_config[ATTR_USE_EXIT_DELAY]
|
||||
):
|
||||
# arming should be aborted if sensor without exit delay is active
|
||||
return False
|
||||
|
||||
elif alarm_state in const.ARM_MODES:
|
||||
# normal triggering case
|
||||
return False
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.PENDING:
|
||||
# Allow both immediate and delayed sensors
|
||||
# during pending for timer shortening/immediate trigger
|
||||
# This enables per-sensor entry delay logic
|
||||
# to process subsequent triggers during countdown
|
||||
return False
|
||||
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class SensorHandler:
|
||||
"""Class to handle sensors for Alarmo."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize the sensor handler."""
|
||||
self._config = None
|
||||
self.hass = hass
|
||||
self._state_listener = None
|
||||
self._subscriptions = []
|
||||
self._arm_timers = {}
|
||||
self._delay_on_timers = {}
|
||||
self._groups = {}
|
||||
self._group_events = {}
|
||||
self._startup_complete = False
|
||||
self._unavailable_state_mem = {}
|
||||
|
||||
@callback
|
||||
def async_update_sensor_config():
|
||||
"""Sensor config updated, reload the configuration."""
|
||||
self._config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_sensors()
|
||||
self._groups = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_sensor_groups()
|
||||
self._group_events = {}
|
||||
self.async_watch_sensor_states()
|
||||
|
||||
# Store the callback for later registration
|
||||
self._async_update_sensor_config = async_update_sensor_config
|
||||
|
||||
@callback
|
||||
def _setup_sensor_listeners():
|
||||
"""Register sensor listeners and perform initial setup."""
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_state_updated", self.async_watch_sensor_states
|
||||
)
|
||||
)
|
||||
self._subscriptions.append(
|
||||
async_dispatcher_connect(
|
||||
hass, "alarmo_sensors_updated", self._async_update_sensor_config
|
||||
)
|
||||
)
|
||||
# Do the initial sensor setup now that HA is running
|
||||
self._async_update_sensor_config()
|
||||
|
||||
# Evaluate initial sensor states for all areas on startup
|
||||
for area_id in self.hass.data[const.DOMAIN]["areas"].keys():
|
||||
self.update_ready_to_arm_status(area_id)
|
||||
# If area is armed, validate sensors and trigger if needed
|
||||
# Schedule this to run in the event loop since it may call async methods
|
||||
hass.async_create_task(
|
||||
self._async_evaluate_armed_state_on_startup(area_id)
|
||||
)
|
||||
|
||||
def handle_startup(_event):
|
||||
self._startup_complete = True
|
||||
# Schedule the setup to run in the event loop (from thread pool executor)
|
||||
hass.loop.call_soon_threadsafe(_setup_sensor_listeners)
|
||||
|
||||
if hass.state == CoreState.running:
|
||||
self._startup_complete = True
|
||||
# Schedule in event loop since we're in __init__ (sync context)
|
||||
hass.loop.call_soon_threadsafe(_setup_sensor_listeners)
|
||||
else:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, handle_startup)
|
||||
|
||||
def __del__(self):
|
||||
"""Prepare for removal."""
|
||||
if self._state_listener:
|
||||
self._state_listener()
|
||||
self._state_listener = None
|
||||
while len(self._subscriptions):
|
||||
self._subscriptions.pop()()
|
||||
|
||||
def async_watch_sensor_states(
|
||||
self,
|
||||
area_id: str | None = None,
|
||||
old_state: str | None = None,
|
||||
state: str | None = None,
|
||||
):
|
||||
"""Watch sensors based on the state of the alarm entities."""
|
||||
watched_sensors_list = []
|
||||
for area in self.hass.data[const.DOMAIN]["areas"].keys():
|
||||
watched_sensors_list.extend(
|
||||
self.active_sensors_for_alarm_state(area, None, True)
|
||||
)
|
||||
|
||||
if self._state_listener:
|
||||
self._state_listener()
|
||||
|
||||
if watched_sensors_list:
|
||||
self._state_listener = async_track_state_change_event(
|
||||
self.hass, watched_sensors_list, self.async_sensor_state_changed
|
||||
)
|
||||
else:
|
||||
self._state_listener = None
|
||||
|
||||
# clear previous sensor group events that are not active for current alarm state
|
||||
if self._group_events:
|
||||
active_sensors_list = []
|
||||
for area in self.hass.data[const.DOMAIN]["areas"].keys():
|
||||
active_sensors_list.extend(
|
||||
self.active_sensors_for_alarm_state(area, None, False)
|
||||
)
|
||||
for group_id in self._group_events.keys():
|
||||
self._group_events[group_id] = dict(
|
||||
filter(
|
||||
lambda el: el[0] in active_sensors_list,
|
||||
self._group_events[group_id].items(),
|
||||
)
|
||||
)
|
||||
|
||||
# handle initial sensor states
|
||||
if area_id and old_state is None:
|
||||
sensors_list = self.active_sensors_for_alarm_state(area_id)
|
||||
for entity in sensors_list:
|
||||
state = self.hass.states.get(entity)
|
||||
sensor_state = parse_sensor_state(state)
|
||||
if state and state.state and sensor_state != STATE_UNKNOWN:
|
||||
_LOGGER.debug(
|
||||
"Initial state for %s is %s",
|
||||
entity,
|
||||
parse_sensor_state(state),
|
||||
)
|
||||
|
||||
if area_id:
|
||||
self.update_ready_to_arm_status(area_id)
|
||||
|
||||
def active_sensors_for_alarm_state(
|
||||
self,
|
||||
area_id: str,
|
||||
to_state: str | None = None,
|
||||
watch_ready_to_arm: bool = False,
|
||||
):
|
||||
"""Compose a list of sensors that are active for the state."""
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
|
||||
if to_state:
|
||||
state = to_state
|
||||
else:
|
||||
state = (
|
||||
alarm_entity.arm_mode if alarm_entity.arm_mode else alarm_entity.state
|
||||
)
|
||||
|
||||
entities = []
|
||||
for entity, config in self._config.items():
|
||||
if config["area"] != area_id or not config["enabled"]:
|
||||
continue
|
||||
elif (
|
||||
alarm_entity.bypassed_sensors
|
||||
and entity in alarm_entity.bypassed_sensors
|
||||
):
|
||||
continue
|
||||
elif state in config[const.ATTR_MODES] or config[ATTR_ALWAYS_ON]:
|
||||
entities.append(entity)
|
||||
elif (
|
||||
not to_state
|
||||
and config["type"] != SENSOR_TYPE_MOTION
|
||||
and watch_ready_to_arm
|
||||
):
|
||||
# always watch all sensors other than motion sensors,
|
||||
# to indicate readiness for arming
|
||||
entities.append(entity)
|
||||
|
||||
return entities
|
||||
|
||||
def validate_arming_event(
|
||||
self, area_id: str, target_state: str | None = None, **kwargs
|
||||
):
|
||||
"""Check whether all sensors have the correct state prior to arming."""
|
||||
use_delay = kwargs.get("use_delay", False)
|
||||
bypass_open_sensors = kwargs.get("bypass_open_sensors", False)
|
||||
|
||||
sensors_list = self.active_sensors_for_alarm_state(area_id, target_state)
|
||||
open_sensors = {}
|
||||
bypassed_sensors = []
|
||||
|
||||
alarm_state = target_state
|
||||
if use_delay and alarm_state in const.ARM_MODES:
|
||||
alarm_state = AlarmControlPanelState.ARMING
|
||||
elif use_delay and alarm_state == AlarmControlPanelState.TRIGGERED:
|
||||
alarm_state = AlarmControlPanelState.PENDING
|
||||
|
||||
for entity in sensors_list:
|
||||
sensor_config = self._config[entity]
|
||||
state = self.hass.states.get(entity)
|
||||
sensor_state = parse_sensor_state(state)
|
||||
if not state or not state.state:
|
||||
# entity does not exist in HA
|
||||
res = False
|
||||
else:
|
||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_state)
|
||||
|
||||
if not res and target_state in const.ARM_MODES:
|
||||
# sensor is active while arming
|
||||
if bypass_open_sensors or (
|
||||
sensor_config[ATTR_AUTO_BYPASS]
|
||||
and target_state in sensor_config[ATTR_AUTO_BYPASS_MODES]
|
||||
):
|
||||
# sensor may be bypassed
|
||||
bypassed_sensors.append(entity)
|
||||
elif sensor_config[ATTR_ALLOW_OPEN] and sensor_state == STATE_OPEN:
|
||||
# sensor is permitted to be open during/after arming
|
||||
continue
|
||||
else:
|
||||
open_sensors[entity] = sensor_state
|
||||
|
||||
return (open_sensors, bypassed_sensors)
|
||||
|
||||
def get_entry_delay_for_trigger(
|
||||
self, open_sensors: dict[str, str], area_id: str, arm_mode: str
|
||||
) -> int | None:
|
||||
"""Calculate entry delay based on type of sensor trigger."""
|
||||
# Check if this is a group trigger
|
||||
if ATTR_GROUP_ID in open_sensors:
|
||||
# For groups: only check for immediate triggers, otherwise use area default
|
||||
for entity_id in open_sensors:
|
||||
if entity_id != ATTR_GROUP_ID and entity_id in self._config:
|
||||
sensor_config = self._config[entity_id]
|
||||
if not sensor_config[ATTR_USE_ENTRY_DELAY]:
|
||||
return 0
|
||||
|
||||
# Groups always use area default (maintainer's preference)
|
||||
return None
|
||||
else:
|
||||
# Individual sensor trigger
|
||||
entity_id = next(iter(open_sensors.keys()))
|
||||
sensor_config = self._config[entity_id]
|
||||
|
||||
if not sensor_config[ATTR_USE_ENTRY_DELAY]:
|
||||
return 0
|
||||
|
||||
# Use sensor's entry delay if set
|
||||
if (
|
||||
ATTR_ENTRY_DELAY in sensor_config
|
||||
and sensor_config[ATTR_ENTRY_DELAY] is not None
|
||||
):
|
||||
return sensor_config[ATTR_ENTRY_DELAY]
|
||||
|
||||
# Fall back to area default (None means use area default)
|
||||
return None
|
||||
|
||||
@callback
|
||||
def async_sensor_state_changed(self, event): # noqa: PLR0912
|
||||
"""Callback fired when a sensor state has changed."""
|
||||
entity = event.data["entity_id"]
|
||||
old_state = parse_sensor_state(event.data["old_state"])
|
||||
new_state = parse_sensor_state(event.data["new_state"])
|
||||
sensor_config = self._config[entity]
|
||||
if old_state == STATE_UNKNOWN:
|
||||
# sensor is unknown at startup,
|
||||
# state which comes after is considered as initial state
|
||||
_LOGGER.debug(
|
||||
"Initial state for %s is %s",
|
||||
entity,
|
||||
new_state,
|
||||
)
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
if old_state == new_state:
|
||||
# not a state change - ignore
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"entity %s changed: old_state=%s, new_state=%s",
|
||||
entity,
|
||||
old_state,
|
||||
new_state,
|
||||
)
|
||||
|
||||
# Cancel any pending delay_on timer when sensor turns off
|
||||
if new_state == STATE_CLOSED and entity in self._delay_on_timers:
|
||||
self._stop_entity_timer(self._delay_on_timers, entity)
|
||||
|
||||
if (
|
||||
new_state == STATE_UNAVAILABLE
|
||||
and not sensor_config[ATTR_TRIGGER_UNAVAILABLE]
|
||||
):
|
||||
# temporarily store the prior state until the sensor becomes available again
|
||||
self._unavailable_state_mem[entity] = old_state
|
||||
elif entity in self._unavailable_state_mem:
|
||||
# if sensor was unavailable, check the state before that,
|
||||
# do not act if the sensor reverted to its prior state.
|
||||
prior_state = self._unavailable_state_mem.pop(entity)
|
||||
if old_state == STATE_UNAVAILABLE and prior_state == new_state:
|
||||
_LOGGER.debug(
|
||||
"state transition from %s to %s to %s detected, ignoring.",
|
||||
prior_state,
|
||||
old_state,
|
||||
new_state,
|
||||
)
|
||||
return
|
||||
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
|
||||
alarm_state = alarm_entity.state
|
||||
|
||||
if (
|
||||
alarm_entity.arm_mode
|
||||
and alarm_entity.arm_mode not in sensor_config[const.ATTR_MODES]
|
||||
and not sensor_config[ATTR_ALWAYS_ON]
|
||||
):
|
||||
# sensor is not active in this arm mode, ignore
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
res = sensor_state_allowed(new_state, sensor_config, alarm_state)
|
||||
|
||||
if (
|
||||
sensor_config[ATTR_ARM_ON_CLOSE]
|
||||
and alarm_state == AlarmControlPanelState.ARMING
|
||||
):
|
||||
# we are arming and sensor is configured to arm on closing
|
||||
if new_state == STATE_CLOSED:
|
||||
self.start_arm_timer(entity)
|
||||
else:
|
||||
self.stop_arm_timer(entity)
|
||||
|
||||
if res:
|
||||
# nothing to do here, sensor state is OK
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
# Check if delay_on is configured
|
||||
delay_on = sensor_config.get(ATTR_DELAY_ON) or 0
|
||||
|
||||
if delay_on > 0:
|
||||
# Start delay_on timer instead of immediate trigger
|
||||
if self._start_delay_on_timer(entity, new_state):
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
# No trigger delay or timer failed to start - execute immediately
|
||||
self._execute_sensor_trigger(entity, new_state)
|
||||
|
||||
def _start_entity_timer(
|
||||
self,
|
||||
timers: dict,
|
||||
entity: str,
|
||||
delay: datetime.timedelta,
|
||||
timer_callback,
|
||||
) -> None:
|
||||
"""Start a timer for an entity, cancelling any existing timer first."""
|
||||
self._stop_entity_timer(timers, entity)
|
||||
|
||||
@callback
|
||||
def timer_finished(_now):
|
||||
timers.pop(entity, None)
|
||||
timer_callback()
|
||||
|
||||
timers[entity] = async_track_point_in_time(
|
||||
self.hass, timer_finished, dt_util.utcnow() + delay
|
||||
)
|
||||
|
||||
def _stop_entity_timer(self, timers: dict, entity: str | None = None) -> None:
|
||||
"""Cancel timer(s) for entities."""
|
||||
if entity:
|
||||
if cancel := timers.pop(entity, None):
|
||||
cancel()
|
||||
else:
|
||||
for cancel in timers.values():
|
||||
cancel()
|
||||
timers.clear()
|
||||
|
||||
def start_arm_timer(self, entity):
|
||||
"""Start timer for automatical arming."""
|
||||
|
||||
def on_arm_timer_finished():
|
||||
_LOGGER.debug("arm timer finished")
|
||||
sensor_config = self._config[entity]
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
|
||||
if alarm_entity.state == AlarmControlPanelState.ARMING:
|
||||
alarm_entity.async_arm(alarm_entity.arm_mode, skip_delay=True)
|
||||
|
||||
self._start_entity_timer(
|
||||
self._arm_timers, entity, const.SENSOR_ARM_TIME, on_arm_timer_finished
|
||||
)
|
||||
|
||||
def stop_arm_timer(self, entity=None):
|
||||
"""Cancel timer(s) for automatical arming."""
|
||||
self._stop_entity_timer(self._arm_timers, entity)
|
||||
|
||||
def _start_delay_on_timer(self, entity: str, new_state: str):
|
||||
"""Start timer for delayed sensor trigger."""
|
||||
sensor_config = self._config[entity]
|
||||
delay = sensor_config.get(ATTR_DELAY_ON) or 0
|
||||
|
||||
if delay <= 0:
|
||||
return False
|
||||
|
||||
def on_delay_on_finished():
|
||||
"""Handle delay_on timer expiration."""
|
||||
# Re-check if sensor is still in violation state
|
||||
current_state = self.hass.states.get(entity)
|
||||
current_sensor_state = parse_sensor_state(current_state)
|
||||
if current_sensor_state != STATE_OPEN and not (
|
||||
current_sensor_state == STATE_UNAVAILABLE
|
||||
and sensor_config[ATTR_TRIGGER_UNAVAILABLE]
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"delay_on finished for %s but sensor no longer in violation",
|
||||
entity,
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"delay_on finished for %s, proceeding with trigger",
|
||||
entity,
|
||||
)
|
||||
self._execute_sensor_trigger(entity, new_state)
|
||||
|
||||
self._start_entity_timer(
|
||||
self._delay_on_timers,
|
||||
entity,
|
||||
datetime.timedelta(seconds=delay),
|
||||
on_delay_on_finished,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Started delay_on timer for %s (%s seconds)",
|
||||
entity,
|
||||
delay,
|
||||
)
|
||||
return True
|
||||
|
||||
def _execute_sensor_trigger(self, entity: str, new_state: str):
|
||||
"""Execute sensor trigger logic (called directly or after trigger delay)."""
|
||||
sensor_config = self._config[entity]
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
|
||||
alarm_state = alarm_entity.state
|
||||
|
||||
open_sensors = self.process_group_event(entity, new_state)
|
||||
if not open_sensors:
|
||||
# triggered sensor is part of a group and should be ignored
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
|
||||
if sensor_config[ATTR_ALWAYS_ON]:
|
||||
# immediate trigger due to always on sensor
|
||||
_LOGGER.info(
|
||||
"Alarm is triggered due to an always-on sensor: %s",
|
||||
entity,
|
||||
)
|
||||
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.ARMING:
|
||||
# sensor triggered while arming, abort arming
|
||||
_LOGGER.debug(
|
||||
"Arming was aborted due to a sensor being active: %s",
|
||||
entity,
|
||||
)
|
||||
alarm_entity.async_arm_failure(open_sensors)
|
||||
|
||||
elif alarm_state in const.ARM_MODES:
|
||||
# standard alarm trigger - calculate entry delay override
|
||||
_LOGGER.info(
|
||||
"Alarm is triggered due to sensor: %s",
|
||||
entity,
|
||||
)
|
||||
entry_delay = self.get_entry_delay_for_trigger(
|
||||
open_sensors, sensor_config["area"], alarm_entity.arm_mode
|
||||
)
|
||||
# remove group_id from open_sensors (only used for entry delay calculation)
|
||||
open_sensors.pop(ATTR_GROUP_ID, None)
|
||||
if entry_delay == 0:
|
||||
# immediate trigger (no entry delay)
|
||||
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
|
||||
else:
|
||||
# use calculated delay (could be None for area default)
|
||||
alarm_entity.async_trigger(
|
||||
entry_delay=entry_delay, open_sensors=open_sensors
|
||||
)
|
||||
|
||||
elif alarm_state == AlarmControlPanelState.PENDING:
|
||||
# trigger while in pending state
|
||||
# calculate entry delay for possible timer shortening
|
||||
_LOGGER.info(
|
||||
"Alarm is triggered due to sensor: %s",
|
||||
entity,
|
||||
)
|
||||
entry_delay = self.get_entry_delay_for_trigger(
|
||||
open_sensors, sensor_config["area"], alarm_entity.arm_mode
|
||||
)
|
||||
# remove group_id from open_sensors (only used for entry delay calculation)
|
||||
open_sensors.pop(ATTR_GROUP_ID, None)
|
||||
|
||||
if entry_delay == 0:
|
||||
# immediate trigger
|
||||
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
|
||||
else:
|
||||
# use calculated delay for possible timer shortening
|
||||
alarm_entity.async_trigger(
|
||||
entry_delay=entry_delay, open_sensors=open_sensors
|
||||
)
|
||||
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
|
||||
def process_group_event(self, entity: str, state: str) -> dict:
|
||||
"""Check if sensor entity is member of a group to evaluate trigger."""
|
||||
group_id = None
|
||||
for group in self._groups.values():
|
||||
if entity in group[ATTR_ENTITIES]:
|
||||
group_id = group[ATTR_GROUP_ID]
|
||||
break
|
||||
|
||||
open_sensors = {entity: state}
|
||||
if group_id is None:
|
||||
return open_sensors
|
||||
|
||||
group = self._groups[group_id]
|
||||
group_events = (
|
||||
self._group_events[group_id]
|
||||
if group_id in self._group_events.keys()
|
||||
else {}
|
||||
)
|
||||
now = dt_util.now()
|
||||
group_events[entity] = {ATTR_STATE: state, ATTR_LAST_TRIP_TIME: now}
|
||||
self._group_events[group_id] = group_events
|
||||
recent_events = {
|
||||
entity: (now - event[ATTR_LAST_TRIP_TIME]).total_seconds()
|
||||
for (entity, event) in group_events.items()
|
||||
}
|
||||
recent_events = dict(
|
||||
filter(lambda el: el[1] <= group[ATTR_TIMEOUT], recent_events.items())
|
||||
)
|
||||
if len(recent_events.keys()) < group[ATTR_EVENT_COUNT]:
|
||||
_LOGGER.debug(
|
||||
"tripped sensor %s was ignored since it belongs to group %s",
|
||||
entity,
|
||||
group[ATTR_NAME],
|
||||
)
|
||||
return {}
|
||||
else:
|
||||
# add all (recently) triggered sensors to open_sensors
|
||||
for entity_id in recent_events.keys():
|
||||
open_sensors[entity_id] = group_events[entity_id][ATTR_STATE]
|
||||
|
||||
# Add group info for override delay calculation
|
||||
open_sensors[ATTR_GROUP_ID] = group_id
|
||||
_LOGGER.debug(
|
||||
"tripped sensor %s caused the triggering of group %s",
|
||||
entity,
|
||||
group[ATTR_NAME],
|
||||
)
|
||||
return open_sensors
|
||||
|
||||
def update_ready_to_arm_status(self, area_id):
|
||||
"""Calculate whether the system is ready for arming."""
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
|
||||
arm_modes = [
|
||||
mode
|
||||
for (mode, config) in alarm_entity._config[const.ATTR_MODES].items()
|
||||
if config[const.ATTR_ENABLED]
|
||||
]
|
||||
|
||||
if alarm_entity.state in const.ARM_MODES or (
|
||||
alarm_entity.state == AlarmControlPanelState.ARMING
|
||||
and alarm_entity.arm_mode
|
||||
):
|
||||
arm_modes.remove(alarm_entity.arm_mode)
|
||||
|
||||
def arm_mode_is_ready(mode):
|
||||
(blocking_sensors, _bypassed_sensors) = self.validate_arming_event(
|
||||
area_id, mode
|
||||
)
|
||||
if alarm_entity.state == AlarmControlPanelState.DISARMED:
|
||||
# exclude motion sensors when determining readiness
|
||||
blocking_sensors = dict(
|
||||
filter(
|
||||
lambda el: self._config[el[0]]["type"] != SENSOR_TYPE_MOTION,
|
||||
blocking_sensors.items(),
|
||||
)
|
||||
)
|
||||
result = not (blocking_sensors)
|
||||
return result
|
||||
|
||||
arm_modes = list(filter(arm_mode_is_ready, arm_modes))
|
||||
prev_arm_modes = alarm_entity._ready_to_arm_modes
|
||||
|
||||
if arm_modes != prev_arm_modes:
|
||||
alarm_entity.update_ready_to_arm_modes(arm_modes)
|
||||
|
||||
async def _async_evaluate_armed_state_on_startup(self, area_id):
|
||||
"""Evaluate sensors when alarm is armed on startup and trigger if necessary.
|
||||
|
||||
On startup, we don't know the actual previous state of sensors
|
||||
(they might have changed while HA was down).
|
||||
This method simulates state changes for all sensors currently in violation,
|
||||
allowing the standard async_sensor_state_changed logic to re-evaluate them
|
||||
with full group logic, entry delays, etc.
|
||||
"""
|
||||
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
|
||||
|
||||
# Only evaluate if the alarm is in an armed state
|
||||
if alarm_entity.state not in const.ARM_MODES:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Evaluating sensors on startup for area %s (state: %s)",
|
||||
area_id,
|
||||
alarm_entity.state,
|
||||
)
|
||||
|
||||
# Get all active sensors for the current armed mode
|
||||
sensors_list = self.active_sensors_for_alarm_state(area_id)
|
||||
|
||||
for entity_id in sensors_list:
|
||||
sensor_config = self._config[entity_id]
|
||||
state = self.hass.states.get(entity_id)
|
||||
sensor_state = parse_sensor_state(state)
|
||||
|
||||
if sensor_state == STATE_UNKNOWN:
|
||||
# Skip unknown sensors - they'll be handled when they become known
|
||||
continue
|
||||
|
||||
# Check if sensor state is allowed in current alarm state
|
||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
||||
|
||||
if not res:
|
||||
# Sensor is in a violation state
|
||||
# (open or unavailable when it shouldn't be)
|
||||
# Simulate a state change to trigger standard processing
|
||||
_LOGGER.info(
|
||||
"Sensor %s is %s on startup while alarm is %s - simulating state change for evaluation", # noqa: E501
|
||||
entity_id,
|
||||
sensor_state,
|
||||
alarm_entity.state,
|
||||
)
|
||||
|
||||
# Create a synthetic event that mimics
|
||||
# a state change from closed to current state
|
||||
# We use STATE_CLOSED as old state
|
||||
# (not STATE_UNKNOWN which would trigger early return)
|
||||
old_state = SimpleNamespace(state=STATE_CLOSED)
|
||||
|
||||
# Create event with the structure expected by async_sensor_state_changed
|
||||
event = SimpleNamespace(
|
||||
data={
|
||||
"entity_id": entity_id,
|
||||
"old_state": old_state,
|
||||
"new_state": state,
|
||||
}
|
||||
)
|
||||
|
||||
# Process through the standard sensor state change handler
|
||||
# This will handle groups, entry delays, always-on sensors, etc.
|
||||
self.async_sensor_state_changed(event)
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
arm:
|
||||
fields:
|
||||
entity_id:
|
||||
example: "alarm_control_panel.alarm"
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: alarmo
|
||||
domain: alarm_control_panel
|
||||
code:
|
||||
example: "1234"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
mode:
|
||||
example: "away"
|
||||
required: false
|
||||
default: away
|
||||
selector:
|
||||
select:
|
||||
translation_key: "arm_mode"
|
||||
options:
|
||||
- away
|
||||
- night
|
||||
- home
|
||||
- vacation
|
||||
- custom
|
||||
skip_delay:
|
||||
example: false
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
force:
|
||||
example: false
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
disarm:
|
||||
fields:
|
||||
entity_id:
|
||||
example: "alarm_control_panel.alarm"
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: alarmo
|
||||
domain: alarm_control_panel
|
||||
code:
|
||||
example: "1234"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
skip_delay:
|
||||
fields:
|
||||
entity_id:
|
||||
example: "alarm_control_panel.alarm"
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: alarmo
|
||||
domain: alarm_control_panel
|
||||
enable_user:
|
||||
fields:
|
||||
name:
|
||||
example: "Frank"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
disable_user:
|
||||
fields:
|
||||
name:
|
||||
example: "Frank"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,722 @@
|
||||
"""Storage handler for Alarmo integration."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import cast
|
||||
from collections import OrderedDict
|
||||
from collections.abc import MutableMapping
|
||||
|
||||
import attr
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.components.alarm_control_panel import CodeFormat
|
||||
|
||||
from . import const
|
||||
from .helpers import omit
|
||||
from .sensors import (
|
||||
SENSOR_TYPE_OTHER,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_REGISTRY = f"{const.DOMAIN}_storage"
|
||||
STORAGE_KEY = f"{const.DOMAIN}.storage"
|
||||
STORAGE_VERSION_MAJOR = 6
|
||||
STORAGE_VERSION_MINOR = 3
|
||||
SAVE_DELAY = 10
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class ModeEntry:
|
||||
"""Mode storage Entry."""
|
||||
|
||||
enabled = attr.ib(type=bool, default=False)
|
||||
exit_time = attr.ib(type=int, default=None)
|
||||
entry_time = attr.ib(type=int, default=None)
|
||||
trigger_time = attr.ib(type=int, default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class MqttConfig:
|
||||
"""MQTT storage Entry."""
|
||||
|
||||
enabled = attr.ib(type=bool, default=False)
|
||||
state_topic = attr.ib(type=str, default="alarmo/state")
|
||||
state_payload = attr.ib(type=dict, default={})
|
||||
command_topic = attr.ib(type=str, default="alarmo/command")
|
||||
command_payload = attr.ib(type=dict, default={})
|
||||
require_code = attr.ib(type=bool, default=True)
|
||||
event_topic = attr.ib(type=str, default="alarmo/event")
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class MasterConfig:
|
||||
"""Master storage Entry."""
|
||||
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
name = attr.ib(type=str, default="master")
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class AreaEntry:
|
||||
"""Area storage Entry."""
|
||||
|
||||
area_id = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default=None)
|
||||
modes = attr.ib(
|
||||
type=[str, ModeEntry],
|
||||
default={
|
||||
const.CONF_ALARM_ARMED_AWAY: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_HOME: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_NIGHT: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_CUSTOM_BYPASS: ModeEntry(),
|
||||
const.CONF_ALARM_ARMED_VACATION: ModeEntry(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class Config:
|
||||
"""(General) Config storage Entry."""
|
||||
|
||||
code_arm_required = attr.ib(type=bool, default=False)
|
||||
code_mode_change_required = attr.ib(type=bool, default=False)
|
||||
code_disarm_required = attr.ib(type=bool, default=False)
|
||||
code_format = attr.ib(type=str, default=CodeFormat.NUMBER)
|
||||
disarm_after_trigger = attr.ib(type=bool, default=False)
|
||||
ignore_blocking_sensors_after_trigger = attr.ib(type=bool, default=False)
|
||||
master = attr.ib(type=MasterConfig, default=MasterConfig())
|
||||
mqtt = attr.ib(type=MqttConfig, default=MqttConfig())
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class SensorEntry:
|
||||
"""Sensor storage Entry."""
|
||||
|
||||
entity_id = attr.ib(type=str, default=None)
|
||||
type = attr.ib(type=str, default=SENSOR_TYPE_OTHER)
|
||||
modes = attr.ib(type=list, default=[])
|
||||
use_exit_delay = attr.ib(type=bool, default=True)
|
||||
use_entry_delay = attr.ib(type=bool, default=True)
|
||||
always_on = attr.ib(type=bool, default=False)
|
||||
arm_on_close = attr.ib(type=bool, default=False)
|
||||
allow_open = attr.ib(type=bool, default=False)
|
||||
trigger_unavailable = attr.ib(type=bool, default=False)
|
||||
auto_bypass = attr.ib(type=bool, default=False)
|
||||
auto_bypass_modes = attr.ib(type=list, default=[])
|
||||
area = attr.ib(type=str, default=None)
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
entry_delay = attr.ib(type=int, default=None)
|
||||
delay_on = attr.ib(type=int, default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class UserEntry:
|
||||
"""User storage Entry."""
|
||||
|
||||
user_id = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default="")
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
code = attr.ib(type=str, default="")
|
||||
can_arm = attr.ib(type=bool, default=False)
|
||||
can_disarm = attr.ib(type=bool, default=False)
|
||||
is_override_code = attr.ib(type=bool, default=False)
|
||||
code_format = attr.ib(type=str, default="")
|
||||
code_length = attr.ib(type=int, default=0)
|
||||
area_limit = attr.ib(type=list, default=[])
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class AlarmoTriggerEntry:
|
||||
"""Trigger storage Entry."""
|
||||
|
||||
event = attr.ib(type=str, default="")
|
||||
area = attr.ib(type=str, default=None)
|
||||
modes = attr.ib(type=list, default=[])
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class EntityTriggerEntry:
|
||||
"""Trigger storage Entry."""
|
||||
|
||||
entity_id = attr.ib(type=str, default=None)
|
||||
state = attr.ib(type=str, default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class ActionEntry:
|
||||
"""Action storage Entry."""
|
||||
|
||||
service = attr.ib(type=str, default="")
|
||||
entity_id = attr.ib(type=str, default=None)
|
||||
data = attr.ib(type=dict, default={})
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class AutomationEntry:
|
||||
"""Automation storage Entry."""
|
||||
|
||||
automation_id = attr.ib(type=str, default=None)
|
||||
type = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default="")
|
||||
triggers = attr.ib(type=[AlarmoTriggerEntry], default=[])
|
||||
actions = attr.ib(type=[ActionEntry], default=[])
|
||||
enabled = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class SensorGroupEntry:
|
||||
"""Sensor group storage Entry."""
|
||||
|
||||
group_id = attr.ib(type=str, default=None)
|
||||
name = attr.ib(type=str, default="")
|
||||
entities = attr.ib(type=list, default=[])
|
||||
timeout = attr.ib(type=int, default=0)
|
||||
event_count = attr.ib(type=int, default=2)
|
||||
|
||||
|
||||
def parse_automation_entry(data: dict):
|
||||
"""Parse automation entry from dict to proper types."""
|
||||
|
||||
def create_trigger_entity(config: dict):
|
||||
if "event" in config:
|
||||
return AlarmoTriggerEntry(**config)
|
||||
else:
|
||||
return EntityTriggerEntry(**config)
|
||||
|
||||
output = {}
|
||||
if "triggers" in data:
|
||||
output["triggers"] = list(map(create_trigger_entity, data["triggers"]))
|
||||
if "actions" in data:
|
||||
output["actions"] = list(map(lambda el: ActionEntry(**el), data["actions"]))
|
||||
if "automation_id" in data:
|
||||
output["automation_id"] = data["automation_id"]
|
||||
if "name" in data:
|
||||
output["name"] = data["name"]
|
||||
if "type" in data:
|
||||
output["type"] = data["type"]
|
||||
if "enabled" in data:
|
||||
output["enabled"] = data["enabled"]
|
||||
return output
|
||||
|
||||
|
||||
class MigratableStore(Store):
|
||||
"""Storage class that can migrate data between versions."""
|
||||
|
||||
async def _async_migrate_func(
|
||||
self, old_major_version: int, old_minor_version: int, data: dict
|
||||
):
|
||||
def migrate_automation(data):
|
||||
if old_major_version <= 2:
|
||||
data["triggers"] = [
|
||||
{
|
||||
"event": el["state"] if "state" in el else el["event"],
|
||||
"area": el.get("area"),
|
||||
"modes": data["modes"],
|
||||
}
|
||||
for el in data["triggers"]
|
||||
]
|
||||
|
||||
data["type"] = (
|
||||
"notification" if data.get("is_notification") else "action"
|
||||
)
|
||||
|
||||
if old_major_version <= 5:
|
||||
data["actions"] = [
|
||||
{
|
||||
"service": el.get("service"),
|
||||
"entity_id": el.get("entity_id"),
|
||||
"data": el.get("service_data"),
|
||||
}
|
||||
for el in data["actions"]
|
||||
]
|
||||
|
||||
return attr.asdict(AutomationEntry(**parse_automation_entry(data)))
|
||||
|
||||
if old_major_version == 1:
|
||||
area_id = str(int(time.time()))
|
||||
data["areas"] = [
|
||||
attr.asdict(
|
||||
AreaEntry(
|
||||
**{
|
||||
"name": "Alarmo",
|
||||
"modes": {
|
||||
mode: attr.asdict(
|
||||
ModeEntry(
|
||||
enabled=bool(config["enabled"]),
|
||||
exit_time=int(config["leave_time"] or 0),
|
||||
entry_time=int(config["entry_time"] or 0),
|
||||
trigger_time=int(
|
||||
data["config"]["trigger_time"] or 0
|
||||
),
|
||||
)
|
||||
)
|
||||
for (mode, config) in data["config"]["modes"].items()
|
||||
},
|
||||
},
|
||||
area_id=area_id,
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
if "sensors" in data:
|
||||
for sensor in data["sensors"]:
|
||||
sensor["area"] = area_id
|
||||
|
||||
if old_major_version <= 3:
|
||||
data["sensors"] = [
|
||||
attr.asdict(
|
||||
SensorEntry(
|
||||
**{
|
||||
**omit(sensor, ["immediate", "name"]),
|
||||
"use_exit_delay": not sensor["immediate"]
|
||||
and not sensor["always_on"],
|
||||
"use_entry_delay": not sensor["immediate"]
|
||||
and not sensor["always_on"],
|
||||
"auto_bypass_modes": sensor["modes"]
|
||||
if sensor.get("auto_bypass")
|
||||
else [],
|
||||
}
|
||||
)
|
||||
)
|
||||
for sensor in data["sensors"]
|
||||
]
|
||||
|
||||
if old_major_version <= 4:
|
||||
data["sensors"] = [
|
||||
attr.asdict(
|
||||
SensorEntry(
|
||||
**omit(sensor, ["name"]),
|
||||
)
|
||||
)
|
||||
for sensor in data["sensors"]
|
||||
]
|
||||
|
||||
data["automations"] = [
|
||||
migrate_automation(automation) for automation in data["automations"]
|
||||
]
|
||||
|
||||
if old_major_version <= 5 or (old_major_version == 6 and old_minor_version < 2):
|
||||
data["config"] = attr.asdict(
|
||||
Config(
|
||||
**omit(data["config"], ["code_mode_change_required"]),
|
||||
code_mode_change_required=data["config"]["code_arm_required"],
|
||||
)
|
||||
)
|
||||
|
||||
if old_major_version <= 5 or (old_major_version == 6 and old_minor_version < 3):
|
||||
data["sensor_groups"] = [
|
||||
attr.asdict(
|
||||
SensorGroupEntry(
|
||||
**{
|
||||
**omit(sensorGroup, ["entities"]),
|
||||
"entities": list(set(sensorGroup["entities"])),
|
||||
}
|
||||
)
|
||||
)
|
||||
for sensorGroup in data["sensor_groups"]
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class AlarmoStorage:
|
||||
"""Class to hold alarmo configuration data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the storage."""
|
||||
self.hass = hass
|
||||
self.config: Config = Config()
|
||||
self.areas: MutableMapping[str, AreaEntry] = {}
|
||||
self.sensors: MutableMapping[str, SensorEntry] = {}
|
||||
self.users: MutableMapping[str, UserEntry] = {}
|
||||
self.automations: MutableMapping[str, AutomationEntry] = {}
|
||||
self.sensor_groups: MutableMapping[str, SensorGroupEntry] = {}
|
||||
self._store = MigratableStore(
|
||||
hass,
|
||||
STORAGE_VERSION_MAJOR,
|
||||
STORAGE_KEY,
|
||||
minor_version=STORAGE_VERSION_MINOR,
|
||||
)
|
||||
|
||||
async def async_load(self) -> None: # noqa: PLR0912
|
||||
"""Load the registry of schedule entries."""
|
||||
data = await self._store.async_load()
|
||||
config: Config = Config()
|
||||
areas: OrderedDict[str, AreaEntry] = OrderedDict()
|
||||
sensors: OrderedDict[str, SensorEntry] = OrderedDict()
|
||||
users: OrderedDict[str, UserEntry] = OrderedDict()
|
||||
automations: OrderedDict[str, AutomationEntry] = OrderedDict()
|
||||
sensor_groups: OrderedDict[str, SensorGroupEntry] = OrderedDict()
|
||||
|
||||
if data is not None:
|
||||
config = Config(
|
||||
code_arm_required=data["config"]["code_arm_required"],
|
||||
code_mode_change_required=data["config"]["code_mode_change_required"],
|
||||
code_disarm_required=data["config"]["code_disarm_required"],
|
||||
code_format=data["config"]["code_format"],
|
||||
disarm_after_trigger=data["config"]["disarm_after_trigger"],
|
||||
ignore_blocking_sensors_after_trigger=data["config"].get(
|
||||
"ignore_blocking_sensors_after_trigger", False
|
||||
),
|
||||
)
|
||||
|
||||
if "mqtt" in data["config"]:
|
||||
config = attr.evolve(
|
||||
config,
|
||||
**{
|
||||
"mqtt": MqttConfig(**data["config"]["mqtt"]),
|
||||
},
|
||||
)
|
||||
|
||||
if "master" in data["config"]:
|
||||
config = attr.evolve(
|
||||
config,
|
||||
**{
|
||||
"master": MasterConfig(**data["config"]["master"]),
|
||||
},
|
||||
)
|
||||
|
||||
if "areas" in data:
|
||||
for area in data["areas"]:
|
||||
modes = {
|
||||
mode: ModeEntry(
|
||||
enabled=config["enabled"],
|
||||
exit_time=config["exit_time"],
|
||||
entry_time=config["entry_time"],
|
||||
trigger_time=config["trigger_time"],
|
||||
)
|
||||
for (mode, config) in area["modes"].items()
|
||||
}
|
||||
areas[area["area_id"]] = AreaEntry(
|
||||
area_id=area["area_id"], name=area["name"], modes=modes
|
||||
)
|
||||
|
||||
if "sensors" in data:
|
||||
for sensor in data["sensors"]:
|
||||
sensors[sensor["entity_id"]] = SensorEntry(**sensor)
|
||||
|
||||
if "users" in data:
|
||||
for user in data["users"]:
|
||||
users[user["user_id"]] = UserEntry(**omit(user, ["is_admin"]))
|
||||
|
||||
if "automations" in data:
|
||||
for automation in data["automations"]:
|
||||
automations[automation["automation_id"]] = AutomationEntry(
|
||||
**parse_automation_entry(automation)
|
||||
)
|
||||
|
||||
if "sensor_groups" in data:
|
||||
for group in data["sensor_groups"]:
|
||||
sensor_groups[group["group_id"]] = SensorGroupEntry(**group)
|
||||
|
||||
self.config = config
|
||||
self.areas = areas
|
||||
self.sensors = sensors
|
||||
self.automations = automations
|
||||
self.users = users
|
||||
self.sensor_groups = sensor_groups
|
||||
|
||||
if not areas:
|
||||
await self.async_factory_default()
|
||||
|
||||
async def async_factory_default(self):
|
||||
"""Reset to factory default configuration."""
|
||||
self.async_create_area(
|
||||
{
|
||||
"name": "Alarmo",
|
||||
"modes": {
|
||||
const.CONF_ALARM_ARMED_AWAY: attr.asdict(
|
||||
ModeEntry(
|
||||
enabled=True, exit_time=60, entry_time=60, trigger_time=1800
|
||||
)
|
||||
),
|
||||
const.CONF_ALARM_ARMED_HOME: attr.asdict(
|
||||
ModeEntry(enabled=True, trigger_time=1800)
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_schedule_save(self) -> None:
|
||||
"""Schedule saving the registry of alarmo."""
|
||||
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
|
||||
|
||||
async def async_save(self) -> None:
|
||||
"""Save the registry of alarmo."""
|
||||
await self._store.async_save(self._data_to_save())
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> dict:
|
||||
"""Return data for the registry for alarmo to store in a file."""
|
||||
store_data = {
|
||||
"config": attr.asdict(self.config),
|
||||
}
|
||||
|
||||
store_data["areas"] = [attr.asdict(entry) for entry in self.areas.values()]
|
||||
store_data["sensors"] = [attr.asdict(entry) for entry in self.sensors.values()]
|
||||
store_data["users"] = [attr.asdict(entry) for entry in self.users.values()]
|
||||
store_data["automations"] = [
|
||||
attr.asdict(entry) for entry in self.automations.values()
|
||||
]
|
||||
store_data["sensor_groups"] = [
|
||||
attr.asdict(entry) for entry in self.sensor_groups.values()
|
||||
]
|
||||
|
||||
return store_data
|
||||
|
||||
async def async_delete(self):
|
||||
"""Delete config."""
|
||||
_LOGGER.warning("Removing alarmo configuration data!")
|
||||
await self._store.async_remove()
|
||||
self.config = Config()
|
||||
self.areas = {}
|
||||
self.sensors = {}
|
||||
self.users = {}
|
||||
self.automations = {}
|
||||
self.sensor_groups = {}
|
||||
await self.async_factory_default()
|
||||
|
||||
@callback
|
||||
def async_get_config(self):
|
||||
"""Get current config."""
|
||||
return attr.asdict(self.config)
|
||||
|
||||
@callback
|
||||
def async_update_config(self, changes: dict):
|
||||
"""Update existing config."""
|
||||
old = self.config
|
||||
new = self.config = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return attr.asdict(new)
|
||||
|
||||
@callback
|
||||
def async_update_mode_config(self, mode: str, changes: dict):
|
||||
"""Update existing config."""
|
||||
modes = self.config.modes
|
||||
old = self.config.modes[mode] if mode in self.config.modes else ModeEntry()
|
||||
new = attr.evolve(old, **changes)
|
||||
modes[mode] = new
|
||||
self.config = attr.evolve(self.config, **{"modes": modes})
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_area(self, area_id) -> AreaEntry:
|
||||
"""Get an existing AreaEntry by id."""
|
||||
res = self.areas.get(area_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_areas(self):
|
||||
"""Get an existing AreaEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.areas.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_area(self, data: dict) -> AreaEntry:
|
||||
"""Create a new AreaEntry."""
|
||||
area_id = str(int(time.time()))
|
||||
new_area = AreaEntry(**data, area_id=area_id)
|
||||
self.areas[area_id] = new_area
|
||||
self.async_schedule_save()
|
||||
return attr.asdict(new_area)
|
||||
|
||||
@callback
|
||||
def async_delete_area(self, area_id: str) -> None:
|
||||
"""Delete AreaEntry."""
|
||||
if area_id in self.areas:
|
||||
del self.areas[area_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_area(self, area_id: str, changes: dict) -> AreaEntry:
|
||||
"""Update existing self."""
|
||||
old = self.areas[area_id]
|
||||
new = self.areas[area_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return attr.asdict(new)
|
||||
|
||||
@callback
|
||||
def async_get_sensor(self, entity_id) -> SensorEntry:
|
||||
"""Get an existing SensorEntry by id."""
|
||||
res = self.sensors.get(entity_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_sensors(self):
|
||||
"""Get an existing SensorEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.sensors.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_sensor(self, entity_id: str, data: dict) -> SensorEntry:
|
||||
"""Create a new SensorEntry."""
|
||||
if entity_id in self.sensors:
|
||||
return False
|
||||
new_sensor = SensorEntry(**data, entity_id=entity_id)
|
||||
self.sensors[entity_id] = new_sensor
|
||||
self.async_schedule_save()
|
||||
return new_sensor
|
||||
|
||||
@callback
|
||||
def async_delete_sensor(self, entity_id: str) -> None:
|
||||
"""Delete SensorEntry."""
|
||||
if entity_id in self.sensors:
|
||||
del self.sensors[entity_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_sensor(self, entity_id: str, changes: dict) -> SensorEntry:
|
||||
"""Update existing SensorEntry."""
|
||||
old = self.sensors[entity_id]
|
||||
new = self.sensors[entity_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_user(self, user_id) -> UserEntry:
|
||||
"""Get an existing UserEntry by id."""
|
||||
res = self.users.get(user_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_users(self):
|
||||
"""Get an existing UserEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.users.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_user(self, data: dict) -> UserEntry:
|
||||
"""Create a new UserEntry."""
|
||||
user_id = str(int(time.time()))
|
||||
new_user = UserEntry(**data, user_id=user_id)
|
||||
self.users[user_id] = new_user
|
||||
self.async_schedule_save()
|
||||
return new_user
|
||||
|
||||
@callback
|
||||
def async_delete_user(self, user_id: str) -> None:
|
||||
"""Delete UserEntry."""
|
||||
if user_id in self.users:
|
||||
del self.users[user_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_user(self, user_id: str, changes: dict) -> UserEntry:
|
||||
"""Update existing UserEntry."""
|
||||
old = self.users[user_id]
|
||||
new = self.users[user_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_automations(self):
|
||||
"""Get an existing AutomationEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.automations.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_automation(self, data: dict) -> AutomationEntry:
|
||||
"""Create a new AutomationEntry."""
|
||||
automation_id = str(int(time.time()))
|
||||
new_automation = AutomationEntry(
|
||||
**parse_automation_entry(data), automation_id=automation_id
|
||||
)
|
||||
self.automations[automation_id] = new_automation
|
||||
self.async_schedule_save()
|
||||
return new_automation
|
||||
|
||||
@callback
|
||||
def async_delete_automation(self, automation_id: str) -> None:
|
||||
"""Delete AutomationEntry."""
|
||||
if automation_id in self.automations:
|
||||
del self.automations[automation_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_automation(
|
||||
self, automation_id: str, changes: dict
|
||||
) -> AutomationEntry:
|
||||
"""Update existing AutomationEntry."""
|
||||
old = self.automations[automation_id]
|
||||
new = self.automations[automation_id] = attr.evolve(
|
||||
old, **parse_automation_entry(changes)
|
||||
)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_get_sensor_group(self, group_id) -> SensorGroupEntry:
|
||||
"""Get an existing SensorGroupEntry by id."""
|
||||
res = self.sensor_groups.get(group_id)
|
||||
return attr.asdict(res) if res else None
|
||||
|
||||
@callback
|
||||
def async_get_sensor_groups(self):
|
||||
"""Get an existing SensorGroupEntry by id."""
|
||||
res = {}
|
||||
for key, val in self.sensor_groups.items():
|
||||
res[key] = attr.asdict(val)
|
||||
return res
|
||||
|
||||
@callback
|
||||
def async_create_sensor_group(self, data: dict) -> SensorGroupEntry:
|
||||
"""Create a new SensorGroupEntry."""
|
||||
group_id = str(int(time.time()))
|
||||
new_group = SensorGroupEntry(**data, group_id=group_id)
|
||||
self.sensor_groups[group_id] = new_group
|
||||
self.async_schedule_save()
|
||||
return group_id
|
||||
|
||||
@callback
|
||||
def async_delete_sensor_group(self, group_id: str) -> None:
|
||||
"""Delete SensorGroupEntry."""
|
||||
if group_id in self.sensor_groups:
|
||||
del self.sensor_groups[group_id]
|
||||
self.async_schedule_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@callback
|
||||
def async_update_sensor_group(
|
||||
self, group_id: str, changes: dict
|
||||
) -> SensorGroupEntry:
|
||||
"""Update existing SensorGroupEntry."""
|
||||
old = self.sensor_groups[group_id]
|
||||
new = self.sensor_groups[group_id] = attr.evolve(old, **changes)
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
|
||||
async def async_get_registry(hass: HomeAssistant) -> AlarmoStorage:
|
||||
"""Return alarmo storage instance."""
|
||||
task = hass.data.get(DATA_REGISTRY)
|
||||
|
||||
if task is None:
|
||||
|
||||
async def _load_reg() -> AlarmoStorage:
|
||||
registry = AlarmoStorage(hass)
|
||||
await registry.async_load()
|
||||
return registry
|
||||
|
||||
task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg())
|
||||
|
||||
return cast(AlarmoStorage, await task)
|
||||
@@ -0,0 +1,596 @@
|
||||
"""WebSocket handler and registration for Alarmo configuration management."""
|
||||
|
||||
import voluptuous as vol
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE,
|
||||
ATTR_NAME,
|
||||
ATTR_STATE,
|
||||
ATTR_SERVICE,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_CODE_FORMAT,
|
||||
CONF_SERVICE_DATA,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.mqtt import (
|
||||
DOMAIN as ATTR_MQTT,
|
||||
)
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
async_dispatcher_connect,
|
||||
)
|
||||
from homeassistant.components.websocket_api import decorators, async_register_command
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
ATTR_CODE_ARM_REQUIRED,
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
|
||||
from . import const
|
||||
from .mqtt import (
|
||||
CONF_EVENT_TOPIC,
|
||||
)
|
||||
from .sensors import (
|
||||
ATTR_GROUP,
|
||||
ATTR_TIMEOUT,
|
||||
SENSOR_TYPES,
|
||||
ATTR_DELAY_ON,
|
||||
ATTR_ENTITIES,
|
||||
ATTR_GROUP_ID,
|
||||
ATTR_ALWAYS_ON,
|
||||
ATTR_ALLOW_OPEN,
|
||||
ATTR_AUTO_BYPASS,
|
||||
ATTR_ENTRY_DELAY,
|
||||
ATTR_EVENT_COUNT,
|
||||
ATTR_ARM_ON_CLOSE,
|
||||
ATTR_NEW_ENTITY_ID,
|
||||
ATTR_USE_EXIT_DELAY,
|
||||
ATTR_USE_ENTRY_DELAY,
|
||||
ATTR_AUTO_BYPASS_MODES,
|
||||
ATTR_TRIGGER_UNAVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@decorators.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "alarmo_config_updated",
|
||||
}
|
||||
)
|
||||
@decorators.async_response
|
||||
async def handle_subscribe_updates(hass, connection, msg):
|
||||
"""Handle subscribe updates."""
|
||||
|
||||
@callback
|
||||
def async_handle_event():
|
||||
"""Forward events to websocket."""
|
||||
connection.send_message(
|
||||
{
|
||||
"id": msg["id"],
|
||||
"type": "event",
|
||||
}
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass, "alarmo_update_frontend", async_handle_event
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
class AlarmoConfigView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/config"
|
||||
name = "api:alarmo:config"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CODE_ARM_REQUIRED): cv.boolean,
|
||||
vol.Optional(const.ATTR_CODE_DISARM_REQUIRED): cv.boolean,
|
||||
vol.Optional(
|
||||
const.ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER
|
||||
): cv.boolean,
|
||||
vol.Optional(const.ATTR_CODE_MODE_CHANGE_REQUIRED): cv.boolean,
|
||||
vol.Optional(ATTR_CODE_FORMAT): vol.In(
|
||||
[CodeFormat.NUMBER, CodeFormat.TEXT]
|
||||
),
|
||||
vol.Optional(const.ATTR_TRIGGER_TIME): cv.positive_int,
|
||||
vol.Optional(const.ATTR_DISARM_AFTER_TRIGGER): cv.boolean,
|
||||
vol.Optional(ATTR_MQTT): vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Required(CONF_STATE_TOPIC): cv.string,
|
||||
vol.Optional(const.ATTR_STATE_PAYLOAD): vol.Schema(
|
||||
{
|
||||
vol.Optional(const.CONF_ALARM_DISARMED): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_HOME): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_AWAY): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_NIGHT): cv.string,
|
||||
vol.Optional(
|
||||
const.CONF_ALARM_ARMED_CUSTOM_BYPASS
|
||||
): cv.string,
|
||||
vol.Optional(
|
||||
const.CONF_ALARM_ARMED_VACATION
|
||||
): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_PENDING): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_ARMING): cv.string,
|
||||
vol.Optional(const.CONF_ALARM_TRIGGERED): cv.string,
|
||||
}
|
||||
),
|
||||
vol.Required(CONF_COMMAND_TOPIC): cv.string,
|
||||
vol.Optional(const.ATTR_COMMAND_PAYLOAD): vol.Schema(
|
||||
{
|
||||
vol.Optional(const.COMMAND_ARM_AWAY): cv.string,
|
||||
vol.Optional(const.COMMAND_ARM_HOME): cv.string,
|
||||
vol.Optional(const.COMMAND_ARM_NIGHT): cv.string,
|
||||
vol.Optional(
|
||||
const.COMMAND_ARM_CUSTOM_BYPASS
|
||||
): cv.string,
|
||||
vol.Optional(const.COMMAND_ARM_VACATION): cv.string,
|
||||
vol.Optional(const.COMMAND_DISARM): cv.string,
|
||||
}
|
||||
),
|
||||
vol.Required(const.ATTR_REQUIRE_CODE): cv.boolean,
|
||||
vol.Required(CONF_EVENT_TOPIC): cv.string,
|
||||
}
|
||||
),
|
||||
vol.Optional(const.ATTR_MASTER): vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
await coordinator.async_update_config(data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoAreaView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/area"
|
||||
name = "api:alarmo:area"
|
||||
|
||||
mode_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Required(const.ATTR_EXIT_TIME): vol.Any(cv.positive_int, None),
|
||||
vol.Required(const.ATTR_ENTRY_TIME): vol.Any(cv.positive_int, None),
|
||||
vol.Optional(const.ATTR_TRIGGER_TIME): vol.Any(cv.positive_int, None),
|
||||
}
|
||||
)
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("area_id"): cv.string,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
vol.Optional(const.ATTR_MODES): vol.Schema(
|
||||
{
|
||||
vol.Optional(const.CONF_ALARM_ARMED_AWAY): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_HOME): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_NIGHT): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_CUSTOM_BYPASS): mode_schema,
|
||||
vol.Optional(const.CONF_ALARM_ARMED_VACATION): mode_schema,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
if "area_id" in data:
|
||||
area = data["area_id"]
|
||||
del data["area_id"]
|
||||
else:
|
||||
area = None
|
||||
await coordinator.async_update_area_config(area, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoSensorView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/sensors"
|
||||
name = "api:alarmo:sensors"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
vol.Optional(const.ATTR_TYPE): vol.In(SENSOR_TYPES),
|
||||
vol.Optional(const.ATTR_MODES): vol.All(
|
||||
cv.ensure_list, [vol.In(const.ARM_MODES)]
|
||||
),
|
||||
vol.Optional(ATTR_USE_EXIT_DELAY): cv.boolean,
|
||||
vol.Optional(ATTR_USE_ENTRY_DELAY): cv.boolean,
|
||||
vol.Optional(ATTR_ARM_ON_CLOSE): cv.boolean,
|
||||
vol.Optional(ATTR_ALLOW_OPEN): cv.boolean,
|
||||
vol.Optional(ATTR_ALWAYS_ON): cv.boolean,
|
||||
vol.Optional(ATTR_TRIGGER_UNAVAILABLE): cv.boolean,
|
||||
vol.Optional(ATTR_AUTO_BYPASS): cv.boolean,
|
||||
vol.Optional(ATTR_AUTO_BYPASS_MODES): vol.All(
|
||||
cv.ensure_list, [vol.In(const.ARM_MODES)]
|
||||
),
|
||||
vol.Optional(const.ATTR_AREA): cv.string,
|
||||
vol.Optional(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_GROUP): vol.Any(cv.string, None),
|
||||
vol.Optional(ATTR_ENTRY_DELAY): vol.Any(cv.positive_int, None),
|
||||
vol.Optional(ATTR_DELAY_ON): vol.Any(cv.positive_int, None),
|
||||
vol.Optional(ATTR_NEW_ENTITY_ID): cv.string,
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
entity = data[ATTR_ENTITY_ID]
|
||||
del data[ATTR_ENTITY_ID]
|
||||
coordinator.async_update_sensor_config(entity, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoUserView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/users"
|
||||
name = "api:alarmo:users"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(const.ATTR_USER_ID): cv.string,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_CODE): cv.string,
|
||||
vol.Optional(const.ATTR_OLD_CODE): cv.string,
|
||||
vol.Optional(const.ATTR_CAN_ARM): cv.boolean,
|
||||
vol.Optional(const.ATTR_CAN_DISARM): cv.boolean,
|
||||
vol.Optional(const.ATTR_IS_OVERRIDE_CODE): cv.boolean,
|
||||
vol.Optional(const.ATTR_AREA_LIMIT): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
user_id = None
|
||||
if const.ATTR_USER_ID in data:
|
||||
user_id = data[const.ATTR_USER_ID]
|
||||
del data[const.ATTR_USER_ID]
|
||||
err = await coordinator.async_update_user_config(user_id, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": not isinstance(err, str), "error": err})
|
||||
|
||||
|
||||
class AlarmoAutomationView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/automations"
|
||||
name = "api:alarmo:automations"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(const.ATTR_AUTOMATION_ID): cv.string,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(const.ATTR_TYPE): cv.string,
|
||||
vol.Optional(const.ATTR_TRIGGERS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Any(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(const.ATTR_EVENT): cv.string,
|
||||
vol.Optional(const.ATTR_AREA): vol.Any(
|
||||
int,
|
||||
cv.string,
|
||||
),
|
||||
vol.Optional(const.ATTR_MODES): vol.All(
|
||||
cv.ensure_list, [vol.In(const.ARM_MODES)]
|
||||
),
|
||||
}
|
||||
),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.string,
|
||||
vol.Required(ATTR_STATE): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
vol.Optional(const.ATTR_ACTIONS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.string,
|
||||
vol.Required(ATTR_SERVICE): cv.string,
|
||||
vol.Optional(CONF_SERVICE_DATA): dict,
|
||||
}
|
||||
)
|
||||
],
|
||||
),
|
||||
vol.Optional(const.ATTR_ENABLED): cv.boolean,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
automation_id = None
|
||||
if const.ATTR_AUTOMATION_ID in data:
|
||||
automation_id = data[const.ATTR_AUTOMATION_ID]
|
||||
del data[const.ATTR_AUTOMATION_ID]
|
||||
coordinator.async_update_automation_config(automation_id, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
class AlarmoSensorGroupView(HomeAssistantView):
|
||||
"""Login to Home Assistant cloud."""
|
||||
|
||||
url = "/api/alarmo/sensor_groups"
|
||||
name = "api:alarmo:sensor_groups"
|
||||
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_GROUP_ID): cv.string,
|
||||
vol.Optional(ATTR_NAME): cv.string,
|
||||
vol.Optional(ATTR_ENTITIES): vol.All(
|
||||
cv.ensure_list, vol.Unique(), [cv.string]
|
||||
),
|
||||
vol.Optional(ATTR_TIMEOUT): cv.positive_int,
|
||||
vol.Optional(ATTR_EVENT_COUNT): cv.positive_int,
|
||||
vol.Optional(const.ATTR_REMOVE): cv.boolean,
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request, data):
|
||||
"""Handle config update request."""
|
||||
hass = request.app["hass"]
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
group_id = None
|
||||
if ATTR_GROUP_ID in data:
|
||||
group_id = data[ATTR_GROUP_ID]
|
||||
del data[ATTR_GROUP_ID]
|
||||
coordinator.async_update_sensor_group_config(group_id, data)
|
||||
async_dispatcher_send(hass, "alarmo_update_frontend")
|
||||
return self.json({"success": True})
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_config(hass, connection, msg):
|
||||
"""Publish config data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
config = coordinator.store.async_get_config()
|
||||
connection.send_result(msg["id"], config)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_areas(hass, connection, msg):
|
||||
"""Publish area data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
areas = coordinator.store.async_get_areas()
|
||||
connection.send_result(msg["id"], areas)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_sensors(hass, connection, msg):
|
||||
"""Publish sensor data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
sensors = coordinator.store.async_get_sensors()
|
||||
for entity_id in sensors.keys():
|
||||
group = coordinator.async_get_group_for_sensor(entity_id)
|
||||
sensors[entity_id]["group"] = group
|
||||
connection.send_result(msg["id"], sensors)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_users(hass, connection, msg):
|
||||
"""Publish user data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
users = coordinator.store.async_get_users()
|
||||
connection.send_result(msg["id"], users)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_automations(hass, connection, msg):
|
||||
"""Publish automations data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
automations = coordinator.store.async_get_automations()
|
||||
connection.send_result(msg["id"], automations)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_alarm_entities(hass, connection, msg):
|
||||
"""Publish alarm entity data."""
|
||||
result = [
|
||||
{"entity_id": entity.entity_id, "area_id": area_id}
|
||||
for (area_id, entity) in hass.data[const.DOMAIN]["areas"].items()
|
||||
]
|
||||
if hass.data[const.DOMAIN]["master"]:
|
||||
result.append(
|
||||
{"entity_id": hass.data[const.DOMAIN]["master"].entity_id, "area_id": 0}
|
||||
)
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_sensor_groups(hass, connection, msg):
|
||||
"""Publish sensor_group data."""
|
||||
coordinator = hass.data[const.DOMAIN]["coordinator"]
|
||||
groups = coordinator.store.async_get_sensor_groups()
|
||||
connection.send_result(msg["id"], groups)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_countdown(hass, connection, msg):
|
||||
"""Publish countdown time for alarm entity."""
|
||||
entity_id = msg["entity_id"]
|
||||
item = next(
|
||||
(
|
||||
entity
|
||||
for entity in hass.data[const.DOMAIN]["areas"].values()
|
||||
if entity.entity_id == entity_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
hass.data[const.DOMAIN]["master"]
|
||||
and not item
|
||||
and hass.data[const.DOMAIN]["master"].entity_id == entity_id
|
||||
):
|
||||
item = hass.data[const.DOMAIN]["master"]
|
||||
|
||||
data = {
|
||||
"delay": item.delay if item else 0,
|
||||
"remaining": round((item.expiration - dt_util.utcnow()).total_seconds(), 2)
|
||||
if item and item.expiration
|
||||
else 0,
|
||||
}
|
||||
connection.send_result(msg["id"], data)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_get_ready_to_arm_modes(hass, connection, msg):
|
||||
"""Publish ready_to_arm_modes for alarm entity."""
|
||||
entity_id = msg["entity_id"]
|
||||
item = next(
|
||||
(
|
||||
entity
|
||||
for entity in hass.data[const.DOMAIN]["areas"].values()
|
||||
if entity.entity_id == entity_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
hass.data[const.DOMAIN]["master"]
|
||||
and not item
|
||||
and hass.data[const.DOMAIN]["master"].entity_id == entity_id
|
||||
):
|
||||
item = hass.data[const.DOMAIN]["master"]
|
||||
|
||||
data = {"modes": item._ready_to_arm_modes if item else None}
|
||||
connection.send_result(msg["id"], data)
|
||||
|
||||
|
||||
async def async_register_websockets(hass):
|
||||
"""Register websocket handlers."""
|
||||
hass.http.register_view(AlarmoConfigView)
|
||||
hass.http.register_view(AlarmoSensorView)
|
||||
hass.http.register_view(AlarmoUserView)
|
||||
hass.http.register_view(AlarmoAutomationView)
|
||||
hass.http.register_view(AlarmoAreaView)
|
||||
hass.http.register_view(AlarmoSensorGroupView)
|
||||
|
||||
async_register_command(hass, handle_subscribe_updates)
|
||||
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/config",
|
||||
websocket_get_config,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/config"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/areas",
|
||||
websocket_get_areas,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/areas"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/sensors",
|
||||
websocket_get_sensors,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/sensors"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/users",
|
||||
websocket_get_users,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/users"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/automations",
|
||||
websocket_get_automations,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/automations"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/entities",
|
||||
websocket_get_alarm_entities,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/entities"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/sensor_groups",
|
||||
websocket_get_sensor_groups,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): "alarmo/sensor_groups"}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/countdown",
|
||||
websocket_get_countdown,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): "alarmo/countdown",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
async_register_command(
|
||||
hass,
|
||||
"alarmo/ready_to_arm_modes",
|
||||
websocket_get_ready_to_arm_modes,
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): "alarmo/ready_to_arm_modes",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
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.
@@ -32,11 +32,28 @@ from .const import (
|
||||
DEFAULT_IMMICH_IMAGE_SIZE,
|
||||
IMMICH_IMAGE_SIZE_OPTIONS,
|
||||
IMMICH_SELECTION_COMPOSITE,
|
||||
CONF_PHOTOPRISM_URL,
|
||||
CONF_PHOTOPRISM_AUTH_METHOD,
|
||||
CONF_PHOTOPRISM_TOKEN,
|
||||
CONF_PHOTOPRISM_USERNAME,
|
||||
CONF_PHOTOPRISM_PASSWORD,
|
||||
CONF_PHOTOPRISM_SELECTION_TYPE,
|
||||
CONF_PHOTOPRISM_SELECTION_ID,
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE,
|
||||
CONF_PHOTOPRISM_FILTER,
|
||||
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
|
||||
PHOTOPRISM_IMAGE_PREVIEW,
|
||||
PHOTOPRISM_IMAGE_FULLSIZE,
|
||||
PHOTOPRISM_IMAGE_ORIGINAL,
|
||||
PHOTOPRISM_AUTH_APP_PASSWORD,
|
||||
PHOTOPRISM_AUTH_USER_PASSWORD,
|
||||
PHOTOPRISM_SELECTION_COMPOSITE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -76,6 +93,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# id -> name maps for the Albums and People multi-selects.
|
||||
self._immich_albums: dict[str, str] = {}
|
||||
self._immich_people: dict[str, str] = {}
|
||||
# PhotoPrism flow state carried between steps.
|
||||
self._pp_url: str | None = None
|
||||
self._pp_auth_method: str | None = None
|
||||
self._pp_token: str | None = None
|
||||
self._pp_username: str | None = None
|
||||
self._pp_password: str | None = None
|
||||
self._pp_albums: dict[str, str] = {}
|
||||
self._pp_people: dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -107,6 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_media_source()
|
||||
if self._provider == PROVIDER_IMMICH:
|
||||
return await self.async_step_immich()
|
||||
if self._provider == PROVIDER_PHOTOPRISM:
|
||||
return await self.async_step_photoprism()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -115,6 +142,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_GOOGLE_SHARED: "Google Photos",
|
||||
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
||||
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -369,6 +397,195 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="immich_select", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_photoprism(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect the PhotoPrism URL + credentials and validate them."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_PHOTOPRISM_URL].strip()
|
||||
method = user_input[CONF_PHOTOPRISM_AUTH_METHOD]
|
||||
token = (user_input.get(CONF_PHOTOPRISM_TOKEN) or "").strip()
|
||||
username = (user_input.get(CONF_PHOTOPRISM_USERNAME) or "").strip()
|
||||
password = user_input.get(CONF_PHOTOPRISM_PASSWORD) or ""
|
||||
|
||||
if method == PHOTOPRISM_AUTH_APP_PASSWORD and not token:
|
||||
errors[CONF_PHOTOPRISM_TOKEN] = "photoprism_token_required"
|
||||
elif method == PHOTOPRISM_AUTH_USER_PASSWORD and not (username and password):
|
||||
errors[CONF_PHOTOPRISM_USERNAME] = "photoprism_user_required"
|
||||
|
||||
if not errors:
|
||||
from . import photoprism as pp_api
|
||||
|
||||
client = pp_api.PhotoprismClient(
|
||||
self.hass,
|
||||
url,
|
||||
auth_method=method,
|
||||
token=token or None,
|
||||
username=username or None,
|
||||
password=password or None,
|
||||
)
|
||||
try:
|
||||
await client.async_validate()
|
||||
albums = await client.async_list_albums()
|
||||
people = await client.async_list_people()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||
errors["base"] = "photoprism_cannot_connect"
|
||||
else:
|
||||
self._pp_url = client.base_url
|
||||
self._pp_auth_method = method
|
||||
self._pp_token = token or None
|
||||
self._pp_username = username or None
|
||||
self._pp_password = password or None
|
||||
self._pp_albums = {
|
||||
a["UID"]: (a.get("Title") or a["UID"])
|
||||
for a in albums
|
||||
if a.get("UID")
|
||||
}
|
||||
self._pp_people = {
|
||||
p["UID"]: p["Name"]
|
||||
for p in people
|
||||
if p.get("UID") and (p.get("Name") or "").strip()
|
||||
}
|
||||
return await self.async_step_photoprism_select()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PHOTOPRISM_URL): str,
|
||||
vol.Required(
|
||||
CONF_PHOTOPRISM_AUTH_METHOD,
|
||||
default=PHOTOPRISM_AUTH_APP_PASSWORD,
|
||||
): vol.In(
|
||||
{
|
||||
PHOTOPRISM_AUTH_APP_PASSWORD: "App password (recommended)",
|
||||
PHOTOPRISM_AUTH_USER_PASSWORD: "Username + password",
|
||||
}
|
||||
),
|
||||
vol.Optional(CONF_PHOTOPRISM_TOKEN): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_PHOTOPRISM_USERNAME): str,
|
||||
vol.Optional(CONF_PHOTOPRISM_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="photoprism", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_photoprism_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Build a composite PhotoPrism selection and finish the entry.
|
||||
|
||||
Same combined picker as Immich: tick any mix of albums, people and
|
||||
favorites (optionally a custom search query); an empty selection means
|
||||
the whole library.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
size = user_input.get(
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE, DEFAULT_PHOTOPRISM_IMAGE_SIZE
|
||||
)
|
||||
raw_filter = (user_input.get(CONF_PHOTOPRISM_FILTER) or "").strip()
|
||||
favorites = bool(user_input.get("favorites"))
|
||||
|
||||
chosen_albums = [a for a in user_input.get("albums", []) if a]
|
||||
if _ALL_ALBUMS in chosen_albums:
|
||||
chosen_albums = list(self._pp_albums.keys())
|
||||
else:
|
||||
chosen_albums = [a for a in chosen_albums if a in self._pp_albums]
|
||||
|
||||
chosen_people = [p for p in user_input.get("people", []) if p]
|
||||
if _ALL_PEOPLE in chosen_people:
|
||||
chosen_people = list(self._pp_people.keys())
|
||||
else:
|
||||
chosen_people = [p for p in chosen_people if p in self._pp_people]
|
||||
|
||||
selection = {
|
||||
"albums": chosen_albums,
|
||||
"people": chosen_people,
|
||||
"favorites": favorites,
|
||||
}
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
|
||||
f"composite:{sel_id}:{raw_filter}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
data = {
|
||||
CONF_PROVIDER: PROVIDER_PHOTOPRISM,
|
||||
CONF_PHOTOPRISM_URL: self._pp_url,
|
||||
CONF_PHOTOPRISM_AUTH_METHOD: self._pp_auth_method,
|
||||
CONF_PHOTOPRISM_SELECTION_TYPE: PHOTOPRISM_SELECTION_COMPOSITE,
|
||||
CONF_PHOTOPRISM_SELECTION_ID: sel_id,
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
}
|
||||
if self._pp_token:
|
||||
data[CONF_PHOTOPRISM_TOKEN] = self._pp_token
|
||||
if self._pp_username:
|
||||
data[CONF_PHOTOPRISM_USERNAME] = self._pp_username
|
||||
if self._pp_password:
|
||||
data[CONF_PHOTOPRISM_PASSWORD] = self._pp_password
|
||||
if raw_filter:
|
||||
data[CONF_PHOTOPRISM_FILTER] = raw_filter
|
||||
return self.async_create_entry(title=name, data=data)
|
||||
|
||||
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
|
||||
if self._pp_albums:
|
||||
album_options = [
|
||||
selector.SelectOptionDict(value=_ALL_ALBUMS, label="Select all albums")
|
||||
] + [
|
||||
selector.SelectOptionDict(value=uid, label=name)
|
||||
for uid, name in self._pp_albums.items()
|
||||
]
|
||||
fields[vol.Optional("albums")] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=album_options,
|
||||
multiple=True,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
)
|
||||
if self._pp_people:
|
||||
people_options = [
|
||||
selector.SelectOptionDict(value=_ALL_PEOPLE, label="Select all people")
|
||||
] + [
|
||||
selector.SelectOptionDict(value=uid, label=name)
|
||||
for uid, name in self._pp_people.items()
|
||||
]
|
||||
fields[vol.Optional("people")] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=people_options,
|
||||
multiple=True,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
)
|
||||
fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector()
|
||||
fields[vol.Optional(CONF_PHOTOPRISM_FILTER)] = str
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE, default=DEFAULT_PHOTOPRISM_IMAGE_SIZE
|
||||
)
|
||||
] = vol.In(
|
||||
{
|
||||
PHOTOPRISM_IMAGE_PREVIEW: "Preview (1280px)",
|
||||
PHOTOPRISM_IMAGE_FULLSIZE: "Full size (1920px)",
|
||||
PHOTOPRISM_IMAGE_ORIGINAL: "High detail (2560px)",
|
||||
}
|
||||
)
|
||||
schema = vol.Schema(fields)
|
||||
return self.async_show_form(
|
||||
step_id="photoprism_select", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
@@ -55,6 +55,40 @@ PROVIDER_GOOGLE_SHARED = "google_shared"
|
||||
PROVIDER_LOCAL_FOLDER = "local_folder"
|
||||
PROVIDER_MEDIA_SOURCE = "media_source"
|
||||
PROVIDER_IMMICH = "immich"
|
||||
PROVIDER_PHOTOPRISM = "photoprism"
|
||||
|
||||
# PhotoPrism (direct API) provider.
|
||||
CONF_PHOTOPRISM_URL = "photoprism_url"
|
||||
CONF_PHOTOPRISM_AUTH_METHOD = "photoprism_auth_method"
|
||||
CONF_PHOTOPRISM_TOKEN = "photoprism_token"
|
||||
CONF_PHOTOPRISM_USERNAME = "photoprism_username"
|
||||
CONF_PHOTOPRISM_PASSWORD = "photoprism_password"
|
||||
CONF_PHOTOPRISM_SELECTION_TYPE = "photoprism_selection_type"
|
||||
CONF_PHOTOPRISM_SELECTION_ID = "photoprism_selection_id"
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE = "photoprism_image_size"
|
||||
CONF_PHOTOPRISM_FILTER = "photoprism_filter"
|
||||
|
||||
PHOTOPRISM_AUTH_APP_PASSWORD = "app_password"
|
||||
PHOTOPRISM_AUTH_USER_PASSWORD = "user_password"
|
||||
|
||||
# PhotoPrism thumbnail sizes (from its Thumbnail Image API). ``preview`` is a
|
||||
# good slideshow default; the larger sizes trade bandwidth for detail.
|
||||
PHOTOPRISM_IMAGE_PREVIEW = "fit_1280"
|
||||
PHOTOPRISM_IMAGE_FULLSIZE = "fit_1920"
|
||||
PHOTOPRISM_IMAGE_ORIGINAL = "fit_2560"
|
||||
PHOTOPRISM_IMAGE_SIZE_OPTIONS = [
|
||||
PHOTOPRISM_IMAGE_PREVIEW,
|
||||
PHOTOPRISM_IMAGE_FULLSIZE,
|
||||
PHOTOPRISM_IMAGE_ORIGINAL,
|
||||
]
|
||||
DEFAULT_PHOTOPRISM_IMAGE_SIZE = PHOTOPRISM_IMAGE_PREVIEW
|
||||
|
||||
# Composite: client-side union of albums + people + favorites (+ optional
|
||||
# search query). PhotoPrism has no OR across filters, so each member is
|
||||
# queried separately and merged. Selection id is a JSON object
|
||||
# ``{"albums": [...], "people": [...], "favorites": bool}``; empty means all.
|
||||
PHOTOPRISM_SELECTION_COMPOSITE = "composite"
|
||||
|
||||
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
|
||||
@@ -31,12 +31,23 @@ from .const import (
|
||||
CONF_IMMICH_IMAGE_SIZE,
|
||||
CONF_IMMICH_FILTER,
|
||||
DEFAULT_IMMICH_IMAGE_SIZE,
|
||||
CONF_PHOTOPRISM_URL,
|
||||
CONF_PHOTOPRISM_AUTH_METHOD,
|
||||
CONF_PHOTOPRISM_TOKEN,
|
||||
CONF_PHOTOPRISM_USERNAME,
|
||||
CONF_PHOTOPRISM_PASSWORD,
|
||||
CONF_PHOTOPRISM_SELECTION_TYPE,
|
||||
CONF_PHOTOPRISM_SELECTION_ID,
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE,
|
||||
CONF_PHOTOPRISM_FILTER,
|
||||
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -920,6 +931,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_media_source()
|
||||
elif self.provider == PROVIDER_IMMICH:
|
||||
data = await self._update_immich()
|
||||
elif self.provider == PROVIDER_PHOTOPRISM:
|
||||
data = await self._update_photoprism()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1359,6 +1372,79 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_photoprism(self) -> dict[str, Any]:
|
||||
"""Fetch photos from PhotoPrism via its REST API.
|
||||
|
||||
Unlike Immich, PhotoPrism returns per-photo metadata inline in the
|
||||
search response, so every ``MediaItem`` is built fully here - there is
|
||||
no background enrichment pass. Thumbnails carry a preview token in the
|
||||
URL, so no auth header is needed to fetch the image bytes.
|
||||
"""
|
||||
from . import photoprism as pp_api
|
||||
|
||||
url = self.entry.data.get(CONF_PHOTOPRISM_URL)
|
||||
auth_method = self.entry.data.get(CONF_PHOTOPRISM_AUTH_METHOD)
|
||||
sel_type = self.entry.data.get(CONF_PHOTOPRISM_SELECTION_TYPE)
|
||||
sel_id = self.entry.data.get(CONF_PHOTOPRISM_SELECTION_ID)
|
||||
size = self.entry.data.get(
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE, DEFAULT_PHOTOPRISM_IMAGE_SIZE
|
||||
)
|
||||
filter_query = self.entry.data.get(CONF_PHOTOPRISM_FILTER)
|
||||
if not url or not auth_method or not sel_type:
|
||||
raise UpdateFailed("PhotoPrism provider is missing URL, auth, or selection")
|
||||
|
||||
client = pp_api.PhotoprismClient(
|
||||
self.hass,
|
||||
url,
|
||||
auth_method=auth_method,
|
||||
token=self.entry.data.get(CONF_PHOTOPRISM_TOKEN),
|
||||
username=self.entry.data.get(CONF_PHOTOPRISM_USERNAME),
|
||||
password=self.entry.data.get(CONF_PHOTOPRISM_PASSWORD),
|
||||
)
|
||||
|
||||
try:
|
||||
photos = await client.async_collect_assets(sel_type, sel_id, filter_query)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying PhotoPrism: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found for the selected PhotoPrism source")
|
||||
|
||||
token = client.preview_token
|
||||
if not token:
|
||||
raise UpdateFailed("PhotoPrism did not return a preview token")
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
uid = p.get("UID")
|
||||
file_hash = p.get("Hash")
|
||||
if not uid or not file_hash:
|
||||
continue
|
||||
meta = pp_api.parse_photo_meta(p)
|
||||
w = p.get("Width")
|
||||
h = p.get("Height")
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=pp_api.build_image_url(client.base_url, file_hash, token, size),
|
||||
width=w if isinstance(w, int) else None,
|
||||
height=h if isinstance(h, int) else None,
|
||||
mime_type=None,
|
||||
filename=p.get("FileName") or p.get("Name"),
|
||||
captured_at=meta.get("captured_at"),
|
||||
latitude=meta.get("latitude"),
|
||||
longitude=meta.get("longitude"),
|
||||
location=meta.get("location"),
|
||||
description=meta.get("description"),
|
||||
source_id=uid,
|
||||
exif_scanned=True,
|
||||
)
|
||||
)
|
||||
|
||||
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.2.2"
|
||||
"version": "1.3.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""PhotoPrism (direct API) client and pure parsing helpers.
|
||||
|
||||
Talks to a PhotoPrism server using either an app password (used directly as a
|
||||
Bearer token) or a username + password (exchanged for a session access token).
|
||||
HTTP lives in ``PhotoprismClient``; the parsing/URL helpers are pure functions
|
||||
so they can be unit-tested without a live server or aiohttp.
|
||||
|
||||
API shape (PhotoPrism ``/api/v1``, Bearer auth):
|
||||
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}``
|
||||
(only needed for the username + password auth method).
|
||||
- ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a
|
||||
filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a
|
||||
custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``,
|
||||
``Lat``/``Lng``, ``PlaceLabel``, ``Title``, ``Description``, ``Portrait``,
|
||||
``Width``/``Height``), so there is no per-asset enrichment call. The
|
||||
response headers carry ``X-Preview-Token`` (for thumbnail URLs) and
|
||||
``X-Count`` (number of items returned, for pagination).
|
||||
- ``GET /api/v1/albums`` / ``GET /api/v1/subjects`` -> albums / people.
|
||||
- Image bytes: ``/api/v1/t/<sha1_hash>/<preview_token>/<size>`` - the preview
|
||||
token in the URL is enough (cookie-free access by design), so no auth
|
||||
header is needed to fetch thumbnails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import PHOTOPRISM_SELECTION_COMPOSITE
|
||||
|
||||
_TIMEOUT = 30
|
||||
_PAGE_SIZE = 1000
|
||||
_MAX_ASSETS = 20_000
|
||||
|
||||
# PhotoPrism media types that render as a still image (videos are skipped).
|
||||
_IMAGE_TYPES = {"image", "raw", "live", "animated"}
|
||||
|
||||
|
||||
def normalize_base_url(url: str) -> str:
|
||||
"""Strip trailing slashes and a trailing ``/api``/``/api/v1`` from a URL."""
|
||||
u = (url or "").strip().rstrip("/")
|
||||
for suffix in ("/api/v1", "/api"):
|
||||
if u.endswith(suffix):
|
||||
u = u[: -len(suffix)]
|
||||
return u.rstrip("/")
|
||||
|
||||
|
||||
def build_image_url(base_url: str, file_hash: str, token: str, size: str) -> str:
|
||||
"""Build the thumbnail URL for a file hash at the requested size.
|
||||
|
||||
The preview ``token`` is included in the URL on purpose: PhotoPrism serves
|
||||
thumbnails cookie-free and gates them with this rotatable token, so no auth
|
||||
header is required to fetch the bytes.
|
||||
"""
|
||||
base = normalize_base_url(base_url)
|
||||
return f"{base}/api/v1/t/{file_hash}/{token}/{size}"
|
||||
|
||||
|
||||
def _to_epoch_ms(value: Any) -> int | None:
|
||||
"""Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
iso = value.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(iso)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
return int(dt.timestamp() * 1000)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def location_label(city: Any, state: Any, country: Any) -> str | None:
|
||||
"""Build a short ``"City, Country"`` label from place fields.
|
||||
|
||||
Prefers ``city`` for the locality, falling back to ``state``. Appends the
|
||||
country when present. Used only when PhotoPrism's own ``PlaceLabel`` is
|
||||
unavailable.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
locality = None
|
||||
for candidate in (city, state):
|
||||
if isinstance(candidate, str) and candidate.strip() and candidate.strip().lower() != "unknown":
|
||||
locality = candidate.strip()
|
||||
break
|
||||
if locality:
|
||||
parts.append(locality)
|
||||
if isinstance(country, str) and country.strip() and country.strip().lower() not in ("unknown", "zz"):
|
||||
parts.append(country.strip())
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _is_image(item: Any) -> bool:
|
||||
"""True for photo items that render as a still image (skip videos)."""
|
||||
if not isinstance(item, dict):
|
||||
return False
|
||||
if not item.get("Hash") or not item.get("UID"):
|
||||
return False
|
||||
t = str(item.get("Type", "image")).lower()
|
||||
return t in _IMAGE_TYPES
|
||||
|
||||
|
||||
def parse_photo_meta(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract the metadata we surface from a search photo item."""
|
||||
out: dict[str, Any] = {}
|
||||
captured = _to_epoch_ms(item.get("TakenAt")) or _to_epoch_ms(item.get("TakenAtLocal"))
|
||||
if captured is not None:
|
||||
out["captured_at"] = captured
|
||||
lat = item.get("Lat")
|
||||
lng = item.get("Lng")
|
||||
if isinstance(lat, (int, float)) and isinstance(lng, (int, float)):
|
||||
# 0/0 means "no fix" in PhotoPrism; treat it as no location.
|
||||
if not (abs(lat) < 1e-6 and abs(lng) < 1e-6):
|
||||
out["latitude"] = float(lat)
|
||||
out["longitude"] = float(lng)
|
||||
# PhotoPrism builds a human-readable ``PlaceLabel`` (e.g. "San Diego,
|
||||
# California, USA"); prefer it, falling back to city/country parts.
|
||||
label = item.get("PlaceLabel")
|
||||
if not (isinstance(label, str) and label.strip() and label.strip().lower() != "unknown"):
|
||||
label = location_label(
|
||||
item.get("PlaceCity"), item.get("PlaceState"), item.get("PlaceCountry")
|
||||
)
|
||||
if isinstance(label, str) and label.strip() and label.strip().lower() != "unknown":
|
||||
out["location"] = label.strip()
|
||||
# Only surface a real caption. PhotoPrism auto-generates ``Title`` from
|
||||
# place + date for every photo, so it would be noise as a caption.
|
||||
desc = item.get("Description")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
out["description"] = desc.strip()
|
||||
return out
|
||||
|
||||
|
||||
def build_query_params(
|
||||
selection_type: str, selection_id: str | None
|
||||
) -> dict[str, str]:
|
||||
"""Build the search query params for a single (non-composite) member.
|
||||
|
||||
``album`` uses the ``s`` scope; ``person`` and ``favorites`` use ``q``
|
||||
filters; ``search`` passes the user's raw query through; ``all`` adds
|
||||
nothing (whole library).
|
||||
"""
|
||||
if selection_type == "album" and selection_id:
|
||||
return {"s": selection_id}
|
||||
if selection_type == "person" and selection_id:
|
||||
return {"q": f"subject:{selection_id}"}
|
||||
if selection_type == "favorites":
|
||||
return {"q": "favorite:true"}
|
||||
if selection_type == "search" and selection_id:
|
||||
return {"q": selection_id}
|
||||
return {}
|
||||
|
||||
|
||||
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
|
||||
"""Parse a composite selection id into ``{albums, people, favorites}``."""
|
||||
albums: list[str] = []
|
||||
people: list[str] = []
|
||||
favorites = False
|
||||
if selection_id:
|
||||
try:
|
||||
data = json.loads(selection_id)
|
||||
except (ValueError, TypeError):
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
albums = [a for a in data.get("albums", []) if isinstance(a, str) and a]
|
||||
people = [p for p in data.get("people", []) if isinstance(p, str) and p]
|
||||
favorites = bool(data.get("favorites"))
|
||||
return {"albums": albums, "people": people, "favorites": favorites}
|
||||
|
||||
|
||||
def build_composite_queries(
|
||||
selection_id: str | None, filter_query: str | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Build one search-param dict per composite union member.
|
||||
|
||||
PhotoPrism has no OR across filters, so each album, person, the favorites
|
||||
flag and any custom query becomes its own request; the caller unions the
|
||||
results. An empty composite yields a single unfiltered query (the whole
|
||||
library).
|
||||
"""
|
||||
sel = parse_composite_selection(selection_id)
|
||||
queries: list[dict[str, str]] = []
|
||||
for uid in sel["albums"]:
|
||||
queries.append({"s": uid})
|
||||
for uid in sel["people"]:
|
||||
queries.append({"q": f"subject:{uid}"})
|
||||
if sel["favorites"]:
|
||||
queries.append({"q": "favorite:true"})
|
||||
if isinstance(filter_query, str) and filter_query.strip():
|
||||
queries.append({"q": filter_query.strip()})
|
||||
if not queries:
|
||||
queries.append({})
|
||||
return queries
|
||||
|
||||
|
||||
class PhotoprismAuthError(Exception):
|
||||
"""Raised when PhotoPrism authentication fails."""
|
||||
|
||||
|
||||
class PhotoprismClient:
|
||||
"""Thin async wrapper over the PhotoPrism REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass,
|
||||
base_url: str,
|
||||
*,
|
||||
auth_method: str,
|
||||
token: str | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
self.hass = hass
|
||||
self.base_url = normalize_base_url(base_url)
|
||||
self.auth_method = auth_method
|
||||
self._token = token
|
||||
self._username = username
|
||||
self._password = password
|
||||
# Bearer token used for API calls: the app password directly, or the
|
||||
# session access token obtained from username + password.
|
||||
self._bearer: str | None = token if auth_method == "app_password" else None
|
||||
# Preview token captured from the most recent search response, used to
|
||||
# build thumbnail URLs.
|
||||
self.preview_token: str | None = None
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
h = {"Accept": "application/json"}
|
||||
if self._bearer:
|
||||
h["Authorization"] = "Bearer " + self._bearer
|
||||
return h
|
||||
|
||||
async def _login(self) -> None:
|
||||
"""Exchange username + password for a session access token."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.post(
|
||||
self.base_url + "/api/v1/session",
|
||||
json={"username": self._username, "password": self._password},
|
||||
headers={"Accept": "application/json"},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise PhotoprismAuthError(f"session login failed: {resp.status}")
|
||||
data = await resp.json()
|
||||
token = data.get("access_token") if isinstance(data, dict) else None
|
||||
if not token:
|
||||
raise PhotoprismAuthError("session login returned no access_token")
|
||||
self._bearer = token
|
||||
|
||||
async def async_authenticate(self) -> None:
|
||||
"""Ensure a usable Bearer token is available."""
|
||||
if self.auth_method == "user_password":
|
||||
await self._login()
|
||||
elif not self._bearer:
|
||||
raise PhotoprismAuthError("no app password configured")
|
||||
|
||||
async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]:
|
||||
"""GET returning ``(json, headers)``, re-authenticating once on 401."""
|
||||
if self._bearer is None:
|
||||
await self.async_authenticate()
|
||||
session = async_get_clientsession(self.hass)
|
||||
for attempt in (1, 2):
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.get(
|
||||
self.base_url + path, params=params, headers=self._headers
|
||||
) as resp:
|
||||
if resp.status == 401 and attempt == 1 and self.auth_method == "user_password":
|
||||
# Session token likely expired; re-login and retry once.
|
||||
await self._login()
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await resp.json(), dict(resp.headers)
|
||||
raise PhotoprismAuthError("unauthorized after re-authentication")
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Authenticate and confirm the search endpoint responds."""
|
||||
await self.async_authenticate()
|
||||
await self._get("/api/v1/photos", {"count": "1", "public": "false"})
|
||||
|
||||
async def async_list_albums(self) -> list[dict[str, Any]]:
|
||||
data, _ = await self._get(
|
||||
"/api/v1/albums", {"count": "1000", "offset": "0", "type": "album"}
|
||||
)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def async_list_people(self) -> list[dict[str, Any]]:
|
||||
data, _ = await self._get(
|
||||
"/api/v1/subjects", {"count": "1000", "type": "person"}
|
||||
)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def async_collect_assets(
|
||||
self,
|
||||
selection_type: str,
|
||||
selection_id: str | None = None,
|
||||
filter_query: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Collect image photo items for a selection.
|
||||
|
||||
``composite`` unions albums + people + favorites (+ optional query);
|
||||
everything else is a single filtered search. The preview token from the
|
||||
responses is captured on ``self.preview_token`` for URL building.
|
||||
"""
|
||||
if selection_type == PHOTOPRISM_SELECTION_COMPOSITE:
|
||||
queries = build_composite_queries(selection_id, filter_query)
|
||||
else:
|
||||
queries = [build_query_params(selection_type, selection_id)]
|
||||
return await self._collect_union(queries)
|
||||
|
||||
async def _collect_union(
|
||||
self, queries: list[dict[str, str]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Union several searches (OR), deduped by photo UID."""
|
||||
seen: set[str] = set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for query in queries:
|
||||
if len(out) >= _MAX_ASSETS:
|
||||
break
|
||||
for item in await self._search(query):
|
||||
uid = item.get("UID")
|
||||
if uid and uid not in seen:
|
||||
seen.add(uid)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
async def _search(self, query: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""Page through ``/api/v1/photos`` for one filter, image items only."""
|
||||
collected: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
while len(collected) < _MAX_ASSETS:
|
||||
params = {
|
||||
"count": str(_PAGE_SIZE),
|
||||
"offset": str(offset),
|
||||
"order": "newest",
|
||||
"primary": "true",
|
||||
"public": "false",
|
||||
"merged": "false",
|
||||
}
|
||||
params.update(query)
|
||||
data, headers = await self._get("/api/v1/photos", params)
|
||||
token = headers.get("X-Preview-Token")
|
||||
if token:
|
||||
self.preview_token = token
|
||||
items = data if isinstance(data, list) else []
|
||||
for it in items:
|
||||
if _is_image(it):
|
||||
collected.append(it)
|
||||
if len(items) < _PAGE_SIZE:
|
||||
break
|
||||
offset += _PAGE_SIZE
|
||||
return collected
|
||||
@@ -52,6 +52,29 @@
|
||||
"immich_filter": "Extra search filter (JSON, optional)",
|
||||
"immich_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"photoprism": {
|
||||
"title": "PhotoPrism",
|
||||
"description": "Connect directly to your PhotoPrism server for full photo metadata (date, location, description). Use an app password (Settings > Account > Apps and Devices) or your username and password. The password is kept on the server side and never reaches the browser.",
|
||||
"data": {
|
||||
"photoprism_url": "PhotoPrism URL (e.g. http://192.168.1.10:2342)",
|
||||
"photoprism_auth_method": "Authentication",
|
||||
"photoprism_token": "App password (for App password)",
|
||||
"photoprism_username": "Username (for Username + password)",
|
||||
"photoprism_password": "Password (for Username + password)"
|
||||
}
|
||||
},
|
||||
"photoprism_select": {
|
||||
"title": "PhotoPrism source",
|
||||
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add a PhotoPrism search query to include those results too - see the README for examples.",
|
||||
"data": {
|
||||
"album_name": "Name",
|
||||
"albums": "Albums",
|
||||
"people": "People",
|
||||
"favorites": "Include favorites",
|
||||
"photoprism_filter": "Extra search query (optional)",
|
||||
"photoprism_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -63,7 +86,10 @@
|
||||
"immich_filter_required": "Custom search needs a JSON filter.",
|
||||
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
|
||||
"immich_people_required": "Pick at least one person for the People source.",
|
||||
"immich_albums_required": "Pick at least one album for the Albums source."
|
||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
||||
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -52,6 +52,29 @@
|
||||
"immich_filter": "Extra search filter (JSON, optional)",
|
||||
"immich_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"photoprism": {
|
||||
"title": "PhotoPrism",
|
||||
"description": "Connect directly to your PhotoPrism server for full photo metadata (date, location, description). Use an app password (Settings > Account > Apps and Devices) or your username and password. The password is kept on the server side and never reaches the browser.",
|
||||
"data": {
|
||||
"photoprism_url": "PhotoPrism URL (e.g. http://192.168.1.10:2342)",
|
||||
"photoprism_auth_method": "Authentication",
|
||||
"photoprism_token": "App password (for App password)",
|
||||
"photoprism_username": "Username (for Username + password)",
|
||||
"photoprism_password": "Password (for Username + password)"
|
||||
}
|
||||
},
|
||||
"photoprism_select": {
|
||||
"title": "PhotoPrism source",
|
||||
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add a PhotoPrism search query to include those results too - see the README for examples.",
|
||||
"data": {
|
||||
"album_name": "Name",
|
||||
"albums": "Albums",
|
||||
"people": "People",
|
||||
"favorites": "Include favorites",
|
||||
"photoprism_filter": "Extra search query (optional)",
|
||||
"photoprism_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -63,7 +86,10 @@
|
||||
"immich_filter_required": "Custom search needs a JSON filter.",
|
||||
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
|
||||
"immich_people_required": "Pick at least one person for the People source.",
|
||||
"immich_albums_required": "Pick at least one album for the Albums source."
|
||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
||||
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.2.2";
|
||||
const VERSION = "1.3.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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.
@@ -12,8 +12,9 @@ menu on the first step:
|
||||
channel / port / bind host / webhook auth / pip spec / server URL.
|
||||
|
||||
The two entry types are discriminated by ``entry.data[CONF_ENTRY_TYPE]``; the
|
||||
options-flow dispatcher branches on it so only the server entry gets a
|
||||
configurable options flow (the tools entry aborts with ``no_options``).
|
||||
options-flow dispatcher branches on it — the server entry gets the configurable
|
||||
options flow, and the tools entry gets a light informational options flow
|
||||
(nothing to configure yet).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,7 +30,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -45,6 +46,9 @@ from .const import (
|
||||
CHANNEL_DEV,
|
||||
CHANNEL_STABLE,
|
||||
CONF_ENTRY_TYPE,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_WEBHOOK_ID,
|
||||
DEFAULT_AUTO_UPDATE,
|
||||
@@ -74,6 +78,9 @@ from .const import (
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_LLM_API_EXPOSURE,
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
OPT_OAUTH_REGENERATE,
|
||||
OPT_PIP_SPEC,
|
||||
OPT_REGENERATE_SECRETS,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
@@ -81,12 +88,14 @@ from .const import (
|
||||
OPT_SERVER_URL,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
TOOLS_ENTRY_TITLE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
)
|
||||
|
||||
# Titles shown for each entry in the integration tile's entry list.
|
||||
_TOOLS_ENTRY_TITLE = "HA MCP Tools"
|
||||
# Title shown for the server entry in the integration tile's entry list; the
|
||||
# tools entry's title lives in const.py (setup migration in __init__ needs it).
|
||||
_SERVER_ENTRY_TITLE = "HA-MCP Server"
|
||||
|
||||
# The single-instance server entry's unique id — distinct from the tools entry's
|
||||
@@ -97,6 +106,29 @@ _SERVER_UNIQUE_ID = f"{DOMAIN}-server"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _legacy_credentials_active(
|
||||
hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str
|
||||
) -> bool:
|
||||
"""Deferred-import seam for :func:`oauth_legacy.legacy_credentials_active`.
|
||||
|
||||
oauth_legacy pulls in the aiohttp/HTTP view layer at import time; the
|
||||
config-flow module must stay importable without it (Home Assistant imports
|
||||
config flows early, and the flow unit tests stub only the config-entries
|
||||
surface) — same pattern as ``oauth_legacy._live_auth_mode``.
|
||||
"""
|
||||
from .oauth_legacy import legacy_credentials_active
|
||||
|
||||
return legacy_credentials_active(hass, client_id, client_secret, signing_key)
|
||||
|
||||
|
||||
def _legacy_restart_pending(hass: HomeAssistant) -> bool:
|
||||
"""Deferred-import seam for :func:`oauth_legacy.legacy_restart_pending`
|
||||
(see :func:`_legacy_credentials_active` for why the import is deferred)."""
|
||||
from .oauth_legacy import legacy_restart_pending
|
||||
|
||||
return legacy_restart_pending(hass)
|
||||
|
||||
|
||||
def _installed_server_version() -> str | None:
|
||||
"""Return the installed ha-mcp server version, or None if not installed.
|
||||
|
||||
@@ -124,13 +156,14 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Return the options flow for this entry type.
|
||||
|
||||
Only the in-process server entry has options (channel / port / bind /
|
||||
auth / pip spec / URL). The tools services entry has none, so it returns
|
||||
a flow that aborts with an explanatory message.
|
||||
The in-process server entry gets the configurable options flow (channel /
|
||||
port / bind / auth / pip spec / URL). The tools services entry has
|
||||
nothing to configure yet, so it gets a light informational options flow
|
||||
instead of aborting.
|
||||
"""
|
||||
if config_entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
||||
return HaMcpServerOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
return HaMcpToolsInfoOptionsFlow()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -164,7 +197,7 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
def _create_tools_entry(self) -> ConfigFlowResult:
|
||||
"""Create the services (tools) config entry."""
|
||||
return self.async_create_entry(
|
||||
title=_TOOLS_ENTRY_TITLE,
|
||||
title=TOOLS_ENTRY_TITLE,
|
||||
data={CONF_ENTRY_TYPE: ENTRY_TYPE_TOOLS},
|
||||
)
|
||||
|
||||
@@ -206,14 +239,31 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
return self.async_show_form(step_id="server")
|
||||
|
||||
|
||||
class _NoOptionsFlow(OptionsFlow):
|
||||
"""Options flow for the tools entry: it has no configurable options."""
|
||||
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
|
||||
"""Options flow for the tools entry: a light informational form.
|
||||
|
||||
The tools services entry has nothing to configure yet, but aborting the
|
||||
Configure dialog reads as an error. Show an empty-schema form that explains
|
||||
what the entry provides instead; submitting persists an empty options
|
||||
payload.
|
||||
|
||||
The form uses the ``tools_info`` step id, NOT ``init``: the server options
|
||||
flow already owns ``options.step.init`` in strings.json, so a shared step id
|
||||
would collide. ``async_step_init`` is the required entry point (it renders
|
||||
the form); HA routes the form's submit to ``async_step_tools_info``.
|
||||
"""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort immediately — the services entry exposes no options."""
|
||||
return self.async_abort(reason="no_options")
|
||||
"""Render the informational form under the ``tools_info`` step id."""
|
||||
return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({}))
|
||||
|
||||
async def async_step_tools_info(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Persist an empty options payload once the info form is submitted."""
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
|
||||
class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
@@ -229,6 +279,24 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
opts = self.config_entry.options
|
||||
schema = vol.Schema(
|
||||
{
|
||||
# Authentication mode first, directly under the connect URLs
|
||||
# shown in the step description (#1875): it is the setting users
|
||||
# most need to find, and legacy mode is what unblocks OAuth-only
|
||||
# clients such as Google Gemini Spark and Copilot CLI.
|
||||
vol.Required(
|
||||
OPT_WEBHOOK_AUTH,
|
||||
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
WEBHOOK_AUTH_NONE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
],
|
||||
translation_key="server_webhook_auth",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
OPT_CHANNEL,
|
||||
default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL),
|
||||
@@ -268,16 +336,6 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
OPT_WEBHOOK_AUTH,
|
||||
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA],
|
||||
translation_key="server_webhook_auth",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
OPT_PIP_SPEC,
|
||||
# Pre-fill via suggested_value, NOT a schema default: a
|
||||
@@ -296,11 +354,13 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_SERVER_URL,
|
||||
description={
|
||||
"suggested_value": opts.get(
|
||||
OPT_SERVER_URL, DEFAULT_LOOPBACK_URL
|
||||
)
|
||||
},
|
||||
# Only a genuinely saved override is suggested (same
|
||||
# pattern as OPT_PIP_SPEC above). Pre-filling
|
||||
# DEFAULT_LOOPBACK_URL made every options save store the
|
||||
# constant as an explicit override, which would pin the
|
||||
# scheme/port even after issue #1890's SSL/port-aware
|
||||
# loopback derivation.
|
||||
description={"suggested_value": opts.get(OPT_SERVER_URL, "")},
|
||||
): str,
|
||||
vol.Required(
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
@@ -352,6 +412,23 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
OPT_REGENERATE_SECRETS,
|
||||
default=False,
|
||||
): bool,
|
||||
# Legacy OAuth mode (Google Gemini Spark) credential overrides —
|
||||
# same shape as the webhook id/secret-path overrides above.
|
||||
# Empty = auto-generate/keep the current value.
|
||||
vol.Optional(
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
description={"suggested_value": opts.get(OPT_OAUTH_CLIENT_ID, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
description={
|
||||
"suggested_value": opts.get(OPT_OAUTH_CLIENT_SECRET, "")
|
||||
},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_OAUTH_REGENERATE,
|
||||
default=False,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
# The sidebar-panel sentence in the description is only truthful while
|
||||
@@ -370,7 +447,8 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"versions": await self._versions_hint(),
|
||||
"connect_url": self._connect_url_hint(),
|
||||
"connect_url": await self._connect_url_hint(),
|
||||
"oauth_creds": self._oauth_creds_hint(),
|
||||
"llm_api_docs_url": LLM_API_DOCS_URL,
|
||||
"panel_hint": panel_hint,
|
||||
},
|
||||
@@ -396,15 +474,21 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
):
|
||||
cleaned[key] = str(cleaned.get(key, "") or "").strip()
|
||||
cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/")
|
||||
# server_url gets no _normalize-forced empty like the fields above; strip
|
||||
# it and drop it entirely when blank so a whitespace-only value can't be
|
||||
# stored verbatim (it would bypass the consumer's empty -> loopback
|
||||
# fallback and break the HA connection).
|
||||
# fallback and break the HA connection). A value equal to
|
||||
# DEFAULT_LOOPBACK_URL is likewise dropped: older forms pre-filled it,
|
||||
# so it reaches here from users who never chose an override, and
|
||||
# storing it would pin the scheme/port the #1890 loopback derivation
|
||||
# exists to resolve.
|
||||
server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/")
|
||||
if server_url:
|
||||
if server_url and server_url != DEFAULT_LOOPBACK_URL:
|
||||
cleaned[OPT_SERVER_URL] = server_url
|
||||
else:
|
||||
cleaned.pop(OPT_SERVER_URL, None)
|
||||
@@ -451,7 +535,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
f"Server ha-mcp {server_version} ({channel} channel)"
|
||||
)
|
||||
|
||||
def _connect_url_hint(self) -> str:
|
||||
async def _connect_url_hint(self) -> str:
|
||||
"""Return the connect URLs for the options form.
|
||||
|
||||
The Configure screen is admin-only, so it shows the real resolved
|
||||
@@ -471,10 +555,13 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
hass = getattr(self, "hass", None)
|
||||
if hass is not None:
|
||||
try:
|
||||
from .embedded_setup import build_connect_urls
|
||||
from .embedded_setup import async_get_lan_hosts, build_connect_urls
|
||||
|
||||
urls = build_connect_urls(
|
||||
hass, self.config_entry, webhook_enabled=webhook_enabled
|
||||
hass,
|
||||
self.config_entry,
|
||||
webhook_enabled=webhook_enabled,
|
||||
extra_hosts=await async_get_lan_hosts(hass),
|
||||
)
|
||||
if urls:
|
||||
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
|
||||
@@ -507,3 +594,50 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
f"http://<home-assistant-ip>:{port}{secret_path}"
|
||||
)
|
||||
return hint
|
||||
|
||||
def _oauth_creds_hint(self) -> str:
|
||||
"""Return the resolved legacy OAuth Client ID + Secret for the options
|
||||
form, or a note pointing at the mode selector when legacy mode isn't
|
||||
the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``),
|
||||
so showing the secret in cleartext here is acceptable — and this is
|
||||
the surface the startup log points at while a rotation is pending
|
||||
(``_surface_connect_urls`` withholds pending credentials from the
|
||||
log, where a still-valid old-identity token could read them; an HA
|
||||
admin here is trusted). A pending rotation gets a caveat so the admin
|
||||
doesn't paste values that only start working after the restart.
|
||||
"""
|
||||
configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "")
|
||||
if configured_mode != WEBHOOK_AUTH_LEGACY:
|
||||
return (
|
||||
"Set Authentication mode to legacy OAuth above and save to "
|
||||
"generate a Client ID and Client Secret."
|
||||
)
|
||||
client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
if not client_id or not client_secret:
|
||||
# Not minted yet — the entry hasn't finished a bring-up cycle
|
||||
# since legacy mode was selected (e.g. this save just turned it
|
||||
# on). They appear after the next reload.
|
||||
return (
|
||||
"The Client ID and Client Secret appear here once the server "
|
||||
"has started."
|
||||
)
|
||||
creds = f"Client ID: {client_id}\nClient Secret: {client_secret}"
|
||||
signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "")
|
||||
active = _legacy_credentials_active(
|
||||
self.hass, str(client_id), str(client_secret), signing_key
|
||||
)
|
||||
if active and not _legacy_restart_pending(self.hass):
|
||||
return creds # bound and live
|
||||
# Not serving these credentials yet: a mid-session first enable
|
||||
# (bound but not live until the restart), a pending rotation (the old
|
||||
# identity still bound), the webhook disabled, or another integration
|
||||
# owning the routes. State-agnostic wording — the previous "the
|
||||
# previous Client ID and Client Secret remain active" was false at a
|
||||
# first enable and when nothing of ours is bound. Matches the startup
|
||||
# log's first-enable caveat and the oauth_regenerate help text.
|
||||
return (
|
||||
f"{creds}\n"
|
||||
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
||||
"when it asks you to, to activate them."
|
||||
)
|
||||
|
||||
@@ -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.1.0"
|
||||
COMPONENT_VERSION = "1.2.2"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
@@ -32,6 +32,12 @@ COMPONENT_VERSION = "1.1.0"
|
||||
CONF_ENTRY_TYPE = "entry_type"
|
||||
ENTRY_TYPE_TOOLS = "tools"
|
||||
ENTRY_TYPE_SERVER = "server"
|
||||
|
||||
# Titles shown for each entry in the integration tile's entry list. Public so
|
||||
# __init__'s setup migration can retitle pre-#1853 tools entries still
|
||||
# carrying the legacy default (a user-customized title is left alone).
|
||||
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)
|
||||
@@ -290,6 +296,16 @@ DEFAULT_AUTO_UPDATE = True
|
||||
OPT_SERVER_PORT = "server_port"
|
||||
OPT_BIND_HOST = "bind_host"
|
||||
OPT_WEBHOOK_AUTH = "webhook_auth"
|
||||
# Legacy OAuth mode (self-hosted authorization server, static client_id/secret
|
||||
# for Google Gemini Spark) credential management — mirrors the
|
||||
# OPT_WEBHOOK_ID_OVERRIDE / OPT_REGENERATE_SECRETS shape below. Empty override
|
||||
# fields mean "keep the current value"; OPT_OAUTH_REGENERATE is one-shot.
|
||||
# _override suffix distinguishes these OPTIONS keys from the DATA_OAUTH_*
|
||||
# entry.data keys (which store the resolved values under the un-suffixed
|
||||
# names) — mirrors OPT_WEBHOOK_ID_OVERRIDE vs DATA_WEBHOOK_ID.
|
||||
OPT_OAUTH_CLIENT_ID = "oauth_client_id_override"
|
||||
OPT_OAUTH_CLIENT_SECRET = "oauth_client_secret_override"
|
||||
OPT_OAUTH_REGENERATE = "oauth_regenerate"
|
||||
OPT_PIP_SPEC = "pip_spec"
|
||||
OPT_SERVER_URL = "server_url"
|
||||
# Connect-URL surface + secret management (owner request, parity with the
|
||||
@@ -331,6 +347,22 @@ OPT_ENABLE_SIDEBAR_PANEL = "enable_sidebar_panel"
|
||||
# entry.data keys (persisted ids + secrets; entry.data is fine for secrets).
|
||||
DATA_WEBHOOK_ID = "webhook_id"
|
||||
DATA_SECRET_PATH = "secret_path"
|
||||
# Legacy OAuth mode credentials, minted by embedded_entry._ensure_secrets and
|
||||
# consumed by oauth_legacy.LegacyOAuthProvider. DATA_OAUTH_SIGNING_KEY is a hex
|
||||
# string (entry.data must be JSON-serializable, so raw bytes aren't stored
|
||||
# directly) — the provider converts it with bytes.fromhex(). The signed token
|
||||
# payload carries the client_id (not the secret), so rotating the client_id
|
||||
# revokes every outstanding token at the restart that rebinds the views (see
|
||||
# LegacyOAuthProvider._validate_token).
|
||||
# Because validation never involves the client_secret, a secret-only override
|
||||
# change instead rotates the signing key, evicting outstanding tokens at the
|
||||
# restart that activates the new credentials (see
|
||||
# embedded_entry._ensure_legacy_oauth_secrets). Until that restart the bound
|
||||
# views keep serving the OLD identity, so the startup log withholds rotated
|
||||
# credentials (embedded_setup._surface_connect_urls).
|
||||
DATA_OAUTH_CLIENT_ID = "oauth_client_id"
|
||||
DATA_OAUTH_CLIENT_SECRET = "oauth_client_secret"
|
||||
DATA_OAUTH_SIGNING_KEY = "oauth_signing_key"
|
||||
DATA_SERVER_USER_ID = "server_user_id"
|
||||
DATA_REFRESH_TOKEN_ID = "refresh_token_id"
|
||||
DATA_ACCESS_TOKEN = "access_token"
|
||||
@@ -375,6 +407,12 @@ DATA_LLM_API_UNSUB = "llm_api_unsub"
|
||||
# Webhook auth modes (mirrors the webhook-proxy add-on's default posture).
|
||||
WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default)
|
||||
WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS)
|
||||
# Self-hosted OAuth 2.1 authorization server with a static client_id/secret,
|
||||
# ported from the webhook-proxy add-on's "legacy" mode. Needed because HA
|
||||
# core's /auth/authorize does not yet fetch Client ID Metadata Documents for
|
||||
# cross-origin redirect_uris (home-assistant/core#176282), which is what
|
||||
# Google Gemini Spark's custom connected apps require.
|
||||
WEBHOOK_AUTH_LEGACY = "legacy"
|
||||
|
||||
# Default bind host + port. 9584 (not the add-on's 9583) so this in-process
|
||||
# server and an add-on install can coexist on the same box.
|
||||
@@ -407,14 +445,34 @@ SERVER_USER_NAME = "HA-MCP Server"
|
||||
# namespace (mirrors the webhook-proxy add-on's /api/mcp_proxy/oauth base).
|
||||
OAUTH_BASE = "/api/ha_mcp_tools/oauth"
|
||||
|
||||
# HACS "add repository" deep link for the custom component. Shared learn_more_url
|
||||
# for every repair issue that ends with "install/reinstall the component via
|
||||
# HACS" (the component-outdated issue and the legacy-HACS-source issue below).
|
||||
# HACS repository full_names (``owner/repo``, the key HACS's repository registry
|
||||
# uses) this component may be tracked under: the dedicated integration mirror is
|
||||
# the current install path; the main ha-mcp server repo is the legacy pre-mirror
|
||||
# path (see install_source_check). Shared by the legacy-source check and the
|
||||
# HACS refresh nudge (hacs_nudge) so a repository rename lands in one place.
|
||||
HACS_MIRROR_REPO_FULL_NAME = "homeassistant-ai/ha-mcp-integration"
|
||||
HACS_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp"
|
||||
|
||||
# HACS "add repository" deep link for the custom component. learn_more_url for
|
||||
# the legacy-HACS-source repair (install_source_check) only, whose fix really is
|
||||
# re-adding the repository. The update-held and component-outdated issues point
|
||||
# at UPDATE_HOLD_DOCS_URL instead — for an already-installed component this deep
|
||||
# link just opens a blank "add repository" dialog.
|
||||
HACS_COMPONENT_URL = (
|
||||
"https://my.home-assistant.io/redirect/hacs_repository/"
|
||||
"?owner=homeassistant-ai&repository=ha-mcp-integration&category=integration"
|
||||
)
|
||||
|
||||
# Docs section explaining the automatic-update hold, linked as learn_more_url
|
||||
# from the update-held and component-outdated repair issues (both resolve by
|
||||
# updating an already-installed component, not by re-adding a repository). The
|
||||
# anchor is the GitHub slug of the "Held server updates" heading in
|
||||
# docs/in-process-server.md; hassfest forbids literal URLs inside strings.json.
|
||||
UPDATE_HOLD_DOCS_URL = (
|
||||
"https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/"
|
||||
"in-process-server.md#held-server-updates"
|
||||
)
|
||||
|
||||
# Usage guide for the conversation-agent LLM API option (#1745). Injected into
|
||||
# the options form as a description placeholder — hassfest forbids literal
|
||||
# URLs inside strings.json.
|
||||
@@ -449,3 +507,10 @@ ISSUE_UPDATE_HELD = "server_update_held"
|
||||
# mechanism, so this only self-resolves if the user re-adds the dedicated
|
||||
# mirror (homeassistant-ai/ha-mcp-integration).
|
||||
ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source"
|
||||
# Repair issue surfaced when the legacy OAuth mode's root /authorize + /token
|
||||
# views are out of sync with the CONFIGURED webhook_auth mode — either just
|
||||
# enabled (views not yet bound with the current credentials) or just disabled
|
||||
# (views still bound and serving the old identity). aiohttp can neither bind
|
||||
# nor unbind an HTTP view without a full Home Assistant restart, so both
|
||||
# transitions need one; see oauth_legacy.bind_legacy_views.
|
||||
ISSUE_LEGACY_OAUTH_RESTART = "legacy_oauth_restart"
|
||||
|
||||
@@ -26,14 +26,23 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from .const import (
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_LAST_OPTIONS,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
DATA_WEBHOOK_ID,
|
||||
DOMAIN,
|
||||
OPT_ENABLE_SIDEBAR_PANEL,
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
OPT_OAUTH_REGENERATE,
|
||||
OPT_REGENERATE_SECRETS,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
)
|
||||
|
||||
# NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook
|
||||
@@ -65,6 +74,9 @@ async def async_setup_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
from .ui_panel import async_register_ui_panel
|
||||
|
||||
_ensure_secrets(hass, entry)
|
||||
# Bind the legacy OAuth root views synchronously here — before the slow
|
||||
# background bring-up below — so they are live at boot (see the helper).
|
||||
_prebind_legacy_oauth_views(hass, entry)
|
||||
|
||||
# Admin-only "Open Web UI" sidebar panel + proxy. Registered while the entry
|
||||
# exists (its proxy returns 503 until the server is actually running), so the
|
||||
@@ -178,6 +190,50 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
def _prebind_legacy_oauth_views(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Register the legacy OAuth root ``/authorize`` + ``/token`` views during
|
||||
entry setup, before the background bring-up's (slow) package install.
|
||||
|
||||
An aiohttp route is only ever live if it is registered before Home Assistant
|
||||
freezes its HTTP app at the end of startup. Binding these views from the
|
||||
background bring-up task races HA reaching RUNNING: on a slow-install boot
|
||||
the routes would register AFTER the freeze — never live until a restart —
|
||||
and ``bind_legacy_views`` would see ``hass.is_running`` True and file a
|
||||
restart repair that the restart cannot clear. Binding here, while the entry
|
||||
is still setting up (``hass.is_running`` is False at boot), mirrors the
|
||||
webhook-proxy add-on, which binds in its own ``async_setup_entry``. The
|
||||
bring-up's ``async_register_webhook`` then reuses this already-bound provider.
|
||||
|
||||
Only relevant when legacy is the configured mode and the webhook endpoint is
|
||||
enabled (legacy OAuth guards that endpoint; with no webhook there is nothing
|
||||
to protect). A route-ownership conflict with the webhook-proxy add-on is
|
||||
swallowed here — the bring-up re-encounters it and files the user-facing
|
||||
start-failed repair.
|
||||
"""
|
||||
if str(entry.options.get(OPT_WEBHOOK_AUTH, "")) != WEBHOOK_AUTH_LEGACY:
|
||||
return
|
||||
if not bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)):
|
||||
return
|
||||
client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
signing_key = entry.data.get(DATA_OAUTH_SIGNING_KEY)
|
||||
if not (client_id and client_secret and signing_key):
|
||||
# _ensure_secrets mints these whenever legacy mode is configured; a gap
|
||||
# means a partial config — let the bring-up path surface it.
|
||||
return
|
||||
from .mcp_webhook import _register_metadata_views
|
||||
from .oauth_legacy import LegacyOAuthRouteConflict, bind_legacy_views
|
||||
|
||||
with suppress(LegacyOAuthRouteConflict):
|
||||
# Register the RFC 8414/9728 discovery views alongside the root
|
||||
# /authorize + /token views, both at setup time, so the discovery
|
||||
# doc's resource_metadata URL resolves at boot for RFC-compliant
|
||||
# clients — not just the root views. Both are idempotent, so the
|
||||
# bring-up's async_register_webhook reuses them.
|
||||
_register_metadata_views(hass)
|
||||
bind_legacy_views(hass, client_id, client_secret, signing_key)
|
||||
|
||||
|
||||
def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Generate + persist the stable webhook id and secret path on first setup.
|
||||
|
||||
@@ -192,6 +248,13 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
``secret_path_override`` replaces the stored value (normalized: the
|
||||
secret path gets a leading ``/``).
|
||||
3. First setup: mint random values for whatever is still missing.
|
||||
|
||||
When the configured webhook auth mode is legacy, the same three-path
|
||||
lifecycle additionally applies to the legacy OAuth client_id/client_secret
|
||||
(see :func:`_ensure_legacy_oauth_secrets`) — folded into this same
|
||||
read-mutate-write cycle so a single ``async_update_entry`` call covers
|
||||
every field that changed this load, including when BOTH a webhook secret
|
||||
regenerate and an OAuth credential regenerate are requested together.
|
||||
"""
|
||||
data = dict(entry.data)
|
||||
options = dict(entry.options)
|
||||
@@ -206,20 +269,19 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
options[OPT_REGENERATE_SECRETS] = False
|
||||
options[OPT_WEBHOOK_ID_OVERRIDE] = ""
|
||||
options[OPT_SECRET_PATH_OVERRIDE] = ""
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
return
|
||||
|
||||
webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
|
||||
if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
|
||||
data[DATA_WEBHOOK_ID] = webhook_override
|
||||
changed = True
|
||||
path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
|
||||
if path_override:
|
||||
if not path_override.startswith("/"):
|
||||
path_override = f"/{path_override}"
|
||||
if data.get(DATA_SECRET_PATH) != path_override:
|
||||
data[DATA_SECRET_PATH] = path_override
|
||||
else:
|
||||
webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
|
||||
if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
|
||||
data[DATA_WEBHOOK_ID] = webhook_override
|
||||
changed = True
|
||||
path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
|
||||
if path_override:
|
||||
if not path_override.startswith("/"):
|
||||
path_override = f"/{path_override}"
|
||||
if data.get(DATA_SECRET_PATH) != path_override:
|
||||
data[DATA_SECRET_PATH] = path_override
|
||||
changed = True
|
||||
|
||||
if not data.get(DATA_WEBHOOK_ID):
|
||||
data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
|
||||
@@ -227,5 +289,117 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
if not data.get(DATA_SECRET_PATH):
|
||||
data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
|
||||
changed = True
|
||||
|
||||
if options.get(OPT_WEBHOOK_AUTH) == WEBHOOK_AUTH_LEGACY:
|
||||
changed = _ensure_legacy_oauth_secrets(data, options) or changed
|
||||
|
||||
if changed:
|
||||
hass.config_entries.async_update_entry(entry, data=data)
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
|
||||
|
||||
def _ensure_legacy_oauth_secrets(data: dict, options: dict) -> bool:
|
||||
"""Mint/persist/rotate the legacy OAuth mode's credentials into ``data``
|
||||
(mutated in place, along with ``options`` for the one-shot regenerate
|
||||
flag). Only called while the configured webhook auth mode is legacy —
|
||||
switching away leaves whatever was last minted in place, so switching
|
||||
back reuses it rather than silently rotating.
|
||||
|
||||
Mirrors the ``OPT_REGENERATE_SECRETS`` shape above: ``OPT_OAUTH_REGENERATE``
|
||||
is one-shot, minting a fresh client_id/client_secret and clearing itself
|
||||
plus the two override fields.
|
||||
|
||||
ANY credential change — regenerate, a client_id override, or a
|
||||
client_secret override — also rotates ``signing_key``, making every
|
||||
rotation a hard revocation of outstanding tokens (they take effect at the
|
||||
restart that rebinds the views). Rotating the key on a secret change is
|
||||
load-bearing (token validation never involves the secret, so the cid
|
||||
claim alone would not evict anything). Rotating it on a client_id change
|
||||
is defence in depth: the cid claim already evicts on a normal rotation,
|
||||
but without a fresh key an ``A → B → A`` client_id sequence would
|
||||
resurrect id-A's still-unexpired tokens, since they re-match the cid
|
||||
claim under the unchanged-key HMAC. Rotating the key kills that echo at
|
||||
zero cost. Rotation does NOT shorten the pre-restart window — the bound
|
||||
views keep serving the old identity until the restart (see
|
||||
``oauth_legacy.bind_legacy_views``) — which is why the startup log also
|
||||
withholds rotated credentials until they are active
|
||||
(``embedded_setup._surface_connect_urls`` via
|
||||
``oauth_legacy.legacy_credentials_active``; review findings on #1880).
|
||||
|
||||
Returns True if ``data``/``options`` were mutated.
|
||||
"""
|
||||
changed = False
|
||||
if options.get(OPT_OAUTH_REGENERATE):
|
||||
data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
|
||||
data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
|
||||
# Every credential change rotates the key — see docstring (kills the
|
||||
# A->B->A client_id resurrection; regenerate mints random ids so it
|
||||
# can't recur to a former id, but the key rotation is kept uniform).
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
options[OPT_OAUTH_REGENERATE] = False
|
||||
options[OPT_OAUTH_CLIENT_ID] = ""
|
||||
options[OPT_OAUTH_CLIENT_SECRET] = ""
|
||||
changed = True
|
||||
else:
|
||||
client_id_override = str(options.get(OPT_OAUTH_CLIENT_ID) or "").strip()
|
||||
if client_id_override and data.get(DATA_OAUTH_CLIENT_ID) != client_id_override:
|
||||
data[DATA_OAUTH_CLIENT_ID] = client_id_override
|
||||
# Rotate the key so a re-used former client_id can't resurrect its
|
||||
# old tokens (see docstring).
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
changed = True
|
||||
client_secret_override = str(options.get(OPT_OAUTH_CLIENT_SECRET) or "").strip()
|
||||
if (
|
||||
client_secret_override
|
||||
and data.get(DATA_OAUTH_CLIENT_SECRET) != client_secret_override
|
||||
):
|
||||
data[DATA_OAUTH_CLIENT_SECRET] = client_secret_override
|
||||
# Evict outstanding tokens along with the old secret — see the
|
||||
# docstring for why a secret-only rotation must not leave them
|
||||
# valid for the rest of their TTL.
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
changed = True
|
||||
# Consume the OAuth override fields once applied. entry.options is
|
||||
# readable through the server's own tools
|
||||
# (ha_get_integration(include_options=True) rebuilds it from the
|
||||
# options-form suggested_values), so a rotated client_secret left here
|
||||
# in cleartext would let a pre-restart old-identity token holder read
|
||||
# the NEW secret that way — the exact party the rotation evicts, and
|
||||
# the same leak the startup log withholds. The resolved values live in
|
||||
# entry.data and on the admin-only Configure screen
|
||||
# (config_flow._oauth_creds_hint), so nothing is lost. Cleared even
|
||||
# when the override matched the current value: the cleartext must not
|
||||
# linger regardless of whether it changed data.
|
||||
#
|
||||
# DELIBERATE divergence from the webhook_id / secret_path overrides
|
||||
# above, which persist. Three reasons the OAuth secret is different:
|
||||
# (1) A client_secret override IS the OAuth rotation path and gates a
|
||||
# per-connection revocable bearer, so a lingering copy defeats the
|
||||
# very revocation the rotation performs. secret_path's own rotation
|
||||
# (OPT_REGENERATE_SECRETS) already clears its override, so that
|
||||
# path is not self-defeating; a standalone secret_path override is
|
||||
# configuration, not rotation.
|
||||
# (2) A connected legacy client learns the OAuth secret only via this
|
||||
# options leak (entry.data is not tool-exposed; the webhook is
|
||||
# OAuth-gated). secret_path gates the direct LAN port, and per
|
||||
# SECURITY.md the local network is the trusted zone and the client
|
||||
# is a trusted principal — LAN-peer access to standard-mode
|
||||
# endpoints is explicitly out of scope.
|
||||
# (3) Persisting the webhook_id/secret_path override is deliberate UX
|
||||
# (the admin sees their configured value in the form).
|
||||
if options.get(OPT_OAUTH_CLIENT_ID):
|
||||
options[OPT_OAUTH_CLIENT_ID] = ""
|
||||
changed = True
|
||||
if options.get(OPT_OAUTH_CLIENT_SECRET):
|
||||
options[OPT_OAUTH_CLIENT_SECRET] = ""
|
||||
changed = True
|
||||
|
||||
if not data.get(DATA_OAUTH_CLIENT_ID):
|
||||
data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
|
||||
changed = True
|
||||
if not data.get(DATA_OAUTH_CLIENT_SECRET):
|
||||
data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
|
||||
changed = True
|
||||
if not data.get(DATA_OAUTH_SIGNING_KEY):
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@@ -50,6 +50,8 @@ from homeassistant.requirements import (
|
||||
pip_kwargs,
|
||||
)
|
||||
from homeassistant.util.package import install_package
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from .const import (
|
||||
@@ -83,6 +85,8 @@ from .const import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -92,13 +96,20 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# from the same refresh token on every start regardless.
|
||||
_ACCESS_TOKEN_TTL = timedelta(days=3650)
|
||||
|
||||
# Readiness probe: how long to wait for the server thread to accept a loopback
|
||||
# TCP connection before declaring the start failed. Generous on purpose: a
|
||||
# cold import of the fastmcp tree takes 1-3s on real hardware but has been
|
||||
# observed to exceed 30s on QEMU-emulated HAOS (the e2e lane), and a single
|
||||
# readiness timeout fails the bring-up outright — there is no retry. Real
|
||||
# deployments only pay this budget on the failure path.
|
||||
_READY_TIMEOUT_SECONDS = 90.0
|
||||
# Readiness probe: fail the bring-up only when there is no observable startup
|
||||
# progress (no new modules landing in sys.modules, no phase advance) for this
|
||||
# long. The previous flat 90s deadline assumed real hardware imports the
|
||||
# server tree in seconds — issue #1904 (HA Green) showed a cold import alone
|
||||
# can take minutes there, and killing the still-importing worker at the
|
||||
# deadline is what created the orphaned-thread/port-collision cascade: the
|
||||
# worker cannot be joined mid-import, lingered as a zombie, and later bound
|
||||
# the port out from under the retry. The module count is process-wide (see
|
||||
# _progress_signature), so a wedged worker trips the stall budget on a quiet
|
||||
# instance and the absolute cap below at the latest.
|
||||
_READY_STALL_TIMEOUT_SECONDS = 90.0
|
||||
# Absolute ceiling on one bring-up regardless of apparent progress, matching
|
||||
# the HAOS e2e lane's own 600s readiness deadline.
|
||||
_READY_TOTAL_CAP_SECONDS = 600.0
|
||||
_READY_POLL_INTERVAL_SECONDS = 0.5
|
||||
|
||||
# How long to wait for the worker thread to exit on stop before giving up and
|
||||
@@ -113,12 +124,66 @@ _PIP_INSTALL_TIMEOUT_SECONDS = 300
|
||||
# subprocess can never tie up an executor thread indefinitely.
|
||||
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
|
||||
|
||||
# How long a bring-up waits for an install job orphaned by a CANCELLED
|
||||
# previous bring-up before giving up: asyncio cancellation detaches the
|
||||
# awaiter but an executor pip job runs to completion regardless. Sized to
|
||||
# the same absolute budget as the readiness cap: pip's own timeout (300s)
|
||||
# is per-download, so a healthy cold install pulling the whole dependency
|
||||
# tree on slow hardware can legitimately run for minutes — a tighter bound
|
||||
# would misreport it as stuck. On expiry the bring-up fails with a clear
|
||||
# message and the next reload retries.
|
||||
_PENDING_INSTALL_WAIT_SECONDS = 600.0
|
||||
|
||||
# The in-process connection API was added with the embedded server in 7.10.0.
|
||||
# Older distributions can be left behind by an unsupported Core version's
|
||||
# constraints and must never enter the worker thread.
|
||||
MIN_EMBEDDED_SERVER_VERSION = "7.10.0"
|
||||
|
||||
|
||||
def _derive_loopback_url(hass: HomeAssistant) -> tuple[str, bool | None]:
|
||||
"""Resolve the loopback base URL for HA core from the http integration.
|
||||
|
||||
Returns ``(url, verify_ssl)`` where ``verify_ssl`` is ``False`` when the
|
||||
URL is ``https`` (HA's certificate is issued for its hostname, never for
|
||||
127.0.0.1, so verification on the loopback hop can only fail) and ``None``
|
||||
when no override of the server's default is needed.
|
||||
|
||||
The hardcoded ``http://127.0.0.1:8123`` default this replaces broke every
|
||||
instance with ``http.ssl_certificate`` configured (issue #1890): port 8123
|
||||
speaks TLS there, so the server's plaintext REST/WS calls died with
|
||||
"Server disconnected without sending a response" / "did not receive a
|
||||
valid HTTP response" on every tool call — while the MCP handshake and
|
||||
tools/list (no HA round-trip) kept working. A custom ``server_port``
|
||||
similarly broke the hardcoded port. Both live in ``hass.config.api``,
|
||||
set by the ``http`` integration this component depends on; the constant
|
||||
remains the fallback if it is ever absent.
|
||||
"""
|
||||
api = getattr(hass.config, "api", None)
|
||||
if api is None:
|
||||
# Leave a trail: if this ever fires on a real instance, the resulting
|
||||
# failure looks exactly like issue #1890 (TLS loopback broken, MCP
|
||||
# handshake fine) and took a live reproduction to diagnose last time.
|
||||
_LOGGER.debug(
|
||||
"hass.config.api unavailable; using hardcoded loopback default %s",
|
||||
DEFAULT_LOOPBACK_URL,
|
||||
)
|
||||
return DEFAULT_LOOPBACK_URL, None
|
||||
# Strict type checks (not coercion / truthiness): a malformed api object
|
||||
# must resolve to the plaintext default on port 8123, never to a surprise
|
||||
# port or https flip. bool is excluded because it is an int subclass.
|
||||
port_raw = getattr(api, "port", None)
|
||||
port = (
|
||||
port_raw
|
||||
if isinstance(port_raw, int)
|
||||
and not isinstance(port_raw, bool)
|
||||
and 0 < port_raw <= 65535
|
||||
else 8123
|
||||
)
|
||||
if getattr(api, "use_ssl", False) is True:
|
||||
return f"https://127.0.0.1:{port}", False
|
||||
return f"http://127.0.0.1:{port}", None
|
||||
|
||||
|
||||
class EmbeddedServerError(Exception):
|
||||
"""Raised when the in-process ha-mcp server could not be installed or started.
|
||||
|
||||
@@ -146,9 +211,17 @@ class EmbeddedServerManager:
|
||||
options = entry.options
|
||||
self._port: int = int(options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
|
||||
self._bind_host: str = str(options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
|
||||
self._server_url: str = str(
|
||||
options.get(OPT_SERVER_URL) or DEFAULT_LOOPBACK_URL
|
||||
).rstrip("/")
|
||||
# An explicit server_url override wins verbatim (the operator manages
|
||||
# scheme/verification themselves via the settings UI). A stored value
|
||||
# equal to DEFAULT_LOOPBACK_URL is treated as no-override: the options
|
||||
# form used to pre-fill that constant as suggested_value, so existing
|
||||
# entries carry it without the user ever having chosen it.
|
||||
_url_override = str(options.get(OPT_SERVER_URL) or "").rstrip("/")
|
||||
self._loopback_verify_ssl: bool | None = None
|
||||
if _url_override and _url_override != DEFAULT_LOOPBACK_URL:
|
||||
self._server_url: str = _url_override
|
||||
else:
|
||||
self._server_url, self._loopback_verify_ssl = _derive_loopback_url(hass)
|
||||
self._channel: str = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
||||
# An explicit pip-spec override (the pre-release test channel) wins over
|
||||
# the channel selector. DEFAULT_PIP_SPEC in the field means "no override,
|
||||
@@ -189,6 +262,10 @@ class EmbeddedServerManager:
|
||||
# Compared against the installed distribution after start to detect a
|
||||
# stale-code worker (see _purge_ha_mcp_modules).
|
||||
self._running_version: str | None = None
|
||||
# Startup phase marker (plain attribute writes: init markers from the
|
||||
# main thread, _note_startup_phase transitions from the worker). Read
|
||||
# by the readiness poll for progress detection and error messages.
|
||||
self._startup_phase: str = "not started"
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
@@ -233,45 +310,39 @@ class EmbeddedServerManager:
|
||||
kind="package",
|
||||
)
|
||||
|
||||
await self._async_ensure_package()
|
||||
# Read the importer registry BEFORE the package step too: replacing
|
||||
# the distribution's files on disk under a live importer corrupts it
|
||||
# exactly like the sys.modules purge does (review finding), so a
|
||||
# mutating install is deferred while any registered worker is alive.
|
||||
ready_version = await self._async_ensure_package(
|
||||
defer_mutations=_prune_and_check_importing_workers()
|
||||
)
|
||||
access_token = await self._async_provision_token()
|
||||
await self._hass.async_add_executor_job(self._prepare_config_dir)
|
||||
|
||||
# Drop cached ha_mcp modules so the worker imports the code that is on
|
||||
# disk NOW. Without this, a reload after a pip install keeps serving
|
||||
# the OLD code forever: all workers are threads of the one HA core
|
||||
# process, and Python resolves ``import ha_mcp`` from sys.modules —
|
||||
# installs only took effect after a full HA core restart (issue
|
||||
# observed live: options saves reinstalled the package, the web UI
|
||||
# footer showed the new on-disk version, yet the serving worker kept
|
||||
# reporting the version it was first imported with).
|
||||
#
|
||||
# SKIPPED while an orphaned worker may still be importing: ripping
|
||||
# entries out of sys.modules under a live importer corrupts its
|
||||
# import in progress (seen on QEMU-slow HAOS, where a cold import
|
||||
# can outlive both the readiness timeout and the stop-join budget).
|
||||
# The post-start staleness check below surfaces the consequence
|
||||
# (old code possibly serving) instead.
|
||||
orphan = self._orphaned_thread
|
||||
if orphan is not None and not orphan.is_alive():
|
||||
self._orphaned_thread = orphan = None
|
||||
if orphan is None:
|
||||
_purge_ha_mcp_modules()
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Skipping the ha_mcp module purge: a previous worker thread "
|
||||
"is still shutting down. The new worker may serve the "
|
||||
"previously imported code until Home Assistant restarts."
|
||||
)
|
||||
self._maybe_purge_stale_modules(ready_version)
|
||||
|
||||
self._thread_exc = None
|
||||
self._startup_phase = "waiting for the worker thread"
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main,
|
||||
args=(access_token,),
|
||||
name="ha-mcp-server",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
# Registered by THIS (main) thread before start(): every bring-up
|
||||
# runs its gate → register → start section synchronously on the one
|
||||
# event loop, so another bring-up's purge gate can never interleave
|
||||
# with a worker that exists but has not yet registered itself
|
||||
# (review finding). The worker itself only ever deregisters.
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.add(self._thread)
|
||||
try:
|
||||
self._thread.start()
|
||||
except BaseException:
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(self._thread)
|
||||
raise
|
||||
|
||||
await self._async_wait_until_ready()
|
||||
|
||||
@@ -402,9 +473,171 @@ class EmbeddedServerManager:
|
||||
return None
|
||||
return DIST_NAME_STABLE if self._channel == CHANNEL_DEV else DIST_NAME_DEV
|
||||
|
||||
async def _async_ensure_package(self) -> None:
|
||||
def _maybe_purge_stale_modules(self, ready_version: str | None) -> None:
|
||||
"""Purge cached ha_mcp modules unless doing so is unsafe or pointless.
|
||||
|
||||
The purge makes the next worker import the code that is on disk NOW.
|
||||
Without it, a reload after a pip install keeps serving the OLD code
|
||||
forever: all workers are threads of the one HA core process, and
|
||||
Python resolves ``import ha_mcp`` from sys.modules — installs only
|
||||
took effect after a full HA core restart (observed live: options
|
||||
saves reinstalled the package, the web UI footer showed the new
|
||||
on-disk version, yet the serving worker kept reporting the version
|
||||
it was first imported with).
|
||||
|
||||
SKIPPED while any previous worker may still be importing — ripping
|
||||
entries out of sys.modules under a live importer corrupts its import
|
||||
in progress. Two guards cover that: the per-manager orphan (a worker
|
||||
this manager's stop could not join), and the process-global
|
||||
``_IMPORTING_WORKERS`` registry, because every bring-up constructs a
|
||||
FRESH manager (async_bring_up_server) and an entry reload during a
|
||||
slow cold import otherwise hands the purge to a manager that has
|
||||
never heard of the still-importing worker — which then crashed
|
||||
mid-import with KeyError: 'ha_mcp.config' (issue #1904, live on
|
||||
1.1.1-dev.107). The post-start staleness check surfaces the
|
||||
consequence (old code possibly serving) instead.
|
||||
|
||||
Also skipped when the cached modules already ARE the generation on
|
||||
disk: purging on every attempt made each retry pay the full cold
|
||||
import again, so slow hardware that missed the readiness window once
|
||||
could never recover (#1904). Never skipped under a pip-spec override
|
||||
— the one workflow where a reinstall can change the code without
|
||||
changing the version string (re-pointed tarball/pin), which a
|
||||
version-keyed skip would serve stale; channel installs mint a
|
||||
distinct version per build.
|
||||
"""
|
||||
orphan = self._orphaned_thread
|
||||
if orphan is not None and not orphan.is_alive():
|
||||
self._orphaned_thread = orphan = None
|
||||
importing_busy = _prune_and_check_importing_workers()
|
||||
if orphan is not None:
|
||||
_LOGGER.warning(
|
||||
"Skipping the ha_mcp module purge: a previous worker thread "
|
||||
"is still shutting down. The new worker may serve the "
|
||||
"previously imported code until Home Assistant restarts."
|
||||
)
|
||||
elif importing_busy:
|
||||
# Distinct from the orphan message: that worker is still STARTING
|
||||
# (mid cold-import, the #1904 incident shape), not shutting down —
|
||||
# naming the actual state matters when reading logs during one.
|
||||
_LOGGER.warning(
|
||||
"Skipping the ha_mcp module purge: a previous bring-up's "
|
||||
"worker thread is still importing. The new worker may serve "
|
||||
"the previously imported code until Home Assistant restarts."
|
||||
)
|
||||
elif (
|
||||
not self._pip_spec_override
|
||||
and ready_version is not None
|
||||
and _CACHED_IMPORT_VERSION is not None
|
||||
and ready_version == _CACHED_IMPORT_VERSION
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Cached ha_mcp modules already match installed version %s; "
|
||||
"skipping the module purge (warm start)",
|
||||
ready_version,
|
||||
)
|
||||
else:
|
||||
_purge_ha_mcp_modules()
|
||||
|
||||
async def _async_run_tracked_install_job(
|
||||
self, func: Callable[[], object]
|
||||
) -> object:
|
||||
"""Run a package-mutating executor job, tracked process-wide.
|
||||
|
||||
Registration happens BEFORE dispatch and completion is signalled on
|
||||
the executor thread in a finally, so a bring-up cancelled mid-job
|
||||
(install or uninstall) leaves behind a waitable job instead of an
|
||||
invisible one.
|
||||
"""
|
||||
global _PENDING_INSTALL_DONE
|
||||
done = threading.Event()
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
_PENDING_INSTALL_DONE = done
|
||||
|
||||
def _run() -> object:
|
||||
global _PENDING_INSTALL_DONE
|
||||
try:
|
||||
return func()
|
||||
finally:
|
||||
done.set()
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
# A newer job may already have replaced the slot.
|
||||
if _PENDING_INSTALL_DONE is done:
|
||||
_PENDING_INSTALL_DONE = None
|
||||
|
||||
try:
|
||||
# Shielded: cancelling the awaiter must NOT cancel the executor
|
||||
# job. Unshielded, a cancel landing while the job is still
|
||||
# QUEUED removes it from the pool — _run never starts, nothing
|
||||
# ever sets the event, and the next bring-up waits the full
|
||||
# budget on a job that does not exist (review finding). With the
|
||||
# shield, _run always runs and its finally always fires; the
|
||||
# awaiter still detaches immediately on cancel.
|
||||
return await asyncio.shield(self._hass.async_add_executor_job(_run))
|
||||
except asyncio.CancelledError:
|
||||
# The job is queued or running and its finally will clear the
|
||||
# slot; leaving it registered is the whole point — the next
|
||||
# bring-up must wait it out.
|
||||
raise
|
||||
except BaseException:
|
||||
# A DISPATCH failure (e.g. executor already shut down) means
|
||||
# _run never ran and nothing will ever set the event — clear our
|
||||
# own registration or the next bring-up waits the full budget on
|
||||
# a job that does not exist. The identity check makes this a
|
||||
# no-op if _run did run and already cleaned up.
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
if _PENDING_INSTALL_DONE is done:
|
||||
_PENDING_INSTALL_DONE = None
|
||||
raise
|
||||
|
||||
async def _async_wait_for_pending_install(self) -> None:
|
||||
"""Wait out an install job orphaned by a cancelled previous bring-up.
|
||||
|
||||
Raises :class:`EmbeddedServerError` if it is still running after the
|
||||
bounded wait — mutating the package (or importing from it) underneath
|
||||
a live pip job is the same corruption class as purging sys.modules
|
||||
under a live importer.
|
||||
|
||||
The wait occupies one pooled executor thread (bounded): the setter
|
||||
runs on an executor thread with no handle to this loop, so a
|
||||
loop-side wakeup would need cross-thread plumbing this rare recovery
|
||||
path does not justify.
|
||||
"""
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
pending = _PENDING_INSTALL_DONE
|
||||
if pending is None or pending.is_set():
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"A previous bring-up's install job is still running on the "
|
||||
"executor (the bring-up was cancelled but pip cannot be); "
|
||||
"waiting for it to finish before touching the ha-mcp package."
|
||||
)
|
||||
finished = await self._hass.async_add_executor_job(
|
||||
pending.wait, _PENDING_INSTALL_WAIT_SECONDS
|
||||
)
|
||||
if not finished:
|
||||
raise EmbeddedServerError(
|
||||
f"A previous install job was still running after "
|
||||
f"{_PENDING_INSTALL_WAIT_SECONDS:.0f}s; refusing to modify "
|
||||
"the ha-mcp package underneath it. Reload the integration "
|
||||
"to retry.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
async def _async_ensure_package(
|
||||
self, *, defer_mutations: bool = False
|
||||
) -> str | None:
|
||||
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.
|
||||
|
||||
Returns the installed version that the worker is about to run, for the
|
||||
caller's warm-cache purge decision.
|
||||
|
||||
``defer_mutations=True`` (a previous bring-up's worker is still
|
||||
importing) downgrades any would-be uninstall/force-install to the
|
||||
non-mutating fast path: replacing the distribution's files on disk
|
||||
under a live importer corrupts it the same way a sys.modules purge
|
||||
does. The deferred update applies on the next reload or HA restart.
|
||||
|
||||
With auto-update on (the default) both channels install their
|
||||
distribution UNPINNED, so every entry reload / HA restart must pick up
|
||||
the newest build. Such a spec ALWAYS takes the force-install path
|
||||
@@ -419,9 +652,12 @@ class EmbeddedServerManager:
|
||||
When that spec matches the one last installed and the package imports,
|
||||
delegate the "already satisfied?" decision to Home Assistant's
|
||||
requirements manager; a pinned spec does not move, so there is nothing to
|
||||
upgrade to. A CHANGED spec (a new override, a toggled auto-update, a
|
||||
channel switch) still falls through to the force-install path below so
|
||||
the change actually takes effect.
|
||||
upgrade to. A CHANGED spec (a new override, a cleared override, a
|
||||
toggled auto-update, a channel switch) falls through to the
|
||||
force-install path below — and additionally uninstalls the replaced
|
||||
distribution first (:meth:`_async_remove_replaced_source`), because
|
||||
``upgrade=True`` alone decides by version and a changed SOURCE can keep
|
||||
the version string (issue #1914).
|
||||
|
||||
On a channel switch the other channel's distribution is uninstalled first
|
||||
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
|
||||
@@ -437,14 +673,14 @@ class EmbeddedServerManager:
|
||||
Never imports ``ha_mcp`` in this (main) process — that happens only inside
|
||||
the worker thread.
|
||||
"""
|
||||
await self._async_wait_for_pending_install()
|
||||
|
||||
stored_spec = self._entry.data.get(DATA_LAST_PIP_SPEC)
|
||||
installed_version = await self._hass.async_add_executor_job(
|
||||
_installed_ha_mcp_version
|
||||
)
|
||||
|
||||
pending_version = str(
|
||||
self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or ""
|
||||
).strip()
|
||||
pending_version = self._pending_install_version(defer_mutations)
|
||||
target_dist = dist_for_channel(self._channel)
|
||||
if not self._pip_spec_override and pending_version:
|
||||
# Pin to the requested version. Its own value differs from
|
||||
@@ -498,13 +734,19 @@ class EmbeddedServerManager:
|
||||
and installed_version is not None
|
||||
and _is_compatible_embedded_version(installed_version)
|
||||
)
|
||||
deferred = False
|
||||
if fast_path_ok:
|
||||
await self._async_process_requirements_fast()
|
||||
elif defer_mutations:
|
||||
deferred = True
|
||||
await self._async_defer_package_mutations(installed_version)
|
||||
else:
|
||||
await self._async_remove_conflicting_dist()
|
||||
await self._async_remove_legacy_target(target_dist, installed_version)
|
||||
await self._async_remove_replaced_source(stored_spec, installed_version)
|
||||
await self._async_force_install()
|
||||
|
||||
version: str | None
|
||||
if not self._pip_spec_override and self._channel == CHANNEL_DEV:
|
||||
version = await self._hass.async_add_executor_job(
|
||||
_installed_ha_mcp_version, target_dist
|
||||
@@ -527,8 +769,13 @@ class EmbeddedServerManager:
|
||||
kind="package",
|
||||
)
|
||||
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
|
||||
if stored_spec != self._pip_spec:
|
||||
# A DEFERRED spec change must not be recorded as installed: with the
|
||||
# stored spec advanced, the next reload would see "unchanged", take the
|
||||
# fast path (for a stable spec) and skip the replaced-source uninstall,
|
||||
# so the deferred change would silently never apply.
|
||||
if not deferred and stored_spec != self._pip_spec:
|
||||
self._store_installed_spec()
|
||||
return version
|
||||
|
||||
async def _async_remove_legacy_target(
|
||||
self, target_dist: str, installed_version: str | None
|
||||
@@ -555,6 +802,165 @@ class EmbeddedServerManager:
|
||||
)
|
||||
await self._async_remove_distribution(target_dist)
|
||||
|
||||
def _pending_install_version(self, defer_mutations: bool) -> str:
|
||||
"""Return the update entity's pending-install version, or ``""``.
|
||||
|
||||
Always empty while mutations are deferred, leaving the marker in
|
||||
``entry.data`` untouched: the deferred branch runs no install, and
|
||||
consuming the marker without an attempt would lose the user's Install
|
||||
click entirely (with auto-update off, the next reload re-pins to the
|
||||
OLD installed version). One-shot means one real ATTEMPT — a deferred
|
||||
bring-up never attempts, so the marker survives to the next
|
||||
undeferred reload (review finding on #1923).
|
||||
"""
|
||||
if defer_mutations:
|
||||
return ""
|
||||
return str(self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or "").strip()
|
||||
|
||||
async def _async_defer_package_mutations(
|
||||
self, installed_version: str | None
|
||||
) -> None:
|
||||
"""Handle the defer-mutations branch of :meth:`_async_ensure_package`.
|
||||
|
||||
A previous bring-up's worker is still importing, so the package files
|
||||
must not be replaced under it. With an importable build on disk
|
||||
(``installed_version`` non-None — importability only, compatibility is
|
||||
checked by the caller afterwards) nothing is touched at all — not even
|
||||
the requirements manager, which installs any unsatisfied spec and
|
||||
would mutate exactly like the deferred force install. When nothing
|
||||
imports there are no distribution files to replace under the live
|
||||
importer, and without an install this bring-up cannot produce a
|
||||
server at all, so the requirements manager still runs.
|
||||
"""
|
||||
_LOGGER.warning(
|
||||
"Deferring the ha-mcp install/upgrade: a previous bring-up's "
|
||||
"worker thread is still importing, and replacing the package "
|
||||
"files under it could corrupt that import. The currently "
|
||||
"installed build will be used; reload the integration (or "
|
||||
"restart Home Assistant) to apply the update."
|
||||
)
|
||||
if installed_version is None:
|
||||
await self._async_process_requirements_fast()
|
||||
|
||||
def _replaced_dist_name(self) -> str | None:
|
||||
"""Return the distribution whose presence could no-op the new spec.
|
||||
|
||||
This is the distribution the effective spec installs *by name* — the
|
||||
channel's distribution for a channel spec, or the named distribution
|
||||
of an override that parses as a requirement (a pin like
|
||||
``ha-mcp==X``, matched against the two known channel dists). It is
|
||||
deliberately NOT the channel's dist for every override: a repo
|
||||
tarball installs as ``ha-mcp`` regardless of the selected channel, so
|
||||
keying on the channel would miss the dev-channel + override case.
|
||||
|
||||
Returns None for an override that names an unknown distribution or
|
||||
does not parse as a requirement at all (a direct URL): the installer
|
||||
re-fetches and rebuilds URL requirements under ``upgrade=True``
|
||||
regardless of the installed version, so a URL install is already
|
||||
real and nothing needs removing.
|
||||
"""
|
||||
if not self._pip_spec_override:
|
||||
return dist_for_channel(self._channel)
|
||||
try:
|
||||
name = canonicalize_name(Requirement(self._pip_spec_override).name)
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
for dist_name in (DIST_NAME_STABLE, DIST_NAME_DEV):
|
||||
if name == canonicalize_name(dist_name):
|
||||
return dist_name
|
||||
return None
|
||||
|
||||
async def _async_remove_replaced_source(
|
||||
self, stored_spec: str | None, installed_version: str | None
|
||||
) -> None:
|
||||
"""Uninstall the replaced distribution when the requested source changed.
|
||||
|
||||
The forced install that follows relies on ``upgrade=True``, and the
|
||||
installer decides "already satisfied" by VERSION alone — but a source
|
||||
change can keep the version string. A PR branch's committed
|
||||
``project.version`` equals the release it branched from (only release
|
||||
automation bumps it), so its tarball installs with that same version
|
||||
string; clearing the override then resolves the channel spec to the
|
||||
exact version already on disk and the install swaps nothing, leaving
|
||||
the PR code running while the entry reports a clean channel install
|
||||
(issue #1914). The same version-blindness bites a manual spec edit
|
||||
that pins the version already installed. The installer cannot see the
|
||||
difference, so when the spec that produced the current install
|
||||
differs from the one about to be installed, the distribution the new
|
||||
spec resolves to by name (:meth:`_replaced_dist_name`) is removed
|
||||
first — the install that follows is then unconditionally real.
|
||||
|
||||
Skipped when nothing is installed, when the last-installed spec is
|
||||
unknown (nothing to compare: first install, or entry data predating
|
||||
the spec tracking), when the spec is unchanged (the routine
|
||||
reload/restart path, where ``upgrade=True`` alone is correct and an
|
||||
uninstall would churn — and briefly break — a healthy install on
|
||||
every restart), when the new spec is a direct URL (always installs
|
||||
for real), when the named distribution is not installed (e.g. a
|
||||
cross-channel switch already removed it), when the stored spec is
|
||||
an index requirement on the SAME distribution (a repin — e.g.
|
||||
toggling auto-update rewrites bare ``ha-mcp`` to ``ha-mcp==X`` —
|
||||
draws from the same index either way, so version resolution is
|
||||
faithful and uninstalling a healthy install on a preference toggle
|
||||
would only add an offline-breakage window), or when the new spec is
|
||||
an exact pin on a version provably different from the installed one
|
||||
(the install cannot no-op, so the working build stays in place as
|
||||
the fallback if it fails).
|
||||
|
||||
Unlike the other pre-install uninstalls this one is NOT best-effort:
|
||||
if the distribution survives a failed uninstall, the forced install
|
||||
would no-op as "already satisfied", the new spec would be persisted,
|
||||
and the next reload would see it as unchanged — reproducing #1914 and
|
||||
then permanently masking it. Raising instead keeps the stored spec on
|
||||
the old value, so the next reload retries the whole source change.
|
||||
"""
|
||||
if installed_version is None or stored_spec is None:
|
||||
return
|
||||
if stored_spec == self._pip_spec:
|
||||
return
|
||||
replaced_dist = self._replaced_dist_name()
|
||||
if replaced_dist is None:
|
||||
return
|
||||
if _spec_is_index_requirement_on(stored_spec, replaced_dist):
|
||||
# Same distribution, same index — only the pin changed. The old
|
||||
# code on disk came from the index too, so "already satisfied by
|
||||
# version" is the truth, not the #1914 lie.
|
||||
return
|
||||
pinned = _exact_pinned_version(self._pip_spec)
|
||||
if pinned is not None:
|
||||
try:
|
||||
version_moves = Version(pinned) != Version(installed_version)
|
||||
except InvalidVersion:
|
||||
version_moves = False # unprovable — keep the uninstall
|
||||
if version_moves:
|
||||
# The new pin cannot be satisfied by the installed version, so
|
||||
# the forced install is guaranteed to be real without any
|
||||
# uninstall — and keeping the working build in place preserves
|
||||
# it as the fallback if that install fails (e.g. offline).
|
||||
return
|
||||
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
|
||||
return
|
||||
_LOGGER.info(
|
||||
"The requested server source changed (%r -> %r); removing the "
|
||||
"installed %r first so the reinstall cannot be skipped as "
|
||||
"already satisfied",
|
||||
stored_spec,
|
||||
self._pip_spec,
|
||||
replaced_dist,
|
||||
)
|
||||
removed = await self._async_remove_distribution(replaced_dist)
|
||||
if not removed and await self._hass.async_add_executor_job(
|
||||
_dist_installed, replaced_dist
|
||||
):
|
||||
raise EmbeddedServerError(
|
||||
f"Could not remove the installed {replaced_dist!r} (from "
|
||||
f"{stored_spec!r}) before installing {self._pip_spec!r}: the "
|
||||
"installer would report the new source as already satisfied "
|
||||
"and keep the old code running. Uninstall details are logged "
|
||||
"above; reload the integration to retry the source change.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
async def _async_process_requirements_fast(self) -> None:
|
||||
"""Fast path: let HA's requirements manager satisfy the override spec."""
|
||||
try:
|
||||
@@ -583,7 +989,7 @@ class EmbeddedServerManager:
|
||||
kwargs["timeout"] = max(
|
||||
int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS
|
||||
)
|
||||
installed = await self._hass.async_add_executor_job(
|
||||
installed = await self._async_run_tracked_install_job(
|
||||
partial(install_package, self._pip_spec, upgrade=True, **kwargs)
|
||||
)
|
||||
if not installed:
|
||||
@@ -624,15 +1030,25 @@ class EmbeddedServerManager:
|
||||
)
|
||||
await self._async_remove_distribution(other)
|
||||
|
||||
async def _async_remove_distribution(self, dist_name: str) -> None:
|
||||
"""Remove a distribution from the same target used for installation."""
|
||||
async def _async_remove_distribution(self, dist_name: str) -> bool:
|
||||
"""Remove a distribution from the same target used for installation.
|
||||
|
||||
Tracked like the install: an uninstall mutates the same package files.
|
||||
Returns whether the uninstall subprocess reported success; callers
|
||||
decide whether a failure is best-effort (channel-conflict / legacy
|
||||
cleanup) or fatal (the replaced-source removal, whose failure would
|
||||
silently void the reinstall — see ``_async_remove_replaced_source``).
|
||||
"""
|
||||
target = pip_kwargs(self._hass.config.config_dir).get("target")
|
||||
if target is None:
|
||||
await self._hass.async_add_executor_job(_uninstall_distribution, dist_name)
|
||||
result = await self._async_run_tracked_install_job(
|
||||
partial(_uninstall_distribution, dist_name)
|
||||
)
|
||||
else:
|
||||
await self._hass.async_add_executor_job(
|
||||
result = await self._async_run_tracked_install_job(
|
||||
partial(_uninstall_distribution, dist_name, target=target)
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
def _store_installed_spec(self) -> None:
|
||||
"""Persist the pip spec just installed so a restart skips the reinstall."""
|
||||
@@ -724,6 +1140,15 @@ class EmbeddedServerManager:
|
||||
|
||||
# -- worker thread -----------------------------------------------------
|
||||
|
||||
def _note_startup_phase(self, phase: str) -> None:
|
||||
"""Publish the worker's startup phase (a plain attribute write).
|
||||
|
||||
Read by the readiness poll: a phase advance counts as progress, and the
|
||||
failure message names the phase the worker was last seen in.
|
||||
"""
|
||||
self._startup_phase = phase
|
||||
_LOGGER.debug("HA-MCP in-process server startup: %s", phase)
|
||||
|
||||
def _thread_main(self, access_token: str) -> None:
|
||||
"""Thread entry point: stage non-secret env, then run the server.
|
||||
|
||||
@@ -744,12 +1169,38 @@ class EmbeddedServerManager:
|
||||
stop_event = asyncio.Event()
|
||||
self._loop = loop
|
||||
self._stop_event = stop_event
|
||||
# Registration in _IMPORTING_WORKERS happened on the MAIN thread,
|
||||
# before start() — see async_start. This thread only deregisters:
|
||||
# in _serve once the import section completes, and in the finally
|
||||
# below on exit as the backstop.
|
||||
try:
|
||||
loop.run_until_complete(self._serve(access_token, stop_event))
|
||||
except SystemExit as err:
|
||||
# uvicorn signals a startup failure (e.g. the port is already in
|
||||
# use) with SystemExit(STARTUP_FAILURE), which ``except Exception``
|
||||
# misses — live issue #1904 saw the real bind error surface only
|
||||
# in HA's generic task-exception log while the component reported
|
||||
# a bare readiness timeout. Unwrap the original error so the
|
||||
# repair issue names the actual cause; a bare SystemExit (no
|
||||
# chained exception) is reported by repr so an empty/zero exit
|
||||
# code still reads as what it is. The phase names where in
|
||||
# _serve the exit happened instead of hardcoding a bind failure.
|
||||
cause = err.__context__ or err.__cause__
|
||||
detail = str(cause) if cause is not None else repr(err)
|
||||
self._thread_exc = EmbeddedServerError(
|
||||
f"the server exited during startup ({self._startup_phase}): {detail}"
|
||||
)
|
||||
_LOGGER.error(
|
||||
"HA-MCP in-process server exited during startup (%s): %s",
|
||||
self._startup_phase,
|
||||
detail,
|
||||
)
|
||||
except Exception as err:
|
||||
self._thread_exc = err
|
||||
_LOGGER.exception("HA-MCP in-process server thread crashed")
|
||||
finally:
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||
# Teardown is best-effort but never SILENT (review finding): a
|
||||
# raise here must not mask the primary outcome, yet a recurring
|
||||
# cleanup failure (leaking executor threads across reloads) has
|
||||
@@ -779,6 +1230,7 @@ class EmbeddedServerManager:
|
||||
# Hand ha-mcp the loopback URL + provisioned admin token in memory, before
|
||||
# the server (and its settings singleton) is built. Keeping the token out
|
||||
# of os.environ is the whole point of the in-process channel.
|
||||
self._note_startup_phase("importing the server package")
|
||||
import ha_mcp.config as _hamcp_config
|
||||
|
||||
# Record which code generation this worker imported. Prefer the
|
||||
@@ -786,6 +1238,11 @@ class EmbeddedServerManager:
|
||||
# ha_mcp.__version__ itself checks stable first and stale stable
|
||||
# metadata can otherwise make a fresh dev worker look outdated.
|
||||
self._running_version = _running_ha_mcp_version(self._channel)
|
||||
# The cache in sys.modules now holds this generation — remembered
|
||||
# process-wide so the next start can skip the purge when the install
|
||||
# has not changed (issue #1904).
|
||||
global _CACHED_IMPORT_VERSION
|
||||
_CACHED_IMPORT_VERSION = self._running_version
|
||||
|
||||
# Drop any settings singleton cached by a PREVIOUS start in this same
|
||||
# Python process: an entry reload must re-read the override files
|
||||
@@ -805,12 +1262,33 @@ class EmbeddedServerManager:
|
||||
"entry may serve stale override values until HA restarts"
|
||||
)
|
||||
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
if self._loopback_verify_ssl is None:
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
else:
|
||||
try:
|
||||
_hamcp_config.set_embedded_connection(
|
||||
self._server_url,
|
||||
access_token,
|
||||
verify_ssl=self._loopback_verify_ssl,
|
||||
)
|
||||
except TypeError:
|
||||
# Server predates the verify_ssl parameter (< the release
|
||||
# carrying issue #1890's fix). Register url+token the old way;
|
||||
# on an SSL-enabled instance the wss loopback will fail
|
||||
# certificate verification until the server package updates —
|
||||
# no worse than the plaintext failure it replaces.
|
||||
_LOGGER.warning(
|
||||
"Installed ha-mcp server does not accept verify_ssl for "
|
||||
"the embedded connection; loopback TLS verification stays "
|
||||
"enabled until the server package updates"
|
||||
)
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
|
||||
# Imported here, in the worker thread, after the connection is registered.
|
||||
from ha_mcp.server import HomeAssistantSmartMCPServer
|
||||
from ha_mcp.settings_ui import register_settings_routes
|
||||
|
||||
self._note_startup_phase("building the server")
|
||||
server = HomeAssistantSmartMCPServer()
|
||||
|
||||
# Startup observability (no secrets): confirm the in-memory connection
|
||||
@@ -849,6 +1327,7 @@ class EmbeddedServerManager:
|
||||
|
||||
# Parity with the CLI HTTP runner: serve the web settings UI under the
|
||||
# same secret path as the MCP endpoint.
|
||||
self._note_startup_phase("registering web routes")
|
||||
register_settings_routes(server.mcp, server, secret_path=self._secret_path)
|
||||
|
||||
# Parity with the CLI HTTP runner: answer a browser GET on the MCP path
|
||||
@@ -891,6 +1370,16 @@ class EmbeddedServerManager:
|
||||
else:
|
||||
ensure_host_origin_guard_default_off()
|
||||
|
||||
# The cold import — the multi-minute window that crashed in #1904 —
|
||||
# is complete: every explicit ha_mcp import in _serve precedes this
|
||||
# line. Leave the registry so a long-running healthy server never
|
||||
# blocks later bring-ups' purges (removal from sys.modules cannot
|
||||
# unload already-bound modules; only in-flight imports are
|
||||
# corruptible, and any later lazy import is outside the window this
|
||||
# registry protects).
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||
|
||||
app = server.mcp.http_app(path=self._secret_path, stateless_http=True)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
@@ -905,6 +1394,7 @@ class EmbeddedServerManager:
|
||||
)
|
||||
uv_server = uvicorn.Server(config)
|
||||
|
||||
self._note_startup_phase("starting the HTTP listener")
|
||||
stop_task = asyncio.create_task(stop_event.wait())
|
||||
async with server.mcp._lifespan_manager():
|
||||
serve_task = asyncio.create_task(uv_server.serve())
|
||||
@@ -924,15 +1414,37 @@ class EmbeddedServerManager:
|
||||
# Surface a server that exited on its own (bind failure, etc.).
|
||||
serve_task.result()
|
||||
|
||||
def _progress_signature(self) -> tuple[int, str]:
|
||||
"""Snapshot the observable startup progress of the worker thread.
|
||||
|
||||
``len(sys.modules)`` moves continuously while the worker grinds
|
||||
through a cold import (the single longest startup step — minutes on
|
||||
slow hardware, issue #1904), and the published phase moves between
|
||||
steps. Any change in the pair counts as progress. The module count is
|
||||
PROCESS-wide — an approximation: nothing finer-grained is observable
|
||||
from outside a thread stuck inside one ``import`` statement, and any
|
||||
other HA thread importing concurrently also refreshes the stall
|
||||
budget. Erring toward patience is the point; the absolute cap bounds
|
||||
the wait regardless.
|
||||
"""
|
||||
return (len(sys.modules), self._startup_phase)
|
||||
|
||||
async def _async_wait_until_ready(self) -> None:
|
||||
"""Poll a loopback TCP connect until the server accepts, or fail.
|
||||
|
||||
On failure (timeout or an early thread crash) stops the thread and raises
|
||||
Patience is progress-based: the wait only gives up when there is no
|
||||
observable progress for ``_READY_STALL_TIMEOUT_SECONDS`` (or the
|
||||
absolute ``_READY_TOTAL_CAP_SECONDS`` ceiling is hit). A slow cold
|
||||
import keeps the wait alive; a wedged worker is caught by the stall
|
||||
budget on a quiet instance, by the cap at the latest (the progress
|
||||
signal is process-wide). On failure stops the thread and raises
|
||||
:class:`EmbeddedServerError` so the caller leaves the webhook
|
||||
unregistered and files a repair issue.
|
||||
"""
|
||||
deadline = self._hass.loop.time() + _READY_TIMEOUT_SECONDS
|
||||
while self._hass.loop.time() < deadline:
|
||||
start = self._hass.loop.time()
|
||||
last_progress = start
|
||||
last_signature = self._progress_signature()
|
||||
while True:
|
||||
if self._thread_exc is not None:
|
||||
raise EmbeddedServerError(
|
||||
f"HA-MCP in-process server failed to start: {self._thread_exc}"
|
||||
@@ -948,15 +1460,32 @@ class EmbeddedServerManager:
|
||||
self._port,
|
||||
)
|
||||
return
|
||||
now = self._hass.loop.time()
|
||||
signature = self._progress_signature()
|
||||
if signature != last_signature:
|
||||
last_signature = signature
|
||||
last_progress = now
|
||||
if now - start >= _READY_TOTAL_CAP_SECONDS:
|
||||
failure = (
|
||||
f"HA-MCP in-process server did not become reachable on "
|
||||
f"port {self._port} within {_READY_TOTAL_CAP_SECONDS:.0f}s "
|
||||
f"(last startup phase: {self._startup_phase})."
|
||||
)
|
||||
break
|
||||
if now - last_progress >= _READY_STALL_TIMEOUT_SECONDS:
|
||||
failure = (
|
||||
f"HA-MCP in-process server did not become reachable on "
|
||||
f"port {self._port}: no startup progress observed for "
|
||||
f"{_READY_STALL_TIMEOUT_SECONDS:.0f}s (last phase: "
|
||||
f"{self._startup_phase}; {now - start:.0f}s since start)."
|
||||
)
|
||||
break
|
||||
await asyncio.sleep(_READY_POLL_INTERVAL_SECONDS)
|
||||
|
||||
# Timed out — tear the thread down so we never leave a half-started
|
||||
# Gave up — tear the thread down so we never leave a half-started
|
||||
# server behind an unregistered webhook.
|
||||
await self.async_stop()
|
||||
raise EmbeddedServerError(
|
||||
f"HA-MCP in-process server did not become reachable on port "
|
||||
f"{self._port} within {_READY_TIMEOUT_SECONDS:.0f}s."
|
||||
)
|
||||
raise EmbeddedServerError(failure)
|
||||
|
||||
async def _async_probe_port(self) -> bool:
|
||||
"""Return True if a loopback TCP connection to the server port succeeds.
|
||||
@@ -977,6 +1506,68 @@ class EmbeddedServerManager:
|
||||
return True
|
||||
|
||||
|
||||
# Worker threads that may still be executing their ha_mcp imports. Purging
|
||||
# sys.modules while any of them is alive corrupts the in-flight import
|
||||
# (KeyError from frozen importlib — issue #1904). Process-global because a
|
||||
# manager is recreated on every bring-up, so per-manager orphan tracking
|
||||
# cannot see a previous manager's abandoned worker. The spawning (main)
|
||||
# thread registers the worker BEFORE start() — see async_start — and the
|
||||
# worker deregisters once its import section completes (or on thread exit,
|
||||
# whichever comes first). All access
|
||||
# goes through the lock: CPython's GIL would make the individual set ops
|
||||
# atomic, but the purge gate's prune-then-check is a composite read and the
|
||||
# lock keeps its correctness independent of GIL scheduling arguments.
|
||||
# Deliberate tradeoff: a worker wedged forever inside its import stays
|
||||
# registered and blocks every later purge until an HA core restart — evicting
|
||||
# a live importer on a timer would reintroduce the very corruption this
|
||||
# registry prevents, and the skip warning plus the post-start staleness check
|
||||
# surface the condition.
|
||||
_IMPORTING_WORKERS_LOCK = threading.Lock()
|
||||
_IMPORTING_WORKERS: set[threading.Thread] = set()
|
||||
|
||||
|
||||
def _prune_and_check_importing_workers() -> bool:
|
||||
"""Drop dead workers from the registry; return True if any live one remains."""
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.difference_update(
|
||||
[t for t in _IMPORTING_WORKERS if not t.is_alive()]
|
||||
)
|
||||
return bool(_IMPORTING_WORKERS)
|
||||
|
||||
|
||||
# Completion event of the package-mutating install/uninstall job currently on
|
||||
# the executor, if any. asyncio cancellation of a bring-up detaches the
|
||||
# awaiter, but the executor job keeps running to completion — untracked, an
|
||||
# orphaned pip could swap the distribution's files under the NEXT bring-up's
|
||||
# install or its worker's cold import (found in review of PR #1911, the
|
||||
# #1904 fixes; pre-existing). The dispatching coroutine registers the event BEFORE handing
|
||||
# the job to the executor, and the executor fn sets it in a finally that
|
||||
# survives cancellation; the next bring-up waits on it before mutating
|
||||
# anything. Process-global for the same reason as _IMPORTING_WORKERS: a
|
||||
# manager is recreated on every bring-up.
|
||||
#
|
||||
# Single slot BY DESIGN: at most one tracked job can exist at a time — the
|
||||
# server entry is single-instance, an entry reload cancels-and-awaits the
|
||||
# previous bring-up before setting up, and every dispatch site sits behind
|
||||
# _async_wait_for_pending_install. A second concurrent dispatcher would
|
||||
# overwrite the slot and silently lose the older live job — keep any new
|
||||
# package-mutating call site behind the wait gate. The slot tracking wraps
|
||||
# the DIRECT-pip sites (force install, uninstalls); the fast path goes
|
||||
# through HA's requirements manager, which is behind the gate but untracked —
|
||||
# it only dispatches pip when the package is missing outright, which cannot
|
||||
# co-occur with a live orphaned job worth waiting on.
|
||||
_PENDING_INSTALL_LOCK = threading.Lock()
|
||||
_PENDING_INSTALL_DONE: threading.Event | None = None
|
||||
|
||||
|
||||
# Version of the ha_mcp generation currently cached in sys.modules — set by
|
||||
# the worker right after its first import lands, cleared by the purge. Process-wide
|
||||
# (the module cache it describes is process-wide too). Lets a retry with an
|
||||
# unchanged install keep the warm cache instead of paying the full cold import
|
||||
# again (issue #1904).
|
||||
_CACHED_IMPORT_VERSION: str | None = None
|
||||
|
||||
|
||||
def _purge_ha_mcp_modules() -> None:
|
||||
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
|
||||
|
||||
@@ -984,11 +1575,15 @@ def _purge_ha_mcp_modules() -> None:
|
||||
Python resolves imports from the process-wide ``sys.modules`` cache — so
|
||||
after a pip install the next worker would silently reuse the OLD code
|
||||
unless the cache is purged first. Safe here because ``ha_mcp`` is pure
|
||||
Python and is only ever imported inside the (currently stopped) worker
|
||||
thread; third-party dependencies are deliberately NOT purged (they are
|
||||
shared with the rest of Home Assistant), so a dependency-version change
|
||||
still needs an HA core restart.
|
||||
Python and is only ever imported inside worker threads, and the caller's
|
||||
gate guarantees no registered worker is mid-import when this runs (a
|
||||
worker past its imports keeps its already-bound modules regardless);
|
||||
third-party dependencies are deliberately NOT purged (they are shared
|
||||
with the rest of Home Assistant), so a dependency-version change still
|
||||
needs an HA core restart.
|
||||
"""
|
||||
global _CACHED_IMPORT_VERSION
|
||||
_CACHED_IMPORT_VERSION = None
|
||||
# Snapshot the keys: sys.modules can be mutated by concurrent imports on
|
||||
# other threads mid-iteration (HA core is heavily threaded).
|
||||
purged = [
|
||||
@@ -1120,6 +1715,46 @@ def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> boo
|
||||
return True
|
||||
|
||||
|
||||
def _exact_pinned_version(spec: str) -> str | None:
|
||||
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.
|
||||
|
||||
Anything else — URL specs, bare names, ranges, multi-clause specifiers —
|
||||
returns None: only an exact pin lets the caller prove, without asking the
|
||||
resolver, whether the installed version could satisfy the spec. A
|
||||
wildcard pin (``==7.13.*``) is returned as-is; the caller's ``Version``
|
||||
parse rejects it, which conservatively keeps the uninstall.
|
||||
"""
|
||||
try:
|
||||
req = Requirement(spec)
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
if req.url:
|
||||
return None
|
||||
clauses = list(req.specifier)
|
||||
if len(clauses) != 1 or clauses[0].operator not in ("==", "==="):
|
||||
return None
|
||||
return clauses[0].version
|
||||
|
||||
|
||||
def _spec_is_index_requirement_on(spec: str, dist_name: str) -> bool:
|
||||
"""Return whether ``spec`` is a plain index requirement on ``dist_name``.
|
||||
|
||||
True only for a PEP 508 requirement with no direct-URL part whose
|
||||
canonical name matches — i.e. a spec that installs ``dist_name`` from the
|
||||
package index (bare name or version pin). A direct URL (whether a plain
|
||||
URL string, which does not parse as a requirement, or a ``name @ url``
|
||||
form) returns False: its origin is not the index, so it is a genuine
|
||||
source change for the replaced-source check.
|
||||
"""
|
||||
try:
|
||||
req = Requirement(spec)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
if req.url:
|
||||
return False
|
||||
return canonicalize_name(req.name) == canonicalize_name(dist_name)
|
||||
|
||||
|
||||
def _is_compatible_embedded_version(version: str) -> bool:
|
||||
"""Return whether a server distribution provides the embedded API."""
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,9 @@ from .const import (
|
||||
COMPONENT_MANIFEST_AT_TAG_URL,
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_MANAGER,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_PENDING_UPDATE_NOTIFY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
@@ -43,8 +46,8 @@ from .const import (
|
||||
DEFAULT_PIP_SPEC,
|
||||
DEFAULT_SERVER_PORT,
|
||||
DOMAIN,
|
||||
HACS_COMPONENT_URL,
|
||||
ISSUE_COMPONENT_OUTDATED,
|
||||
ISSUE_LEGACY_OAUTH_RESTART,
|
||||
ISSUE_PACKAGE_FAILED,
|
||||
ISSUE_START_FAILED,
|
||||
ISSUE_UPDATE_HELD,
|
||||
@@ -58,12 +61,16 @@ from .const import (
|
||||
OPT_PIP_SPEC,
|
||||
OPT_SERVER_PORT,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
UPDATE_HOLD_DOCS_URL,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
channel_for_dist,
|
||||
)
|
||||
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
|
||||
from .hacs_nudge import async_schedule_hacs_nudge
|
||||
from .llm_api import async_register_llm_api, async_unregister_llm_api
|
||||
from .mcp_webhook import async_register_webhook, async_unregister_webhook
|
||||
from .oauth_legacy import legacy_credentials_active
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -111,23 +118,51 @@ async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None
|
||||
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
|
||||
secret_path = str(entry.data[DATA_SECRET_PATH])
|
||||
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||
oauth_client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
oauth_client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
# Always set up the loopback forwarding config — the sidebar settings
|
||||
# panel proxies through it (#1803); the option gates only the public
|
||||
# webhook endpoint.
|
||||
await async_register_webhook(
|
||||
# webhook endpoint. oauth_* args are ignored unless auth_mode is legacy.
|
||||
oauth_restart_needed = await async_register_webhook(
|
||||
hass,
|
||||
entry,
|
||||
port=manager.port,
|
||||
secret_path=secret_path,
|
||||
auth_mode=auth_mode,
|
||||
register_endpoint=webhook_enabled,
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
oauth_signing_key=entry.data.get(DATA_OAUTH_SIGNING_KEY),
|
||||
)
|
||||
_async_update_legacy_oauth_issue(hass, oauth_restart_needed)
|
||||
if not webhook_enabled:
|
||||
_LOGGER.info(
|
||||
"Webhook access disabled by option - the server is local-only "
|
||||
"(direct port + sidebar panel)"
|
||||
)
|
||||
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
|
||||
# Only surface cleartext credentials once the bound provider actually
|
||||
# serves them: while a rotation is pending restart, an old-identity
|
||||
# token still validates and can read this log (see
|
||||
# legacy_credentials_active).
|
||||
oauth_creds_active = True
|
||||
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
oauth_creds_active = legacy_credentials_active(
|
||||
hass,
|
||||
str(oauth_client_id or ""),
|
||||
str(oauth_client_secret or ""),
|
||||
str(entry.data.get(DATA_OAUTH_SIGNING_KEY) or ""),
|
||||
)
|
||||
_surface_connect_urls(
|
||||
hass,
|
||||
entry,
|
||||
auth_mode,
|
||||
webhook_enabled=webhook_enabled,
|
||||
extra_hosts=await async_get_lan_hosts(hass),
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
oauth_creds_active=oauth_creds_active,
|
||||
oauth_restart_pending=oauth_restart_needed,
|
||||
)
|
||||
# Conversation-agent LLM API (#1745), gated on its option (default on).
|
||||
# Advisory: registration failures are contained inside (logged, feature
|
||||
# absent) — the running server must never be taken down by them.
|
||||
@@ -189,6 +224,87 @@ async def async_revoke_credentials_on_remove(
|
||||
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
|
||||
_clear_issues(hass)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
# Clear the legacy-OAuth restart repair too: it is filed only from bring-up,
|
||||
# which never runs again for a removed entry, so a restart that was still
|
||||
# pending at removal would otherwise leave a dangling warning for a server
|
||||
# that no longer exists. (Re-enabling legacy on a fresh entry re-files it.)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
|
||||
|
||||
|
||||
async def async_get_lan_hosts(hass: HomeAssistant) -> list[str]:
|
||||
"""Every IPv4 address on an enabled network adapter, in adapter order (#1862).
|
||||
|
||||
Lets the connect-URL surfaces list one entry per interface on a
|
||||
multi-interface / multi-VLAN host, instead of only the single address
|
||||
``get_url`` resolves. IPv4 only: ``async_get_adapters`` returns a bare
|
||||
IPv6 ``address`` with a separate ``scope_id`` int, so a link-local adapter
|
||||
address is not a usable URL host without rejoining that zone id (and every
|
||||
IPv6 host additionally needs bracket-wrapping), so building those correctly
|
||||
is out of scope; the reported setups are IPv4. Best-effort: any failure
|
||||
yields an empty list so URL
|
||||
surfacing degrades to the single ``get_url`` host rather than taking down
|
||||
the caller (bring-up would otherwise file a repair issue for a display-only
|
||||
lookup).
|
||||
"""
|
||||
try:
|
||||
from homeassistant.components import network
|
||||
|
||||
adapters = await network.async_get_adapters(hass)
|
||||
# The whole extraction is inside the try (not just the fetch): a
|
||||
# malformed adapter entry must degrade to the single get_url host too,
|
||||
# never escape into async_bring_up_server's handler, which would tear
|
||||
# the running server down and file a start-failure for a display-only
|
||||
# lookup.
|
||||
return [
|
||||
ipv4["address"]
|
||||
for adapter in adapters
|
||||
if adapter["enabled"]
|
||||
for ipv4 in adapter["ipv4"]
|
||||
]
|
||||
except Exception: # display-only enumeration; must never fail the caller
|
||||
_LOGGER.warning(
|
||||
"Adapter enumeration failed; using the single resolved host",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _swap_url_host(base: str, host: str) -> str:
|
||||
"""Return ``base`` with its host replaced by ``host`` (scheme/port/path kept)."""
|
||||
parsed = urlparse(base)
|
||||
netloc = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||
return parsed._replace(netloc=netloc).geturl()
|
||||
|
||||
|
||||
def _dedup_hosts(primary: str | None, extra: list[str] | None) -> list[str]:
|
||||
"""Ordered unique hosts, ``primary`` (the canonical get_url host) first."""
|
||||
ordered: list[str] = []
|
||||
for host in (primary, *(extra or [])):
|
||||
if host and host not in ordered:
|
||||
ordered.append(host)
|
||||
return ordered
|
||||
|
||||
|
||||
def _resolve_local_url(hass: HomeAssistant) -> tuple[str | None, str | None]:
|
||||
"""The get_url internal base URL and its host, or ``(None, None)``."""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
try:
|
||||
base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
except NoURLAvailableError:
|
||||
return None, None # No internal/local URL configured - hint form instead.
|
||||
return base, urlparse(base).hostname
|
||||
|
||||
|
||||
def _local_webhook_urls(
|
||||
local_base: str, local_host: str | None, lan_hosts: list[str], webhook_id: str
|
||||
) -> list[str]:
|
||||
"""One local webhook URL per LAN host (canonical ``local_base`` verbatim)."""
|
||||
return [
|
||||
f"{local_base if host == local_host else _swap_url_host(local_base, host)}"
|
||||
f"/api/webhook/{webhook_id}"
|
||||
for host in lan_hosts
|
||||
]
|
||||
|
||||
|
||||
def build_connect_urls(
|
||||
@@ -196,6 +312,7 @@ def build_connect_urls(
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
extra_hosts: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve the entry's connect URLs (webhook forms first, then direct).
|
||||
|
||||
@@ -203,9 +320,13 @@ def build_connect_urls(
|
||||
log on start-up and the entry's Configure screen (the notification
|
||||
deliberately carries none - it is visible to every signed-in user). Each
|
||||
source is best-effort: a URL that cannot be resolved is omitted.
|
||||
"""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
``extra_hosts`` (from :func:`async_get_lan_hosts`) adds one webhook and one
|
||||
direct-access URL per additional LAN address, so a multi-interface /
|
||||
multi-VLAN host surfaces every reachable interface rather than only the
|
||||
single host ``get_url`` picks (#1862). The ``get_url`` host stays canonical
|
||||
and first; any repeat of it in ``extra_hosts`` is deduped away.
|
||||
"""
|
||||
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
|
||||
urls: list[str] = []
|
||||
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
|
||||
@@ -234,14 +355,15 @@ def build_connect_urls(
|
||||
except ImportError:
|
||||
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
|
||||
|
||||
local_host: str | None = None
|
||||
try:
|
||||
local_base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
local_host = urlparse(local_base).hostname
|
||||
if webhook_id:
|
||||
urls.append(f"{local_base}/api/webhook/{webhook_id}")
|
||||
except NoURLAvailableError:
|
||||
pass # No internal/local URL configured - fall through to the hint form.
|
||||
local_base, local_host = _resolve_local_url(hass)
|
||||
|
||||
# One entry per enabled LAN address so a multi-interface / multi-VLAN host
|
||||
# surfaces every reachable interface rather than get_url's single pick
|
||||
# (#1862). The get_url host stays canonical and first.
|
||||
lan_hosts = _dedup_hosts(local_host, extra_hosts)
|
||||
|
||||
if local_base and webhook_id:
|
||||
urls.extend(_local_webhook_urls(local_base, local_host, lan_hosts, webhook_id))
|
||||
|
||||
if not urls and webhook_id:
|
||||
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
|
||||
@@ -253,9 +375,9 @@ def build_connect_urls(
|
||||
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
|
||||
# Guarded on the secret path so a missing one omits the line instead of
|
||||
# rendering a valid-looking URL without its credential segment.
|
||||
urls.append(
|
||||
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
|
||||
" (direct access)"
|
||||
urls.extend(
|
||||
f"http://{host}:{port}{secret_path} (direct access)"
|
||||
for host in lan_hosts or ["<home-assistant-ip>"]
|
||||
)
|
||||
return urls
|
||||
|
||||
@@ -266,23 +388,83 @@ def _surface_connect_urls(
|
||||
auth_mode: str,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
extra_hosts: list[str] | None = None,
|
||||
oauth_client_id: str | None = None,
|
||||
oauth_client_secret: str | None = None,
|
||||
oauth_creds_active: bool = True,
|
||||
oauth_restart_pending: bool = False,
|
||||
) -> None:
|
||||
"""Log the connect URLs and (re)create a persistent notification."""
|
||||
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
|
||||
auth_note = (
|
||||
"Webhook access is disabled (local-only mode)."
|
||||
if not webhook_enabled
|
||||
else "The webhook URL is the shared secret (no bearer required)."
|
||||
if auth_mode == WEBHOOK_AUTH_NONE
|
||||
else "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
urls = build_connect_urls(
|
||||
hass, entry, webhook_enabled=webhook_enabled, extra_hosts=extra_hosts
|
||||
)
|
||||
if not webhook_enabled:
|
||||
auth_note = "Webhook access is disabled (local-only mode)."
|
||||
elif auth_mode == WEBHOOK_AUTH_NONE:
|
||||
auth_note = "The webhook URL is the shared secret (no bearer required)."
|
||||
elif auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
# Kept secret-free (unlike the log line below) — see the SECURITY note
|
||||
# on the persistent notification further down, which reuses this text.
|
||||
creds_where = (
|
||||
"the Home Assistant log or the entry's Configure screen"
|
||||
if oauth_creds_active
|
||||
else "the entry's Configure screen"
|
||||
)
|
||||
auth_note = (
|
||||
"OAuth (Beta) is ENABLED for this URL (legacy mode) - see "
|
||||
f"{creds_where} for the Client ID and Client Secret to paste "
|
||||
"into your MCP client."
|
||||
)
|
||||
else:
|
||||
auth_note = "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
|
||||
url_lines = "\n".join(f"- {url}" for url in urls)
|
||||
_LOGGER.info(
|
||||
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
|
||||
url_lines,
|
||||
auth_note,
|
||||
log_message = (
|
||||
"HA-MCP in-process server is running. "
|
||||
f"Connect URL(s):\n{url_lines}\n{auth_note}"
|
||||
)
|
||||
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
if oauth_creds_active:
|
||||
# Admin-only log (mirrors the webhook-proxy add-on's own startup
|
||||
# log, start.py). Cleartext credentials — deliberately NOT in the
|
||||
# persistent notification below, which every signed-in user can
|
||||
# see.
|
||||
log_message += (
|
||||
f"\n OAuth Client ID: {oauth_client_id}"
|
||||
f"\n OAuth Client Secret: {oauth_client_secret}"
|
||||
)
|
||||
if oauth_restart_pending:
|
||||
# First-enable mid-session late-binds the root views, so
|
||||
# /authorize is not live until the restart the repair asks
|
||||
# for. The credentials ARE the ones that will be served
|
||||
# (oauth_creds_active is True), but pasting them now gets a
|
||||
# connection that fails until the restart — same caveat the
|
||||
# rotation branch, the options hint, and the oauth_regenerate
|
||||
# help text carry.
|
||||
log_message += (
|
||||
"\n Legacy OAuth is not live until the restart Home "
|
||||
"Assistant is asking for; these credentials work once "
|
||||
"you restart."
|
||||
)
|
||||
log_message += (
|
||||
"\n Paste both into your MCP client's OAuth connector setup "
|
||||
"(e.g. Google Gemini Spark: Advanced settings)."
|
||||
)
|
||||
else:
|
||||
# SECURITY (review finding on #1880): while a credential rotation
|
||||
# is pending the restart, the bound root views still serve the OLD
|
||||
# identity, so a token issued under it stays valid — and could
|
||||
# read this log through the server's own log tools. Logging the
|
||||
# NEW credentials here would hand them to exactly the party the
|
||||
# rotation is meant to evict, so they are withheld until the
|
||||
# restart makes them active (which also kills every old token).
|
||||
log_message += (
|
||||
"\n The OAuth credentials were rotated and take effect after "
|
||||
"the restart Home Assistant is asking for; until then the "
|
||||
"previous credentials remain active. The new Client ID and "
|
||||
"Client Secret are on the entry's Configure screen."
|
||||
)
|
||||
_LOGGER.info(log_message)
|
||||
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
|
||||
# Notification suppressed by option: clear any notification created
|
||||
# before the toggle was turned off, then skip creating a fresh one. The
|
||||
@@ -322,6 +504,29 @@ def _surface_connect_urls(
|
||||
)
|
||||
|
||||
|
||||
def _async_update_legacy_oauth_issue(hass: HomeAssistant, restart_needed: bool) -> None:
|
||||
"""File/clear the legacy-OAuth restart repair per ``async_register_webhook``'s
|
||||
return value.
|
||||
|
||||
Raised on BOTH transitions (see that function's docstring): enabling
|
||||
legacy mode (the root views just bound, or bound with different
|
||||
credentials than before) and disabling it (the views are still bound from
|
||||
a prior legacy registration). aiohttp can neither bind nor release an HTTP
|
||||
view without a full Home Assistant restart either way.
|
||||
"""
|
||||
if restart_needed:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_LEGACY_OAUTH_RESTART,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_LEGACY_OAUTH_RESTART,
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
|
||||
|
||||
|
||||
_ISSUE_BY_KIND = {
|
||||
"package": ISSUE_PACKAGE_FAILED,
|
||||
"start": ISSUE_START_FAILED,
|
||||
@@ -450,8 +655,13 @@ async def async_maybe_auto_update(
|
||||
"shipped": shipped,
|
||||
"running": running,
|
||||
},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
learn_more_url=UPDATE_HOLD_DOCS_URL,
|
||||
)
|
||||
# A newer component exists but HACS may not surface it for ~48h; ask
|
||||
# HACS to refresh this repository now so the update becomes visible
|
||||
# promptly. Fire-and-forget + throttled per shipped version; fully
|
||||
# advisory (see hacs_nudge).
|
||||
async_schedule_hacs_nudge(hass, shipped)
|
||||
return
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
||||
|
||||
@@ -707,7 +917,12 @@ async def _async_check_component_compat(
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_COMPONENT_OUTDATED,
|
||||
translation_placeholders={"required": required, "installed": own},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
learn_more_url=UPDATE_HOLD_DOCS_URL,
|
||||
)
|
||||
# The server needs a newer component than HACS has surfaced; ask HACS
|
||||
# to refresh this repository now so the required update becomes visible
|
||||
# promptly. Fire-and-forget + throttled per required version; fully
|
||||
# advisory (see hacs_nudge).
|
||||
async_schedule_hacs_nudge(hass, required)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Nudge HACS to refresh this component's repository info when a newer
|
||||
component is detected (#1783/#1785 follow-up).
|
||||
|
||||
The component notices a newer custom component quickly — it checks PyPI every
|
||||
6 hours (and on every reload/restart) and the auto-update gate reads the
|
||||
component version shipped at each server release's tag. HACS, by contrast,
|
||||
refreshes a *custom* repository's release data only about every 48 hours, so
|
||||
the component update the hold (or the component-outdated repair) is waiting on
|
||||
is usually not yet visible in HACS. This module runs the same force-refresh
|
||||
that HACS's own repository "Update information" menu action performs, so HACS's
|
||||
update entity flips promptly and Home Assistant advertises the component update
|
||||
natively instead of the user waiting out HACS's cache.
|
||||
|
||||
HACS registers no service and ``homeassistant.update_entity`` is a no-op on its
|
||||
entities, so there is no supported API for this: the refresh reaches directly
|
||||
into HACS internals (``hass.data["hacs"]``). Those internals can change under us
|
||||
at any HACS release, so EVERY access here is defensive and the whole interaction
|
||||
is advisory — any failure degrades to a debug log and never touches the caller's
|
||||
update-check path (which runs on bring-up, gating webhook registration, and on
|
||||
the version coordinator's listener). The same unsupported ``hass.data["hacs"]``
|
||||
reach as install_source_check, but deliberately logged at debug where that
|
||||
module warns: this path retries on every 6h check, so a persistent HACS shape
|
||||
change would otherwise warn forever about an advisory nicety.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
HACS_LEGACY_REPO_FULL_NAME,
|
||||
HACS_MIRROR_REPO_FULL_NAME,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# hass.data[DOMAIN] sub-key holding the SET of component versions HACS was
|
||||
# already asked to refresh for. The hold check runs every 6h; without this the
|
||||
# same pending version would force a fresh HACS network refresh on every pass.
|
||||
# A set, not a single string: the update hold nudges with the shipped version
|
||||
# while the component-outdated check nudges with the required version, and a
|
||||
# scalar marker would ping-pong between the two, re-refreshing every pass
|
||||
# (review finding). Distinct from the other DOMAIN sub-keys (const.py) so both
|
||||
# entry types can share hass.data[DOMAIN].
|
||||
_DATA_HACS_NUDGED_VERSIONS = "hacs_nudged_versions"
|
||||
|
||||
# The repository full_names (owner/repo) HACS may track this component under, in
|
||||
# lookup order: the dedicated mirror first (the current install path), the
|
||||
# legacy main-repo path second (pre-mirror installs — see install_source_check).
|
||||
_CANDIDATE_REPO_FULL_NAMES = (
|
||||
HACS_MIRROR_REPO_FULL_NAME,
|
||||
HACS_LEGACY_REPO_FULL_NAME,
|
||||
)
|
||||
|
||||
|
||||
def async_schedule_hacs_nudge(hass: HomeAssistant, target_version: str) -> None:
|
||||
"""Fire-and-forget the HACS refresh nudge for ``target_version``.
|
||||
|
||||
Scheduled as a background task rather than awaited: ``update_repository``
|
||||
makes a GitHub network call, and the callers must not block on it — the
|
||||
component-compat check is awaited inside the server bring-up *before*
|
||||
webhook registration (blocking it would delay the connect URLs for a
|
||||
display-only refresh), and the auto-update hold runs on the version
|
||||
coordinator's listener. ``async_create_task`` keeps a strong reference so
|
||||
the task is not garbage-collected mid-flight; every failure is contained
|
||||
inside :func:`async_nudge_hacs_refresh`.
|
||||
"""
|
||||
hass.async_create_task(
|
||||
async_nudge_hacs_refresh(hass, target_version),
|
||||
f"{DOMAIN}_hacs_nudge",
|
||||
)
|
||||
|
||||
|
||||
async def async_nudge_hacs_refresh(hass: HomeAssistant, target_version: str) -> None:
|
||||
"""Ask HACS to re-fetch this component's repository info for ``target_version``.
|
||||
|
||||
Throttled to at most one refresh per detected component version (the marker
|
||||
lives in ``hass.data[DOMAIN]``): the hold check repeats every 6h, and
|
||||
hammering HACS's GitHub fetch each pass for the same pending version would
|
||||
be pointless. Absent / broken HACS and a not-yet-registered repository stay
|
||||
unthrottled so a later pass (once HACS is ready) still gets its one refresh.
|
||||
"""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
nudged_versions: set[str] = domain_data.setdefault(
|
||||
_DATA_HACS_NUDGED_VERSIONS, set()
|
||||
)
|
||||
if target_version in nudged_versions:
|
||||
# Already refreshed HACS for this pending component version.
|
||||
return
|
||||
|
||||
try:
|
||||
refreshed = await _async_force_hacs_repo_refresh(hass)
|
||||
except Exception:
|
||||
# HACS internals are unsupported and may change shape (missing hacs,
|
||||
# renamed attributes, a network failure inside update_repository); any
|
||||
# of it must degrade to a debug log, never fault the caller.
|
||||
_LOGGER.debug(
|
||||
"HA-MCP: could not nudge HACS to refresh the component repository",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if refreshed:
|
||||
# Throttle only on a completed refresh, so a transient miss (HACS not
|
||||
# set up yet, repo not registered this pass) is retried next check
|
||||
# rather than suppressed for this version forever.
|
||||
nudged_versions.add(target_version)
|
||||
|
||||
|
||||
async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
|
||||
"""Run HACS's "Update information" force-refresh for this component's repo.
|
||||
|
||||
Returns True when a tracked repository was found and its refresh completed,
|
||||
False when there is nothing to refresh (no HACS, or no INSTALLED repository
|
||||
under either candidate name). Reaches into HACS internals —
|
||||
the top-level lookups are ``getattr``-guarded so a wholly different HACS
|
||||
shape returns False cleanly; anything deeper that changes shape raises and is
|
||||
swallowed by :func:`async_nudge_hacs_refresh`.
|
||||
"""
|
||||
hacs = hass.data.get("hacs")
|
||||
if hacs is None:
|
||||
# No HACS (manual/copy install, or HACS set up later this run) — nothing
|
||||
# to refresh; the caller leaves the throttle unset so a later pass retries.
|
||||
return False
|
||||
|
||||
repositories = getattr(hacs, "repositories", None)
|
||||
get_by_full_name = getattr(repositories, "get_by_full_name", None)
|
||||
if get_by_full_name is None:
|
||||
return False
|
||||
|
||||
repository = None
|
||||
for full_name in _CANDIDATE_REPO_FULL_NAMES:
|
||||
candidate = get_by_full_name(full_name)
|
||||
if candidate is None:
|
||||
continue
|
||||
# HACS keeps a repository record for every ADDED repo, downloaded or
|
||||
# not, but only creates an update entity for DOWNLOADED ones — and a
|
||||
# legacy->mirror migration can leave the mirror added but not yet
|
||||
# (re)installed while the running component is still tracked under the
|
||||
# legacy record. Refreshing an uninstalled record lights up nothing, so
|
||||
# only an installed candidate counts (review finding).
|
||||
if not getattr(getattr(candidate, "data", None), "installed", False):
|
||||
continue
|
||||
repository = candidate
|
||||
break
|
||||
if repository is None:
|
||||
return False
|
||||
|
||||
# The repository's "Update information" menu action: re-fetch its release
|
||||
# data ignoring cached state, then push the fresh data to HACS's own update
|
||||
# entity so Home Assistant advertises the component update immediately.
|
||||
await repository.update_repository(ignore_issues=True, force=True)
|
||||
# The refresh is complete at this point; the listener push below only
|
||||
# re-publishes the fresh data to HACS's update entity sooner. Guarded
|
||||
# separately so a HACS shape change here cannot void the completed
|
||||
# refresh's throttle and re-run the network fetch every pass (review
|
||||
# finding).
|
||||
try:
|
||||
coordinators = getattr(hacs, "coordinators", None) or {}
|
||||
category = getattr(getattr(repository, "data", None), "category", None)
|
||||
coordinator = coordinators.get(category)
|
||||
if coordinator is not None:
|
||||
coordinator.async_update_listeners()
|
||||
except Exception:
|
||||
_LOGGER.debug(
|
||||
"HA-MCP: HACS listener push after the repository refresh failed",
|
||||
exc_info=True,
|
||||
)
|
||||
return True
|
||||
@@ -21,17 +21,18 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, HACS_COMPONENT_URL, ISSUE_LEGACY_HACS_SOURCE
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
HACS_COMPONENT_URL,
|
||||
HACS_LEGACY_REPO_FULL_NAME,
|
||||
ISSUE_LEGACY_HACS_SOURCE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The main server repo's full_name, as HACS's repository registry keys it —
|
||||
# the legacy (pre-mirror) install path this module detects.
|
||||
_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp"
|
||||
|
||||
# Guards the schedule below so multiple config entries (tools + server) on the
|
||||
# same HA run only ever schedule the check once.
|
||||
_DATA_SCHEDULED = "install_source_check_scheduled"
|
||||
@@ -86,7 +87,7 @@ async def _async_check_install_source(hass: HomeAssistant) -> None:
|
||||
hacs = hass.data.get("hacs")
|
||||
installed = False
|
||||
if hacs is not None:
|
||||
repo = hacs.repositories.get_by_full_name(_LEGACY_REPO_FULL_NAME)
|
||||
repo = hacs.repositories.get_by_full_name(HACS_LEGACY_REPO_FULL_NAME)
|
||||
installed = repo is not None and bool(repo.data.installed)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
"domain": "ha_mcp_tools",
|
||||
"name": "HA-MCP Custom Component",
|
||||
"after_dependencies": [
|
||||
"http",
|
||||
"backup",
|
||||
"cloud",
|
||||
"frontend"
|
||||
"frontend",
|
||||
"http",
|
||||
"lovelace",
|
||||
"network"
|
||||
],
|
||||
"codeowners": [
|
||||
"@homeassistant-ai"
|
||||
@@ -19,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.2"
|
||||
}
|
||||
|
||||
@@ -5,21 +5,34 @@ Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
|
||||
the response back, so the server is reachable through Nabu Casa remote UI (or any
|
||||
reverse proxy) with the webhook id as the shared secret.
|
||||
|
||||
Two auth postures, chosen in the options flow:
|
||||
Three auth postures, chosen in the options flow:
|
||||
|
||||
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
|
||||
default). No bearer is required.
|
||||
default). No bearer is required and the forwarder always returns 200. It still
|
||||
serves our own corrected RFC 8414 / RFC 9728 discovery documents plus an
|
||||
invisible auto-approve authorization server (:mod:`oauth_autoapprove`), so
|
||||
claude.ai's intermittent OAuth discovery resolves against us — not HA core's
|
||||
broken origin-root doc — and connects with no HA login (issue #1969).
|
||||
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
|
||||
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
|
||||
ChatGPT can sign in with the user's HA account) and validates inbound bearer
|
||||
tokens via ``hass.auth``. There is no bespoke authorization-server code here —
|
||||
every protocol step is HA core's own ``/auth/*``.
|
||||
* ``legacy`` — this module (via :mod:`oauth_legacy`) is its own OAuth 2.1
|
||||
authorization server with a static client_id/secret, for MCP clients (Google
|
||||
Gemini Spark) that need a credential to paste rather than an HA sign-in.
|
||||
|
||||
The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop
|
||||
header stripping, the SSE streaming branch with anti-buffering headers, the
|
||||
content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error
|
||||
mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
|
||||
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``.
|
||||
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``; the ``legacy``
|
||||
provider + its root ``/authorize`` + ``/token`` views live in
|
||||
:mod:`oauth_legacy`, ported from the ``legacy`` subset of the add-on's
|
||||
``oauth.py``. The seven RFC 8414 / RFC 9728 discovery views below are shared by
|
||||
``ha_auth``, ``legacy``, and ``none`` (which serves a distinct auto-approve
|
||||
authorization-server document pointing at :mod:`oauth_autoapprove`'s endpoints)
|
||||
— see :func:`active_auth_mode`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,8 +54,22 @@ from .const import (
|
||||
DOMAIN,
|
||||
OAUTH_BASE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
)
|
||||
from .oauth_autoapprove import (
|
||||
CFG_AUTOAPPROVE_PROVIDER,
|
||||
AutoApproveProvider,
|
||||
bind_autoapprove_views,
|
||||
)
|
||||
from .oauth_legacy import (
|
||||
AUTHORIZE_PATH,
|
||||
OAUTH_ROUTE_OWNER_KEY,
|
||||
TOKEN_PATH,
|
||||
LegacyOAuthProvider,
|
||||
LegacyOAuthRouteConflict,
|
||||
bind_legacy_views,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -145,14 +172,6 @@ class ResourceServer:
|
||||
"""This install's private webhook id."""
|
||||
return self._webhook_id
|
||||
|
||||
def resource_url(self, base_url: str) -> str:
|
||||
"""Absolute URL of the protected webhook resource under ``base_url``."""
|
||||
return f"{base_url}/api/webhook/{self._webhook_id}"
|
||||
|
||||
def authorization_server_url(self, base_url: str) -> str:
|
||||
"""Issuer / authorization-server URL under ``base_url``."""
|
||||
return f"{base_url}{OAUTH_BASE}"
|
||||
|
||||
async def validate_request(self, request: web.Request) -> bool:
|
||||
"""Return True iff the request carries a Bearer token HA core accepts.
|
||||
|
||||
@@ -195,29 +214,62 @@ class ResourceServer:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
|
||||
# RFC 8414 / RFC 9728 discovery views (ha_auth + legacy modes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
|
||||
"""Return the CURRENT entry's ha_auth resource server, or None.
|
||||
|
||||
The discovery views resolve this per request instead of binding a provider
|
||||
at registration time: aiohttp can't drop a bound view until HA restarts, so
|
||||
a remove + re-add of the config entry (which mints a NEW webhook id in the
|
||||
same HA session) would otherwise leave the views advertising the old id.
|
||||
Returns None when no entry is live, the webhook auth mode is not ha_auth,
|
||||
or the public endpoint is disabled (local-only mode constructs no resource
|
||||
server even under ha_auth) — the views then 404 like an unregistered route.
|
||||
"""
|
||||
def _active_webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
"""Return the live webhook forwarding cfg dict, or None if not set up."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return None
|
||||
cfg = domain_data.get(DATA_WEBHOOK)
|
||||
if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA:
|
||||
return cfg if isinstance(cfg, dict) else None
|
||||
|
||||
|
||||
def active_auth_mode(hass: HomeAssistant) -> str | None:
|
||||
"""Return the OAuth-relevant auth mode of the live webhook registration.
|
||||
|
||||
``WEBHOOK_AUTH_HA``, ``WEBHOOK_AUTH_LEGACY``, or ``WEBHOOK_AUTH_NONE`` (the
|
||||
none-mode auto-approve surface, issue #1969), or None when no discovery
|
||||
surface is live. Checked via PROVIDER PRESENCE, not the raw configured
|
||||
``auth_mode`` string, so local-only mode (remote webhook disabled by
|
||||
option — ``register_endpoint=False`` in ``async_register_webhook``)
|
||||
correctly reports None even when ``webhook_auth`` is set: no provider is
|
||||
constructed for a webhook that was never registered, so there is nothing to
|
||||
advertise or authenticate against. Read live from hass.data (not captured at view/provider
|
||||
construction time) so the SAME registered/bound instances serve whichever
|
||||
mode is active now — mirrors the add-on's ``_active_oauth_mode``. Used by
|
||||
the discovery views below AND by ``LegacyOAuthProvider.is_active`` (via
|
||||
the getter passed into :func:`oauth_legacy.bind_legacy_views`) so the
|
||||
root ``/authorize``/``/token`` views, which aiohttp can never unbind, 404
|
||||
once the operator switches away from legacy (or to local-only mode)
|
||||
without a restart. The webhook forwarder's own bearer gate
|
||||
(``_async_handle_webhook``) reads ``cfg["resource_server"]`` /
|
||||
``cfg["oauth_provider"]`` directly instead of through this function — it
|
||||
already has ``cfg`` in hand and needs the provider OBJECT, not just the
|
||||
mode name.
|
||||
"""
|
||||
cfg = _active_webhook_cfg(hass)
|
||||
if cfg is None:
|
||||
return None
|
||||
provider = cfg.get("resource_server")
|
||||
return provider if isinstance(provider, ResourceServer) else None
|
||||
if cfg.get("resource_server") is not None:
|
||||
return WEBHOOK_AUTH_HA
|
||||
if cfg.get("oauth_provider") is not None:
|
||||
return WEBHOOK_AUTH_LEGACY
|
||||
if cfg.get(CFG_AUTOAPPROVE_PROVIDER) is not None:
|
||||
return WEBHOOK_AUTH_NONE
|
||||
return None
|
||||
|
||||
|
||||
def _active_webhook_id(hass: HomeAssistant) -> str | None:
|
||||
"""Webhook id of the live registration, gated the same as the AS document
|
||||
(None whenever :func:`active_auth_mode` is None) so the protected-resource
|
||||
document 404s in exactly the same cases."""
|
||||
if active_auth_mode(hass) is None:
|
||||
return None
|
||||
cfg = _active_webhook_cfg(hass)
|
||||
return cfg.get("webhook_id") if cfg is not None else None
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
@@ -225,16 +277,63 @@ def _json_not_found() -> web.Response:
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]:
|
||||
"""RFC 9728 protected-resource document for ``provider`` under ``base``."""
|
||||
def _protected_resource_document(webhook_id: str, base: str) -> dict[str, Any]:
|
||||
"""RFC 9728 protected-resource document for ``webhook_id`` under ``base``.
|
||||
|
||||
Identical shape in both OAuth modes — only the authorization-server
|
||||
document (below) differs by mode.
|
||||
"""
|
||||
return {
|
||||
"resource": provider.resource_url(base),
|
||||
"authorization_servers": [provider.authorization_server_url(base)],
|
||||
"resource": f"{base}/api/webhook/{webhook_id}",
|
||||
"authorization_servers": [f"{base}{OAUTH_BASE}"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
|
||||
"""RFC 8414 authorization-server metadata for legacy mode's own root
|
||||
``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`)."""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"authorization_endpoint": f"{base}{AUTHORIZE_PATH}",
|
||||
"token_endpoint": f"{base}{TOKEN_PATH}",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
|
||||
"""RFC 8414 authorization-server metadata for none mode's auto-approve server.
|
||||
|
||||
Points at OUR OWN ``OAUTH_BASE`` ``/authorize`` + ``/token`` (the invisible
|
||||
auto-approve endpoints in :mod:`oauth_autoapprove`), NOT HA core's
|
||||
``/auth/*``. Serving this — with ``token_endpoint_auth_methods_supported:
|
||||
["none"]`` (public PKCE client) and ``client_id_metadata_document_supported``
|
||||
— is the none-mode fix: claude.ai's intermittent discovery resolves against
|
||||
this corrected document instead of HA core's origin-root
|
||||
``/.well-known/oauth-authorization-server``, which omits the ``"none"`` auth
|
||||
method and has no ``registration_endpoint`` (issue #1969). No refresh grant:
|
||||
the token is cosmetic (none mode ignores bearers), so only
|
||||
``authorization_code`` is advertised.
|
||||
"""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
|
||||
"token_endpoint": f"{base}{OAUTH_BASE}/token",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"client_id_metadata_document_supported": True,
|
||||
}
|
||||
|
||||
|
||||
class _ProtectedResourceMetadataView(HomeAssistantView):
|
||||
"""RFC 9728 Protected Resource Metadata."""
|
||||
|
||||
@@ -244,21 +343,36 @@ class _ProtectedResourceMetadataView(HomeAssistantView):
|
||||
name = "ha_mcp_tools:oauth:protected-resource"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; the provider is resolved per request."""
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Serve the protected-resource document (or 404 when ha_auth is off)."""
|
||||
provider = _active_resource_server(self._hass)
|
||||
if provider is None:
|
||||
"""Serve the protected-resource document for the bearer-gated modes only.
|
||||
|
||||
SECURITY (#1976 review): this ANONYMOUS, fixed (guessable) path exposes
|
||||
``resource: <base>/api/webhook/<id>``. In none mode the webhook id is the
|
||||
SOLE credential, so serving it here would leak it to any unauthenticated
|
||||
GET. Serve only for ``ha_auth``/``legacy`` (where the id is not a secret
|
||||
and the 401 ``WWW-Authenticate`` pointer legitimately directs a client
|
||||
here); 404 otherwise. The PATH-SCOPED well-known view still serves in none
|
||||
mode — its caller must already know the id (it is a route parameter).
|
||||
"""
|
||||
if active_auth_mode(self._hass) not in (WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
|
||||
return _json_not_found()
|
||||
webhook_id = _active_webhook_id(self._hass)
|
||||
if webhook_id is None:
|
||||
return _json_not_found()
|
||||
return web.json_response(
|
||||
_protected_resource_document(provider, _build_base_url(request))
|
||||
_protected_resource_document(webhook_id, _build_base_url(request))
|
||||
)
|
||||
|
||||
|
||||
class _AuthorizationServerMetadataView(HomeAssistantView):
|
||||
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
|
||||
"""RFC 8414 Authorization Server Metadata.
|
||||
|
||||
Mode-aware: ha_auth points at HA core's own ``/auth/*``; legacy points at
|
||||
this module's root ``/authorize``/``/token`` views.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
@@ -270,10 +384,15 @@ class _AuthorizationServerMetadataView(HomeAssistantView):
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Serve the authorization-server document (or 404 when ha_auth is off)."""
|
||||
if _active_resource_server(self._hass) is None:
|
||||
"""Serve the AS document (or 404 when no OAuth mode is live)."""
|
||||
mode = active_auth_mode(self._hass)
|
||||
if mode is None:
|
||||
return _json_not_found()
|
||||
base = _build_base_url(request)
|
||||
if mode == WEBHOOK_AUTH_LEGACY:
|
||||
return web.json_response(_legacy_authorization_server_document(base))
|
||||
if mode == WEBHOOK_AUTH_NONE:
|
||||
return web.json_response(_none_mode_authorization_server_document(base))
|
||||
return web.json_response(_authorization_server_document(base))
|
||||
|
||||
|
||||
@@ -296,16 +415,16 @@ class _WellKnownProtectedResourceView(HomeAssistantView):
|
||||
url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; the provider is resolved per request."""
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
|
||||
"""Serve the document only for the CURRENT entry's webhook id."""
|
||||
provider = _active_resource_server(self._hass)
|
||||
if provider is None or webhook_id != provider.webhook_id:
|
||||
active_id = _active_webhook_id(self._hass)
|
||||
if active_id is None or webhook_id != active_id:
|
||||
return _json_not_found()
|
||||
return web.json_response(
|
||||
_protected_resource_document(provider, _build_base_url(request))
|
||||
_protected_resource_document(active_id, _build_base_url(request))
|
||||
)
|
||||
|
||||
|
||||
@@ -324,7 +443,8 @@ class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView
|
||||
|
||||
|
||||
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
|
||||
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
|
||||
"""Build the seven discovery-document views, shared by ha_auth and legacy
|
||||
(mode-agnostic — each view resolves the active mode per request)."""
|
||||
views: list[HomeAssistantView] = [
|
||||
_ProtectedResourceMetadataView(hass),
|
||||
_AuthorizationServerMetadataView(hass),
|
||||
@@ -355,13 +475,14 @@ def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
|
||||
|
||||
|
||||
def _register_metadata_views(hass: HomeAssistant) -> None:
|
||||
"""Register the ha_auth discovery views at most once per HA session.
|
||||
"""Register the seven discovery views at most once per HA session.
|
||||
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must
|
||||
reuse the already-bound views — they resolve the ACTIVE provider from
|
||||
hass.data per request, so a later entry (even with a new webhook id) is
|
||||
served correctly. The guard flag lives at a top-level hass.data key that
|
||||
survives config-entry teardown.
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add /
|
||||
ha_auth<->legacy mode switch must all reuse the already-bound views — they
|
||||
resolve the ACTIVE mode + provider from hass.data per request (see
|
||||
``active_auth_mode``), so a later entry (even with a new webhook id, or a
|
||||
different auth mode) is served correctly. The guard flag lives at a
|
||||
top-level hass.data key that survives config-entry teardown.
|
||||
"""
|
||||
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
@@ -370,9 +491,7 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
|
||||
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
|
||||
|
||||
|
||||
def _build_unauthorized_response(
|
||||
request: web.Request, provider: ResourceServer
|
||||
) -> web.Response:
|
||||
def _build_unauthorized_response(request: web.Request) -> web.Response:
|
||||
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
|
||||
|
||||
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
|
||||
@@ -397,6 +516,29 @@ def _build_unauthorized_response(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _check_webhook_auth(
|
||||
request: web.Request, cfg: dict[str, Any]
|
||||
) -> web.StreamResponse | None:
|
||||
"""Return a 401 challenge response if the request fails the auth gate, else None."""
|
||||
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
|
||||
# validates the bearer via HA core; ``legacy`` validates it against this
|
||||
# module's own opaque tokens. Either failure emits the same 401 discovery
|
||||
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
|
||||
# (constructed only for the matching mode) rather than a string compare,
|
||||
# so the coupling "provider present <=> mode" has a single owner and an
|
||||
# inconsistent cfg cannot fail open. auth_mode makes the two mutually
|
||||
# exclusive, so at most one of these is ever set.
|
||||
resource_server: ResourceServer | None = cfg.get("resource_server")
|
||||
if resource_server is not None and not await resource_server.validate_request(
|
||||
request
|
||||
):
|
||||
return _build_unauthorized_response(request)
|
||||
oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider")
|
||||
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
|
||||
return _build_unauthorized_response(request)
|
||||
return None
|
||||
|
||||
|
||||
async def _async_handle_webhook(
|
||||
hass: HomeAssistant, webhook_id: str, request: web.Request
|
||||
) -> web.StreamResponse:
|
||||
@@ -406,16 +548,9 @@ async def _async_handle_webhook(
|
||||
if not isinstance(cfg, dict):
|
||||
return web.Response(status=503, text="MCP server is not available")
|
||||
|
||||
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
|
||||
# = validate the bearer via HA core, and on failure emit the 401 discovery
|
||||
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
|
||||
# (constructed only for ha_auth) rather than a string compare, so the
|
||||
# coupling "provider present <=> ha_auth" has a single owner and an
|
||||
# inconsistent cfg cannot fail open.
|
||||
provider = cfg.get("resource_server")
|
||||
if provider is not None:
|
||||
if not await provider.validate_request(request):
|
||||
return _build_unauthorized_response(request, provider)
|
||||
auth_response = await _check_webhook_auth(request, cfg)
|
||||
if auth_response is not None:
|
||||
return auth_response
|
||||
|
||||
target_url: str = cfg["target_url"]
|
||||
session: aiohttp.ClientSession = cfg["session"]
|
||||
@@ -504,22 +639,34 @@ async def async_register_webhook(
|
||||
secret_path: str,
|
||||
auth_mode: str,
|
||||
register_endpoint: bool = True,
|
||||
) -> None:
|
||||
"""Register the ingress webhook (and, for ha_auth, the discovery views).
|
||||
oauth_client_id: str | None = None,
|
||||
oauth_client_secret: str | None = None,
|
||||
oauth_signing_key: str | None = None,
|
||||
) -> bool:
|
||||
"""Register the ingress webhook (and, for ha_auth/legacy, the OAuth surface).
|
||||
|
||||
Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens
|
||||
a long-lived aiohttp session for streaming. Raises on failure with the webhook
|
||||
already unregistered, so the caller never leaves a half-configured endpoint
|
||||
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
|
||||
before this runs.
|
||||
before this runs. ``oauth_client_id``/``oauth_client_secret``/
|
||||
``oauth_signing_key`` are required when ``auth_mode == WEBHOOK_AUTH_LEGACY``
|
||||
(ignored otherwise); ``oauth_signing_key`` is the hex string persisted in
|
||||
``entry.data`` — see ``oauth_legacy._normalize_signing_key``.
|
||||
|
||||
With ``register_endpoint=False`` (remote webhook access disabled by option)
|
||||
no public endpoint or ha_auth surface is created — and any leftover endpoint
|
||||
from a crashed unload is cleared, so off means off; only the forwarding
|
||||
config is stored, which same-host consumers — the sidebar settings panel
|
||||
proxy — need to reach the loopback server (#1803).
|
||||
no public endpoint or ha_auth/legacy surface is created — and any leftover
|
||||
endpoint from a crashed unload is cleared, so off means off; only the
|
||||
forwarding config is stored, which same-host consumers — the sidebar
|
||||
settings panel proxy — need to reach the loopback server (#1803).
|
||||
|
||||
Returns True when the caller should surface ``ISSUE_LEGACY_OAUTH_RESTART``:
|
||||
the root ``/authorize``/``/token`` views just bound for the first time this
|
||||
HA session (or with changed credentials), or they are still bound from a
|
||||
prior legacy registration that this call has moved away from — either way
|
||||
aiohttp cannot bind or release a view without a full HA restart.
|
||||
"""
|
||||
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
|
||||
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
|
||||
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
|
||||
# bring-up files a repair issue, instead of an unrecognized string
|
||||
# silently taking the unauthenticated forward path.
|
||||
@@ -540,8 +687,11 @@ async def async_register_webhook(
|
||||
"session": session,
|
||||
"auth_mode": auth_mode,
|
||||
"resource_server": None,
|
||||
"oauth_provider": None,
|
||||
CFG_AUTOAPPROVE_PROVIDER: None,
|
||||
}
|
||||
|
||||
oauth_restart_needed = False
|
||||
if register_endpoint:
|
||||
try:
|
||||
async_register(
|
||||
@@ -556,6 +706,38 @@ async def async_register_webhook(
|
||||
provider = ResourceServer(hass, webhook_id)
|
||||
_register_metadata_views(hass)
|
||||
cfg["resource_server"] = provider
|
||||
elif auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
if not (oauth_client_id and oauth_client_secret and oauth_signing_key):
|
||||
raise ValueError(
|
||||
"legacy webhook auth mode requires oauth_client_id, "
|
||||
"oauth_client_secret, and oauth_signing_key"
|
||||
)
|
||||
_register_metadata_views(hass)
|
||||
try:
|
||||
oauth_provider, oauth_restart_needed = bind_legacy_views(
|
||||
hass, oauth_client_id, oauth_client_secret, oauth_signing_key
|
||||
)
|
||||
except LegacyOAuthRouteConflict as err:
|
||||
raise ValueError(
|
||||
"The Webhook Proxy add-on (or its dev flavor) already "
|
||||
f"owns the root /authorize and /token routes ({err}). "
|
||||
"Stop that add-on and restart Home Assistant, then "
|
||||
"enable legacy mode again."
|
||||
) from err
|
||||
cfg["oauth_provider"] = oauth_provider
|
||||
else:
|
||||
# WEBHOOK_AUTH_NONE (the only remaining mode — unknown modes
|
||||
# already raised above). The secret webhook URL is the
|
||||
# credential, but we still serve our own corrected discovery +
|
||||
# an invisible auto-approve authorization server so claude.ai's
|
||||
# intermittent OAuth discovery resolves against us instead of HA
|
||||
# core's broken origin-root document, and completes with no HA
|
||||
# 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()
|
||||
except Exception:
|
||||
# Never leave a live endpoint (or a leaked session) behind a failed
|
||||
# auth-setup path. suppress: the ORIGINAL error must be what
|
||||
@@ -566,14 +748,27 @@ async def async_register_webhook(
|
||||
await session.close()
|
||||
raise
|
||||
|
||||
# A PRIOR registration this HA session may still own the legacy root views
|
||||
# even though THIS call bound no legacy provider — either the mode is no
|
||||
# longer legacy, OR legacy is still selected but the webhook endpoint is now
|
||||
# off (register_endpoint=False skips the bind block above). aiohttp can never
|
||||
# release a bound view without a restart, so gate on "no provider bound this
|
||||
# call" (not the mode string) to surface the restart that releases route
|
||||
# ownership in both cases.
|
||||
if cfg["oauth_provider"] is None and hass.data.get(OAUTH_ROUTE_OWNER_KEY) == DOMAIN:
|
||||
oauth_restart_needed = True
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
|
||||
return oauth_restart_needed
|
||||
|
||||
|
||||
async def async_unregister_webhook(hass: HomeAssistant) -> None:
|
||||
"""Unregister the ingress webhook and close its aiohttp session.
|
||||
|
||||
Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp
|
||||
can't unregister them until HA restarts); they 404 while ha_auth is not live.
|
||||
Idempotent. The discovery views and the legacy root ``/authorize``/``/token``
|
||||
views are intentionally left bound (aiohttp can't unregister them until HA
|
||||
restarts); they 404 while their mode is not live (see ``active_auth_mode``
|
||||
/ ``LegacyOAuthProvider.is_active``).
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""None-mode auto-approve OAuth authorization server (issue #1969).
|
||||
|
||||
In ``none`` webhook auth mode the secret webhook URL *is* the credential, so no
|
||||
bearer is required and the forwarder always returns 200. But claude.ai's
|
||||
connector onboarding intermittently front-loads OAuth discovery, and because the
|
||||
component registers no ``/.well-known`` views in none mode, claude.ai falls
|
||||
through to Home Assistant *core*'s own origin-root
|
||||
``/.well-known/oauth-authorization-server`` — which advertises
|
||||
``client_id_metadata_document_supported`` but omits
|
||||
``token_endpoint_auth_methods_supported: ["none"]`` and has no
|
||||
``registration_endpoint``. claude.ai then can neither use CIMD nor do dynamic
|
||||
client registration and shows "Automatic client registration isn't supported…".
|
||||
|
||||
This module is the none-mode fix's authorization-server half: a pair of
|
||||
path-scoped ``OAUTH_BASE`` endpoints that complete OAuth *invisibly* — no login,
|
||||
no consent — so a connector that does run discovery resolves against our own
|
||||
corrected documents (served by :mod:`mcp_webhook`) instead of HA core's broken
|
||||
root doc, and connects with zero HA login:
|
||||
|
||||
* ``GET {OAUTH_BASE}/authorize`` issues a PKCE-bound one-time code and
|
||||
immediately 302-redirects back to the client with ``?code=…&state=…`` — no
|
||||
page is rendered.
|
||||
* ``POST {OAUTH_BASE}/token`` exchanges that code (public client, PKCE S256, no
|
||||
``client_secret``) for an opaque access token. The token is *cosmetic* — none
|
||||
mode ignores bearers entirely — but is a real random string so a spec-strict
|
||||
client is satisfied.
|
||||
|
||||
Both views are gated per request off ``hass.data`` (they 404 unless none mode is
|
||||
the live webhook auth mode), mirroring the discovery views, so a
|
||||
``none``\\ ↔\\ ``ha_auth`` switch needs no restart. The PKCE code store and the
|
||||
redirect-URI floor are reused from :mod:`oauth_legacy` rather than copied.
|
||||
|
||||
**Open-redirect defence.** ``/authorize`` 302-redirects to a caller-supplied
|
||||
``redirect_uri`` on the Home Assistant origin, so an unvalidated target would be
|
||||
an open redirector. On top of :func:`oauth_legacy._is_valid_redirect_uri`'s
|
||||
scheme/host/port floor, the redirect must EXACTLY match a known MCP callback
|
||||
(:data:`_AUTOAPPROVE_REDIRECT_ALLOWLIST`). Anything else is a hard 400 (no
|
||||
redirect). A "same origin as the client_id" rule was deliberately NOT used: the
|
||||
client_id is fully attacker-controlled, so ``client_id == redirect_uri origin``
|
||||
still lets an attacker bounce a victim to any site of their choosing (a real
|
||||
open redirect on a public HA origin). Properly honouring an arbitrary CIMD
|
||||
client would require fetching the attacker-supplied client_id URL — an SSRF
|
||||
vector — so the allowlist is both the safe and the simple choice. Add a client's
|
||||
callback here to support it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
from .const import DATA_WEBHOOK, DOMAIN, OAUTH_BASE
|
||||
from .oauth_legacy import (
|
||||
_PKCE_CHALLENGE_RE,
|
||||
_TOKEN_RESPONSE_HEADERS,
|
||||
ACCESS_TOKEN_TTL,
|
||||
PKCECodeStore,
|
||||
_is_valid_redirect_uri,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
# cfg (hass.data[DOMAIN][DATA_WEBHOOK]) key holding the live AutoApproveProvider.
|
||||
# Present ONLY in none mode with the remote endpoint enabled; its presence is
|
||||
# how :func:`mcp_webhook.active_auth_mode` recognises the none-autoapprove live
|
||||
# mode (mirrors the "resource_server"/"oauth_provider" presence keys).
|
||||
CFG_AUTOAPPROVE_PROVIDER = "autoapprove_provider"
|
||||
|
||||
# TOP-LEVEL hass.data flag recording that the two auto-approve views are bound
|
||||
# for this HA session. Not under DOMAIN so it survives async_unload_entry's
|
||||
# teardown — aiohttp cannot unregister a bound view until HA restarts, so the
|
||||
# views (and this ownership flag) must outlive the config entry (mirrors
|
||||
# mcp_webhook._OAUTH_VIEWS_REGISTERED_KEY).
|
||||
_AUTOAPPROVE_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_autoapprove_views_registered"
|
||||
|
||||
# Known MCP OAuth callback URLs always accepted as a redirect target even when
|
||||
# the client_id is not a same-origin URL — claude.ai's connector onboarding
|
||||
# posts its authorization code here. Exact-match only (never a prefix test, so
|
||||
# ``https://claude.ai/api/mcp/auth_callback.evil.example`` cannot slip through).
|
||||
_AUTOAPPROVE_REDIRECT_ALLOWLIST = frozenset(
|
||||
{
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
"""404 JSON body used when none-autoapprove is not the live mode."""
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
def _json_error(
|
||||
error: str, status: int, description: str | None = None
|
||||
) -> web.Response:
|
||||
"""OAuth-style JSON error (RFC 6749 §5.2 shape) with no-store headers."""
|
||||
body: dict[str, str] = {"error": error}
|
||||
if description is not None:
|
||||
body["error_description"] = description
|
||||
return web.json_response(body, status=status, headers=_TOKEN_RESPONSE_HEADERS)
|
||||
|
||||
|
||||
def _is_valid_autoapprove_redirect(redirect_uri: str) -> bool:
|
||||
"""Open-redirect gate for the auto-approve ``/authorize`` view.
|
||||
|
||||
Exact-match allowlist only, on top of
|
||||
:func:`oauth_legacy._is_valid_redirect_uri`'s scheme/host/port floor. The
|
||||
``client_id`` is NOT consulted: it is attacker-controlled, so validating the
|
||||
redirect against it (even "same origin") does not constrain the redirect
|
||||
target to a trusted host. See the module docstring.
|
||||
"""
|
||||
return (
|
||||
_is_valid_redirect_uri(redirect_uri)
|
||||
and redirect_uri in _AUTOAPPROVE_REDIRECT_ALLOWLIST
|
||||
)
|
||||
|
||||
|
||||
def _redirect_with(redirect_uri: str, **params: str) -> web.Response:
|
||||
"""302 to ``redirect_uri`` with ``params`` merged into its query string."""
|
||||
# yarl ships with aiohttp and handles existing-query merging + encoding
|
||||
# correctly — safer than hand-rolling (matches oauth_legacy.AuthorizeView).
|
||||
import yarl
|
||||
|
||||
url = yarl.URL(redirect_uri).update_query(params)
|
||||
return web.Response(status=302, headers={"Location": str(url)})
|
||||
|
||||
|
||||
class AutoApproveProvider:
|
||||
"""None-mode auto-approve authorization-server state.
|
||||
|
||||
Holds only the PKCE code store shared with :mod:`oauth_legacy`; it owns no
|
||||
signing key and no client credentials (the token it issues is cosmetic).
|
||||
Constructed per registration and stored in ``cfg`` — the views resolve it
|
||||
from ``hass.data`` per request, so a reload minting a fresh provider is
|
||||
transparent (no bound view captures the old one, unlike legacy mode).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._code_store = PKCECodeStore()
|
||||
|
||||
def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None:
|
||||
"""Issue a one-shot PKCE-bound authorization code (see PKCECodeStore)."""
|
||||
return self._code_store.issue_code(redirect_uri, code_challenge)
|
||||
|
||||
def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool:
|
||||
"""Verify PKCE S256 + one-shot consume a code (see PKCECodeStore)."""
|
||||
return self._code_store.consume_code(code, redirect_uri, code_verifier)
|
||||
|
||||
@staticmethod
|
||||
def issue_access_token() -> str:
|
||||
"""Mint an opaque access token.
|
||||
|
||||
None mode ignores bearers (the secret webhook URL is the credential),
|
||||
so this token grants nothing — but it is a real random string, so a
|
||||
spec-strict client that stores/echoes it is satisfied.
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None:
|
||||
"""The live none-mode auto-approve provider, or None when it is not live.
|
||||
|
||||
Read live from ``hass.data`` (not captured at view construction) so the
|
||||
bound views serve only while none-autoapprove is the active mode and 404
|
||||
otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return None
|
||||
cfg = domain_data.get(DATA_WEBHOOK)
|
||||
if not isinstance(cfg, dict):
|
||||
return None
|
||||
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
||||
return provider if isinstance(provider, AutoApproveProvider) else None
|
||||
|
||||
|
||||
class AutoApproveAuthorizeView(HomeAssistantView):
|
||||
"""None-mode auto-approve ``/authorize`` — issues a code, 302s, no UI.
|
||||
|
||||
Validates ``response_type=code``, PKCE S256, and the redirect_uri
|
||||
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).
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/authorize"
|
||||
name = "ha_mcp_tools:oauth:autoapprove-authorize"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Auto-approve the authorization request or reject with a 400/404."""
|
||||
provider = _active_autoapprove_provider(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
|
||||
params = request.query
|
||||
response_type = params.get("response_type", "")
|
||||
redirect_uri = params.get("redirect_uri", "")
|
||||
state = params.get("state", "")
|
||||
code_challenge = params.get("code_challenge", "")
|
||||
code_challenge_method = params.get("code_challenge_method", "")
|
||||
|
||||
if response_type != "code":
|
||||
return _json_error("unsupported_response_type", 400)
|
||||
if code_challenge_method != "S256":
|
||||
return _json_error(
|
||||
"invalid_request", 400, "code_challenge_method must be S256"
|
||||
)
|
||||
if not _PKCE_CHALLENGE_RE.match(code_challenge):
|
||||
return _json_error(
|
||||
"invalid_request", 400, "invalid code_challenge (43-char base64url)"
|
||||
)
|
||||
# SECURITY: an unvalidated redirect_uri would be an open redirector on
|
||||
# the HA origin. Reject in-place (never redirect) unless it exactly
|
||||
# matches a known MCP callback (client_id is attacker-controlled and is
|
||||
# deliberately not consulted — see module docstring).
|
||||
if not _is_valid_autoapprove_redirect(redirect_uri):
|
||||
return _json_error("invalid_request", 400, "invalid redirect_uri")
|
||||
|
||||
code = provider.issue_code(redirect_uri, code_challenge)
|
||||
if code is None:
|
||||
# Pending-code store at capacity (abuse guard) — surface per
|
||||
# RFC 6749 §4.1.2.1 instead of a silent failure.
|
||||
return _redirect_with(
|
||||
redirect_uri, error="temporarily_unavailable", state=state
|
||||
)
|
||||
redirect_params = {"code": code}
|
||||
if state:
|
||||
redirect_params["state"] = state
|
||||
return _redirect_with(redirect_uri, **redirect_params)
|
||||
|
||||
|
||||
class AutoApproveTokenView(HomeAssistantView):
|
||||
"""None-mode auto-approve ``/token`` — PKCE code → opaque access token.
|
||||
|
||||
Public client (no ``client_secret``): the PKCE code_verifier is the only
|
||||
proof required. The returned access token is cosmetic (none mode ignores
|
||||
bearers), but real and opaque. Only the ``authorization_code`` grant is
|
||||
supported — none mode has no refresh cycle.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/token"
|
||||
name = "ha_mcp_tools:oauth:autoapprove-token"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Exchange a PKCE authorization code for an opaque access token."""
|
||||
provider = _active_autoapprove_provider(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
|
||||
form: dict[str, Any] = dict(await request.post())
|
||||
if form.get("grant_type", "") != "authorization_code":
|
||||
return _json_error("unsupported_grant_type", 400)
|
||||
|
||||
code = str(form.get("code", ""))
|
||||
redirect_uri = str(form.get("redirect_uri", ""))
|
||||
code_verifier = str(form.get("code_verifier", ""))
|
||||
if not (code and redirect_uri and code_verifier):
|
||||
return _json_error("invalid_request", 400)
|
||||
if not provider.consume_code(code, redirect_uri, code_verifier):
|
||||
return _json_error("invalid_grant", 400)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"access_token": provider.issue_access_token(),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_TTL,
|
||||
},
|
||||
headers=_TOKEN_RESPONSE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
def bind_autoapprove_views(hass: HomeAssistant) -> None:
|
||||
"""Bind the two auto-approve views at most once per HA session.
|
||||
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / mode
|
||||
switch must reuse the already-bound views — they resolve the active
|
||||
provider from ``hass.data`` per request (see
|
||||
:func:`_active_autoapprove_provider`), so they serve only while
|
||||
none-autoapprove is live and 404 otherwise. The guard flag lives at a
|
||||
top-level ``hass.data`` key that survives config-entry teardown (mirrors
|
||||
:func:`mcp_webhook._register_metadata_views`).
|
||||
"""
|
||||
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
hass.http.register_view(AutoApproveAuthorizeView(hass))
|
||||
hass.http.register_view(AutoApproveTokenView(hass))
|
||||
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user