Files
Tetra-AI-HA-Integration/custom_components/openclaw/config_flow.py
T

606 lines
22 KiB
Python

"""Config flow for the OpenClaw integration.
Supports two discovery methods:
1. Supervisor API + filesystem scan — auto-detects the addon in HAOS/Supervised
2. Manual entry — user provides gateway host, port, and token
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import voluptuous as vol
try:
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithReload,
)
except ImportError:
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
ConfigFlowResult = dict[str, Any]
OptionsFlowWithReload = OptionsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import OpenClawApiClient, OpenClawApiError, OpenClawAuthError, OpenClawConnectionError
from .const import (
ADDON_CONFIGS_ROOT,
ADDON_SLUG,
ADDON_SLUG_FRAGMENTS,
CONF_ADDON_CONFIG_PATH,
CONF_AGENT_ID,
CONF_ASSIST_SESSION_ID,
CONF_GATEWAY_HOST,
CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN,
CONF_USE_SSL,
CONF_VERIFY_SSL,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS,
CONF_FALLBACK_AGENT_ID,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED,
CONF_VOICE_AGENT_ID,
CONF_ALLOW_BRAVE_WEBSPEECH,
CONF_BROWSER_VOICE_LANGUAGE,
CONF_VOICE_PROVIDER,
CONF_THINKING_TIMEOUT,
CONF_USER_AGENT_MAP,
BROWSER_VOICE_LANGUAGES,
CONTEXT_STRATEGY_CLEAR,
CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_AGENT_ID,
DEFAULT_ASSIST_SESSION_ID,
DEFAULT_GATEWAY_HOST,
DEFAULT_GATEWAY_PORT,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_ENABLE_TOOL_CALLS,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_BROWSER_VOICE_LANGUAGE,
DEFAULT_VOICE_PROVIDER,
DEFAULT_THINKING_TIMEOUT,
DEFAULT_VOICE_AGENT_ID,
DOMAIN,
OPENCLAW_CONFIG_REL_PATH,
)
from .routing import (
DEFAULT_CHAT_AGENT_ID,
DEFAULT_HA_USER_AGENT_MAP,
normalize_optional_text,
parse_user_agent_map,
serialize_user_agent_map,
)
_LOGGER = logging.getLogger(__name__)
# ── Filesystem helpers ────────────────────────────────────────────────────────
def _find_addon_config_dir() -> Path | None:
"""Scan /addon_configs/ for the OpenClaw addon directory.
The Supervisor prepends a repository-specific hash to the addon slug:
/addon_configs/<hash>_<addon_name>/
e.g. /addon_configs/0bfc167e_openclaw_assistant/
We cannot predict the hash, so we scan for directories whose name
ends with one of the known slug fragments.
Returns:
Path to the addon config dir, or None if not found.
"""
root = Path(ADDON_CONFIGS_ROOT)
if not root.is_dir():
return None
# Exact slug match first (works if there's no hash prefix)
exact = root / ADDON_SLUG
if exact.is_dir():
return exact
# Scan for <hash>_<fragment> pattern
try:
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
name = entry.name.lower()
for fragment in ADDON_SLUG_FRAGMENTS:
if name.endswith(f"_{fragment}") or name == fragment:
_LOGGER.debug("Discovered addon config dir: %s", entry)
return entry
except PermissionError:
_LOGGER.debug("No permission to scan %s", root)
return None
def _read_gateway_token_from_path(config_dir: Path) -> str | None:
"""Read the gateway auth token from openclaw.json inside a config dir.
Args:
config_dir: The addon's mapped config directory.
Returns:
Token string if found, else None.
"""
config_file = config_dir / OPENCLAW_CONFIG_REL_PATH
if not config_file.exists():
_LOGGER.debug("openclaw.json not found at %s", config_file)
return None
try:
config = json.loads(config_file.read_text(encoding="utf-8"))
token = config.get("gateway", {}).get("auth", {}).get("token")
if token:
_LOGGER.debug("Found gateway token in %s", config_file)
return token
_LOGGER.debug("No gateway.auth.token in %s", config_file)
except (json.JSONDecodeError, IOError, KeyError) as err:
_LOGGER.debug("Error reading %s: %s", config_file, err)
return None
def _read_gateway_port_from_path(config_dir: Path) -> int | None:
"""Read the gateway port from openclaw.json.
Useful as a fallback when the Supervisor API is not available.
"""
config_file = config_dir / OPENCLAW_CONFIG_REL_PATH
if not config_file.exists():
return None
try:
config = json.loads(config_file.read_text(encoding="utf-8"))
return config.get("gateway", {}).get("port")
except (json.JSONDecodeError, IOError):
return None
# ── Discovery helpers ─────────────────────────────────────────────────────────
async def _async_try_discover_addon(hass: HomeAssistant) -> dict[str, Any] | None:
"""Try to discover the OpenClaw addon via Supervisor API + filesystem.
Steps:
1. Query Supervisor API for addon state & options (if available).
2. Scan /addon_configs/ to find the actual directory (hash-prefixed).
3. Read the gateway auth token from the discovered config directory.
Returns:
Dict with connection details if found, else None.
"""
addon_state: str | None = None
addon_options: dict[str, Any] = {}
# ── Step 1: Supervisor API (optional — gives us state & port) ────
try:
if "hassio" in hass.data:
from homeassistant.components.hassio import async_get_addon_info
addon_info = await async_get_addon_info(hass, ADDON_SLUG)
if addon_info:
addon_state = addon_info.get("state")
addon_options = addon_info.get("options", {})
_LOGGER.debug(
"Supervisor reports addon state=%s, options=%s",
addon_state,
{k: v for k, v in addon_options.items() if "token" not in k.lower()},
)
else:
_LOGGER.debug("Addon %s not found via Supervisor API", ADDON_SLUG)
else:
_LOGGER.debug("Supervisor not available — will try filesystem only")
except Exception as err: # noqa: BLE001
_LOGGER.debug("Supervisor API call failed: %s", err)
# If Supervisor says the addon is not running, don't bother scanning
if addon_state is not None and addon_state != "started":
_LOGGER.info(
"Addon discovered but not running (state=%s). Start it first.", addon_state
)
return None
# Warn only when Supervisor explicitly reports the option as disabled.
# If the key is missing (older/newer addon schema), do not warn.
enable_openai_api = addon_options.get("enable_openai_api")
if enable_openai_api is None and isinstance(addon_options.get("gateway"), dict):
enable_openai_api = addon_options["gateway"].get("enable_openai_api")
if enable_openai_api is False:
_LOGGER.warning(
"Addon option 'enable_openai_api' is false. "
"The integration requires this to be enabled. "
"Enable it in the addon configuration and restart the addon."
)
# ── Step 2: Find the addon config directory on the filesystem ────
config_dir = await hass.async_add_executor_job(_find_addon_config_dir)
if not config_dir:
_LOGGER.debug("Could not find addon config directory under %s", ADDON_CONFIGS_ROOT)
return None
# ── Step 3: Read the gateway token ────────────────────────────────
token = await hass.async_add_executor_job(_read_gateway_token_from_path, config_dir)
if not token:
_LOGGER.info(
"Found addon config at %s but could not read gateway token. "
"Has the addon been started and onboarded?",
config_dir,
)
return None
# ── Build discovered config ───────────────────────────────────────
# Prefer Supervisor-reported port, fall back to openclaw.json, then default
port = addon_options.get("gateway_port")
if port is None:
port = await hass.async_add_executor_job(
_read_gateway_port_from_path, config_dir
)
if port is None:
port = DEFAULT_GATEWAY_PORT
# Detect access mode — lan_https uses a built-in HTTPS reverse proxy.
# In that mode nginx terminates TLS on the external (user-facing) port
# and the gateway itself listens on port+1 on loopback (plain HTTP).
# Since HA and the addon share host_network, we connect to the internal
# port via loopback to avoid self-signed certificate issues entirely.
access_mode = addon_options.get("access_mode", "custom")
use_ssl = False
verify_ssl = True
if access_mode == "lan_https":
_LOGGER.info(
"Detected access_mode=lan_https — using internal gateway port %d "
"(external HTTPS proxy is on port %d)",
port + 1,
port,
)
port = port + 1
return {
CONF_GATEWAY_HOST: DEFAULT_GATEWAY_HOST,
CONF_GATEWAY_PORT: port,
CONF_GATEWAY_TOKEN: token,
CONF_USE_SSL: use_ssl,
CONF_VERIFY_SSL: verify_ssl,
CONF_ADDON_CONFIG_PATH: str(config_dir),
}
async def _async_validate_connection(
hass: HomeAssistant,
host: str,
port: int,
token: str,
use_ssl: bool = False,
verify_ssl: bool = True,
) -> bool:
"""Validate that we can connect and authenticate to the gateway."""
session = async_get_clientsession(hass, verify_ssl=verify_ssl)
client = OpenClawApiClient(
host=host,
port=port,
token=token,
use_ssl=use_ssl,
verify_ssl=verify_ssl,
session=session,
)
return await client.async_check_connection()
# ── Config flow ───────────────────────────────────────────────────────────────
class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenClaw."""
VERSION = 1
@staticmethod
def async_get_options_flow(config_entry: ConfigEntry) -> OpenClawOptionsFlow:
"""Get options flow for this handler."""
return OpenClawOptionsFlow(config_entry)
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step.
Tries auto-discovery first, then falls back to manual entry.
"""
# Prevent duplicate entries
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
# Try auto-discovery
discovered = await _async_try_discover_addon(self.hass)
if discovered:
self._discovered = discovered
return await self.async_step_confirm()
# No addon found or token unreadable — show manual entry form
return await self.async_step_manual()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm auto-discovered addon connection."""
errors: dict[str, str] = {}
if user_input is not None:
assert self._discovered is not None
try:
connected = await _async_validate_connection(
self.hass,
self._discovered[CONF_GATEWAY_HOST],
self._discovered[CONF_GATEWAY_PORT],
self._discovered[CONF_GATEWAY_TOKEN],
self._discovered.get(CONF_USE_SSL, False),
self._discovered.get(CONF_VERIFY_SSL, True),
)
except OpenClawAuthError:
connected = False
errors["base"] = "invalid_auth"
except OpenClawConnectionError:
connected = False
errors["base"] = "cannot_connect"
except OpenClawApiError as err:
connected = False
errors["base"] = "openai_api_disabled"
_LOGGER.warning("Gateway API error during connection check: %s", err)
if connected:
return self.async_create_entry(
title="OpenClaw Assistant",
data=self._discovered,
)
if not errors:
errors["base"] = "cannot_connect"
assert self._discovered is not None
return self.async_show_form(
step_id="confirm",
description_placeholders={
"addon_name": "OpenClaw Assistant",
"host": self._discovered[CONF_GATEWAY_HOST],
"port": str(self._discovered[CONF_GATEWAY_PORT]),
"config_path": self._discovered.get(CONF_ADDON_CONFIG_PATH, "unknown"),
},
errors=errors,
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle manual configuration entry."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_GATEWAY_HOST]
port = user_input[CONF_GATEWAY_PORT]
token = user_input[CONF_GATEWAY_TOKEN]
use_ssl = user_input.get(CONF_USE_SSL, False)
verify_ssl = user_input.get(CONF_VERIFY_SSL, False)
try:
connected = await _async_validate_connection(
self.hass, host, port, token, use_ssl, verify_ssl
)
except OpenClawAuthError:
errors["base"] = "invalid_auth"
connected = False
except OpenClawConnectionError as err:
err_msg = str(err).lower()
if "ssl" in err_msg or "certificate" in err_msg:
errors["base"] = "ssl_error"
else:
errors["base"] = "cannot_connect"
connected = False
_LOGGER.warning("Connection error during config check: %s", err)
except OpenClawApiError as err:
errors["base"] = "openai_api_disabled"
connected = False
_LOGGER.warning("Gateway API error during connection check: %s", err)
if connected:
return self.async_create_entry(
title="OpenClaw Assistant",
data={
CONF_GATEWAY_HOST: host,
CONF_GATEWAY_PORT: port,
CONF_GATEWAY_TOKEN: token,
CONF_USE_SSL: use_ssl,
CONF_VERIFY_SSL: verify_ssl,
CONF_AGENT_ID: user_input.get(CONF_AGENT_ID, DEFAULT_AGENT_ID),
},
)
if "base" not in errors:
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="manual",
data_schema=vol.Schema(
{
vol.Required(
CONF_GATEWAY_HOST, default=DEFAULT_GATEWAY_HOST
): str,
vol.Required(
CONF_GATEWAY_PORT, default=DEFAULT_GATEWAY_PORT
): vol.All(int, vol.Range(min=1, max=65535)),
vol.Required(CONF_GATEWAY_TOKEN): str,
vol.Optional(CONF_USE_SSL, default=False): bool,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Optional(CONF_AGENT_ID, default=DEFAULT_AGENT_ID): str,
}
),
errors=errors,
)
class OpenClawOptionsFlow(OptionsFlowWithReload):
"""Handle OpenClaw options."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage integration options."""
errors: dict[str, str] = {}
if user_input is not None:
try:
user_agent_map = parse_user_agent_map(
user_input.get(CONF_USER_AGENT_MAP)
)
except ValueError:
errors[CONF_USER_AGENT_MAP] = "invalid_user_agent_map"
else:
fallback_agent_id = normalize_optional_text(
user_input.get(CONF_FALLBACK_AGENT_ID)
) or DEFAULT_CHAT_AGENT_ID
new_options = dict(self._config_entry.options)
new_options.update(user_input)
new_options[CONF_FALLBACK_AGENT_ID] = fallback_agent_id
new_options[CONF_USER_AGENT_MAP] = serialize_user_agent_map(
user_agent_map
)
return self.async_create_entry(title="", data=new_options)
options = dict(self._config_entry.options)
if user_input is not None and errors:
options.update(user_input)
selected_provider = options.get(CONF_VOICE_PROVIDER, DEFAULT_VOICE_PROVIDER)
user_agent_map_default = options.get(
CONF_USER_AGENT_MAP,
serialize_user_agent_map(DEFAULT_HA_USER_AGENT_MAP),
)
schema: dict[Any, Any] = {
vol.Optional(
CONF_AGENT_ID,
default=options.get(
CONF_AGENT_ID,
self._config_entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID),
),
): str,
vol.Optional(
CONF_FALLBACK_AGENT_ID,
default=options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID),
): str,
vol.Optional(
CONF_USER_AGENT_MAP,
default=user_agent_map_default,
): str,
vol.Optional(
CONF_VOICE_AGENT_ID,
default=options.get(
CONF_VOICE_AGENT_ID,
DEFAULT_VOICE_AGENT_ID,
),
): str,
vol.Optional(
CONF_ASSIST_SESSION_ID,
default=options.get(
CONF_ASSIST_SESSION_ID,
DEFAULT_ASSIST_SESSION_ID,
),
): str,
vol.Optional(
CONF_INCLUDE_EXPOSED_CONTEXT,
default=options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
),
): bool,
vol.Optional(
CONF_CONTEXT_MAX_CHARS,
default=options.get(
CONF_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_MAX_CHARS,
),
): vol.All(int, vol.Range(min=1000, max=200000)),
vol.Optional(
CONF_CONTEXT_STRATEGY,
default=options.get(
CONF_CONTEXT_STRATEGY,
DEFAULT_CONTEXT_STRATEGY,
),
): vol.In([CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_CLEAR]),
vol.Optional(
CONF_ENABLE_TOOL_CALLS,
default=options.get(
CONF_ENABLE_TOOL_CALLS,
DEFAULT_ENABLE_TOOL_CALLS,
),
): bool,
vol.Optional(
CONF_WAKE_WORD_ENABLED,
default=options.get(
CONF_WAKE_WORD_ENABLED,
DEFAULT_WAKE_WORD_ENABLED,
),
): bool,
vol.Optional(
CONF_WAKE_WORD,
default=options.get(
CONF_WAKE_WORD,
DEFAULT_WAKE_WORD,
),
): str,
vol.Optional(
CONF_ALLOW_BRAVE_WEBSPEECH,
default=options.get(
CONF_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
),
): bool,
vol.Optional(
CONF_VOICE_PROVIDER,
default=selected_provider,
): vol.In(["browser", "assist_stt"]),
vol.Optional(
CONF_THINKING_TIMEOUT,
default=options.get(
CONF_THINKING_TIMEOUT,
DEFAULT_THINKING_TIMEOUT,
),
): vol.All(int, vol.Range(min=10, max=3600)),
}
if selected_provider == "browser":
schema[
vol.Optional(
CONF_BROWSER_VOICE_LANGUAGE,
default=options.get(
CONF_BROWSER_VOICE_LANGUAGE,
DEFAULT_BROWSER_VOICE_LANGUAGE,
),
)
] = vol.In(BROWSER_VOICE_LANGUAGES)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema),
errors=errors,
)