From e9cd5e1ffff4bdc5380bd7198cdd23b3fece09c9 Mon Sep 17 00:00:00 2001 From: techartdev Date: Fri, 20 Feb 2026 13:07:28 +0200 Subject: [PATCH] first commit --- IMPLEMENTATION_PLAN.md | 355 ++++++++ README.md | 83 ++ custom_components/openclaw/__init__.py | 269 ++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 11025 bytes .../openclaw/__pycache__/api.cpython-312.pyc | Bin 0 -> 14137 bytes .../__pycache__/binary_sensor.cpython-312.pyc | Bin 0 -> 3009 bytes .../__pycache__/config_flow.cpython-312.pyc | Bin 0 -> 13154 bytes .../__pycache__/const.cpython-312.pyc | Bin 0 -> 1680 bytes .../__pycache__/conversation.cpython-312.pyc | Bin 0 -> 8125 bytes .../__pycache__/coordinator.cpython-312.pyc | Bin 0 -> 8496 bytes .../__pycache__/sensor.cpython-312.pyc | Bin 0 -> 5078 bytes custom_components/openclaw/api.py | 324 +++++++ custom_components/openclaw/binary_sensor.py | 58 ++ custom_components/openclaw/config_flow.py | 345 +++++++ custom_components/openclaw/const.py | 59 ++ custom_components/openclaw/conversation.py | 196 ++++ custom_components/openclaw/coordinator.py | 176 ++++ custom_components/openclaw/manifest.json | 14 + custom_components/openclaw/sensor.py | 138 +++ custom_components/openclaw/services.yaml | 37 + custom_components/openclaw/strings.json | 84 ++ .../openclaw/translations/en.json | 84 ++ hacs.json | 7 + www/openclaw-chat-card.js | 854 ++++++++++++++++++ 24 files changed, 3083 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 README.md create mode 100644 custom_components/openclaw/__init__.py create mode 100644 custom_components/openclaw/__pycache__/__init__.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/api.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/binary_sensor.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/config_flow.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/const.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/conversation.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/coordinator.cpython-312.pyc create mode 100644 custom_components/openclaw/__pycache__/sensor.cpython-312.pyc create mode 100644 custom_components/openclaw/api.py create mode 100644 custom_components/openclaw/binary_sensor.py create mode 100644 custom_components/openclaw/config_flow.py create mode 100644 custom_components/openclaw/const.py create mode 100644 custom_components/openclaw/conversation.py create mode 100644 custom_components/openclaw/coordinator.py create mode 100644 custom_components/openclaw/manifest.json create mode 100644 custom_components/openclaw/sensor.py create mode 100644 custom_components/openclaw/services.yaml create mode 100644 custom_components/openclaw/strings.json create mode 100644 custom_components/openclaw/translations/en.json create mode 100644 hacs.json create mode 100644 www/openclaw-chat-card.js 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 0000000000000000000000000000000000000000..b77594995eb3b901e8f7c66ea382bf3847cc81ae GIT binary patch literal 11025 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bd0bfb0a564803482a5439216c004aa585e7deaa GIT binary patch literal 14137 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e7623d8b00e36c115965afd49b9d2ebaa8ba3dea GIT binary patch literal 3009 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 Jf~|Bi{|glD$`b$p literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..85e9fc9341db073c1c4b6b01aa63a4a12abc517b GIT binary patch literal 5078 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 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(/