commit e9cd5e1ffff4bdc5380bd7198cdd23b3fece09c9 Author: techartdev Date: Fri Feb 20 13:07:28 2026 +0200 first commit diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..51200db --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -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/` 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 | 1–2 weeks | Sensors visible, auto-discovery works | +| Phase 2 | 1–2 weeks | Conversation agent in Assist, services/events | +| Phase 3 | 2–3 weeks | Chat card MVP (text + streaming + files) | +| Phase 4 | 2–3 weeks | Voice mode, media player, polish | + +**Total estimated: 6–10 weeks to full feature set.** diff --git a/README.md b/README.md new file mode 100644 index 0000000..a19b1f5 --- /dev/null +++ b/README.md @@ -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). diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py new file mode 100644 index 0000000..743c4cc --- /dev/null +++ b/custom_components/openclaw/__init__.py @@ -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 diff --git a/custom_components/openclaw/__pycache__/__init__.cpython-312.pyc b/custom_components/openclaw/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b775949 Binary files /dev/null and b/custom_components/openclaw/__pycache__/__init__.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/api.cpython-312.pyc b/custom_components/openclaw/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..bd0bfb0 Binary files /dev/null and b/custom_components/openclaw/__pycache__/api.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/binary_sensor.cpython-312.pyc b/custom_components/openclaw/__pycache__/binary_sensor.cpython-312.pyc new file mode 100644 index 0000000..e7623d8 Binary files /dev/null and b/custom_components/openclaw/__pycache__/binary_sensor.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/config_flow.cpython-312.pyc b/custom_components/openclaw/__pycache__/config_flow.cpython-312.pyc new file mode 100644 index 0000000..f3f35bb Binary files /dev/null and b/custom_components/openclaw/__pycache__/config_flow.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/const.cpython-312.pyc b/custom_components/openclaw/__pycache__/const.cpython-312.pyc new file mode 100644 index 0000000..7cfc707 Binary files /dev/null and b/custom_components/openclaw/__pycache__/const.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/conversation.cpython-312.pyc b/custom_components/openclaw/__pycache__/conversation.cpython-312.pyc new file mode 100644 index 0000000..b15cf4a Binary files /dev/null and b/custom_components/openclaw/__pycache__/conversation.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/coordinator.cpython-312.pyc b/custom_components/openclaw/__pycache__/coordinator.cpython-312.pyc new file mode 100644 index 0000000..9842200 Binary files /dev/null and b/custom_components/openclaw/__pycache__/coordinator.cpython-312.pyc differ diff --git a/custom_components/openclaw/__pycache__/sensor.cpython-312.pyc b/custom_components/openclaw/__pycache__/sensor.cpython-312.pyc new file mode 100644 index 0000000..85e9fc9 Binary files /dev/null and b/custom_components/openclaw/__pycache__/sensor.cpython-312.pyc differ diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py new file mode 100644 index 0000000..56c7ea7 --- /dev/null +++ b/custom_components/openclaw/api.py @@ -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() diff --git a/custom_components/openclaw/binary_sensor.py b/custom_components/openclaw/binary_sensor.py new file mode 100644 index 0000000..01d90e3 --- /dev/null +++ b/custom_components/openclaw/binary_sensor.py @@ -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) diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py new file mode 100644 index 0000000..c13af05 --- /dev/null +++ b/custom_components/openclaw/config_flow.py @@ -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/_/ + 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 _ 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, + ) diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py new file mode 100644 index 0000000..9e0b09c --- /dev/null +++ b/custom_components/openclaw/const.py @@ -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" diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py new file mode 100644 index 0000000..f115dae --- /dev/null +++ b/custom_components/openclaw/conversation.py @@ -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, + ) diff --git a/custom_components/openclaw/coordinator.py b/custom_components/openclaw/coordinator.py new file mode 100644 index 0000000..d0edb88 --- /dev/null +++ b/custom_components/openclaw/coordinator.py @@ -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) diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json new file mode 100644 index 0000000..fb4ef19 --- /dev/null +++ b/custom_components/openclaw/manifest.json @@ -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"] +} diff --git a/custom_components/openclaw/sensor.py b/custom_components/openclaw/sensor.py new file mode 100644 index 0000000..22a1dcd --- /dev/null +++ b/custom_components/openclaw/sensor.py @@ -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 diff --git a/custom_components/openclaw/services.yaml b/custom_components/openclaw/services.yaml new file mode 100644 index 0000000..4acc61e --- /dev/null +++ b/custom_components/openclaw/services.yaml @@ -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: diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json new file mode 100644 index 0000000..d68172d --- /dev/null +++ b/custom_components/openclaw/strings.json @@ -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." + } + } + } + } +} diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json new file mode 100644 index 0000000..d68172d --- /dev/null +++ b/custom_components/openclaw/translations/en.json @@ -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." + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..9fbd7cd --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "OpenClaw", + "render_readme": true, + "domains": ["sensor", "binary_sensor", "conversation"], + "iot_class": "local_polling", + "homeassistant": "2024.1.0" +} diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js new file mode 100644 index 0000000..3532ae9 --- /dev/null +++ b/www/openclaw-chat-card.js @@ -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, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, "$1") + // Bold + .replace(/\*\*(.+?)\*\*/g, "$1") + // Italic + .replace(/\*(.+?)\*/g, "$1") + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Headers + .replace(/^### (.+)$/gm, "

$1

") + .replace(/^## (.+)$/gm, "

$1

") + .replace(/^# (.+)$/gm, "

$1

") + // Line breaks + .replace(/\n/g, "
"); + 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 = `
`; + } else if (isError) { + contentHtml = `
${this._escapeHtml(msg.content)}
`; + } else if (isUser) { + contentHtml = this._escapeHtml(msg.content); + } else { + contentHtml = renderMarkdown(msg.content); + } + + const timeHtml = + config.show_timestamps && msg.timestamp + ? `
${this._formatTime(msg.timestamp)}
` + : ""; + + return ` +
+
${contentHtml}
+ ${timeHtml} +
`; + }) + .join(""); + + const voiceActive = this._recognition !== null; + + this.shadowRoot.innerHTML = ` + + + +
+

${this._escapeHtml(config.title)}

+
+ ${ + config.show_voice_button + ? ` + ` + : "" + } + + ${config.show_clear_button ? '' : ''} +
+
+ +
+ ${ + messages.length === 0 + ? '
Send a message to start a conversation
' + : messagesHtml + } +
+ +
+ + +
+
+ `; + + // ── 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 = ` + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + // 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, """).replace(/