From 95bfe5e21458f4632c6e0aa6faf004352a09de18 Mon Sep 17 00:00:00 2001 From: techartdev Date: Sat, 7 Mar 2026 14:37:39 +0200 Subject: [PATCH] Add voice agent and session ID options, update changelog to version 0.1.61 - Introduced `voice_agent_id` and `assist_session_id` options for enhanced integration. - Updated request handling to support new headers for voice interactions. - Preserved existing behavior when new options are left blank. - Updated changelog for version 0.1.61 to reflect these changes. --- CHANGELOG.md | 16 +++++++++ custom_components/openclaw/__init__.py | 33 +++++++++++++++--- custom_components/openclaw/api.py | 19 ++++++++--- custom_components/openclaw/config_flow.py | 18 ++++++++++ custom_components/openclaw/const.py | 5 +++ custom_components/openclaw/conversation.py | 34 +++++++++++++++++++ custom_components/openclaw/manifest.json | 2 +- custom_components/openclaw/strings.json | 2 ++ .../openclaw/translations/en.json | 2 ++ .../openclaw/www/openclaw-chat-card.js | 11 +++--- 10 files changed, 128 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8a238..029105d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.61] - 2026-03-07 + +### Added +- Added an optional dedicated `voice_agent_id` integration option so Assist and microphone-originated chat requests can be routed to a separate OpenClaw agent without changing the default text/chat agent. +- Added an optional `assist_session_id` integration option so the native Home Assistant conversation agent can reuse a fixed OpenClaw session when desired. + +### Changed +- Leaving either of the new options blank preserves the existing behavior: voice requests still use the configured default agent, and Assist still uses the Home Assistant conversation or fallback session ID. + +## [0.1.60] - 2026-03-07 + +### Added +- Added voice-origin request headers for Home Assistant Assist / voice pipeline traffic: `x-openclaw-source: voice` and `x-ha-voice: true`. +- Added matching voice-origin support for microphone-triggered messages from the OpenClaw chat card, so card voice input and Assist voice pipeline requests are both marked as spoken interactions. +- This allows OpenClaw agents and hooks to detect spoken interactions and return TTS-friendly responses without affecting regular typed chat requests. + ## [0.1.59] - 2026-03-07 ### Fixed diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index b0e5d44..7e4d656 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -46,9 +46,11 @@ from .const import ( ATTR_DRY_RUN, ATTR_MESSAGE_CHANNEL, ATTR_ACCOUNT_ID, + ATTR_SOURCE, ATTR_TIMESTAMP, CONF_ADDON_CONFIG_PATH, CONF_AGENT_ID, + CONF_VOICE_AGENT_ID, CONF_GATEWAY_HOST, CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, @@ -66,6 +68,7 @@ from .const import ( CONF_THINKING_TIMEOUT, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_AGENT_ID, + DEFAULT_VOICE_AGENT_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, DEFAULT_ENABLE_TOOL_CALLS, @@ -92,13 +95,18 @@ _LOGGER = logging.getLogger(__name__) _MAX_CHAT_HISTORY = 200 +_VOICE_REQUEST_HEADERS = { + "x-openclaw-source": "voice", + "x-ha-voice": "true", +} + # 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 is served (registered via register_static_path) _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" # Versioned URL used for Lovelace resource registration to avoid stale browser cache -_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.56" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.60" OpenClawConfigEntry = ConfigEntry @@ -107,6 +115,7 @@ OpenClawConfigEntry = ConfigEntry SEND_MESSAGE_SCHEMA = vol.Schema( { vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_SOURCE): cv.string, vol.Optional(ATTR_SESSION_ID): cv.string, vol.Optional(ATTR_ATTACHMENTS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_AGENT_ID): cv.string, @@ -390,11 +399,19 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool: def _async_register_services(hass: HomeAssistant) -> None: """Register openclaw.send_message and openclaw.clear_history services.""" + def _normalize_optional_text(value: Any) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None + async def handle_send_message(call: ServiceCall) -> None: """Handle the openclaw.send_message service call.""" message: str = call.data[ATTR_MESSAGE] + source: str | None = call.data.get(ATTR_SOURCE) session_id: str = call.data.get(ATTR_SESSION_ID) or "default" - call_agent_id: str | None = call.data.get(ATTR_AGENT_ID) + call_agent_id = _normalize_optional_text(call.data.get(ATTR_AGENT_ID)) + extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None entry_data = _get_first_entry_data(hass) if not entry_data: @@ -404,6 +421,12 @@ def _async_register_services(hass: HomeAssistant) -> None: client: OpenClawApiClient = entry_data["client"] coordinator: OpenClawCoordinator = entry_data["coordinator"] options = _get_entry_options(hass, entry_data) + voice_agent_id = _normalize_optional_text( + options.get(CONF_VOICE_AGENT_ID, DEFAULT_VOICE_AGENT_ID) + ) + resolved_agent_id = call_agent_id + if resolved_agent_id is None and source == "voice": + resolved_agent_id = voice_agent_id try: include_context = options.get( @@ -430,7 +453,8 @@ def _async_register_services(hass: HomeAssistant) -> None: message=message, session_id=session_id, system_prompt=system_prompt, - agent_id=call_agent_id, + agent_id=resolved_agent_id, + extra_headers=extra_headers, ) if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS): @@ -444,7 +468,8 @@ def _async_register_services(hass: HomeAssistant) -> None: ), session_id=session_id, system_prompt=system_prompt, - agent_id=call_agent_id, + agent_id=resolved_agent_id, + extra_headers=extra_headers, ) assistant_message = _extract_assistant_message(response) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index 16f4892..65f4c69 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -85,14 +85,21 @@ class OpenClawApiClient: """Update the authentication token (e.g., after addon restart).""" self._token = token - def _headers(self, agent_id: str | None = None) -> dict[str, str]: + def _headers( + self, + agent_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, str]: """Build request headers with auth token and agent ID.""" effective_agent = agent_id or self._agent_id or "main" - return { + headers = { "Authorization": f"Bearer {self._token}", "Content-Type": "application/json", "x-openclaw-agent-id": effective_agent, } + if extra_headers: + headers.update(extra_headers) + return headers async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" @@ -188,6 +195,7 @@ class OpenClawApiClient: system_prompt: str | None = None, stream: bool = False, agent_id: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> dict[str, Any]: """Send a chat message (non-streaming). @@ -197,6 +205,7 @@ class OpenClawApiClient: model: Optional model override. stream: If True, raises ValueError (use async_stream_message). agent_id: Optional per-call agent ID override. + extra_headers: Optional additional headers for gateway-side routing or hints. Returns: Complete chat completion response. @@ -220,7 +229,7 @@ class OpenClawApiClient: payload["model"] = model # Pass session_id as a custom header or param if supported by gateway - headers = self._headers(agent_id=agent_id) + headers = self._headers(agent_id=agent_id, extra_headers=extra_headers) if session_id: headers["X-Session-Id"] = session_id headers["x-openclaw-session-key"] = session_id @@ -255,6 +264,7 @@ class OpenClawApiClient: model: str | None = None, system_prompt: str | None = None, agent_id: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[str]: """Send a chat message and stream the response via SSE. @@ -265,6 +275,7 @@ class OpenClawApiClient: session_id: Optional session/conversation ID. model: Optional model override. agent_id: Optional per-call agent ID override. + extra_headers: Optional additional headers for gateway-side routing or hints. Yields: Content delta strings from the streaming response. @@ -284,7 +295,7 @@ class OpenClawApiClient: if model: payload["model"] = model - headers = self._headers(agent_id=agent_id) + headers = self._headers(agent_id=agent_id, extra_headers=extra_headers) if session_id: headers["X-Session-Id"] = session_id headers["x-openclaw-session-key"] = session_id diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index f52ee3f..6cb7a3d 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -37,6 +37,7 @@ from .const import ( ADDON_SLUG_FRAGMENTS, CONF_ADDON_CONFIG_PATH, CONF_AGENT_ID, + CONF_ASSIST_SESSION_ID, CONF_GATEWAY_HOST, CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, @@ -48,6 +49,7 @@ from .const import ( CONF_INCLUDE_EXPOSED_CONTEXT, CONF_WAKE_WORD, CONF_WAKE_WORD_ENABLED, + CONF_VOICE_AGENT_ID, CONF_ALLOW_BRAVE_WEBSPEECH, CONF_BROWSER_VOICE_LANGUAGE, CONF_VOICE_PROVIDER, @@ -56,6 +58,7 @@ from .const import ( CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_AGENT_ID, + DEFAULT_ASSIST_SESSION_ID, DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, DEFAULT_CONTEXT_MAX_CHARS, @@ -68,6 +71,7 @@ from .const import ( DEFAULT_BROWSER_VOICE_LANGUAGE, DEFAULT_VOICE_PROVIDER, DEFAULT_THINKING_TIMEOUT, + DEFAULT_VOICE_AGENT_ID, DOMAIN, OPENCLAW_CONFIG_REL_PATH, ) @@ -466,6 +470,20 @@ class OpenClawOptionsFlow(OptionsFlowWithReload): self._config_entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID), ), ): str, + vol.Optional( + CONF_VOICE_AGENT_ID, + default=options.get( + CONF_VOICE_AGENT_ID, + DEFAULT_VOICE_AGENT_ID, + ), + ): str, + vol.Optional( + CONF_ASSIST_SESSION_ID, + default=options.get( + CONF_ASSIST_SESSION_ID, + DEFAULT_ASSIST_SESSION_ID, + ), + ): str, vol.Optional( CONF_INCLUDE_EXPOSED_CONTEXT, default=options.get( diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index 6f700b3..b96a69c 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -24,6 +24,8 @@ CONF_USE_SSL = "use_ssl" CONF_VERIFY_SSL = "verify_ssl" CONF_ADDON_CONFIG_PATH = "addon_config_path" CONF_AGENT_ID = "agent_id" +CONF_VOICE_AGENT_ID = "voice_agent_id" +CONF_ASSIST_SESSION_ID = "assist_session_id" # Options CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context" @@ -38,6 +40,8 @@ CONF_BROWSER_VOICE_LANGUAGE = "browser_voice_language" CONF_THINKING_TIMEOUT = "thinking_timeout" DEFAULT_AGENT_ID = "main" +DEFAULT_VOICE_AGENT_ID = "" +DEFAULT_ASSIST_SESSION_ID = "" DEFAULT_INCLUDE_EXPOSED_CONTEXT = True DEFAULT_CONTEXT_MAX_CHARS = 13000 DEFAULT_CONTEXT_STRATEGY = "truncate" @@ -121,6 +125,7 @@ SERVICE_INVOKE_TOOL = "invoke_tool" # Attributes ATTR_MESSAGE = "message" +ATTR_SOURCE = "source" ATTR_SESSION_ID = "session_id" ATTR_ATTACHMENTS = "attachments" ATTR_MODEL = "model" diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 4d9dfd3..6baa134 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -22,9 +22,12 @@ from .const import ( ATTR_MODEL, ATTR_SESSION_ID, ATTR_TIMESTAMP, + CONF_ASSIST_SESSION_ID, CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, CONF_INCLUDE_EXPOSED_CONTEXT, + CONF_VOICE_AGENT_ID, + DEFAULT_ASSIST_SESSION_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, DEFAULT_INCLUDE_EXPOSED_CONTEXT, @@ -37,6 +40,11 @@ from .exposure import apply_context_policy, build_exposed_entities_context _LOGGER = logging.getLogger(__name__) +_VOICE_REQUEST_HEADERS = { + "x-openclaw-source": "voice", + "x-ha-voice": "true", +} + async def async_setup_entry( hass: HomeAssistant, @@ -110,6 +118,9 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): conversation_id = self._resolve_conversation_id(user_input) assistant_id = "conversation" options = self.entry.options + voice_agent_id = self._normalize_optional_text( + options.get(CONF_VOICE_AGENT_ID) + ) include_context = options.get( CONF_INCLUDE_EXPOSED_CONTEXT, DEFAULT_INCLUDE_EXPOSED_CONTEXT, @@ -136,6 +147,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): client, message, conversation_id, + voice_agent_id, system_prompt, ) except OpenClawApiError as err: @@ -151,6 +163,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): client, message, conversation_id, + voice_agent_id, system_prompt, ) except OpenClawApiError as retry_err: @@ -191,6 +204,15 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -> str: """Return conversation id from HA or a stable Assist fallback session key.""" + configured_session_id = self._normalize_optional_text( + self.entry.options.get( + CONF_ASSIST_SESSION_ID, + DEFAULT_ASSIST_SESSION_ID, + ) + ) + if configured_session_id: + return configured_session_id + if user_input.conversation_id: return user_input.conversation_id @@ -205,11 +227,19 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): return "assist_default" + def _normalize_optional_text(self, value: Any) -> str | None: + """Return a stripped string or None for blank values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None + async def _get_response( self, client: OpenClawApiClient, message: str, conversation_id: str, + agent_id: str | None = None, system_prompt: str | None = None, ) -> str: """Get a response from OpenClaw, trying streaming first.""" @@ -219,6 +249,8 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): message=message, session_id=conversation_id, system_prompt=system_prompt, + agent_id=agent_id, + extra_headers=_VOICE_REQUEST_HEADERS, ): full_response += chunk @@ -230,6 +262,8 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): message=message, session_id=conversation_id, system_prompt=system_prompt, + agent_id=agent_id, + extra_headers=_VOICE_REQUEST_HEADERS, ) extracted = self._extract_text_recursive(response) return extracted or "" diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index e863e2d..9d6a209 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.59", + "version": "0.1.61", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 82cbedf..69909f9 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -39,6 +39,8 @@ "description": "Configure context and tool-calling behavior.", "data": { "agent_id": "Agent ID (e.g. main)", + "voice_agent_id": "Voice agent ID (optional)", + "assist_session_id": "Assist session ID override (optional)", "include_exposed_context": "Include exposed entities context", "context_max_chars": "Max context characters", "context_strategy": "When context exceeds max", diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index ba85a8a..b14609f 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -41,6 +41,8 @@ "description": "Configure context and tool-calling behavior.", "data": { "agent_id": "Agent ID (e.g. main)", + "voice_agent_id": "Voice agent ID (optional)", + "assist_session_id": "Assist session ID override (optional)", "include_exposed_context": "Include exposed entities context", "context_max_chars": "Max context characters", "context_strategy": "When context exceeds max", diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index f6a5b4a..a9ab1c4 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -13,7 +13,7 @@ * + subscribes to openclaw_message_received events. */ -const CARD_VERSION = "0.3.12"; +const CARD_VERSION = "0.3.13"; // Max time (ms) to show the thinking indicator before falling back to an error (default; overridable via card config `thinking_timeout` in seconds) const THINKING_TIMEOUT_MS = 120_000; @@ -516,7 +516,7 @@ class OpenClawChatCard extends HTMLElement { this._persistMessages(); } - async _sendMessage(text) { + async _sendMessage(text, source = null) { if (!text || !text.trim() || !this._hass) return; await this._subscribeToEvents(); @@ -541,6 +541,7 @@ class OpenClawChatCard extends HTMLElement { try { await this._hass.callService("openclaw", "send_message", { message: message, + source: source || undefined, session_id: this._config.session_id || undefined, }); @@ -948,7 +949,7 @@ class OpenClawChatCard extends HTMLElement { this._voiceStatus = "Sending…"; this._render(); - this._sendMessage(transcript); + this._sendMessage(transcript, "voice"); } catch (err) { console.error("OpenClaw: Assist STT transcription failed", err); this._voiceStatus = "Assist STT transcription failed."; @@ -1161,13 +1162,13 @@ class OpenClawChatCard extends HTMLElement { } this._voiceStatus = "Sending…"; this._render(); - this._sendMessage(command); + this._sendMessage(command, "voice"); return; } this._voiceStatus = "Sending…"; this._render(); - this._sendMessage(text); + this._sendMessage(text, "voice"); } };