Files
Tetra-AI-HA-Integration/custom_components/openclaw/__init__.py
T
2026-02-20 13:07:28 +02:00

270 lines
9.1 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__)
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,
}
)
# Path to the chat card JS inside the integration
_CARD_FILENAME = "openclaw-chat-card.js"
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_frontend(hass: HomeAssistant) -> None:
"""Register the Lovelace custom card resource.
Attempts to register as a Lovelace module resource so the card
is available without manual YAML edits. Falls back to a log hint
if programmatic registration isn't possible.
"""
frontend_key = f"{DOMAIN}_frontend_registered"
if hass.data.get(frontend_key):
return
hass.data[frontend_key] = True
# The card JS is distributed in www/ alongside the integration.
# Users install via HACS which symlinks it into config/www/community/openclaw/
# or manually copy to config/www/.
# We register the resource URL so it auto-loads.
url = f"/local/community/openclaw/{_CARD_FILENAME}"
# Static path registration is handled by HACS for HACS installs,
# or manually by the user. We just log the expected URL.
_LOGGER.info(
"OpenClaw chat card: add as a Lovelace resource at '%s' "
"(type: JavaScript Module). HACS does this automatically.",
url,
)
# ── 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