From 2308ddfc4568480fe29077be8d9972c1f6625818 Mon Sep 17 00:00:00 2001 From: techartdev Date: Fri, 20 Feb 2026 14:37:00 +0200 Subject: [PATCH] Update changelog for version 0.1.5, add automatic Lovelace resource registration, and enhance chat card functionality --- CHANGELOG.md | 20 + README.md | 3 +- custom_components/openclaw/__init__.py | 113 ++- custom_components/openclaw/manifest.json | 4 +- .../openclaw/www/openclaw-chat-card.js | 854 ++++++++++++++++++ 5 files changed, 971 insertions(+), 23 deletions(-) create mode 100644 custom_components/openclaw/www/openclaw-chat-card.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d4ca8..1ceedeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.5] - 2026-02-20 + +### Added +- **Automatic Lovelace resource registration** — the chat card JS is now served + directly from inside the integration package (`custom_components/openclaw/www/`) + via a registered static HTTP path at `/openclaw/openclaw-chat-card.js`. The + integration also adds it to Lovelace's resource store automatically on first + setup. No manual "Add resource" step is required. +- `async_setup()` registered so the static path is available before any config + entry setup runs. +- `lovelace` added to `after_dependencies` so Lovelace is ready when we attempt + to register the resource. + +### Changed +- `openclaw-chat-card.js` is now shipped inside `custom_components/openclaw/www/` + (in addition to the top-level `www/` for backward compatibility with HACS installs + that copy files to `config/www/`). +- If programmatic Lovelace registration fails (e.g. Lovelace not loaded), a clear + warning with manual fallback instructions is logged instead of silently doing nothing. + ## [0.1.4] - 2026-02-20 ### Fixed diff --git a/README.md b/README.md index a19b1f5..0096082 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ The integration will auto-detect it — no manual configuration needed. ## Chat Card -Add the card to any dashboard: +The chat card is registered automatically when the integration loads — no +manual resource configuration needed. Just add it to any dashboard: ```yaml type: custom:openclaw-chat-card diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 743c4cc..4370f54 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -40,8 +40,25 @@ from .coordinator import OpenClawCoordinator _LOGGER = logging.getLogger(__name__) +# Path to the chat card JS inside the integration package (custom_components/openclaw/www/) +_CARD_FILENAME = "openclaw-chat-card.js" +_CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME +# URL at which the card JS will be served (registered via register_static_path) +_CARD_URL = f"/openclaw/{_CARD_FILENAME}" + type OpenClawConfigEntry = ConfigEntry + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Global integration setup — runs once before any config entries. + + Registers the static HTTP path for the chat card so the JS file is served + directly from inside the integration package, regardless of whether the + user installed via HACS or manually. + """ + _async_register_static_path(hass) + return True + # Service call schemas SEND_MESSAGE_SCHEMA = vol.Schema( { @@ -57,9 +74,6 @@ CLEAR_HISTORY_SCHEMA = vol.Schema( } ) -# 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. @@ -164,31 +178,90 @@ def _async_setup_token_refresh( # ── Frontend registration ───────────────────────────────────────────────────── @callback -def _async_register_frontend(hass: HomeAssistant) -> None: - """Register the Lovelace custom card resource. +def _async_register_static_path(hass: HomeAssistant) -> None: + """Register the integration's www/ folder as a static HTTP path. - 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. + After this the card JS is always available at /openclaw/openclaw-chat-card.js + regardless of how the integration was installed (HACS or manual). + """ + static_key = f"{DOMAIN}_static_registered" + if hass.data.get(static_key): + return + hass.data[static_key] = True + + if not _CARD_PATH.exists(): + _LOGGER.warning( + "Chat card JS not found at %s — frontend resource will not be available", + _CARD_PATH, + ) + return + + hass.http.register_static_path( + f"/openclaw/{_CARD_FILENAME}", + str(_CARD_PATH), + cache_headers=True, + ) + _LOGGER.debug("Registered static path: /openclaw/%s", _CARD_FILENAME) + + +@callback +def _async_register_frontend(hass: HomeAssistant) -> None: + """Register the Lovelace custom card resource (called once per setup). + + Adds the card URL to Lovelace's resource list so it loads on every + dashboard automatically. No manual step required. """ 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}" + # Ensure static path is registered (may have been missed if async_setup + # didn't run, e.g. during a config entry reload). + _async_register_static_path(hass) - # 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, - ) + hass.async_create_task(_async_add_lovelace_resource(hass, _CARD_URL)) + + +async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> None: + """Add the card URL to Lovelace's resource store if not already present.""" + # Lovelace stores resources in hass.data["lovelace"]["resources"]. + # It is a ResourceStorageCollection with: + # .async_items() → list of {"id", "res_type", "url"} dicts + # .async_create_item(data) → persists a new resource + lovelace_data = hass.data.get("lovelace") + if not lovelace_data: + _LOGGER.debug( + "Lovelace not loaded; resource '%s' must be added manually if needed", + url, + ) + return + + resource_collection = lovelace_data.get("resources") + if resource_collection is None: + _LOGGER.debug("Lovelace resource store not available") + return + + try: + existing_urls = {item["url"] for item in resource_collection.async_items()} + if url in existing_urls: + _LOGGER.debug("Lovelace resource already registered: %s", url) + return + + await resource_collection.async_create_item( + {"res_type": "module", "url": url} + ) + _LOGGER.info( + "Auto-registered Lovelace resource: %s — the chat card is ready to use.", + url, + ) + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Could not auto-register Lovelace resource '%s': %s. " + "Add it manually: Settings → Dashboards → Resources.", + url, + err, + ) # ── Service registration ────────────────────────────────────────────────────── diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index a6388f4..25e165d 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "requirements": [], - "version": "0.1.4", + "version": "0.1.5", "dependencies": ["conversation"], - "after_dependencies": ["hassio"] + "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js new file mode 100644 index 0000000..3532ae9 --- /dev/null +++ b/custom_components/openclaw/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(/