Files
Tetra-AI-HA-Integration/custom_components/openclaw/__init__.py
T
2026-02-20 20:36:25 +02:00

697 lines
24 KiB
Python

"""The OpenClaw integration.
Sets up the OpenClaw integration: API client, coordinator, platforms, and services.
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
import logging
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import voluptuous as vol
from homeassistant.components import websocket_api
try:
from homeassistant.components.lovelace.const import LOVELACE_DATA
except ImportError: # pragma: no cover
LOVELACE_DATA = "lovelace"
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import OpenClawApiClient, OpenClawApiError
from .const import (
ATTR_ATTACHMENTS,
ATTR_MESSAGE,
ATTR_MODEL,
ATTR_SESSION_ID,
ATTR_TIMESTAMP,
CONF_ADDON_CONFIG_PATH,
CONF_GATEWAY_HOST,
CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN,
CONF_USE_SSL,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE,
CONF_ALLOW_BRAVE_WEBSPEECH,
CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_ENABLE_TOOL_CALLS,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
DOMAIN,
EVENT_MESSAGE_RECEIVED,
OPENCLAW_CONFIG_REL_PATH,
PLATFORMS,
SERVICE_CLEAR_HISTORY,
SERVICE_SEND_MESSAGE,
)
from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context
_LOGGER = logging.getLogger(__name__)
_MAX_CHAT_HISTORY = 200
# Path to the chat card JS inside the integration package (custom_components/openclaw/www/)
_CARD_FILENAME = "openclaw-chat-card.js"
_CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
# URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.28"
OpenClawConfigEntry = ConfigEntry
# Service call schemas
SEND_MESSAGE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_SESSION_ID): cv.string,
vol.Optional(ATTR_ATTACHMENTS): vol.All(cv.ensure_list, [cv.string]),
}
)
CLEAR_HISTORY_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_SESSION_ID): cv.string,
}
)
async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> bool:
"""Set up OpenClaw from a config entry.
Creates the API client, coordinator, and forwards setup to platforms.
"""
session = async_get_clientsession(hass)
client = OpenClawApiClient(
host=entry.data[CONF_GATEWAY_HOST],
port=entry.data[CONF_GATEWAY_PORT],
token=entry.data[CONF_GATEWAY_TOKEN],
use_ssl=entry.data.get(CONF_USE_SSL, False),
session=session,
)
coordinator = OpenClawCoordinator(hass, client)
# Store the addon config path for token re-reads
addon_config_path = entry.data.get(CONF_ADDON_CONFIG_PATH)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"client": client,
"coordinator": coordinator,
"addon_config_path": addon_config_path,
"entry": entry,
}
# First data fetch — if it fails the coordinator marks entities unavailable
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register services (once, idempotent)
_async_register_services(hass)
_async_register_websocket_api(hass)
# Register the frontend card resource
hass.async_create_task(_async_register_frontend(hass))
# Listen for addon restart events to re-read token
if addon_config_path:
_async_setup_token_refresh(hass, entry, client, addon_config_path)
return True
async def async_unload_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> bool:
"""Unload an OpenClaw config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN, None)
return unload_ok
# ── Token refresh on reconnect ────────────────────────────────────────────────
def _async_setup_token_refresh(
hass: HomeAssistant,
entry: ConfigEntry,
client: OpenClawApiClient,
addon_config_path: str,
) -> None:
"""Set up a listener that re-reads the gateway token if auth fails.
The addon may regenerate the token on restart. When we detect an auth
failure during polling, we re-read the token from the shared filesystem
and update the config entry + client transparently.
"""
import json as _json
async def _try_refresh_token() -> bool:
"""Re-read token from filesystem and update client if changed."""
config_file = Path(addon_config_path) / OPENCLAW_CONFIG_REL_PATH
def _read() -> str | None:
if not config_file.exists():
return None
try:
cfg = _json.loads(config_file.read_text(encoding="utf-8"))
return cfg.get("gateway", {}).get("auth", {}).get("token")
except Exception: # noqa: BLE001
return None
new_token = await hass.async_add_executor_job(_read)
if new_token and new_token != entry.data.get(CONF_GATEWAY_TOKEN):
_LOGGER.info("Gateway token changed — updating config entry")
new_data = {**entry.data, CONF_GATEWAY_TOKEN: new_token}
hass.config_entries.async_update_entry(entry, data=new_data)
# Update the live client in-place
client.update_token(new_token)
return True
return False
# Expose refresh function for the coordinator to call on auth errors
hass.data[DOMAIN][entry.entry_id]["refresh_token"] = _try_refresh_token
# ── Frontend registration ─────────────────────────────────────────────────────
async def _async_register_frontend(hass: HomeAssistant) -> None:
"""Register static path + Lovelace resource for the chat card.
Called as a fire-and-forget task from async_setup_entry.
Wrapped entirely in try/except so it can NEVER crash the integration.
"""
frontend_done_key = f"{DOMAIN}_frontend_registered"
frontend_task_key = f"{DOMAIN}_frontend_registration_task"
if hass.data.get(frontend_done_key):
return
existing_task = hass.data.get(frontend_task_key)
if existing_task and not existing_task.done():
return
async def _register_with_retries() -> None:
for _ in range(60):
url = await _async_register_static_path(hass)
if url and await _async_add_lovelace_resource(hass, url):
hass.data[frontend_done_key] = True
return
await asyncio.sleep(5)
_LOGGER.warning(
"Could not auto-register OpenClaw chat card resource after retries. "
"Add it manually in Dashboard resources: %s",
_CARD_URL,
)
task = hass.async_create_task(_register_with_retries())
hass.data[frontend_task_key] = task
def _clear_task(_fut: asyncio.Future) -> None:
hass.data.pop(frontend_task_key, None)
task.add_done_callback(_clear_task)
async def _on_ha_started(_event) -> None:
if hass.data.get(frontend_done_key):
return
await _register_with_retries()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_ha_started)
async def _async_register_static_path(hass: HomeAssistant) -> str | None:
"""Register the packaged chat-card JS as a static path when HTTP is ready."""
static_key = f"{DOMAIN}_static_registered"
if hass.data.get(static_key):
return _CARD_URL
if not _CARD_PATH.exists():
_LOGGER.warning("Chat card JS not found at %s", _CARD_PATH)
return None
if hass.http is None:
return None
try:
from homeassistant.components.http import StaticPathConfig # noqa: PLC0415
await hass.http.async_register_static_paths(
[StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=True)]
)
except (ImportError, AttributeError):
hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), True)
hass.data[static_key] = True
_LOGGER.debug("Registered static path: %s", _CARD_STATIC_URL)
return _CARD_URL
async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool:
"""Add the card URL to Lovelace's resource store if not already present."""
# Lovelace stores resources in hass.data[LOVELACE_DATA].resources on
# modern Home Assistant versions (fallback to legacy dict key below).
# Resource collection supports:
# .async_items() → list of {"id", "res_type", "url"} dicts
# .async_create_item(data) → persists a new resource
lovelace_data = hass.data.get(LOVELACE_DATA) or hass.data.get("lovelace")
if not lovelace_data:
return False
if isinstance(lovelace_data, dict):
resource_collection = lovelace_data.get("resources")
else:
resource_collection = getattr(lovelace_data, "resources", None)
if resource_collection is None:
return False
try:
existing_items = list(resource_collection.async_items())
desired_path = urlsplit(url).path
legacy_paths = {
"/openclaw/openclaw-chat-card.js",
"/local/openclaw-chat-card.js",
"/hacsfiles/openclaw/openclaw-chat-card.js",
}
to_remove: list[str] = []
for item in existing_items:
item_id = item.get("id")
item_url = item.get("url")
if not item_id or not item_url:
continue
item_path = urlsplit(item_url).path
if item_path in legacy_paths and item_url != url:
to_remove.append(item_id)
for item_id in to_remove:
await resource_collection.async_delete_item(item_id)
_LOGGER.info("Removed legacy/duplicate OpenClaw Lovelace resource id=%s", item_id)
existing_urls = {item["url"] for item in resource_collection.async_items()}
if url in existing_urls:
_LOGGER.debug("Lovelace resource already registered: %s", url)
return True
await resource_collection.async_create_item(
{"res_type": "module", "url": url}
)
_LOGGER.info(
"Auto-registered Lovelace resource: %s — the chat card is ready to use.",
url,
)
return True
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Could not auto-register Lovelace resource '%s': %s. "
"Add it manually: Settings → Dashboards → Resources.",
url,
err,
)
return False
# ── Service registration ──────────────────────────────────────────────────────
@callback
def _async_register_services(hass: HomeAssistant) -> None:
"""Register openclaw.send_message and openclaw.clear_history services."""
if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
return
async def handle_send_message(call: ServiceCall) -> None:
"""Handle the openclaw.send_message service call."""
message: str = call.data[ATTR_MESSAGE]
session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
entry_data = _get_first_entry_data(hass)
if not entry_data:
_LOGGER.error("No OpenClaw integration configured")
return
client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
entry: ConfigEntry = entry_data["entry"]
options = entry.options
try:
include_context = options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
)
max_chars = int(
options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS)
)
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}:
strategy = DEFAULT_CONTEXT_STRATEGY
raw_context = (
build_exposed_entities_context(hass, assistant="conversation")
if include_context
else None
)
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
_append_chat_history(hass, session_id, "user", message)
response = await client.async_send_message(
message=message,
session_id=session_id,
system_prompt=system_prompt,
)
if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS):
tool_results = await _async_execute_tool_calls(hass, response)
if tool_results:
response = await client.async_send_message(
message=(
"Tool execution results:\n"
+ "\n".join(f"- {line}" for line in tool_results)
+ "\nRespond to the user based on these results."
),
session_id=session_id,
system_prompt=system_prompt,
)
assistant_message = _extract_assistant_message(response)
model_used = response.get("model", "unknown")
if not assistant_message:
assistant_message = "Response received, but no readable message content was found."
_LOGGER.warning(
"OpenClaw response had no parseable assistant message. Keys: %s",
list(response.keys()),
)
_append_chat_history(hass, session_id, "assistant", assistant_message)
hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED,
{
ATTR_MESSAGE: assistant_message,
ATTR_SESSION_ID: session_id,
ATTR_MODEL: model_used,
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
},
)
coordinator.update_last_activity()
except OpenClawApiError as err:
_LOGGER.error("Failed to send message to OpenClaw: %s", err)
_append_chat_history(hass, session_id, "assistant", f"OpenClaw error: {err}")
hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED,
{
ATTR_MESSAGE: f"OpenClaw error: {err}",
ATTR_SESSION_ID: session_id,
ATTR_MODEL: "unknown",
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
},
)
async def handle_clear_history(call: ServiceCall) -> None:
"""Handle the openclaw.clear_history service call."""
session_id: str | None = call.data.get(ATTR_SESSION_ID)
_LOGGER.info("Clear history requested (session=%s)", session_id or "all")
store = _get_chat_history_store(hass)
if session_id:
store.pop(session_id, None)
else:
store.clear()
hass.services.async_register(
DOMAIN,
SERVICE_SEND_MESSAGE,
handle_send_message,
schema=SEND_MESSAGE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_HISTORY,
handle_clear_history,
schema=CLEAR_HISTORY_SCHEMA,
)
def _get_first_entry_data(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return entry data dict for the first configured OpenClaw entry."""
domain_data: dict = hass.data.get(DOMAIN, {})
for entry_id, entry_data in domain_data.items():
if isinstance(entry_data, dict) and "client" in entry_data:
return entry_data
return None
def _extract_text_recursive(value: Any, depth: int = 0) -> str | None:
"""Recursively extract assistant text from nested response payloads."""
if depth > 8:
return None
if isinstance(value, str):
text = value.strip()
return text or None
if isinstance(value, list):
parts: list[str] = []
for item in value:
extracted = _extract_text_recursive(item, depth + 1)
if extracted:
parts.append(extracted)
if parts:
return "\n".join(parts)
return None
if isinstance(value, dict):
priority_keys = (
"output_text",
"text",
"content",
"message",
"response",
"answer",
"choices",
"output",
"delta",
)
for key in priority_keys:
if key not in value:
continue
extracted = _extract_text_recursive(value.get(key), depth + 1)
if extracted:
return extracted
for nested_value in value.values():
extracted = _extract_text_recursive(nested_value, depth + 1)
if extracted:
return extracted
return None
def _extract_assistant_message(response: dict[str, Any]) -> str | None:
"""Extract assistant text from modern/legacy OpenAI-compatible responses."""
return _extract_text_recursive(response)
def _extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract OpenAI-style tool calls from a response payload."""
choices = response.get("choices")
if not isinstance(choices, list) or not choices:
return []
message = choices[0].get("message") if isinstance(choices[0], dict) else None
if not isinstance(message, dict):
return []
tool_calls = message.get("tool_calls")
if not isinstance(tool_calls, list):
return []
valid_calls: list[dict[str, Any]] = []
for call in tool_calls:
if isinstance(call, dict):
valid_calls.append(call)
return valid_calls
async def _async_execute_tool_calls(
hass: HomeAssistant,
response: dict[str, Any],
) -> list[str]:
"""Execute supported tool calls and return result lines."""
results: list[str] = []
tool_calls = _extract_tool_calls(response)
for call in tool_calls:
function_data = call.get("function")
if not isinstance(function_data, dict):
continue
function_name = function_data.get("name")
arguments = function_data.get("arguments")
if function_name not in {"execute_service", "execute_services"}:
results.append(f"Skipped unsupported tool '{function_name}'")
continue
if not isinstance(arguments, str):
results.append("Skipped tool call with invalid arguments format")
continue
try:
parsed = json.loads(arguments)
except json.JSONDecodeError:
results.append("Skipped tool call due to invalid JSON arguments")
continue
services_list = parsed.get("list") if isinstance(parsed, dict) else None
if not isinstance(services_list, list):
results.append("Skipped tool call without 'list' payload")
continue
for item in services_list:
if not isinstance(item, dict):
continue
domain = item.get("domain")
service = item.get("service")
service_data = item.get("service_data", {})
if not isinstance(domain, str) or not isinstance(service, str):
results.append("Skipped invalid service item (missing domain/service)")
continue
if not isinstance(service_data, dict):
service_data = {}
try:
await hass.services.async_call(
domain,
service,
service_data,
blocking=True,
)
results.append(f"Executed {domain}.{service}")
except Exception as err: # noqa: BLE001
results.append(f"Failed {domain}.{service}: {err}")
return results
def _get_chat_history_store(hass: HomeAssistant) -> dict[str, list[dict[str, str]]]:
"""Return in-memory per-session chat history store."""
store_key = f"{DOMAIN}_chat_history"
store = hass.data.get(store_key)
if store is None:
store = {}
hass.data[store_key] = store
return store
def _append_chat_history(hass: HomeAssistant, session_id: str, role: str, content: str) -> None:
"""Append a message to in-memory chat history."""
store = _get_chat_history_store(hass)
history = store.setdefault(session_id, [])
history.append(
{
"role": role,
"content": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
if len(history) > _MAX_CHAT_HISTORY:
del history[:-_MAX_CHAT_HISTORY]
@callback
def _async_register_websocket_api(hass: HomeAssistant) -> None:
"""Register websocket API for chat history retrieval."""
key = f"{DOMAIN}_ws_registered"
if hass.data.get(key):
return
hass.data[key] = True
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_history",
vol.Optional("session_id"): cv.string,
}
)
@callback
def websocket_get_history(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return chat history for a session."""
session_id = msg.get("session_id") or "default"
history = _get_chat_history_store(hass).get(session_id, [])
connection.send_result(msg["id"], {"session_id": session_id, "messages": history})
websocket_api.async_register_command(hass, websocket_get_history)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_settings",
}
)
@callback
def websocket_get_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return frontend-related integration settings."""
entry_data = _get_first_entry_data(hass)
options = entry_data.get("entry").options if entry_data and entry_data.get("entry") else {}
connection.send_result(
msg["id"],
{
CONF_WAKE_WORD_ENABLED: options.get(
CONF_WAKE_WORD_ENABLED,
DEFAULT_WAKE_WORD_ENABLED,
),
CONF_WAKE_WORD: options.get(CONF_WAKE_WORD, DEFAULT_WAKE_WORD),
CONF_ALWAYS_VOICE_MODE: options.get(
CONF_ALWAYS_VOICE_MODE,
DEFAULT_ALWAYS_VOICE_MODE,
),
CONF_ALLOW_BRAVE_WEBSPEECH: options.get(
CONF_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_ALLOW_BRAVE_WEBSPEECH,
),
"language": hass.config.language,
},
)
websocket_api.async_register_command(hass, websocket_get_settings)