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

369 lines
12 KiB
Python

"""The OpenClaw integration.
Sets up the OpenClaw integration: API client, coordinator, platforms, and services.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import logging
from pathlib import Path
from typing import Any
import voluptuous as vol
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,
DOMAIN,
EVENT_MESSAGE_RECEIVED,
OPENCLAW_CONFIG_REL_PATH,
PLATFORMS,
SERVICE_CLEAR_HISTORY,
SERVICE_SEND_MESSAGE,
)
from .coordinator import OpenClawCoordinator
_LOGGER = logging.getLogger(__name__)
# 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 will be served (registered via register_static_path)
_CARD_URL = f"/openclaw/{_CARD_FILENAME}"
type 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,
}
# 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)
# 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_URL, str(_CARD_PATH), cache_headers=True)]
)
except (ImportError, AttributeError):
hass.http.register_static_path(_CARD_URL, str(_CARD_PATH), True)
hass.data[static_key] = True
_LOGGER.debug("Registered static path: %s", _CARD_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_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 | None = call.data.get(ATTR_SESSION_ID)
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"]
try:
response = await client.async_send_message(
message=message,
session_id=session_id,
)
choices = response.get("choices", [])
if choices:
assistant_message = (
choices[0].get("message", {}).get("content", "")
)
model_used = response.get("model", "unknown")
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)
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")
# TODO: Implement when gateway exposes a session-clear endpoint
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