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

343 lines
12 KiB
Python

"""The OpenClaw integration.
Sets up the OpenClaw integration: API client, coordinator, platforms, and services.
"""
from __future__ import annotations
from datetime import datetime, timezone
import logging
from pathlib import Path
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
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
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Global integration setup — runs once before any config entries.
Registers the static HTTP path for the chat card so the JS file is served
directly from inside the integration package, regardless of whether the
user installed via HACS or manually.
"""
_async_register_static_path(hass)
return True
# 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
_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 ─────────────────────────────────────────────────────
@callback
def _async_register_static_path(hass: HomeAssistant) -> None:
"""Register the integration's www/ folder as a static HTTP path.
After this the card JS is always available at /openclaw/openclaw-chat-card.js
regardless of how the integration was installed (HACS or manual).
"""
static_key = f"{DOMAIN}_static_registered"
if hass.data.get(static_key):
return
hass.data[static_key] = True
if not _CARD_PATH.exists():
_LOGGER.warning(
"Chat card JS not found at %s — frontend resource will not be available",
_CARD_PATH,
)
return
hass.http.register_static_path(
f"/openclaw/{_CARD_FILENAME}",
str(_CARD_PATH),
cache_headers=True,
)
_LOGGER.debug("Registered static path: /openclaw/%s", _CARD_FILENAME)
@callback
def _async_register_frontend(hass: HomeAssistant) -> None:
"""Register the Lovelace custom card resource (called once per setup).
Adds the card URL to Lovelace's resource list so it loads on every
dashboard automatically. No manual step required.
"""
frontend_key = f"{DOMAIN}_frontend_registered"
if hass.data.get(frontend_key):
return
hass.data[frontend_key] = True
# Ensure static path is registered (may have been missed if async_setup
# didn't run, e.g. during a config entry reload).
_async_register_static_path(hass)
hass.async_create_task(_async_add_lovelace_resource(hass, _CARD_URL))
async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> None:
"""Add the card URL to Lovelace's resource store if not already present."""
# Lovelace stores resources in hass.data["lovelace"]["resources"].
# It is a ResourceStorageCollection with:
# .async_items() → list of {"id", "res_type", "url"} dicts
# .async_create_item(data) → persists a new resource
lovelace_data = hass.data.get("lovelace")
if not lovelace_data:
_LOGGER.debug(
"Lovelace not loaded; resource '%s' must be added manually if needed",
url,
)
return
resource_collection = lovelace_data.get("resources")
if resource_collection is None:
_LOGGER.debug("Lovelace resource store not available")
return
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
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,
)
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Could not auto-register Lovelace resource '%s': %s. "
"Add it manually: Settings → Dashboards → Resources.",
url,
err,
)
# ── 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