first commit
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user