From 0445c595ef853b0fe9f6f8b9c3c9431db798d197 Mon Sep 17 00:00:00 2001 From: techartdev Date: Fri, 20 Feb 2026 17:21:14 +0200 Subject: [PATCH] Update changelog for version 0.1.16, add configurable wake-word support, optional always-on voice mode, and websocket settings endpoint; enhance chat card voice recognition and context handling. --- CHANGELOG.md | 35 +++ README.md | 12 + custom_components/openclaw/__init__.py | 236 +++++++++++++++++- custom_components/openclaw/config_flow.py | 96 ++++++- custom_components/openclaw/const.py | 20 ++ custom_components/openclaw/conversation.py | 29 ++- custom_components/openclaw/exposure.py | 45 +++- custom_components/openclaw/manifest.json | 2 +- custom_components/openclaw/strings.json | 17 ++ .../openclaw/translations/en.json | 17 ++ .../openclaw/www/openclaw-chat-card.js | 103 +++++++- www/openclaw-chat-card.js | 103 +++++++- 12 files changed, 691 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4427c88..6cad7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.16] - 2026-02-20 + +### Added +- Added configurable wake-word support in integration options. +- Added optional always-on voice mode in integration options. +- Added websocket settings endpoint (`openclaw/get_settings`) used by the chat card to apply integration-level voice settings. + +### Changed +- Chat card voice recognition now supports two modes: + - Manual voice input without wake word + - Continuous listening with required wake word + +## [0.1.15] - 2026-02-20 + +### Added +- Added integration options for prompt/context behavior: + - Include exposed-entities context + - Max context characters + - Context overflow strategy (`truncate` or `clear`) + - Enable tool calls (`execute_service` / `execute_services`) + +### Changed +- Service and conversation requests now apply configurable context policy before sending prompts. +- Optional tool-call execution now mirrors Extended OpenAI-style service tool usage and feeds execution results back into a follow-up model response. + +## [0.1.14] - 2026-02-20 + +### Fixed +- Fixed Assist exposure context lookup to use Home Assistant's conversation assistant identifier (`conversation`) instead of `assist`, which could result in empty exposed-entity context. +- Added backend in-memory chat history and websocket endpoint (`openclaw/get_history`) so card responses are recoverable after leaving and returning to the dashboard. +- Normalized default session handling to `default` for service calls/events/history, avoiding session mismatch drops. + +### Changed +- Chat card now syncs history from backend on mount/initialization to restore missed assistant messages. + ## [0.1.13] - 2026-02-20 ### Added diff --git a/README.md b/README.md index 0754143..fb2a9ec 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ Configure this in Home Assistant: 2. Open the **Expose** tab 3. Select which entities are exposed to **Assist** +## Advanced options + +Open **Settings → Devices & Services → OpenClaw → Configure** to tune: + +- Include exposed entities context +- Max context characters +- Context overflow strategy (`truncate` or `clear`) +- Enable tool calls (execute HA services from model tool-call responses) +- Require wake word +- Wake word text (for example: `hey openclaw`) +- Always-on voice mode (continuous listening while the card is open) + ## Chat Card The chat card is registered automatically when the integration loads — no diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index b8e061b..66ce295 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -6,12 +6,14 @@ Sets up the OpenClaw integration: API client, coordinator, platforms, and servic from __future__ import annotations import asyncio +import json from datetime import datetime, timezone import logging from pathlib import Path from typing import Any import voluptuous as vol +from homeassistant.components import websocket_api try: from homeassistant.components.lovelace.const import LOVELACE_DATA @@ -36,6 +38,21 @@ from .const import ( CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, CONF_USE_SSL, + CONF_CONTEXT_MAX_CHARS, + CONF_CONTEXT_STRATEGY, + CONF_ENABLE_TOOL_CALLS, + CONF_INCLUDE_EXPOSED_CONTEXT, + CONF_WAKE_WORD, + CONF_WAKE_WORD_ENABLED, + CONF_ALWAYS_VOICE_MODE, + CONTEXT_STRATEGY_TRUNCATE, + DEFAULT_CONTEXT_MAX_CHARS, + DEFAULT_CONTEXT_STRATEGY, + DEFAULT_ENABLE_TOOL_CALLS, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_WAKE_WORD, + DEFAULT_WAKE_WORD_ENABLED, + DEFAULT_ALWAYS_VOICE_MODE, DOMAIN, EVENT_MESSAGE_RECEIVED, OPENCLAW_CONFIG_REL_PATH, @@ -44,10 +61,12 @@ from .const import ( SERVICE_SEND_MESSAGE, ) from .coordinator import OpenClawCoordinator -from .exposure import build_exposed_entities_context +from .exposure import apply_context_policy, build_exposed_entities_context _LOGGER = logging.getLogger(__name__) +_MAX_CHAT_HISTORY = 200 + # 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 @@ -98,6 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> "client": client, "coordinator": coordinator, "addon_config_path": addon_config_path, + "entry": entry, } # First data fetch — if it fails the coordinator marks entities unavailable @@ -107,6 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> # Register services (once, idempotent) _async_register_services(hass) + _async_register_websocket_api(hass) # Register the frontend card resource hass.async_create_task(_async_register_frontend(hass)) @@ -303,7 +324,7 @@ def _async_register_services(hass: HomeAssistant) -> None: 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) + session_id: str = call.data.get(ATTR_SESSION_ID) or "default" entry_data = _get_first_entry_data(hass) if not entry_data: @@ -312,15 +333,48 @@ def _async_register_services(hass: HomeAssistant) -> None: client: OpenClawApiClient = entry_data["client"] coordinator: OpenClawCoordinator = entry_data["coordinator"] + entry: ConfigEntry = entry_data["entry"] + options = entry.options try: - system_prompt = build_exposed_entities_context(hass, assistant="assist") + include_context = options.get( + CONF_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, + ) + max_chars = int( + options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS) + ) + strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY) + if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}: + strategy = DEFAULT_CONTEXT_STRATEGY + + raw_context = ( + build_exposed_entities_context(hass, assistant="conversation") + if include_context + else None + ) + system_prompt = apply_context_policy(raw_context, max_chars, strategy) + + _append_chat_history(hass, session_id, "user", message) response = await client.async_send_message( message=message, session_id=session_id, system_prompt=system_prompt, ) + if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS): + tool_results = await _async_execute_tool_calls(hass, response) + if tool_results: + response = await client.async_send_message( + message=( + "Tool execution results:\n" + + "\n".join(f"- {line}" for line in tool_results) + + "\nRespond to the user based on these results." + ), + session_id=session_id, + system_prompt=system_prompt, + ) + assistant_message = _extract_assistant_message(response) model_used = response.get("model", "unknown") @@ -331,6 +385,7 @@ def _async_register_services(hass: HomeAssistant) -> None: list(response.keys()), ) + _append_chat_history(hass, session_id, "assistant", assistant_message) hass.bus.async_fire( EVENT_MESSAGE_RECEIVED, { @@ -344,6 +399,7 @@ def _async_register_services(hass: HomeAssistant) -> None: except OpenClawApiError as err: _LOGGER.error("Failed to send message to OpenClaw: %s", err) + _append_chat_history(hass, session_id, "assistant", f"OpenClaw error: {err}") hass.bus.async_fire( EVENT_MESSAGE_RECEIVED, { @@ -358,7 +414,11 @@ def _async_register_services(hass: HomeAssistant) -> 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 + store = _get_chat_history_store(hass) + if session_id: + store.pop(session_id, None) + else: + store.clear() hass.services.async_register( DOMAIN, @@ -433,3 +493,171 @@ def _extract_text_recursive(value: Any, depth: int = 0) -> str | None: def _extract_assistant_message(response: dict[str, Any]) -> str | None: """Extract assistant text from modern/legacy OpenAI-compatible responses.""" return _extract_text_recursive(response) + + +def _extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]: + """Extract OpenAI-style tool calls from a response payload.""" + choices = response.get("choices") + if not isinstance(choices, list) or not choices: + return [] + + message = choices[0].get("message") if isinstance(choices[0], dict) else None + if not isinstance(message, dict): + return [] + + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list): + return [] + + valid_calls: list[dict[str, Any]] = [] + for call in tool_calls: + if isinstance(call, dict): + valid_calls.append(call) + return valid_calls + + +async def _async_execute_tool_calls( + hass: HomeAssistant, + response: dict[str, Any], +) -> list[str]: + """Execute supported tool calls and return result lines.""" + results: list[str] = [] + tool_calls = _extract_tool_calls(response) + + for call in tool_calls: + function_data = call.get("function") + if not isinstance(function_data, dict): + continue + + function_name = function_data.get("name") + arguments = function_data.get("arguments") + + if function_name not in {"execute_service", "execute_services"}: + results.append(f"Skipped unsupported tool '{function_name}'") + continue + + if not isinstance(arguments, str): + results.append("Skipped tool call with invalid arguments format") + continue + + try: + parsed = json.loads(arguments) + except json.JSONDecodeError: + results.append("Skipped tool call due to invalid JSON arguments") + continue + + services_list = parsed.get("list") if isinstance(parsed, dict) else None + if not isinstance(services_list, list): + results.append("Skipped tool call without 'list' payload") + continue + + for item in services_list: + if not isinstance(item, dict): + continue + domain = item.get("domain") + service = item.get("service") + service_data = item.get("service_data", {}) + + if not isinstance(domain, str) or not isinstance(service, str): + results.append("Skipped invalid service item (missing domain/service)") + continue + + if not isinstance(service_data, dict): + service_data = {} + + try: + await hass.services.async_call( + domain, + service, + service_data, + blocking=True, + ) + results.append(f"Executed {domain}.{service}") + except Exception as err: # noqa: BLE001 + results.append(f"Failed {domain}.{service}: {err}") + + return results + + +def _get_chat_history_store(hass: HomeAssistant) -> dict[str, list[dict[str, str]]]: + """Return in-memory per-session chat history store.""" + store_key = f"{DOMAIN}_chat_history" + store = hass.data.get(store_key) + if store is None: + store = {} + hass.data[store_key] = store + return store + + +def _append_chat_history(hass: HomeAssistant, session_id: str, role: str, content: str) -> None: + """Append a message to in-memory chat history.""" + store = _get_chat_history_store(hass) + history = store.setdefault(session_id, []) + history.append( + { + "role": role, + "content": content, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + if len(history) > _MAX_CHAT_HISTORY: + del history[:-_MAX_CHAT_HISTORY] + + +@callback +def _async_register_websocket_api(hass: HomeAssistant) -> None: + """Register websocket API for chat history retrieval.""" + key = f"{DOMAIN}_ws_registered" + if hass.data.get(key): + return + hass.data[key] = True + + @websocket_api.websocket_command( + { + vol.Required("type"): f"{DOMAIN}/get_history", + vol.Optional("session_id"): cv.string, + } + ) + @callback + def websocket_get_history( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Return chat history for a session.""" + session_id = msg.get("session_id") or "default" + history = _get_chat_history_store(hass).get(session_id, []) + connection.send_result(msg["id"], {"session_id": session_id, "messages": history}) + + websocket_api.async_register_command(hass, websocket_get_history) + + @websocket_api.websocket_command( + { + vol.Required("type"): f"{DOMAIN}/get_settings", + } + ) + @callback + def websocket_get_settings( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Return frontend-related integration settings.""" + entry_data = _get_first_entry_data(hass) + options = entry_data.get("entry").options if entry_data and entry_data.get("entry") else {} + connection.send_result( + msg["id"], + { + CONF_WAKE_WORD_ENABLED: options.get( + CONF_WAKE_WORD_ENABLED, + DEFAULT_WAKE_WORD_ENABLED, + ), + CONF_WAKE_WORD: options.get(CONF_WAKE_WORD, DEFAULT_WAKE_WORD), + CONF_ALWAYS_VOICE_MODE: options.get( + CONF_ALWAYS_VOICE_MODE, + DEFAULT_ALWAYS_VOICE_MODE, + ), + }, + ) + + websocket_api.async_register_command(hass, websocket_get_settings) diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index dfa9c96..2727791 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -14,7 +14,7 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -28,8 +28,24 @@ from .const import ( CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, CONF_USE_SSL, + CONF_CONTEXT_MAX_CHARS, + CONF_CONTEXT_STRATEGY, + CONF_ENABLE_TOOL_CALLS, + CONF_INCLUDE_EXPOSED_CONTEXT, + CONF_WAKE_WORD, + CONF_WAKE_WORD_ENABLED, + CONF_ALWAYS_VOICE_MODE, + CONTEXT_STRATEGY_CLEAR, + CONTEXT_STRATEGY_TRUNCATE, DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, + DEFAULT_CONTEXT_MAX_CHARS, + DEFAULT_CONTEXT_STRATEGY, + DEFAULT_ENABLE_TOOL_CALLS, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_WAKE_WORD, + DEFAULT_WAKE_WORD_ENABLED, + DEFAULT_ALWAYS_VOICE_MODE, DOMAIN, OPENCLAW_CONFIG_REL_PATH, ) @@ -237,6 +253,11 @@ class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + def async_get_options_flow(config_entry: ConfigEntry) -> OpenClawOptionsFlow: + """Get options flow for this handler.""" + return OpenClawOptionsFlow(config_entry) + def __init__(self) -> None: """Initialize the config flow.""" self._discovered: dict[str, Any] | None = None @@ -364,3 +385,76 @@ class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + +class OpenClawOptionsFlow(OptionsFlow): + """Handle OpenClaw options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage integration options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = self._config_entry.options + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_INCLUDE_EXPOSED_CONTEXT, + default=options.get( + CONF_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, + ), + ): bool, + vol.Optional( + CONF_CONTEXT_MAX_CHARS, + default=options.get( + CONF_CONTEXT_MAX_CHARS, + DEFAULT_CONTEXT_MAX_CHARS, + ), + ): vol.All(int, vol.Range(min=1000, max=200000)), + vol.Optional( + CONF_CONTEXT_STRATEGY, + default=options.get( + CONF_CONTEXT_STRATEGY, + DEFAULT_CONTEXT_STRATEGY, + ), + ): vol.In([CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_CLEAR]), + vol.Optional( + CONF_ENABLE_TOOL_CALLS, + default=options.get( + CONF_ENABLE_TOOL_CALLS, + DEFAULT_ENABLE_TOOL_CALLS, + ), + ): bool, + vol.Optional( + CONF_WAKE_WORD_ENABLED, + default=options.get( + CONF_WAKE_WORD_ENABLED, + DEFAULT_WAKE_WORD_ENABLED, + ), + ): bool, + vol.Optional( + CONF_WAKE_WORD, + default=options.get( + CONF_WAKE_WORD, + DEFAULT_WAKE_WORD, + ), + ): str, + vol.Optional( + CONF_ALWAYS_VOICE_MODE, + default=options.get( + CONF_ALWAYS_VOICE_MODE, + DEFAULT_ALWAYS_VOICE_MODE, + ), + ): bool, + } + ), + ) diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index f43b702..f93c167 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -23,6 +23,26 @@ CONF_GATEWAY_TOKEN = "gateway_token" CONF_USE_SSL = "use_ssl" CONF_ADDON_CONFIG_PATH = "addon_config_path" +# Options +CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context" +CONF_CONTEXT_MAX_CHARS = "context_max_chars" +CONF_CONTEXT_STRATEGY = "context_strategy" +CONF_ENABLE_TOOL_CALLS = "enable_tool_calls" +CONF_WAKE_WORD_ENABLED = "wake_word_enabled" +CONF_WAKE_WORD = "wake_word" +CONF_ALWAYS_VOICE_MODE = "always_voice_mode" + +DEFAULT_INCLUDE_EXPOSED_CONTEXT = True +DEFAULT_CONTEXT_MAX_CHARS = 13000 +DEFAULT_CONTEXT_STRATEGY = "truncate" +DEFAULT_ENABLE_TOOL_CALLS = False +DEFAULT_WAKE_WORD_ENABLED = False +DEFAULT_WAKE_WORD = "hey openclaw" +DEFAULT_ALWAYS_VOICE_MODE = False + +CONTEXT_STRATEGY_TRUNCATE = "truncate" +CONTEXT_STRATEGY_CLEAR = "clear" + # Coordinator data keys DATA_STATUS = "status" DATA_MODEL = "model" diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index b694df3..c0c3b38 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -21,12 +21,18 @@ from .const import ( ATTR_MODEL, ATTR_SESSION_ID, ATTR_TIMESTAMP, + CONF_CONTEXT_MAX_CHARS, + CONF_CONTEXT_STRATEGY, + CONF_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_CONTEXT_MAX_CHARS, + DEFAULT_CONTEXT_STRATEGY, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, DATA_MODEL, DOMAIN, EVENT_MESSAGE_RECEIVED, ) from .coordinator import OpenClawCoordinator -from .exposure import build_exposed_entities_context +from .exposure import apply_context_policy, build_exposed_entities_context _LOGGER = logging.getLogger(__name__) @@ -101,11 +107,24 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): message = user_input.text conversation_id = user_input.conversation_id or "default" - assistant_id = getattr(user_input, "assistant", None) or "assist" - exposed_context = build_exposed_entities_context( - self.hass, - assistant=assistant_id, + assistant_id = "conversation" + options = self.entry.options + include_context = options.get( + CONF_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, ) + max_chars = int(options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS)) + strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY) + + raw_context = ( + build_exposed_entities_context( + self.hass, + assistant=assistant_id, + ) + if include_context + else None + ) + exposed_context = apply_context_policy(raw_context, max_chars, strategy) extra_system_prompt = getattr(user_input, "extra_system_prompt", None) system_prompt = "\n\n".join( part for part in (exposed_context, extra_system_prompt) if part diff --git a/custom_components/openclaw/exposure.py b/custom_components/openclaw/exposure.py index c3b2bcb..8fd694e 100644 --- a/custom_components/openclaw/exposure.py +++ b/custom_components/openclaw/exposure.py @@ -10,20 +10,26 @@ from homeassistant.core import HomeAssistant def build_exposed_entities_context( hass: HomeAssistant, - assistant: str | None = "assist", + assistant: str | None = "conversation", max_entities: int = 250, ) -> str | None: """Build a compact prompt block of entities exposed to an assistant. Uses Home Assistant's built-in expose rules (Settings -> Voice assistants -> Expose). """ - assistant_id = assistant or "assist" + assistant_id = assistant or "conversation" - exposed_states = [ - state - for state in hass.states.async_all() - if async_should_expose(hass, assistant_id, state.entity_id) - ] + def _collect_for(assistant_value: str) -> list: + return [ + state + for state in hass.states.async_all() + if async_should_expose(hass, assistant_value, state.entity_id) + ] + + exposed_states = _collect_for(assistant_id) + + if not exposed_states and assistant_id != "conversation": + exposed_states = _collect_for("conversation") if not exposed_states: return None @@ -55,3 +61,28 @@ def build_exposed_entities_context( ) return "\n".join(lines) + + +def apply_context_policy( + context_text: str | None, + max_chars: int, + strategy: str, +) -> str | None: + """Apply context truncation policy to an optional prompt block.""" + if not context_text: + return None + + if max_chars <= 0: + return None + + if len(context_text) <= max_chars: + return context_text + + if strategy == "clear": + return None + + marker = "\n[Context truncated to fit configured max length]\n" + available = max_chars - len(marker) + if available <= 0: + return context_text[-max_chars:] + return marker + context_text[-available:] diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 223a001..5ebcdf6 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.13", + "version": "0.1.16", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 424917c..2a7e497 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -31,6 +31,23 @@ "already_configured": "OpenClaw is already configured." } }, + "options": { + "step": { + "init": { + "title": "OpenClaw Options", + "description": "Configure context and tool-calling behavior.", + "data": { + "include_exposed_context": "Include exposed entities context", + "context_max_chars": "Max context characters", + "context_strategy": "When context exceeds max", + "enable_tool_calls": "Enable tool calls (execute services)", + "wake_word_enabled": "Require wake word", + "wake_word": "Wake word", + "always_voice_mode": "Always-on voice mode" + } + } + } + }, "entity": { "sensor": { "status": { diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index 424917c..2a7e497 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -31,6 +31,23 @@ "already_configured": "OpenClaw is already configured." } }, + "options": { + "step": { + "init": { + "title": "OpenClaw Options", + "description": "Configure context and tool-calling behavior.", + "data": { + "include_exposed_context": "Include exposed entities context", + "context_max_chars": "Max context characters", + "context_strategy": "When context exceeds max", + "enable_tool_calls": "Enable tool calls (execute services)", + "wake_word_enabled": "Require wake word", + "wake_word": "Wake word", + "always_voice_mode": "Always-on voice mode" + } + } + } + }, "entity": { "sensor": { "status": { diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index c9ae805..1423500 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -59,6 +59,9 @@ class OpenClawChatCard extends HTMLElement { this._recognition = null; this._eventUnsubscribe = null; this._thinkingTimer = null; + this._wakeWordEnabled = false; + this._wakeWord = "hey openclaw"; + this._alwaysVoiceMode = false; } // ── HA card interface ─────────────────────────────────────────────── @@ -91,6 +94,8 @@ class OpenClawChatCard extends HTMLElement { this._hass = hass; if (firstSet) { this._subscribeToEvents(); + this._syncHistoryFromBackend(); + this._loadIntegrationSettings(); this._render(); } } @@ -100,6 +105,8 @@ class OpenClawChatCard extends HTMLElement { } connectedCallback() { + this._syncHistoryFromBackend(); + this._loadIntegrationSettings(); this._render(); } @@ -136,7 +143,7 @@ class OpenClawChatCard extends HTMLElement { if (!data || !data.message) return; // Check if this event is for our session - const sessionId = this._config.session_id || "default"; + const sessionId = this._getSessionId(); if (data.session_id && data.session_id !== sessionId) return; // If we're waiting for a response, update the last "thinking" message @@ -179,10 +186,14 @@ class OpenClawChatCard extends HTMLElement { // ── Message persistence ────────────────────────────────────────────── _getStorageKey() { - const session = this._config.session_id || "default"; + const session = this._getSessionId(); return `${STORAGE_PREFIX}${session}`; } + _getSessionId() { + return this._config.session_id || "default"; + } + _persistMessages() { try { const toSave = this._messages.filter((m) => !m._thinking); @@ -206,6 +217,73 @@ class OpenClawChatCard extends HTMLElement { } } + async _syncHistoryFromBackend() { + if (!this._hass) return; + + const sessionId = this._getSessionId(); + + try { + let result; + if (typeof this._hass.callWS === "function") { + result = await this._hass.callWS({ + type: "openclaw/get_history", + session_id: sessionId, + }); + } else { + result = await this._hass.connection.sendMessagePromise({ + type: "openclaw/get_history", + session_id: sessionId, + }); + } + + const serverMessages = Array.isArray(result?.messages) ? result.messages : []; + if (!serverMessages.length) return; + + const validMessages = serverMessages.filter( + (m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string" + ); + if (!validMessages.length) return; + + if (validMessages.length > this._messages.length) { + this._messages = validMessages; + this._isProcessing = false; + this._clearThinkingTimer(); + this._persistMessages(); + this._render(); + this._scrollToBottom(); + } + } catch (err) { + console.debug("OpenClaw: history sync skipped:", err); + } + } + + async _loadIntegrationSettings() { + if (!this._hass) return; + + try { + let result; + if (typeof this._hass.callWS === "function") { + result = await this._hass.callWS({ type: "openclaw/get_settings" }); + } else { + result = await this._hass.connection.sendMessagePromise({ + type: "openclaw/get_settings", + }); + } + + this._wakeWordEnabled = !!result?.wake_word_enabled; + this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase(); + this._alwaysVoiceMode = !!result?.always_voice_mode; + + if (this._alwaysVoiceMode && !this._isVoiceMode) { + this._isVoiceMode = true; + this._startVoiceRecognition(); + } + this._render(); + } catch (err) { + console.debug("OpenClaw: settings sync skipped:", err); + } + } + // ── Thinking timer ────────────────────────────────────────────────── _startThinkingTimer() { @@ -302,7 +380,26 @@ class OpenClawChatCard extends HTMLElement { this._recognition.onresult = (event) => { const result = event.results[event.results.length - 1]; if (result.isFinal) { - const text = result[0].transcript; + const text = result[0].transcript?.trim(); + if (!text) return; + + if (this._wakeWordEnabled) { + const wake = this._wakeWord || "hey openclaw"; + const lower = text.toLowerCase(); + const wakePos = lower.indexOf(wake); + if (wakePos < 0) { + return; + } + + let command = text.slice(wakePos + wake.length).trim(); + command = command.replace(/^[,:;.!?\-]+\s*/, ""); + if (!command) { + return; + } + this._sendMessage(command); + return; + } + this._sendMessage(text); } }; diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js index c9ae805..1423500 100644 --- a/www/openclaw-chat-card.js +++ b/www/openclaw-chat-card.js @@ -59,6 +59,9 @@ class OpenClawChatCard extends HTMLElement { this._recognition = null; this._eventUnsubscribe = null; this._thinkingTimer = null; + this._wakeWordEnabled = false; + this._wakeWord = "hey openclaw"; + this._alwaysVoiceMode = false; } // ── HA card interface ─────────────────────────────────────────────── @@ -91,6 +94,8 @@ class OpenClawChatCard extends HTMLElement { this._hass = hass; if (firstSet) { this._subscribeToEvents(); + this._syncHistoryFromBackend(); + this._loadIntegrationSettings(); this._render(); } } @@ -100,6 +105,8 @@ class OpenClawChatCard extends HTMLElement { } connectedCallback() { + this._syncHistoryFromBackend(); + this._loadIntegrationSettings(); this._render(); } @@ -136,7 +143,7 @@ class OpenClawChatCard extends HTMLElement { if (!data || !data.message) return; // Check if this event is for our session - const sessionId = this._config.session_id || "default"; + const sessionId = this._getSessionId(); if (data.session_id && data.session_id !== sessionId) return; // If we're waiting for a response, update the last "thinking" message @@ -179,10 +186,14 @@ class OpenClawChatCard extends HTMLElement { // ── Message persistence ────────────────────────────────────────────── _getStorageKey() { - const session = this._config.session_id || "default"; + const session = this._getSessionId(); return `${STORAGE_PREFIX}${session}`; } + _getSessionId() { + return this._config.session_id || "default"; + } + _persistMessages() { try { const toSave = this._messages.filter((m) => !m._thinking); @@ -206,6 +217,73 @@ class OpenClawChatCard extends HTMLElement { } } + async _syncHistoryFromBackend() { + if (!this._hass) return; + + const sessionId = this._getSessionId(); + + try { + let result; + if (typeof this._hass.callWS === "function") { + result = await this._hass.callWS({ + type: "openclaw/get_history", + session_id: sessionId, + }); + } else { + result = await this._hass.connection.sendMessagePromise({ + type: "openclaw/get_history", + session_id: sessionId, + }); + } + + const serverMessages = Array.isArray(result?.messages) ? result.messages : []; + if (!serverMessages.length) return; + + const validMessages = serverMessages.filter( + (m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string" + ); + if (!validMessages.length) return; + + if (validMessages.length > this._messages.length) { + this._messages = validMessages; + this._isProcessing = false; + this._clearThinkingTimer(); + this._persistMessages(); + this._render(); + this._scrollToBottom(); + } + } catch (err) { + console.debug("OpenClaw: history sync skipped:", err); + } + } + + async _loadIntegrationSettings() { + if (!this._hass) return; + + try { + let result; + if (typeof this._hass.callWS === "function") { + result = await this._hass.callWS({ type: "openclaw/get_settings" }); + } else { + result = await this._hass.connection.sendMessagePromise({ + type: "openclaw/get_settings", + }); + } + + this._wakeWordEnabled = !!result?.wake_word_enabled; + this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase(); + this._alwaysVoiceMode = !!result?.always_voice_mode; + + if (this._alwaysVoiceMode && !this._isVoiceMode) { + this._isVoiceMode = true; + this._startVoiceRecognition(); + } + this._render(); + } catch (err) { + console.debug("OpenClaw: settings sync skipped:", err); + } + } + // ── Thinking timer ────────────────────────────────────────────────── _startThinkingTimer() { @@ -302,7 +380,26 @@ class OpenClawChatCard extends HTMLElement { this._recognition.onresult = (event) => { const result = event.results[event.results.length - 1]; if (result.isFinal) { - const text = result[0].transcript; + const text = result[0].transcript?.trim(); + if (!text) return; + + if (this._wakeWordEnabled) { + const wake = this._wakeWord || "hey openclaw"; + const lower = text.toLowerCase(); + const wakePos = lower.indexOf(wake); + if (wakePos < 0) { + return; + } + + let command = text.slice(wakePos + wake.length).trim(); + command = command.replace(/^[,:;.!?\-]+\s*/, ""); + if (!command) { + return; + } + this._sendMessage(command); + return; + } + this._sendMessage(text); } };