Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+426
View File
@@ -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
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],
},
)
+40
View File
@@ -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)"
}
}
}
}
}
+603
View File
@@ -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,
)
File diff suppressed because it is too large Load Diff
+409
View File
@@ -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 ""
+34
View File
@@ -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)
+30
View File
@@ -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={})
+234
View File
@@ -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,
}
)
+101
View File
@@ -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)
File diff suppressed because one or more lines are too long
+19
View File
@@ -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}
+8
View File
@@ -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"
}
}
+21
View File
@@ -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"
}
+319
View File
@@ -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
)
+56
View File
@@ -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")
+774
View File
@@ -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)
+77
View File
@@ -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:
+722
View File
@@ -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)
+596
View File
@@ -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,
}
),
)
@@ -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
+27 -1
View File
@@ -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
+169 -35
View File
@@ -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."
)
+69 -4
View File
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "1.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"
+187 -13
View File
@@ -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
+699 -64
View File
@@ -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:
+245 -30
View File
@@ -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(
+6 -3
View File
@@ -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"
}
+267 -72
View File
@@ -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