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
+355
View File
@@ -0,0 +1,355 @@
# OpenClaw Integration — Implementation Plan
## 1. Overview
This document describes the architecture and phased implementation plan for the
**OpenClaw Integration** — a native Home Assistant custom integration that acts as
a satellite companion to the existing **OpenClaw Assistant** addon.
### Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Chat card (text, streaming, files, voice) embedded in HA dashboard | P0 |
| 2 | Sensor / binary-sensor entities for status, model, sessions | P0 |
| 3 | Bidirectional addon ↔ integration communication (zero manual setup) | P0 |
| 4 | Native HA conversation agent (Assist / Voice PE) | P1 |
| 5 | Service calls & events for automations | P1 |
| 6 | Media player entity for TTS output | P2 |
---
## 2. Existing Addon — Key Facts
| Property | Value |
|----------|-------|
| Slug | `openclaw_assistant_dev` |
| Gateway port | `18789` (configurable via `gateway_port`) |
| Auth | Token-based (`gateway.auth.token` in `openclaw.json`) |
| Config file | `/config/.openclaw/openclaw.json` (addon container) |
| OpenAI-compatible endpoint | `POST /v1/chat/completions` (opt-in via `enable_openai_api`) |
| Gateway bind | `127.0.0.1` (loopback) or `0.0.0.0` (lan) |
| Node process | `openclaw gateway run` |
| Ingress port | `48099` (nginx) |
---
## 3. Architecture
```
┌──────────────────────────────────────────────────────────┐
│ Home Assistant Core │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Sensors / │ │ Conversation │ │ Services / │ │
│ │ Binary │ │ Agent │ │ Events │ │
│ │ Sensors │ │ (Assist/VPE) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │
│ └────────┬────────┴───────────────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ OpenClawAPI │ (api.py — HTTP client) │
│ │ Client │ │
│ └───────┬────────┘ │
│ │ HTTP / SSE │
├──────────────────┼────────────────────────────────────────┤
│ │ │
│ ┌───────────────▼──────────────────────────────┐ │
│ │ OpenClaw Gateway (addon) │ │
│ │ │ │
│ │ /v1/chat/completions (SSE streaming) │ │
│ │ /api/status (JSON) │ │
│ │ /api/sessions (JSON) │ │
│ │ /api/models (JSON) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ OpenClaw Assistant Addon Container │
└───────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Lovelace Dashboard │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ openclaw-chat-card │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ Message history (markdown) │ │ │
│ │ │ Typing indicator │ │ │
│ │ │ File / image attachments │ │ │
│ │ │ Voice input button │ │ │
│ │ │ Voice mode toggle │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ ▲ │
│ │ HA WebSocket API │
│ │ (openclaw.send_message service) │
│ │ + event subscriptions │
└───────┼─────────────────────────────────────┘
Home Assistant Core (services / events)
```
### 3.1 Auto-Discovery (Zero-Config)
The integration discovers the addon with **no manual setup** using two mechanisms:
1. **Supervisor API** — at config-flow time the integration calls
`GET /addons/openclaw_assistant_dev/info` via the HA Supervisor client.
This gives us: addon state, network ports, options.
2. **Shared filesystem** — the addon mounts `/addon_configs/<slug>` which maps
to `/config` inside the container. The integration can read
`openclaw.json` to get the gateway auth token, mode, and port.
**Net effect:** the user clicks "Add Integration → OpenClaw" and everything
connects automatically — no token or URL entry required.
### 3.2 Communication Protocol
| Direction | Transport | Endpoint / Mechanism |
|-----------|-----------|----------------------|
| Integration → Chat | HTTP POST + SSE stream | `/v1/chat/completions` (OpenAI compat) |
| Integration → Status | HTTP GET (polled every 30 s) | `/api/status` |
| Integration → Sessions | HTTP GET (polled every 60 s) | `/api/sessions` |
| Integration → Models | HTTP GET (on startup + hourly) | `/v1/models` |
| Addon → HA (events) | HA REST API (long-lived token) | `POST /api/events/openclaw_*` |
| Frontend → Integration | HA WebSocket API | service calls + event subscriptions |
### 3.3 Latency Budget (Voice Mode)
| Step | Target | Notes |
|------|--------|-------|
| STT (browser/HA) | ≤ 500 ms | WebSpeech API or Whisper via HA |
| Network to OpenClaw | ≤ 100 ms | localhost or LAN |
| OpenClaw inference | ≤ 1500 ms | First token via SSE stream |
| TTS | ≤ 500 ms | HA `tts.speak` or browser SpeechSynthesis |
| **Total** | **≤ 2600 ms** | Under 3 s target |
---
## 4. File Structure
```
openclaw_integration/
├── IMPLEMENTATION_PLAN.md ← this file
├── hacs.json ← HACS metadata
├── README.md
├── custom_components/
│ └── openclaw/
│ ├── __init__.py ← integration setup, coordinator
│ ├── manifest.json ← HA integration manifest
│ ├── config_flow.py ← auto-discovery config flow
│ ├── const.py ← constants, defaults
│ ├── api.py ← OpenClaw gateway HTTP client
│ ├── coordinator.py ← DataUpdateCoordinator for polling
│ ├── sensor.py ← sensor entities
│ ├── binary_sensor.py ← binary sensor entities
│ ├── conversation.py ← native conversation agent
│ ├── services.py ← service call handlers
│ ├── services.yaml ← service definitions
│ ├── strings.json ← English UI strings
│ └── translations/
│ └── en.json ← English translations
└── www/
└── openclaw-chat-card.js ← Lovelace custom card (Lit)
```
---
## 5. Phased Implementation
### Phase 1 — Foundation (MVP)
**Goal:** Integration installs, auto-discovers addon, exposes sensors.
| Task | File(s) | Description |
|------|---------|-------------|
| 1.1 | `manifest.json` | Integration metadata, dependencies |
| 1.2 | `const.py` | Domain, default ports, config keys |
| 1.3 | `api.py` | `OpenClawApiClient` — HTTP client to gateway |
| 1.4 | `config_flow.py` | Auto-discovery via Supervisor + shared config |
| 1.5 | `coordinator.py` | `DataUpdateCoordinator` polling status/sessions/model |
| 1.6 | `__init__.py` | Integration setup, platforms, coordinator init |
| 1.7 | `sensor.py` | `openclaw_status`, `openclaw_last_activity`, `openclaw_session_count`, `openclaw_model` |
| 1.8 | `binary_sensor.py` | `openclaw_connected` |
| 1.9 | `strings.json`, `translations/en.json` | UI text |
| 1.10 | `hacs.json` | HACS configuration |
**Acceptance criteria:**
- `Add Integration → OpenClaw` works with zero config
- All 5 entities appear and update
- Integration reconnects gracefully when addon restarts
### Phase 2 — Services, Events & Conversation Agent
**Goal:** Automations can send/receive messages; Assist pipeline works.
| Task | File(s) | Description |
|------|---------|-------------|
| 2.1 | `services.yaml`, `services.py` | `openclaw.send_message`, `openclaw.clear_history` |
| 2.2 | `__init__.py` (update) | Fire `openclaw_message_received` HA event on response |
| 2.3 | `conversation.py` | Register as `conversation` agent |
| 2.4 | `api.py` (update) | SSE streaming support (`async for` over response chunks) |
| 2.5 | Testing | Verify Assist Text/Voice pipeline end-to-end |
**Acceptance criteria:**
- `openclaw.send_message` callable from automations
- `openclaw_message_received` event fires and can trigger automations
- OpenClaw appears in Assist agent picker
- Voice PE works: wake word → STT → OpenClaw → TTS → speaker
### Phase 3 — Chat Card (Frontend)
**Goal:** Full chat UI as a Lovelace custom card.
| Task | File(s) | Description |
|------|---------|-------------|
| 3.1 | `openclaw-chat-card.js` | Base card shell (Lit element, card config) |
| 3.2 | (continued) | Message history with timestamps, markdown rendering |
| 3.3 | (continued) | Streaming response display (typing indicator) |
| 3.4 | (continued) | File/image attachment support (upload via service) |
| 3.5 | (continued) | Voice input (WebSpeech/MediaRecorder → send audio) |
| 3.6 | (continued) | Voice mode toggle (continuous listen → auto-respond with TTS) |
| 3.7 | `__init__.py` (update) | Register card as Lovelace resource |
| 3.8 | Card editor | Visual card configuration editor |
**Acceptance criteria:**
- Card renders chat history with markdown
- Real-time streaming of AI responses
- File upload works in both directions
- Voice input captures and sends audio
- Voice mode maintains continuous conversation
### Phase 4 — Media Player & Polish
**Goal:** Native TTS output, wake word integration, production hardening.
| Task | File(s) | Description |
|------|---------|-------------|
| 4.1 | `media_player.py` | Media player entity for TTS output routing |
| 4.2 | | Wake word detection integration |
| 4.3 | | WebSocket connection management (token refresh, reconnect) |
| 4.4 | | Error handling, rate limiting, connection pooling |
| 4.5 | | Performance profiling (voice latency budget) |
| 4.6 | | Documentation and HACS submission |
---
## 6. Entity Reference
### Sensors
| Entity ID | Class | State | Attributes |
|-----------|-------|-------|------------|
| `sensor.openclaw_status` | — | `online` / `offline` / `processing` | `gateway_version`, `uptime` |
| `sensor.openclaw_last_activity` | `timestamp` | ISO 8601 datetime | `last_message_preview` |
| `sensor.openclaw_session_count` | — | integer (active count) | `sessions` (list of IDs) |
| `sensor.openclaw_model` | — | model name string | `provider`, `context_window` |
### Binary Sensors
| Entity ID | Class | State |
|-----------|-------|-------|
| `binary_sensor.openclaw_connected` | `connectivity` | `on` / `off` |
### Services
| Service | Fields | Description |
|---------|--------|-------------|
| `openclaw.send_message` | `message` (str), `session_id` (str, optional), `attachments` (list, optional) | Send a message to OpenClaw |
| `openclaw.clear_history` | `session_id` (str, optional) | Clear conversation history |
### Events
| Event | Data | Description |
|-------|------|-------------|
| `openclaw_message_received` | `message`, `session_id`, `model`, `timestamp` | Fired when OpenClaw sends a response |
---
## 7. Key Technical Decisions
### 7.1 Why HTTP+SSE instead of WebSocket?
- The addon already exposes an **OpenAI-compatible** HTTP endpoint with SSE streaming
- No additional gateway code needed on the addon side
- SSE is simpler to manage (no bi-directional state, automatic reconnect)
- WebSocket can be added later for real-time push features
### 7.2 Why Supervisor API for discovery?
- Available in all HAOS / Supervised installs (target audience)
- Provides addon state, network config, and options without filesystem access
- Filesystem fallback (`/addon_configs/`) handles edge cases
### 7.3 Why register as a conversation agent?
- Native Assist integration = works with Voice PE, S3 satellite, etc.
- No need for third-party HACS integrations (Extended OpenAI Conversation)
- Event-driven: HA handles STT/TTS pipeline, we just process text
### 7.4 Frontend card communication
- Card uses HA WebSocket API (already authenticated)
- Calls `openclaw.send_message` service
- Subscribes to `openclaw_message_received` events
- No separate WebSocket to gateway needed (avoids CORS, auth issues)
---
## 8. Challenges & Mitigations
| Challenge | Mitigation |
|-----------|------------|
| Gateway not reachable (loopback bind) | Integration runs in same host; `127.0.0.1` works. Document `lan` mode for remote setups. |
| Token rotation / mismatch | Re-read `openclaw.json` on connection failure; config flow stores token in HA config entry |
| Voice latency > 3s | Use SSE streaming (first token fast), browser-side SpeechSynthesis for TTS, Whisper locally for STT |
| Addon not installed | Config flow gracefully fails with a message directing user to install the addon first |
| HACS distribution | Standard HACS custom integration + Lovelace card resources |
| Large file uploads | Chunk uploads through service call; gateway handles multipart |
---
## 9. Dependencies
### Python (integration)
| Package | Purpose | In HA? |
|---------|---------|--------|
| `aiohttp` | HTTP client for gateway API | ✅ built-in |
| `homeassistant` | HA core APIs | ✅ built-in |
### JavaScript (frontend card)
| Library | Purpose | Bundled? |
|---------|---------|----------|
| `lit` | Web component framework | ✅ available via HA |
| `marked` | Markdown rendering | Bundle in card JS |
No additional pip dependencies required — the integration uses only HA built-ins.
---
## 10. Testing Strategy
| Level | Tool | Coverage |
|-------|------|----------|
| Unit | `pytest` + `pytest-homeassistant-custom-component` | API client, coordinator, config flow |
| Integration | HA dev container with addon mock | End-to-end entity updates, service calls |
| Frontend | Manual + Playwright | Card rendering, streaming, voice |
---
## 11. Timeline Estimate
| Phase | Duration | Milestone |
|-------|----------|-----------|
| Phase 1 | 12 weeks | Sensors visible, auto-discovery works |
| Phase 2 | 12 weeks | Conversation agent in Assist, services/events |
| Phase 3 | 23 weeks | Chat card MVP (text + streaming + files) |
| Phase 4 | 23 weeks | Voice mode, media player, polish |
**Total estimated: 610 weeks to full feature set.**
+83
View File
@@ -0,0 +1,83 @@
# OpenClaw Integration for Home Assistant
A native Home Assistant integration for communicating with the
[OpenClaw Assistant](https://github.com/techartdev/OpenClawHomeAssistant) addon.
## Features
- **Chat card** — Lovelace card with streaming AI responses, file attachments, and voice mode
- **Sensors** — `openclaw_status`, `openclaw_model`, `openclaw_session_count`, `openclaw_last_activity`, `openclaw_connected`
- **Conversation agent** — appears in Assist & Voice PE as a native agent
- **Service calls** — `openclaw.send_message` for automations
- **Events** — `openclaw_message_received` for triggering automations from AI responses
- **Zero-config** — auto-discovers the addon via Supervisor API, no tokens or URLs to enter
## Installation
### HACS (recommended)
1. Open HACS → Integrations → **+ Explore & Download Repositories**
2. Search for **OpenClaw** and install
3. Restart Home Assistant
4. Go to Settings → Devices & Services → **Add Integration → OpenClaw**
### Manual
1. Copy `custom_components/openclaw/` into your HA `config/custom_components/` directory
2. Copy `www/openclaw-chat-card.js` into `config/www/`
3. Restart Home Assistant
4. Add the integration via Settings → Devices & Services
## Prerequisites
The **OpenClaw Assistant** addon must be installed and running.
The integration will auto-detect it — no manual configuration needed.
## Chat Card
Add the card to any dashboard:
```yaml
type: custom:openclaw-chat-card
```
## Services
### `openclaw.send_message`
Send a message to OpenClaw from an automation or script.
```yaml
service: openclaw.send_message
data:
message: "What's the weather like today?"
session_id: "optional-session-id"
```
### `openclaw.clear_history`
```yaml
service: openclaw.clear_history
data:
session_id: "optional-session-id"
```
## Events
### `openclaw_message_received`
Fired whenever OpenClaw sends a response. Use in automations:
```yaml
trigger:
- platform: event
event_type: openclaw_message_received
action:
- service: notify.mobile_app
data:
message: "{{ trigger.event.data.message }}"
```
## License
See [LICENSE](../LICENSE).
+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."
}
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "OpenClaw",
"render_readme": true,
"domains": ["sensor", "binary_sensor", "conversation"],
"iot_class": "local_polling",
"homeassistant": "2024.1.0"
}
+854
View File
@@ -0,0 +1,854 @@
/**
* OpenClaw Chat Card — Lovelace custom card for Home Assistant.
*
* Features:
* - Message history with timestamps
* - Streaming AI response display (typing indicator)
* - Markdown rendering
* - File/image attachment support
* - Voice input (WebSpeech / MediaRecorder)
* - Voice mode toggle (continuous conversation)
*
* Communication: uses HA WebSocket API → openclaw.send_message service
* + subscribes to openclaw_message_received events.
*/
const CARD_VERSION = "0.2.0";
// Max time (ms) to show the thinking indicator before falling back to an error
const THINKING_TIMEOUT_MS = 120_000;
// Session storage key prefix for message persistence across dashboard navigations
const STORAGE_PREFIX = "openclaw_chat_";
// ─── Minimal Markdown renderer ──────────────────────────────────────────────
// Handles: **bold**, *italic*, `code`, ```code blocks```, [links](url), headers
function renderMarkdown(text) {
if (!text) return "";
let html = text
// Code blocks (``` ... ```)
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, "<code>$1</code>")
// Bold
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
// Italic
.replace(/\*(.+?)\*/g, "<em>$1</em>")
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
// Headers
.replace(/^### (.+)$/gm, "<h4>$1</h4>")
.replace(/^## (.+)$/gm, "<h3>$1</h3>")
.replace(/^# (.+)$/gm, "<h2>$1</h2>")
// Line breaks
.replace(/\n/g, "<br>");
return html;
}
// ─── Card class ─────────────────────────────────────────────────────────────
class OpenClawChatCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
this._config = {};
this._messages = [];
this._isProcessing = false;
this._isVoiceMode = false;
this._recognition = null;
this._eventUnsubscribe = null;
this._thinkingTimer = null;
}
// ── HA card interface ───────────────────────────────────────────────
static getConfigElement() {
return document.createElement("openclaw-chat-card-editor");
}
static getStubConfig() {
return {};
}
setConfig(config) {
this._config = {
title: config.title || "OpenClaw Chat",
height: config.height || "500px",
show_timestamps: config.show_timestamps !== false,
show_voice_button: config.show_voice_button !== false,
show_clear_button: config.show_clear_button !== false,
session_id: config.session_id || null,
...config,
};
// Restore messages from sessionStorage if available
this._restoreMessages();
this._render();
}
set hass(hass) {
const firstSet = !this._hass;
this._hass = hass;
if (firstSet) {
this._subscribeToEvents();
this._render();
}
}
getCardSize() {
return 6;
}
connectedCallback() {
this._render();
}
disconnectedCallback() {
this._unsubscribeEvents();
this._stopVoiceRecognition();
this._clearThinkingTimer();
}
// ── Event subscription ──────────────────────────────────────────────
async _subscribeToEvents() {
if (!this._hass || this._eventUnsubscribe) return;
try {
this._eventUnsubscribe = await this._hass.connection.subscribeEvents(
(event) => this._handleOpenClawEvent(event),
"openclaw_message_received"
);
} catch (err) {
console.error("OpenClaw: Failed to subscribe to events:", err);
}
}
_unsubscribeEvents() {
if (this._eventUnsubscribe) {
this._eventUnsubscribe();
this._eventUnsubscribe = null;
}
}
_handleOpenClawEvent(event) {
const data = event.data;
if (!data || !data.message) return;
// Check if this event is for our session
const sessionId = this._config.session_id || "default";
if (data.session_id && data.session_id !== sessionId) return;
// If we're waiting for a response, update the last "thinking" message
if (this._isProcessing) {
this._isProcessing = false;
this._clearThinkingTimer();
// Replace the thinking indicator with the actual response
const thinkingIdx = this._messages.findIndex((m) => m._thinking);
if (thinkingIdx >= 0) {
this._messages[thinkingIdx] = {
role: "assistant",
content: data.message,
timestamp: data.timestamp || new Date().toISOString(),
};
} else {
this._addMessage("assistant", data.message);
}
} else {
this._addMessage("assistant", data.message);
}
this._persistMessages();
this._render();
this._scrollToBottom();
// In voice mode, speak the response
if (this._isVoiceMode && data.message) {
this._speak(data.message);
}
}
_clearChat() {
this._messages = [];
this._isProcessing = false;
this._clearThinkingTimer();
this._persistMessages();
this._render();
}
// ── Message persistence ──────────────────────────────────────────────
_getStorageKey() {
const session = this._config.session_id || "default";
return `${STORAGE_PREFIX}${session}`;
}
_persistMessages() {
try {
const toSave = this._messages.filter((m) => !m._thinking);
// Keep last 100 messages to avoid storage bloat
const trimmed = toSave.slice(-100);
sessionStorage.setItem(this._getStorageKey(), JSON.stringify(trimmed));
} catch (e) {
// sessionStorage full or unavailable — ignore
}
}
_restoreMessages() {
try {
const stored = sessionStorage.getItem(this._getStorageKey());
if (stored) {
this._messages = JSON.parse(stored);
}
} catch (e) {
// Corrupted data — start fresh
this._messages = [];
}
}
// ── Thinking timer ──────────────────────────────────────────────────
_startThinkingTimer() {
this._clearThinkingTimer();
this._thinkingTimer = setTimeout(() => {
if (!this._isProcessing) return;
const idx = this._messages.findIndex((m) => m._thinking);
if (idx >= 0) {
this._messages[idx] = {
role: "assistant",
content: "Response timed out. The model may still be processing — try again.",
timestamp: new Date().toISOString(),
_error: true,
};
}
this._isProcessing = false;
this._render();
}, THINKING_TIMEOUT_MS);
}
_clearThinkingTimer() {
if (this._thinkingTimer) {
clearTimeout(this._thinkingTimer);
this._thinkingTimer = null;
}
}
// ── Message handling ────────────────────────────────────────────────
_addMessage(role, content) {
this._messages.push({
role,
content,
timestamp: new Date().toISOString(),
});
this._persistMessages();
}
async _sendMessage(text) {
if (!text || !text.trim() || !this._hass) return;
const message = text.trim();
this._addMessage("user", message);
// Add thinking indicator with timeout safeguard
this._isProcessing = true;
this._messages.push({
role: "assistant",
content: "",
_thinking: true,
timestamp: new Date().toISOString(),
});
this._startThinkingTimer();
this._render();
this._scrollToBottom();
try {
await this._hass.callService("openclaw", "send_message", {
message: message,
session_id: this._config.session_id || undefined,
});
} catch (err) {
console.error("OpenClaw: Failed to send message:", err);
// Replace thinking with error
const thinkingIdx = this._messages.findIndex((m) => m._thinking);
if (thinkingIdx >= 0) {
this._messages[thinkingIdx] = {
role: "assistant",
content: `Error: ${err.message || "Failed to send message"}`,
timestamp: new Date().toISOString(),
_error: true,
};
}
this._isProcessing = false;
this._render();
}
}
// ── Voice ───────────────────────────────────────────────────────────
_startVoiceRecognition() {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
console.warn("OpenClaw: Speech recognition not supported in this browser");
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
this._recognition = new SpeechRecognition();
this._recognition.continuous = this._isVoiceMode;
this._recognition.interimResults = true;
this._recognition.lang = this._hass?.language || "en-US";
this._recognition.onresult = (event) => {
const result = event.results[event.results.length - 1];
if (result.isFinal) {
const text = result[0].transcript;
this._sendMessage(text);
}
};
this._recognition.onerror = (event) => {
console.error("OpenClaw: Speech recognition error:", event.error);
this._render();
};
this._recognition.onend = () => {
if (this._isVoiceMode) {
// Restart recognition in voice mode
try {
this._recognition.start();
} catch (e) {
// Ignore — may already be started
}
} else {
this._render();
}
};
this._recognition.start();
this._render();
}
_stopVoiceRecognition() {
if (this._recognition) {
this._recognition.abort();
this._recognition = null;
}
this._isVoiceMode = false;
}
_toggleVoiceMode() {
this._isVoiceMode = !this._isVoiceMode;
if (this._isVoiceMode) {
this._startVoiceRecognition();
} else {
this._stopVoiceRecognition();
}
this._render();
}
_speak(text) {
if (!("speechSynthesis" in window)) return;
// Strip markdown for TTS
const plain = text.replace(/[*_`#\[\]()]/g, "");
const utterance = new SpeechSynthesisUtterance(plain);
utterance.lang = this._hass?.language || "en-US";
speechSynthesis.speak(utterance);
}
// ── File attachments ────────────────────────────────────────────────
_handleFileAttachment() {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = "image/*,.pdf,.txt,.md,.json,.csv";
input.onchange = (e) => {
const files = Array.from(e.target.files);
if (files.length > 0) {
// TODO: Implement file upload via service call
const names = files.map((f) => f.name).join(", ");
this._addMessage("user", `📎 Attached: ${names}`);
this._render();
}
};
input.click();
}
// ── UI helpers ──────────────────────────────────────────────────────
_scrollToBottom() {
requestAnimationFrame(() => {
const container = this.shadowRoot?.querySelector(".messages");
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
_formatTime(isoString) {
if (!isoString) return "";
try {
const d = new Date(isoString);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
}
// ── Render ──────────────────────────────────────────────────────────
_render() {
const config = this._config;
const messages = this._messages;
const messagesHtml = messages
.map((msg) => {
const isUser = msg.role === "user";
const isThinking = msg._thinking;
const isError = msg._error;
let contentHtml;
if (isThinking) {
contentHtml = `<div class="thinking"><span></span><span></span><span></span></div>`;
} else if (isError) {
contentHtml = `<div class="error">${this._escapeHtml(msg.content)}</div>`;
} else if (isUser) {
contentHtml = this._escapeHtml(msg.content);
} else {
contentHtml = renderMarkdown(msg.content);
}
const timeHtml =
config.show_timestamps && msg.timestamp
? `<div class="time">${this._formatTime(msg.timestamp)}</div>`
: "";
return `
<div class="msg ${isUser ? "user" : "assistant"}">
<div class="bubble">${contentHtml}</div>
${timeHtml}
</div>`;
})
.join("");
const voiceActive = this._recognition !== null;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
--oc-bg: var(--card-background-color, #1c1c1c);
--oc-msg-user: var(--primary-color, #2563eb);
--oc-msg-assistant: var(--secondary-background-color, #2a2a2a);
--oc-text: var(--primary-text-color, #e6edf3);
--oc-text-secondary: var(--secondary-text-color, #9ca3af);
--oc-border: var(--divider-color, #333);
--oc-radius: 12px;
}
ha-card {
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--oc-border);
}
.header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--oc-text);
}
.header-actions {
display: flex;
gap: 8px;
}
.icon-btn {
background: none;
border: none;
color: var(--oc-text-secondary);
cursor: pointer;
padding: 4px;
border-radius: 6px;
font-size: 20px;
line-height: 1;
}
.icon-btn:hover { color: var(--oc-text); background: var(--oc-border); }
.icon-btn.active { color: var(--oc-msg-user); }
.messages {
height: ${config.height || "500px"};
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.msg {
display: flex;
flex-direction: column;
max-width: 85%;
}
.msg.user {
align-self: flex-end;
align-items: flex-end;
}
.msg.assistant {
align-self: flex-start;
align-items: flex-start;
}
.bubble {
padding: 10px 14px;
border-radius: var(--oc-radius);
font-size: 14px;
line-height: 1.5;
color: var(--oc-text);
word-wrap: break-word;
overflow-wrap: break-word;
}
.msg.user .bubble {
background: var(--oc-msg-user);
color: white;
border-bottom-right-radius: 4px;
}
.msg.assistant .bubble {
background: var(--oc-msg-assistant);
border-bottom-left-radius: 4px;
}
.bubble code {
background: rgba(0,0,0,0.3);
padding: 1px 4px;
border-radius: 4px;
font-size: 13px;
}
.bubble pre {
background: rgba(0,0,0,0.3);
padding: 8px;
border-radius: 6px;
overflow-x: auto;
margin: 4px 0;
}
.bubble pre code {
background: none;
padding: 0;
}
.bubble a { color: #60a5fa; }
.time {
font-size: 11px;
color: var(--oc-text-secondary);
margin-top: 2px;
padding: 0 4px;
}
.error { color: #ef4444; }
.thinking {
display: flex;
gap: 4px;
padding: 4px 0;
}
.thinking span {
width: 8px;
height: 8px;
background: var(--oc-text-secondary);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.thinking span:nth-child(1) { animation-delay: -0.32s; }
.thinking span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.input-area {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--oc-border);
}
.input-area textarea {
flex: 1;
resize: none;
border: 1px solid var(--oc-border);
border-radius: var(--oc-radius);
background: var(--oc-bg);
color: var(--oc-text);
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
min-height: 20px;
max-height: 120px;
outline: none;
}
.input-area textarea:focus {
border-color: var(--oc-msg-user);
}
.send-btn {
background: var(--oc-msg-user);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--oc-text-secondary);
font-size: 14px;
}
.voice-indicator {
display: inline-block;
width: 10px;
height: 10px;
background: #ef4444;
border-radius: 50%;
animation: pulse 1.5s infinite;
margin-right: 4px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
<ha-card>
<div class="header">
<h3>${this._escapeHtml(config.title)}</h3>
<div class="header-actions">
${
config.show_voice_button
? `<button class="icon-btn ${voiceActive ? "active" : ""}"
id="voice-btn"
title="${voiceActive ? "Stop voice" : "Voice input"}">
${voiceActive ? '<span class="voice-indicator"></span>🎙️' : "🎙️"}
</button>
<button class="icon-btn ${this._isVoiceMode ? "active" : ""}"
id="voice-mode-btn"
title="Toggle voice mode (continuous)">
🔊
</button>`
: ""
}
<button class="icon-btn" id="attach-btn" title="Attach file">📎</button>
${config.show_clear_button ? '<button class="icon-btn" id="clear-btn" title="Clear chat">🗑️</button>' : ''}
</div>
</div>
<div class="messages" id="messages">
${
messages.length === 0
? '<div class="empty-state">Send a message to start a conversation</div>'
: messagesHtml
}
</div>
<div class="input-area">
<textarea
id="input"
rows="1"
placeholder="Type a message..."
${this._isProcessing ? "disabled" : ""}
></textarea>
<button class="send-btn" id="send-btn" ${this._isProcessing ? "disabled" : ""}>➤</button>
</div>
</ha-card>
`;
// ── Event listeners ────────────────────────────────────────────
const input = this.shadowRoot.getElementById("input");
const sendBtn = this.shadowRoot.getElementById("send-btn");
const attachBtn = this.shadowRoot.getElementById("attach-btn");
const voiceBtn = this.shadowRoot.getElementById("voice-btn");
const voiceModeBtn = this.shadowRoot.getElementById("voice-mode-btn");
if (input) {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this._sendMessage(input.value);
input.value = "";
}
});
// Auto-resize textarea
input.addEventListener("input", () => {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 120) + "px";
});
}
if (sendBtn) {
sendBtn.addEventListener("click", () => {
if (input) {
this._sendMessage(input.value);
input.value = "";
input.style.height = "auto";
}
});
}
if (attachBtn) {
attachBtn.addEventListener("click", () => this._handleFileAttachment());
}
if (voiceBtn) {
voiceBtn.addEventListener("click", () => {
if (this._recognition) {
this._stopVoiceRecognition();
this._render();
} else {
this._startVoiceRecognition();
}
});
}
if (voiceModeBtn) {
voiceModeBtn.addEventListener("click", () => this._toggleVoiceMode());
}
const clearBtn = this.shadowRoot.getElementById("clear-btn");
if (clearBtn) {
clearBtn.addEventListener("click", () => {
if (confirm("Clear chat history?")) this._clearChat();
});
}
this._scrollToBottom();
}
_escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
// ── Card editor element ─────────────────────────────────────────────────────
class OpenClawChatCardEditor extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._config = {};
}
setConfig(config) {
this._config = { ...config };
this._render();
}
set hass(_hass) {
// We don't need hass reference in the editor
}
_render() {
const c = this._config;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.row { display: flex; align-items: center; padding: 8px 0; }
.row label { flex: 1; font-size: 14px; color: var(--primary-text-color); }
.row input[type="text"] {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--divider-color, #ccc);
border-radius: 6px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
}
.row input[type="checkbox"] {
width: 18px; height: 18px;
}
</style>
<div>
<div class="row">
<label for="title">Title</label>
<input type="text" id="title" value="${this._esc(c.title || "OpenClaw Chat")}" />
</div>
<div class="row">
<label for="height">Height</label>
<input type="text" id="height" value="${this._esc(c.height || "500px")}" />
</div>
<div class="row">
<label for="session_id">Session ID (optional)</label>
<input type="text" id="session_id" value="${this._esc(c.session_id || "")}" />
</div>
<div class="row">
<label for="show_timestamps">Show timestamps</label>
<input type="checkbox" id="show_timestamps" ${c.show_timestamps !== false ? "checked" : ""} />
</div>
<div class="row">
<label for="show_voice_button">Show voice button</label>
<input type="checkbox" id="show_voice_button" ${c.show_voice_button !== false ? "checked" : ""} />
</div>
<div class="row">
<label for="show_clear_button">Show clear button</label>
<input type="checkbox" id="show_clear_button" ${c.show_clear_button !== false ? "checked" : ""} />
</div>
</div>
`;
// Bind events
for (const id of ["title", "height", "session_id"]) {
const el = this.shadowRoot.getElementById(id);
if (el) {
el.addEventListener("change", (e) => this._fireChanged(id, e.target.value));
}
}
for (const id of ["show_timestamps", "show_voice_button", "show_clear_button"]) {
const el = this.shadowRoot.getElementById(id);
if (el) {
el.addEventListener("change", (e) => this._fireChanged(id, e.target.checked));
}
}
}
_fireChanged(key, value) {
this._config = { ...this._config, [key]: value };
const event = new CustomEvent("config-changed", {
detail: { config: this._config },
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
}
_esc(str) {
return String(str).replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
}
// ── Card registration ───────────────────────────────────────────────────────
customElements.define("openclaw-chat-card-editor", OpenClawChatCardEditor);
customElements.define("openclaw-chat-card", OpenClawChatCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: "openclaw-chat-card",
name: "OpenClaw Chat",
description: "Chat interface for OpenClaw AI Assistant with streaming, voice, and file support.",
preview: true,
});
console.info(
`%c OPENCLAW-CHAT-CARD %c v${CARD_VERSION} `,
"color: white; background: #2563eb; font-weight: bold; padding: 2px 6px; border-radius: 4px 0 0 4px;",
"color: #2563eb; background: #e5e7eb; font-weight: bold; padding: 2px 6px; border-radius: 0 4px 4px 0;"
);