first commit

This commit is contained in:
techartdev
2026-02-20 13:07:28 +02:00
commit e9cd5e1fff
24 changed files with 3083 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
"""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
+324
View File
@@ -0,0 +1,324 @@
"""OpenClaw Gateway API client."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator
import aiohttp
from .const import (
API_CHAT_COMPLETIONS,
API_MODELS,
API_SESSIONS,
API_STATUS,
)
_LOGGER = logging.getLogger(__name__)
# Timeout for regular API calls (seconds)
API_TIMEOUT = aiohttp.ClientTimeout(total=10)
# Timeout for streaming chat completions (long-running)
STREAM_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_read=120)
class OpenClawApiError(Exception):
"""Base exception for OpenClaw API errors."""
class OpenClawConnectionError(OpenClawApiError):
"""Connection to OpenClaw gateway failed."""
class OpenClawAuthError(OpenClawApiError):
"""Authentication with OpenClaw gateway failed."""
class OpenClawApiClient:
"""HTTP client for the OpenClaw gateway API.
Communicates with the OpenClaw gateway running inside the addon container.
Supports both regular JSON API calls and SSE streaming for chat completions.
"""
def __init__(
self,
host: str,
port: int,
token: str,
use_ssl: bool = False,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Initialize the API client.
Args:
host: Gateway hostname or IP.
port: Gateway port number.
token: Authentication token from openclaw.json.
use_ssl: Use HTTPS instead of HTTP.
session: Optional aiohttp session (reused from HA).
"""
self._host = host
self._port = port
self._token = token
self._use_ssl = use_ssl
self._session = session
self._base_url = f"{'https' if use_ssl else 'http'}://{host}:{port}"
@property
def base_url(self) -> str:
"""Return the base URL of the gateway."""
return self._base_url
def update_token(self, token: str) -> None:
"""Update the authentication token (e.g., after addon restart)."""
self._token = token
def _headers(self) -> dict[str, str]:
"""Build request headers with auth token."""
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create an aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def _request(
self,
method: str,
path: str,
timeout: aiohttp.ClientTimeout = API_TIMEOUT,
**kwargs: Any,
) -> dict[str, Any]:
"""Make an HTTP request to the gateway.
Args:
method: HTTP method (GET, POST, etc.).
path: API path (e.g., /api/status).
timeout: Request timeout.
**kwargs: Additional arguments passed to aiohttp request.
Returns:
Parsed JSON response.
Raises:
OpenClawConnectionError: If the gateway is unreachable.
OpenClawAuthError: If authentication fails.
OpenClawApiError: For other API errors.
"""
session = await self._get_session()
url = f"{self._base_url}{path}"
try:
async with session.request(
method,
url,
headers=self._headers(),
timeout=timeout,
**kwargs,
) as resp:
if resp.status == 401:
raise OpenClawAuthError(
"Authentication failed — check gateway token"
)
if resp.status == 403:
raise OpenClawAuthError(
"Access forbidden — token may be invalid"
)
if resp.status >= 400:
text = await resp.text()
raise OpenClawApiError(
f"API error {resp.status}: {text[:200]}"
)
return await resp.json()
except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, asyncio.TimeoutError) as err:
raise OpenClawConnectionError(
f"Cannot connect to OpenClaw gateway at {url}: {err}"
) from err
# ─── Public API methods ────────────────────────────────────────────
async def async_get_status(self) -> dict[str, Any]:
"""Get gateway status.
Returns:
Status dict with keys like 'status', 'version', 'uptime'.
Raises:
OpenClawConnectionError: If the gateway is unreachable.
OpenClawAuthError: If authentication fails.
"""
return await self._request("GET", API_STATUS)
async def async_get_sessions(self) -> dict[str, Any]:
"""Get active sessions list.
Returns:
Dict with 'sessions' list and 'count'.
"""
return await self._request("GET", API_SESSIONS)
async def async_get_models(self) -> dict[str, Any]:
"""Get available models (OpenAI-compatible).
Returns:
Dict with 'data' containing model objects.
"""
return await self._request("GET", API_MODELS)
async def async_send_message(
self,
message: str,
session_id: str | None = None,
model: str | None = None,
stream: bool = False,
) -> dict[str, Any]:
"""Send a chat message (non-streaming).
Args:
message: The user message text.
session_id: Optional session/conversation ID.
model: Optional model override.
stream: If True, raises ValueError (use async_stream_message).
Returns:
Complete chat completion response.
"""
if stream:
raise ValueError("Use async_stream_message() for streaming")
payload: dict[str, Any] = {
"messages": [{"role": "user", "content": message}],
"stream": False,
}
if model:
payload["model"] = model
# Pass session_id as a custom header or param if supported by gateway
headers = self._headers()
if session_id:
headers["X-Session-Id"] = session_id
session = await self._get_session()
url = f"{self._base_url}{API_CHAT_COMPLETIONS}"
try:
async with session.post(
url,
headers=headers,
json=payload,
timeout=STREAM_TIMEOUT,
) as resp:
if resp.status == 401:
raise OpenClawAuthError("Authentication failed")
if resp.status >= 400:
text = await resp.text()
raise OpenClawApiError(f"Chat error {resp.status}: {text[:200]}")
return await resp.json()
except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, asyncio.TimeoutError) as err:
raise OpenClawConnectionError(
f"Cannot connect to OpenClaw gateway: {err}"
) from err
async def async_stream_message(
self,
message: str,
session_id: str | None = None,
model: str | None = None,
) -> AsyncIterator[str]:
"""Send a chat message and stream the response via SSE.
Yields delta content strings as they arrive from the gateway.
Args:
message: The user message text.
session_id: Optional session/conversation ID.
model: Optional model override.
Yields:
Content delta strings from the streaming response.
"""
payload: dict[str, Any] = {
"messages": [{"role": "user", "content": message}],
"stream": True,
}
if model:
payload["model"] = model
headers = self._headers()
if session_id:
headers["X-Session-Id"] = session_id
session = await self._get_session()
url = f"{self._base_url}{API_CHAT_COMPLETIONS}"
try:
async with session.post(
url,
headers=headers,
json=payload,
timeout=STREAM_TIMEOUT,
) as resp:
if resp.status == 401:
raise OpenClawAuthError("Authentication failed")
if resp.status >= 400:
text = await resp.text()
raise OpenClawApiError(f"Chat error {resp.status}: {text[:200]}")
# Parse SSE stream
async for line in resp.content:
decoded = line.decode("utf-8").strip()
if not decoded or not decoded.startswith("data: "):
continue
data_str = decoded[6:] # strip "data: " prefix
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
choices = chunk.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
content = delta.get("content")
if content:
yield content
except json.JSONDecodeError:
_LOGGER.debug("Skipping non-JSON SSE line: %s", data_str[:100])
except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, asyncio.TimeoutError) as err:
raise OpenClawConnectionError(
f"Cannot connect to OpenClaw gateway: {err}"
) from err
async def async_check_connection(self) -> bool:
"""Check if the gateway is reachable and authenticated.
Returns:
True if connected and authenticated.
Raises:
OpenClawAuthError: If authentication fails (re-raised so callers
can distinguish bad tokens from unreachable gateways).
"""
try:
await self.async_get_status()
return True
except OpenClawAuthError:
raise
except OpenClawApiError:
return False
async def async_close(self) -> None:
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
@@ -0,0 +1,58 @@
"""Binary sensor entities for the OpenClaw integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DATA_CONNECTED, DATA_GATEWAY_VERSION, DOMAIN
from .coordinator import OpenClawCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up OpenClaw binary sensors from a config entry."""
coordinator: OpenClawCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities([OpenClawConnectedSensor(coordinator, entry)])
class OpenClawConnectedSensor(CoordinatorEntity[OpenClawCoordinator], BinarySensorEntity):
"""Binary sensor indicating whether HA is connected to the OpenClaw gateway."""
_attr_has_entity_name = True
_attr_translation_key = "connected"
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
_attr_icon = "mdi:lan-connect"
def __init__(
self,
coordinator: OpenClawCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_connected"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": "OpenClaw Assistant",
"manufacturer": "OpenClaw",
"model": "OpenClaw Gateway",
"sw_version": coordinator.data.get(DATA_GATEWAY_VERSION) if coordinator.data else None,
}
@property
def is_on(self) -> bool | None:
"""Return True if connected to the gateway."""
if not self.coordinator.data:
return False
return self.coordinator.data.get(DATA_CONNECTED, False)
+345
View File
@@ -0,0 +1,345 @@
"""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
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import OpenClawApiClient, OpenClawAuthError, OpenClawConnectionError
from .const import (
ADDON_CONFIGS_ROOT,
ADDON_SLUG,
ADDON_SLUG_FRAGMENTS,
CONF_ADDON_CONFIG_PATH,
CONF_GATEWAY_HOST,
CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN,
CONF_USE_SSL,
DEFAULT_GATEWAY_HOST,
DEFAULT_GATEWAY_PORT,
DOMAIN,
OPENCLAW_CONFIG_REL_PATH,
)
_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
# ── 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
return {
CONF_GATEWAY_HOST: DEFAULT_GATEWAY_HOST,
CONF_GATEWAY_PORT: port,
CONF_GATEWAY_TOKEN: token,
CONF_USE_SSL: False,
CONF_ADDON_CONFIG_PATH: str(config_dir),
}
async def _async_validate_connection(
hass: HomeAssistant,
host: str,
port: int,
token: str,
use_ssl: bool = False,
) -> bool:
"""Validate that we can connect and authenticate to the gateway."""
session = async_get_clientsession(hass)
client = OpenClawApiClient(
host=host,
port=port,
token=token,
use_ssl=use_ssl,
session=session,
)
return await client.async_check_connection()
# ── Config flow ───────────────────────────────────────────────────────────────
class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenClaw."""
VERSION = 1
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),
)
except OpenClawAuthError:
connected = False
errors["base"] = "invalid_auth"
except OpenClawConnectionError:
connected = False
errors["base"] = "cannot_connect"
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)
try:
connected = await _async_validate_connection(
self.hass, host, port, token, use_ssl
)
except OpenClawAuthError:
errors["base"] = "invalid_auth"
connected = False
except OpenClawConnectionError:
errors["base"] = "cannot_connect"
connected = False
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,
},
)
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,
}
),
errors=errors,
)
+59
View File
@@ -0,0 +1,59 @@
"""Constants for the OpenClaw integration."""
DOMAIN = "openclaw"
# Addon
ADDON_SLUG = "openclaw_assistant_dev"
# The Supervisor prefixes a repo hash to the slug in the filesystem path
# e.g. /addon_configs/0bfc167e_openclaw_assistant
# We cannot hardcode this — it must be discovered at runtime.
ADDON_CONFIGS_ROOT = "/addon_configs"
ADDON_SLUG_FRAGMENTS = ("openclaw_assistant", "openclaw")
OPENCLAW_CONFIG_REL_PATH = ".openclaw/openclaw.json"
# Defaults
DEFAULT_GATEWAY_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = 18789
DEFAULT_SCAN_INTERVAL = 30 # seconds
# Config entry keys
CONF_GATEWAY_HOST = "gateway_host"
CONF_GATEWAY_PORT = "gateway_port"
CONF_GATEWAY_TOKEN = "gateway_token"
CONF_USE_SSL = "use_ssl"
CONF_ADDON_CONFIG_PATH = "addon_config_path"
# Coordinator data keys
DATA_STATUS = "status"
DATA_MODEL = "model"
DATA_SESSION_COUNT = "session_count"
DATA_SESSIONS = "sessions"
DATA_LAST_ACTIVITY = "last_activity"
DATA_CONNECTED = "connected"
DATA_GATEWAY_VERSION = "gateway_version"
DATA_UPTIME = "uptime"
DATA_PROVIDER = "provider"
DATA_CONTEXT_WINDOW = "context_window"
# Platforms
PLATFORMS = ["sensor", "binary_sensor", "conversation"]
# Events
EVENT_MESSAGE_RECEIVED = f"{DOMAIN}_message_received"
# Services
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_CLEAR_HISTORY = "clear_history"
# Attributes
ATTR_MESSAGE = "message"
ATTR_SESSION_ID = "session_id"
ATTR_ATTACHMENTS = "attachments"
ATTR_MODEL = "model"
ATTR_TIMESTAMP = "timestamp"
# API endpoints
API_STATUS = "/api/status"
API_SESSIONS = "/api/sessions"
API_MODELS = "/v1/models"
API_CHAT_COMPLETIONS = "/v1/chat/completions"
+196
View File
@@ -0,0 +1,196 @@
"""OpenClaw conversation agent for Home Assistant Assist pipeline.
Registers OpenClaw as a native conversation agent so it can be used
with Assist, Voice PE, and any HA voice satellite.
"""
from __future__ import annotations
from datetime import datetime, timezone
import logging
from typing import Any
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api import OpenClawApiClient, OpenClawApiError
from .const import (
ATTR_MESSAGE,
ATTR_MODEL,
ATTR_SESSION_ID,
ATTR_TIMESTAMP,
DATA_MODEL,
DOMAIN,
EVENT_MESSAGE_RECEIVED,
)
from .coordinator import OpenClawCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the OpenClaw conversation agent."""
agent = OpenClawConversationAgent(hass, entry)
conversation.async_set_agent(hass, entry, agent)
async def async_unload_entry(
hass: HomeAssistant,
entry: ConfigEntry,
) -> bool:
"""Unload the conversation agent."""
conversation.async_unset_agent(hass, entry)
return True
class OpenClawConversationAgent(conversation.AbstractConversationAgent):
"""Conversation agent that routes messages through OpenClaw.
Enables OpenClaw to appear as a selectable agent in the Assist pipeline,
allowing use with Voice PE, satellites, and the built-in HA Assist dialog.
"""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the conversation agent."""
self.hass = hass
self.entry = entry
@property
def attribution(self) -> dict[str, str]:
"""Return attribution info."""
return {"name": "Powered by OpenClaw", "url": "https://openclaw.dev"}
@property
def supported_languages(self) -> list[str] | str:
"""Return supported languages.
OpenClaw handles language via its configured model, so we declare
support for all languages and let the model handle translation.
"""
return conversation.MATCH_ALL
async def async_process(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a user message through OpenClaw.
Tries streaming first for lower latency (first-token fast).
Falls back to non-streaming if the stream yields nothing.
Args:
user_input: The conversation input from HA Assist.
Returns:
ConversationResult with the assistant's response.
"""
entry_data = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id)
if not entry_data:
return self._error_result(
user_input, "OpenClaw integration not configured"
)
client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
message = user_input.text
conversation_id = user_input.conversation_id or "default"
try:
full_response = await self._get_response(client, message, conversation_id)
except OpenClawApiError as err:
_LOGGER.error("OpenClaw conversation error: %s", err)
# Try token refresh if we have the capability
refresh_fn = entry_data.get("refresh_token")
if refresh_fn:
refreshed = await refresh_fn()
if refreshed:
try:
full_response = await self._get_response(
client, message, conversation_id
)
except OpenClawApiError as retry_err:
return self._error_result(
user_input,
f"Error communicating with OpenClaw: {retry_err}",
)
else:
return self._error_result(
user_input,
f"Error communicating with OpenClaw: {err}",
)
else:
return self._error_result(
user_input,
f"Error communicating with OpenClaw: {err}",
)
# Fire event so automations can react to the response
self.hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED,
{
ATTR_MESSAGE: full_response,
ATTR_SESSION_ID: conversation_id,
ATTR_MODEL: coordinator.data.get(DATA_MODEL) if coordinator.data else None,
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
},
)
coordinator.update_last_activity()
intent_response = conversation.IntentResponse(language=user_input.language)
intent_response.async_set_speech(full_response)
return conversation.ConversationResult(
response=intent_response,
conversation_id=conversation_id,
)
async def _get_response(
self,
client: OpenClawApiClient,
message: str,
conversation_id: str,
) -> str:
"""Get a response from OpenClaw, trying streaming first."""
# Try streaming (lower TTFB for voice pipeline)
full_response = ""
async for chunk in client.async_stream_message(
message=message,
session_id=conversation_id,
):
full_response += chunk
if full_response:
return full_response
# Fallback to non-streaming
response = await client.async_send_message(
message=message,
session_id=conversation_id,
)
choices = response.get("choices", [])
if choices:
return choices[0].get("message", {}).get("content", "")
return ""
def _error_result(
self,
user_input: conversation.ConversationInput,
error_message: str,
) -> conversation.ConversationResult:
"""Build an error ConversationResult."""
intent_response = conversation.IntentResponse(language=user_input.language)
intent_response.async_set_error(
conversation.IntentResponseErrorCode.UNKNOWN,
error_message,
)
return conversation.ConversationResult(
response=intent_response,
conversation_id=user_input.conversation_id,
)
+176
View File
@@ -0,0 +1,176 @@
"""DataUpdateCoordinator for the OpenClaw integration."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import (
OpenClawApiClient,
OpenClawApiError,
OpenClawAuthError,
OpenClawConnectionError,
)
from .const import (
DATA_CONNECTED,
DATA_CONTEXT_WINDOW,
DATA_GATEWAY_VERSION,
DATA_LAST_ACTIVITY,
DATA_MODEL,
DATA_PROVIDER,
DATA_SESSION_COUNT,
DATA_SESSIONS,
DATA_STATUS,
DATA_UPTIME,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator that polls the OpenClaw gateway for status updates.
Fetches status, session info, and model info on a regular interval
and makes the data available to sensor/binary_sensor entities.
Handles:
- Transient connection failures (returns offline data, no UpdateFailed)
- Auth failures (triggers filesystem token re-read)
- Model info cached separately with a longer poll interval
"""
def __init__(
self,
hass: HomeAssistant,
client: OpenClawApiClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
self.client = client
self._last_activity: datetime | None = None
self._model_cache: dict[str, Any] = {}
self._model_poll_counter = 0
self._consecutive_failures = 0
def _offline_data(self) -> dict[str, Any]:
"""Return a data dict representing the offline state."""
return {
DATA_STATUS: "offline",
DATA_CONNECTED: False,
DATA_SESSION_COUNT: 0,
DATA_SESSIONS: [],
DATA_MODEL: self._model_cache.get(DATA_MODEL),
DATA_LAST_ACTIVITY: self._last_activity,
DATA_GATEWAY_VERSION: None,
DATA_UPTIME: None,
DATA_PROVIDER: self._model_cache.get(DATA_PROVIDER),
DATA_CONTEXT_WINDOW: self._model_cache.get(DATA_CONTEXT_WINDOW),
}
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the OpenClaw gateway.
Returns:
Aggregated data dict for all entities.
"""
data = self._offline_data()
# ── Status ──────────────────────────────────────────────────
try:
status_resp = await self.client.async_get_status()
data[DATA_STATUS] = status_resp.get("status", "online")
data[DATA_CONNECTED] = True
data[DATA_GATEWAY_VERSION] = status_resp.get("version")
data[DATA_UPTIME] = status_resp.get("uptime")
self._consecutive_failures = 0
except OpenClawAuthError as err:
_LOGGER.warning("Gateway auth failed during poll: %s", err)
await self._try_refresh_token()
return data
except OpenClawConnectionError:
self._consecutive_failures += 1
if self._consecutive_failures <= 3:
_LOGGER.debug("Gateway unreachable (attempt %d)", self._consecutive_failures)
elif self._consecutive_failures == 4:
_LOGGER.warning(
"Gateway has been unreachable for %d consecutive polls",
self._consecutive_failures,
)
return data
except OpenClawApiError as err:
_LOGGER.warning("Error fetching gateway status: %s", err)
return data
# ── Sessions ────────────────────────────────────────────────
try:
sessions_resp = await self.client.async_get_sessions()
sessions = sessions_resp.get("sessions", [])
data[DATA_SESSION_COUNT] = len(sessions)
data[DATA_SESSIONS] = sessions
if sessions:
latest = max(
(s.get("updated_at") or s.get("created_at", "") for s in sessions),
default=None,
)
if latest:
try:
self._last_activity = datetime.fromisoformat(latest)
except (ValueError, TypeError):
pass
data[DATA_LAST_ACTIVITY] = self._last_activity
except OpenClawApiError as err:
_LOGGER.debug("Error fetching sessions: %s", err)
# ── Models (polled every ~4 intervals ≈ 2 minutes) ──────────
self._model_poll_counter += 1
if not self._model_cache or self._model_poll_counter >= 4:
self._model_poll_counter = 0
try:
models_resp = await self.client.async_get_models()
models = models_resp.get("data", [])
if models:
current = models[0]
self._model_cache = {
DATA_MODEL: current.get("id", "unknown"),
DATA_PROVIDER: current.get("owned_by"),
DATA_CONTEXT_WINDOW: current.get("context_window"),
}
except OpenClawApiError as err:
_LOGGER.debug("Error fetching models: %s", err)
data.update(self._model_cache)
return data
async def _try_refresh_token(self) -> None:
"""Attempt to re-read the gateway token via the refresh callback."""
entry_data = self.hass.data.get(DOMAIN, {})
for eid, ed in entry_data.items():
if isinstance(ed, dict) and "refresh_token" in ed:
refresh_fn = ed["refresh_token"]
if await refresh_fn():
_LOGGER.info("Token refreshed successfully — next poll should succeed")
return
_LOGGER.debug("No token refresh callback available")
def update_last_activity(self) -> None:
"""Update the last activity timestamp to now.
Called when a message is sent/received through the integration.
"""
self._last_activity = datetime.now(timezone.utc)
+14
View File
@@ -0,0 +1,14 @@
{
"domain": "openclaw",
"name": "OpenClaw",
"codeowners": ["@techartdev"],
"config_flow": true,
"documentation": "https://github.com/techartdev/OpenClawHomeAssistant",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [],
"version": "0.1.0",
"dependencies": ["conversation"],
"after_dependencies": ["hassio"]
}
+138
View File
@@ -0,0 +1,138 @@
"""Sensor entities for the OpenClaw integration."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DATA_GATEWAY_VERSION,
DATA_LAST_ACTIVITY,
DATA_MODEL,
DATA_PROVIDER,
DATA_SESSION_COUNT,
DATA_SESSIONS,
DATA_STATUS,
DATA_UPTIME,
DOMAIN,
)
from .coordinator import OpenClawCoordinator
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=DATA_STATUS,
translation_key="status",
name="OpenClaw Status",
icon="mdi:robot",
),
SensorEntityDescription(
key=DATA_LAST_ACTIVITY,
translation_key="last_activity",
name="OpenClaw Last Activity",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:clock-outline",
),
SensorEntityDescription(
key=DATA_SESSION_COUNT,
translation_key="session_count",
name="OpenClaw Session Count",
icon="mdi:forum",
native_unit_of_measurement="sessions",
),
SensorEntityDescription(
key=DATA_MODEL,
translation_key="model",
name="OpenClaw Model",
icon="mdi:brain",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up OpenClaw sensors from a config entry."""
coordinator: OpenClawCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
entities = [
OpenClawSensor(coordinator, description, entry)
for description in SENSOR_DESCRIPTIONS
]
async_add_entities(entities)
class OpenClawSensor(CoordinatorEntity[OpenClawCoordinator], SensorEntity):
"""Sensor entity for OpenClaw data."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: OpenClawCoordinator,
description: SensorEntityDescription,
entry: ConfigEntry,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": "OpenClaw Assistant",
"manufacturer": "OpenClaw",
"model": "OpenClaw Gateway",
"sw_version": coordinator.data.get(DATA_GATEWAY_VERSION) if coordinator.data else None,
}
@property
def native_value(self) -> str | int | datetime | None:
"""Return the state of the sensor."""
if not self.coordinator.data:
return None
return self.coordinator.data.get(self.entity_description.key)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return additional attributes based on sensor type."""
if not self.coordinator.data:
return None
key = self.entity_description.key
data = self.coordinator.data
if key == DATA_STATUS:
return {
"gateway_version": data.get(DATA_GATEWAY_VERSION),
"uptime": data.get(DATA_UPTIME),
}
if key == DATA_SESSION_COUNT:
sessions = data.get(DATA_SESSIONS, [])
return {
"sessions": [s.get("id", "unknown") for s in sessions[:10]],
}
if key == DATA_MODEL:
return {
"provider": data.get(DATA_PROVIDER),
}
if key == DATA_LAST_ACTIVITY:
return {
"last_message_preview": None, # TODO: populate from last message
}
return None
+37
View File
@@ -0,0 +1,37 @@
send_message:
name: Send Message
description: Send a text message to OpenClaw and get a response.
fields:
message:
name: Message
description: The message text to send to OpenClaw.
required: true
example: "What's the weather like today?"
selector:
text:
multiline: true
session_id:
name: Session ID
description: Optional conversation session ID. Messages with the same session ID share context.
required: false
example: "my-automation-session"
selector:
text:
attachments:
name: Attachments
description: Optional list of file paths to attach to the message.
required: false
selector:
text:
multiline: true
clear_history:
name: Clear History
description: Clear the conversation history for a session.
fields:
session_id:
name: Session ID
description: Session ID to clear. If omitted, clears the default session.
required: false
selector:
text:
+84
View File
@@ -0,0 +1,84 @@
{
"config": {
"step": {
"user": {
"title": "OpenClaw Assistant",
"description": "Set up the OpenClaw integration. The addon will be auto-detected if installed."
},
"confirm": {
"title": "Addon Discovered",
"description": "Found **{addon_name}** running at `{host}:{port}`.\n\nConfig path: `{config_path}`\n\nPress Submit to connect.",
"data": {}
},
"manual": {
"title": "Manual Configuration",
"description": "The OpenClaw addon was not auto-detected. Enter the gateway connection details manually.",
"data": {
"gateway_host": "Gateway Host",
"gateway_port": "Gateway Port",
"gateway_token": "Gateway Token",
"use_ssl": "Use SSL (HTTPS)"
}
}
},
"error": {
"cannot_connect": "Cannot connect to the OpenClaw gateway. Ensure the addon is running.",
"invalid_auth": "Invalid gateway token. Check your OpenClaw configuration.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "OpenClaw is already configured."
}
},
"entity": {
"sensor": {
"status": {
"name": "Status"
},
"last_activity": {
"name": "Last Activity"
},
"session_count": {
"name": "Session Count"
},
"model": {
"name": "Model"
}
},
"binary_sensor": {
"connected": {
"name": "Connected"
}
}
},
"services": {
"send_message": {
"name": "Send Message",
"description": "Send a text message to OpenClaw.",
"fields": {
"message": {
"name": "Message",
"description": "The message text to send."
},
"session_id": {
"name": "Session ID",
"description": "Optional session ID for conversation context."
},
"attachments": {
"name": "Attachments",
"description": "Optional file attachments."
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Clear conversation history.",
"fields": {
"session_id": {
"name": "Session ID",
"description": "Session to clear."
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"step": {
"user": {
"title": "OpenClaw Assistant",
"description": "Set up the OpenClaw integration. The addon will be auto-detected if installed."
},
"confirm": {
"title": "Addon Discovered",
"description": "Found **{addon_name}** running at `{host}:{port}`.\n\nConfig path: `{config_path}`\n\nPress Submit to connect.",
"data": {}
},
"manual": {
"title": "Manual Configuration",
"description": "The OpenClaw addon was not auto-detected. Enter the gateway connection details manually.",
"data": {
"gateway_host": "Gateway Host",
"gateway_port": "Gateway Port",
"gateway_token": "Gateway Token",
"use_ssl": "Use SSL (HTTPS)"
}
}
},
"error": {
"cannot_connect": "Cannot connect to the OpenClaw gateway. Ensure the addon is running.",
"invalid_auth": "Invalid gateway token. Check your OpenClaw configuration.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "OpenClaw is already configured."
}
},
"entity": {
"sensor": {
"status": {
"name": "Status"
},
"last_activity": {
"name": "Last Activity"
},
"session_count": {
"name": "Session Count"
},
"model": {
"name": "Model"
}
},
"binary_sensor": {
"connected": {
"name": "Connected"
}
}
},
"services": {
"send_message": {
"name": "Send Message",
"description": "Send a text message to OpenClaw.",
"fields": {
"message": {
"name": "Message",
"description": "The message text to send."
},
"session_id": {
"name": "Session ID",
"description": "Optional session ID for conversation context."
},
"attachments": {
"name": "Attachments",
"description": "Optional file attachments."
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Clear conversation history.",
"fields": {
"session_id": {
"name": "Session ID",
"description": "Session to clear."
}
}
}
}
}