From 2a89103e14f725a2360639dce455ceb1bb6c158b Mon Sep 17 00:00:00 2001 From: Dale Hamel Date: Tue, 17 Mar 2026 23:44:02 -0400 Subject: [PATCH 01/13] debug(assist): log agent routing payload --- custom_components/openclaw/api.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index 65f4c69..42f7c39 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -85,6 +85,18 @@ def update_token(self, token: str) -> None: """Update the authentication token (e.g., after addon restart).""" self._token = token + + def _log_request(self, label: str, agent_id: str | None, model: str | None, session_id: str | None, headers: dict[str, str]) -> None: + safe_headers = {k: ("" if k.lower() == "authorization" else v) for k, v in headers.items()} + _LOGGER.warning( + "OpenClaw API %s: agent=%s model=%s session=%s headers=%s", + label, + agent_id or self._agent_id or "main", + model, + session_id, + safe_headers, + ) + def _headers( self, agent_id: str | None = None, @@ -237,6 +249,8 @@ async def async_send_message( session = await self._get_session() url = f"{self._base_url}{API_CHAT_COMPLETIONS}" + self._log_request("chat", agent_id, payload.get("model"), session_id, headers) + try: async with session.post( url, @@ -303,6 +317,8 @@ async def async_stream_message( session = await self._get_session() url = f"{self._base_url}{API_CHAT_COMPLETIONS}" + self._log_request("chat", agent_id, payload.get("model"), session_id, headers) + try: async with session.post( url, @@ -368,6 +384,8 @@ async def async_check_connection(self) -> bool: session = await self._get_session() url = f"{self._base_url}{API_CHAT_COMPLETIONS}" + self._log_request("chat", agent_id, payload.get("model"), session_id, headers) + try: async with session.post( url, From f18b5226d04fe98687c0cc2246a814ebcfde7c31 Mon Sep 17 00:00:00 2001 From: Dale Hamel Date: Wed, 18 Mar 2026 12:05:35 -0400 Subject: [PATCH 02/13] feat(assist): debug logging configuration --- custom_components/openclaw/__init__.py | 7 +++++++ custom_components/openclaw/api.py | 4 ++++ custom_components/openclaw/config_flow.py | 9 +++++++++ custom_components/openclaw/const.py | 2 ++ custom_components/openclaw/conversation.py | 9 +++++++++ custom_components/openclaw/translations/en.json | 3 ++- 6 files changed, 33 insertions(+), 1 deletion(-) diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 7e4d656..07ef3c8 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -66,6 +66,7 @@ CONF_BROWSER_VOICE_LANGUAGE, CONF_VOICE_PROVIDER, CONF_THINKING_TIMEOUT, + CONF_DEBUG_LOGGING, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_AGENT_ID, DEFAULT_VOICE_AGENT_ID, @@ -79,6 +80,7 @@ DEFAULT_BROWSER_VOICE_LANGUAGE, DEFAULT_VOICE_PROVIDER, DEFAULT_THINKING_TIMEOUT, + DEFAULT_DEBUG_LOGGING, DOMAIN, EVENT_MESSAGE_RECEIVED, EVENT_TOOL_INVOKED, @@ -162,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> verify_ssl=verify_ssl, session=session, agent_id=agent_id, + debug_logging=entry.options.get(CONF_DEBUG_LOGGING, DEFAULT_DEBUG_LOGGING), ) coordinator = OpenClawCoordinator(hass, client) @@ -880,6 +883,10 @@ def websocket_get_settings( CONF_THINKING_TIMEOUT, DEFAULT_THINKING_TIMEOUT, ), + CONF_DEBUG_LOGGING: options.get( + CONF_DEBUG_LOGGING, + DEFAULT_DEBUG_LOGGING, + ), "language": hass.config.language, }, ) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index 42f7c39..00ec45f 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -52,6 +52,7 @@ def __init__( verify_ssl: bool = True, session: aiohttp.ClientSession | None = None, agent_id: str = "main", + debug_logging: bool = False, ) -> None: """Initialize the API client. @@ -71,6 +72,7 @@ def __init__( self._verify_ssl = verify_ssl self._session = session self._agent_id = agent_id + self._debug_logging = debug_logging self._base_url = f"{'https' if use_ssl else 'http'}://{host}:{port}" # ssl=False disables cert verification for self-signed certs; # ssl=None uses default verification. @@ -87,6 +89,8 @@ def update_token(self, token: str) -> None: def _log_request(self, label: str, agent_id: str | None, model: str | None, session_id: str | None, headers: dict[str, str]) -> None: + if not self._debug_logging: + return safe_headers = {k: ("" if k.lower() == "authorization" else v) for k, v in headers.items()} _LOGGER.warning( "OpenClaw API %s: agent=%s model=%s session=%s headers=%s", diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index 6cb7a3d..b40b6fe 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -54,6 +54,7 @@ CONF_BROWSER_VOICE_LANGUAGE, CONF_VOICE_PROVIDER, CONF_THINKING_TIMEOUT, + CONF_DEBUG_LOGGING, BROWSER_VOICE_LANGUAGES, CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_TRUNCATE, @@ -71,6 +72,7 @@ DEFAULT_BROWSER_VOICE_LANGUAGE, DEFAULT_VOICE_PROVIDER, DEFAULT_THINKING_TIMEOUT, + DEFAULT_DEBUG_LOGGING, DEFAULT_VOICE_AGENT_ID, DOMAIN, OPENCLAW_CONFIG_REL_PATH, @@ -537,6 +539,13 @@ async def async_step_init( CONF_VOICE_PROVIDER, default=selected_provider, ): vol.In(["browser", "assist_stt"]), + vol.Optional( + CONF_DEBUG_LOGGING, + default=options.get( + CONF_DEBUG_LOGGING, + DEFAULT_DEBUG_LOGGING, + ), + ): bool, vol.Optional( CONF_THINKING_TIMEOUT, default=options.get( diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index b96a69c..91a7a0f 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -38,6 +38,7 @@ CONF_VOICE_PROVIDER = "voice_provider" CONF_BROWSER_VOICE_LANGUAGE = "browser_voice_language" CONF_THINKING_TIMEOUT = "thinking_timeout" +CONF_DEBUG_LOGGING = "debug_logging" DEFAULT_AGENT_ID = "main" DEFAULT_VOICE_AGENT_ID = "" @@ -52,6 +53,7 @@ DEFAULT_VOICE_PROVIDER = "browser" DEFAULT_BROWSER_VOICE_LANGUAGE = "auto" DEFAULT_THINKING_TIMEOUT = 120 +DEFAULT_DEBUG_LOGGING = False BROWSER_VOICE_LANGUAGES: tuple[str, ...] = ( "auto", diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 6baa134..15544da 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -25,11 +25,13 @@ CONF_ASSIST_SESSION_ID, CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, + CONF_DEBUG_LOGGING, CONF_INCLUDE_EXPOSED_CONTEXT, CONF_VOICE_AGENT_ID, DEFAULT_ASSIST_SESSION_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, + DEFAULT_DEBUG_LOGGING, DEFAULT_INCLUDE_EXPOSED_CONTEXT, DATA_MODEL, DOMAIN, @@ -142,6 +144,13 @@ async def async_process( part for part in (exposed_context, extra_system_prompt) if part ) or None + if options.get(CONF_DEBUG_LOGGING, DEFAULT_DEBUG_LOGGING): + _LOGGER.info( + "OpenClaw Assist routing: agent=%s session=%s", + voice_agent_id or "main", + conversation_id, + ) + try: full_response = await self._get_response( client, diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index b14609f..3ccf002 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -52,7 +52,8 @@ "allow_brave_webspeech": "Allow Web Speech in Brave (experimental)", "voice_provider": "Voice input provider (browser or HA STT)", "browser_voice_language": "Browser voice language", - "thinking_timeout": "Response timeout (seconds)" + "thinking_timeout": "Response timeout (seconds)", + "debug_logging": "Enable debug logging" } } } From cc8c42335b14f256bd75bdaf4d83d3eb834f051a Mon Sep 17 00:00:00 2001 From: Dale Hamel Date: Wed, 18 Mar 2026 12:06:42 -0400 Subject: [PATCH 03/13] feat(assist): sticky sessions and agent routing --- custom_components/openclaw/__init__.py | 3 ++ custom_components/openclaw/api.py | 4 ++ custom_components/openclaw/config_flow.py | 4 ++ custom_components/openclaw/const.py | 3 ++ custom_components/openclaw/conversation.py | 54 ++++++++++++++++------ 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 7e4d656..8312ec9 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -162,6 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> verify_ssl=verify_ssl, session=session, agent_id=agent_id, + debug_logging=entry.options.get(CONF_DEBUG_LOGGING, DEFAULT_DEBUG_LOGGING), ) coordinator = OpenClawCoordinator(hass, client) @@ -880,6 +881,8 @@ def websocket_get_settings( CONF_THINKING_TIMEOUT, DEFAULT_THINKING_TIMEOUT, ), + CONF_DEBUG_LOGGING: options.get( + ), "language": hass.config.language, }, ) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index 65f4c69..352f1b1 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -227,6 +227,8 @@ async def async_send_message( payload["user"] = session_id if model: payload["model"] = model + elif agent_id: + payload["model"] = f"openclaw:{agent_id}" # Pass session_id as a custom header or param if supported by gateway headers = self._headers(agent_id=agent_id, extra_headers=extra_headers) @@ -294,6 +296,8 @@ async def async_stream_message( payload["user"] = session_id if model: payload["model"] = model + elif agent_id: + payload["model"] = f"openclaw:{agent_id}" headers = self._headers(agent_id=agent_id, extra_headers=extra_headers) if session_id: diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index 6cb7a3d..5c7c76f 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -537,6 +537,10 @@ async def async_step_init( CONF_VOICE_PROVIDER, default=selected_provider, ): vol.In(["browser", "assist_stt"]), + vol.Optional( + default=options.get( + ), + ): bool, vol.Optional( CONF_THINKING_TIMEOUT, default=options.get( diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index b96a69c..548274c 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -10,6 +10,7 @@ ADDON_CONFIGS_ROOT = "/addon_configs" ADDON_SLUG_FRAGMENTS = ("openclaw_assistant", "openclaw") OPENCLAW_CONFIG_REL_PATH = ".openclaw/openclaw.json" +ASSIST_SESSION_STORE_KEY = "openclaw_assist_sessions" # Defaults DEFAULT_GATEWAY_HOST = "127.0.0.1" @@ -110,6 +111,8 @@ DATA_LAST_TOOL_INVOKED_AT = "last_tool_invoked_at" DATA_LAST_TOOL_ERROR = "last_tool_error" DATA_LAST_TOOL_RESULT_PREVIEW = "last_tool_result_preview" +DATA_ASSIST_SESSIONS = "assist_sessions" +DATA_ASSIST_SESSION_STORE = "assist_session_store" # Platforms PLATFORMS = ["sensor", "binary_sensor", "conversation", "event", "button", "select"] diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 6baa134..ce36b5e 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -7,6 +7,7 @@ from __future__ import annotations from datetime import datetime, timezone +from uuid import uuid4 import logging from typing import Any @@ -14,6 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.storage import Store from homeassistant.helpers import intent from .api import OpenClawApiClient, OpenClawApiError @@ -23,17 +25,22 @@ ATTR_SESSION_ID, ATTR_TIMESTAMP, CONF_ASSIST_SESSION_ID, + CONF_AGENT_ID, CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, CONF_INCLUDE_EXPOSED_CONTEXT, CONF_VOICE_AGENT_ID, DEFAULT_ASSIST_SESSION_ID, + DEFAULT_AGENT_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, DEFAULT_INCLUDE_EXPOSED_CONTEXT, DATA_MODEL, DOMAIN, EVENT_MESSAGE_RECEIVED, + DATA_ASSIST_SESSIONS, + DATA_ASSIST_SESSION_STORE, + ASSIST_SESSION_STORE_KEY, ) from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context @@ -52,6 +59,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the OpenClaw conversation agent.""" + # Load persisted assist sessions + store = Store(hass, 1, ASSIST_SESSION_STORE_KEY) + stored = await store.async_load() or {} + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][DATA_ASSIST_SESSIONS] = stored + hass.data[DOMAIN][DATA_ASSIST_SESSION_STORE] = store + agent = OpenClawConversationAgent(hass, entry) conversation.async_set_agent(hass, entry, agent) @@ -115,12 +129,19 @@ async def async_process( coordinator: OpenClawCoordinator = entry_data["coordinator"] message = user_input.text - 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) ) + configured_agent_id = self._normalize_optional_text( + options.get( + CONF_AGENT_ID, + self.entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID), + ) + ) + resolved_agent_id = voice_agent_id or configured_agent_id + conversation_id = self._resolve_conversation_id(user_input, resolved_agent_id) include_context = options.get( CONF_INCLUDE_EXPOSED_CONTEXT, DEFAULT_INCLUDE_EXPOSED_CONTEXT, @@ -147,7 +168,7 @@ async def async_process( client, message, conversation_id, - voice_agent_id, + resolved_agent_id, system_prompt, ) except OpenClawApiError as err: @@ -197,12 +218,13 @@ async def async_process( intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(full_response) + return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id, ) - def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -> str: + def _resolve_conversation_id(self, user_input: conversation.ConversationInput, agent_id: str | None) -> str: """Return conversation id from HA or a stable Assist fallback session key.""" configured_session_id = self._normalize_optional_text( self.entry.options.get( @@ -213,19 +235,21 @@ def _resolve_conversation_id(self, user_input: conversation.ConversationInput) - if configured_session_id: return configured_session_id - if user_input.conversation_id: - return user_input.conversation_id + domain_store = self.hass.data.setdefault(DOMAIN, {}) + session_cache = domain_store.setdefault(DATA_ASSIST_SESSIONS, {}) + cache_key = agent_id or "main" + cached_session = session_cache.get(cache_key) + if cached_session: + return cached_session - context = getattr(user_input, "context", None) - user_id = getattr(context, "user_id", None) - if user_id: - return f"assist_user_{user_id}" + new_session = f"agent:{cache_key}:assist_{uuid4().hex[:12]}" + session_cache[cache_key] = new_session - device_id = getattr(user_input, "device_id", None) - if device_id: - return f"assist_device_{device_id}" + store = domain_store.get(DATA_ASSIST_SESSION_STORE) + if store: + self.hass.async_create_task(store.async_save(session_cache)) - return "assist_default" + return new_session def _normalize_optional_text(self, value: Any) -> str | None: """Return a stripped string or None for blank values.""" @@ -243,11 +267,14 @@ async def _get_response( system_prompt: str | None = None, ) -> str: """Get a response from OpenClaw, trying streaming first.""" + model_override = f"openclaw:{agent_id}" if agent_id else None + # Try streaming (lower TTFB for voice pipeline) full_response = "" async for chunk in client.async_stream_message( message=message, session_id=conversation_id, + model=model_override, system_prompt=system_prompt, agent_id=agent_id, extra_headers=_VOICE_REQUEST_HEADERS, @@ -261,6 +288,7 @@ async def _get_response( response = await client.async_send_message( message=message, session_id=conversation_id, + model=model_override, system_prompt=system_prompt, agent_id=agent_id, extra_headers=_VOICE_REQUEST_HEADERS, From 0b4321fc5be94b31910981e2f80c9e1f44eade36 Mon Sep 17 00:00:00 2001 From: Dale Hamel Date: Wed, 18 Mar 2026 12:19:25 -0400 Subject: [PATCH 04/13] chore: remove broken debug logging stubs from assist sessions branch Partial/incomplete debug logging references (CONF_DEBUG_LOGGING, broken options.get() calls) were accidentally included in this branch. The full debug logging feature lives in feature/debug-logging. --- custom_components/openclaw/__init__.py | 3 --- custom_components/openclaw/config_flow.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 8312ec9..7e4d656 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -162,7 +162,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> verify_ssl=verify_ssl, session=session, agent_id=agent_id, - debug_logging=entry.options.get(CONF_DEBUG_LOGGING, DEFAULT_DEBUG_LOGGING), ) coordinator = OpenClawCoordinator(hass, client) @@ -881,8 +880,6 @@ def websocket_get_settings( CONF_THINKING_TIMEOUT, DEFAULT_THINKING_TIMEOUT, ), - CONF_DEBUG_LOGGING: options.get( - ), "language": hass.config.language, }, ) diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index 5c7c76f..6cb7a3d 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -537,10 +537,6 @@ async def async_step_init( CONF_VOICE_PROVIDER, default=selected_provider, ): vol.In(["browser", "assist_stt"]), - vol.Optional( - default=options.get( - ), - ): bool, vol.Optional( CONF_THINKING_TIMEOUT, default=options.get( From 2fe9e6d6c086f973570b478104408adb542fb0e1 Mon Sep 17 00:00:00 2001 From: "Jarvis (OpenClaw)" Date: Thu, 26 Mar 2026 21:27:44 +0000 Subject: [PATCH 05/13] feat: enable continue_conversation for Voice PE follow-up dialog When the assistant's response ends with a question mark or contains common follow-up patterns (EN/DE), set continue_conversation=True on the ConversationResult. This tells Voice PE and other HA voice satellites to automatically re-listen after the response finishes playing, enabling natural back-and-forth dialog without requiring the wake word between turns. Fixes #7 --- custom_components/openclaw/conversation.py | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 6baa134..f8e2a92 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone import logging +import re from typing import Any from homeassistant.components import conversation @@ -200,6 +201,7 @@ async def async_process( return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id, + continue_conversation=self._should_continue(full_response), ) def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -> str: @@ -314,6 +316,53 @@ def _extract_text_recursive(self, value: Any, depth: int = 0) -> str | None: return None + @staticmethod + def _should_continue(response: str) -> bool: + """Determine if the conversation should continue after this response. + + Returns True when the assistant's reply ends with a question or + an explicit prompt for follow-up, so that Voice PE and other + satellites automatically re-listen without requiring a wake word. + + The heuristic checks for: + - Trailing question marks (including after closing quotes/parens) + - Common conversational follow-up patterns in English and German + """ + if not response: + return False + + text = response.strip() + + # Check if the response ends with a question mark + # (allow trailing punctuation like quotes, parens, or emoji) + if re.search(r"\?\s*[\"'""»)\]]*\s*$", text): + return True + + # Common follow-up patterns (EN + DE) + lower = text.lower() + follow_up_patterns = ( + "what do you think", + "would you like", + "do you want", + "shall i", + "should i", + "can i help", + "anything else", + "let me know", + "was meinst du", + "möchtest du", + "willst du", + "soll ich", + "kann ich", + "noch etwas", + "sonst noch", + ) + for pattern in follow_up_patterns: + if pattern in lower: + return True + + return False + def _error_result( self, user_input: conversation.ConversationInput, From f3ab293cd92e1f8c58f25322591d667236c98765 Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sat, 28 Mar 2026 23:56:37 +0000 Subject: [PATCH 06/13] feat: inject device area context into voice conversation requests Resolves the originating voice satellite's area from the HA device registry and injects it as "[Voice command from: ]" in the system prompt sent to OpenClaw. Also passes x-openclaw-area and x-openclaw-device-id headers for structured access. This enables room-aware voice responses -- the agent knows which room the user is speaking from without being told. Co-Authored-By: Claude Opus 4.6 (1M context) --- custom_components/openclaw/conversation.py | 55 ++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 6baa134..0c4236c 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -13,6 +13,7 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers import intent @@ -138,10 +139,26 @@ async def async_process( ) exposed_context = apply_context_policy(raw_context, max_chars, strategy) extra_system_prompt = getattr(user_input, "extra_system_prompt", None) + + # Resolve the originating device's area for room-aware responses + device_area_context = self._resolve_device_area(user_input) + system_prompt = "\n\n".join( - part for part in (exposed_context, extra_system_prompt) if part + part + for part in (device_area_context, exposed_context, extra_system_prompt) + if part ) or None + # Build voice headers, optionally including area context + voice_headers = dict(_VOICE_REQUEST_HEADERS) + device_id = getattr(user_input, "device_id", None) + if device_id: + voice_headers["x-openclaw-device-id"] = device_id + if device_area_context: + # Extract just the area name from "[Voice command from: Study]" + area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]") + voice_headers["x-openclaw-area"] = area_name + try: full_response = await self._get_response( client, @@ -149,6 +166,7 @@ async def async_process( conversation_id, voice_agent_id, system_prompt, + voice_headers, ) except OpenClawApiError as err: _LOGGER.error("OpenClaw conversation error: %s", err) @@ -165,6 +183,7 @@ async def async_process( conversation_id, voice_agent_id, system_prompt, + voice_headers, ) except OpenClawApiError as retry_err: return self._error_result( @@ -227,6 +246,34 @@ def _resolve_conversation_id(self, user_input: conversation.ConversationInput) - return "assist_default" + def _resolve_device_area( + self, user_input: conversation.ConversationInput + ) -> str | None: + """Resolve the area name for the device that initiated the conversation. + + Returns a short context string like '[Voice command from: Study]' + so the agent knows which room the user is in. + """ + device_id = getattr(user_input, "device_id", None) + if not device_id: + return None + + try: + dev_reg = dr.async_get(self.hass) + device_entry = dev_reg.async_get(device_id) + if not device_entry or not device_entry.area_id: + return None + + area_reg = ar.async_get(self.hass) + area_entry = area_reg.async_get_area(device_entry.area_id) + if not area_entry: + return None + + return f"[Voice command from: {area_entry.name}]" + except Exception: + _LOGGER.debug("Could not resolve area for device %s", device_id) + return None + def _normalize_optional_text(self, value: Any) -> str | None: """Return a stripped string or None for blank values.""" if not isinstance(value, str): @@ -241,8 +288,10 @@ async def _get_response( conversation_id: str, agent_id: str | None = None, system_prompt: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> str: """Get a response from OpenClaw, trying streaming first.""" + headers = extra_headers or _VOICE_REQUEST_HEADERS # Try streaming (lower TTFB for voice pipeline) full_response = "" async for chunk in client.async_stream_message( @@ -250,7 +299,7 @@ async def _get_response( session_id=conversation_id, system_prompt=system_prompt, agent_id=agent_id, - extra_headers=_VOICE_REQUEST_HEADERS, + extra_headers=headers, ): full_response += chunk @@ -263,7 +312,7 @@ async def _get_response( session_id=conversation_id, system_prompt=system_prompt, agent_id=agent_id, - extra_headers=_VOICE_REQUEST_HEADERS, + extra_headers=headers, ) extracted = self._extract_text_recursive(response) return extracted or "" From b1202c8048e64ce6cc502441c0c7e1ff0534bc5b Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 05:45:26 +0100 Subject: [PATCH 07/13] fix: only send area headers when device area is resolved When no area is found (no device_id, device has no area, etc.), the request is now identical to the original unmodified code -- no extra headers, no area context in the system prompt. Co-Authored-By: Claude Opus 4.6 (1M context) --- custom_components/openclaw/conversation.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 0c4236c..f9d9983 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -149,15 +149,16 @@ async def async_process( if part ) or None - # Build voice headers, optionally including area context - voice_headers = dict(_VOICE_REQUEST_HEADERS) - device_id = getattr(user_input, "device_id", None) - if device_id: - voice_headers["x-openclaw-device-id"] = device_id + # Only add area headers when we actually resolved a room if device_area_context: - # Extract just the area name from "[Voice command from: Study]" area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]") - voice_headers["x-openclaw-area"] = area_name + voice_headers = { + **_VOICE_REQUEST_HEADERS, + "x-openclaw-area": area_name, + "x-openclaw-device-id": getattr(user_input, "device_id", "") or "", + } + else: + voice_headers = None try: full_response = await self._get_response( From dc5e3c82b60c5c5095599d0b1a29d58bd63fd9c9 Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 05:46:04 +0100 Subject: [PATCH 08/13] feat: send device_id header even when area is unresolved The device ID is useful on its own for logging, debugging, and future device-specific logic. Area header is still only sent when the area is successfully resolved. Co-Authored-By: Claude Opus 4.6 (1M context) --- custom_components/openclaw/conversation.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index f9d9983..19a12ef 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -149,14 +149,15 @@ async def async_process( if part ) or None - # Only add area headers when we actually resolved a room - if device_area_context: - area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]") - voice_headers = { - **_VOICE_REQUEST_HEADERS, - "x-openclaw-area": area_name, - "x-openclaw-device-id": getattr(user_input, "device_id", "") or "", - } + # Add device/area headers when available + device_id = getattr(user_input, "device_id", None) + if device_id or device_area_context: + voice_headers = dict(_VOICE_REQUEST_HEADERS) + if device_id: + voice_headers["x-openclaw-device-id"] = device_id + if device_area_context: + area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]") + voice_headers["x-openclaw-area"] = area_name else: voice_headers = None From 8cb0b1a759958ad494295cb9d3237a30ce2734a1 Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 06:14:09 +0100 Subject: [PATCH 09/13] refactor: extract shared utils, granular error codes, session fix Tier 1 improvements from code review: - Extract _extract_text_recursive and _normalize_optional_text to shared utils.py module (was duplicated in __init__.py and conversation.py) - Map OpenClawConnectionError/AuthError to FAILED_TO_HANDLE instead of UNKNOWN, helping HA's Assist pipeline make smarter fallback decisions - Fix api.py session fallback to log warnings when HA-managed session is unexpectedly closed instead of silently creating orphan sessions - Merge PR #11 continue_conversation for Voice PE follow-up dialog - Fix regex syntax warning in _should_continue Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + custom_components/openclaw/__init__.py | 60 +------------ .../openclaw/__pycache__/api.cpython-312.pyc | Bin 19956 -> 23672 bytes custom_components/openclaw/api.py | 17 +++- custom_components/openclaw/conversation.py | 82 +++++------------- custom_components/openclaw/utils.py | 60 +++++++++++++ 6 files changed, 101 insertions(+), 119 deletions(-) create mode 100644 custom_components/openclaw/utils.py diff --git a/.gitignore b/.gitignore index 4e299d3..7c5ead3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ env/ # OS/editor noise .DS_Store Thumbs.db +*.pyc diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 07ef3c8..bff38a7 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -29,6 +29,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import OpenClawApiClient, OpenClawApiError +from .utils import extract_text_recursive, normalize_optional_text from .const import ( ATTR_AGENT_ID, ATTR_ATTACHMENTS, @@ -402,18 +403,12 @@ 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 = _normalize_optional_text(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) @@ -424,7 +419,7 @@ async def handle_send_message(call: ServiceCall) -> None: client: OpenClawApiClient = entry_data["client"] coordinator: OpenClawCoordinator = entry_data["coordinator"] options = _get_entry_options(hass, entry_data) - voice_agent_id = _normalize_optional_text( + voice_agent_id = normalize_optional_text( options.get(CONF_VOICE_AGENT_ID, DEFAULT_VOICE_AGENT_ID) ) resolved_agent_id = call_agent_id @@ -638,53 +633,6 @@ def _get_entry_options(hass: HomeAssistant, entry_data: dict[str, Any]) -> dict[ return latest_entry.options if latest_entry else {} -def _extract_text_recursive(value: Any, depth: int = 0) -> str | None: - """Recursively extract assistant text from nested response payloads.""" - if depth > 8: - return None - - if isinstance(value, str): - text = value.strip() - return text or None - - if isinstance(value, list): - parts: list[str] = [] - for item in value: - extracted = _extract_text_recursive(item, depth + 1) - if extracted: - parts.append(extracted) - if parts: - return "\n".join(parts) - return None - - if isinstance(value, dict): - priority_keys = ( - "output_text", - "text", - "content", - "message", - "response", - "answer", - "choices", - "output", - "delta", - ) - - for key in priority_keys: - if key not in value: - continue - extracted = _extract_text_recursive(value.get(key), depth + 1) - if extracted: - return extracted - - for nested_value in value.values(): - extracted = _extract_text_recursive(nested_value, depth + 1) - if extracted: - return extracted - - return None - - def _summarize_tool_result(value: Any, max_len: int = 240) -> str | None: """Return compact string preview of tool result payload.""" if value is None: @@ -706,7 +654,7 @@ def _summarize_tool_result(value: Any, max_len: int = 240) -> 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) + return extract_text_recursive(response) def _extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/custom_components/openclaw/__pycache__/api.cpython-312.pyc b/custom_components/openclaw/__pycache__/api.cpython-312.pyc index ab2066d55b5293231067683b6856b55c9f96afe1..fc8d26db84061265790e2a9f051c5dc6e50f13cf 100644 GIT binary patch delta 10732 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 delta 7324 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 A4gdfE diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index c0ff12f..edbc937 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -118,8 +118,21 @@ def _headers( return headers async def _get_session(self) -> aiohttp.ClientSession: - """Get or create an aiohttp session.""" - if self._session is None or self._session.closed: + """Get the aiohttp session. + + Prefers the HA-managed session passed in the constructor. + Falls back to creating a new session only if none was provided. + """ + if self._session is not None and not self._session.closed: + return self._session + if self._session is None: + # No session was provided at init; create one as last resort + _LOGGER.debug("Creating fallback aiohttp session (no HA session provided)") + self._session = aiohttp.ClientSession() + else: + # Session was provided but is now closed; this shouldn't happen + # with HA-managed sessions, but handle gracefully + _LOGGER.warning("HA-managed aiohttp session was closed unexpectedly, creating replacement") self._session = aiohttp.ClientSession() return self._session diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 27fc232..3d4cf45 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -20,7 +20,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers import intent -from .api import OpenClawApiClient, OpenClawApiError +from .api import OpenClawApiClient, OpenClawApiError, OpenClawConnectionError, OpenClawAuthError from .const import ( ATTR_MESSAGE, ATTR_MODEL, @@ -48,6 +48,7 @@ ) from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context +from .utils import extract_text_recursive, normalize_optional_text _LOGGER = logging.getLogger(__name__) @@ -135,10 +136,10 @@ async def async_process( message = user_input.text assistant_id = "conversation" options = self.entry.options - voice_agent_id = self._normalize_optional_text( + voice_agent_id = normalize_optional_text( options.get(CONF_VOICE_AGENT_ID) ) - configured_agent_id = self._normalize_optional_text( + configured_agent_id = normalize_optional_text( options.get( CONF_AGENT_ID, self.entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID), @@ -204,6 +205,7 @@ async def async_process( ) except OpenClawApiError as err: _LOGGER.error("OpenClaw conversation error: %s", err) + error_code = self._map_error_code(err) # Try token refresh if we have the capability refresh_fn = entry_data.get("refresh_token") @@ -215,7 +217,7 @@ async def async_process( client, message, conversation_id, - voice_agent_id, + resolved_agent_id, system_prompt, voice_headers, ) @@ -223,16 +225,19 @@ async def async_process( return self._error_result( user_input, f"Error communicating with OpenClaw: {retry_err}", + self._map_error_code(retry_err), ) else: return self._error_result( user_input, f"Error communicating with OpenClaw: {err}", + error_code, ) else: return self._error_result( user_input, f"Error communicating with OpenClaw: {err}", + error_code, ) # Fire event so automations can react to the response @@ -259,7 +264,7 @@ async def async_process( def _resolve_conversation_id(self, user_input: conversation.ConversationInput, agent_id: str | None) -> str: """Return conversation id from HA or a stable Assist fallback session key.""" - configured_session_id = self._normalize_optional_text( + configured_session_id = normalize_optional_text( self.entry.options.get( CONF_ASSIST_SESSION_ID, DEFAULT_ASSIST_SESSION_ID, @@ -312,13 +317,6 @@ def _resolve_device_area( _LOGGER.debug("Could not resolve area for device %s", device_id) return None - 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, @@ -356,55 +354,9 @@ async def _get_response( agent_id=agent_id, extra_headers=headers, ) - extracted = self._extract_text_recursive(response) + extracted = extract_text_recursive(response) return extracted or "" - def _extract_text_recursive(self, value: Any, depth: int = 0) -> str | None: - """Recursively extract assistant text from nested response payloads.""" - if depth > 8: - return None - - if isinstance(value, str): - text = value.strip() - return text or None - - if isinstance(value, list): - parts: list[str] = [] - for item in value: - extracted = self._extract_text_recursive(item, depth + 1) - if extracted: - parts.append(extracted) - if parts: - return "\n".join(parts) - return None - - if isinstance(value, dict): - priority_keys = ( - "output_text", - "text", - "content", - "message", - "response", - "answer", - "choices", - "output", - "delta", - ) - - for key in priority_keys: - if key not in value: - continue - extracted = self._extract_text_recursive(value.get(key), depth + 1) - if extracted: - return extracted - - for nested_value in value.values(): - extracted = self._extract_text_recursive(nested_value, depth + 1) - if extracted: - return extracted - - return None - @staticmethod def _should_continue(response: str) -> bool: """Determine if the conversation should continue after this response. @@ -424,7 +376,7 @@ def _should_continue(response: str) -> bool: # Check if the response ends with a question mark # (allow trailing punctuation like quotes, parens, or emoji) - if re.search(r"\?\s*[\"'""»)\]]*\s*$", text): + if re.search(r"\?\s*[\"'\u201c\u201d\u00bb)\]]*\s*$", text): return True # Common follow-up patterns (EN + DE) @@ -452,15 +404,23 @@ def _should_continue(response: str) -> bool: return False + @staticmethod + def _map_error_code(err: OpenClawApiError) -> intent.IntentResponseErrorCode: + """Map OpenClaw exceptions to HA intent error codes.""" + if isinstance(err, (OpenClawConnectionError, OpenClawAuthError)): + return intent.IntentResponseErrorCode.FAILED_TO_HANDLE + return intent.IntentResponseErrorCode.UNKNOWN + def _error_result( self, user_input: conversation.ConversationInput, error_message: str, + error_code: intent.IntentResponseErrorCode = intent.IntentResponseErrorCode.UNKNOWN, ) -> conversation.ConversationResult: """Build an error ConversationResult.""" intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, + error_code, error_message, ) return conversation.ConversationResult( diff --git a/custom_components/openclaw/utils.py b/custom_components/openclaw/utils.py new file mode 100644 index 0000000..57a1742 --- /dev/null +++ b/custom_components/openclaw/utils.py @@ -0,0 +1,60 @@ +"""Shared utility functions for the OpenClaw integration.""" + +from __future__ import annotations + +from typing import Any + + +def normalize_optional_text(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 + + +def extract_text_recursive(value: Any, depth: int = 0) -> str | None: + """Recursively extract assistant text from nested response payloads.""" + if depth > 8: + return None + + if isinstance(value, str): + text = value.strip() + return text or None + + if isinstance(value, list): + parts: list[str] = [] + for item in value: + extracted = extract_text_recursive(item, depth + 1) + if extracted: + parts.append(extracted) + if parts: + return "\n".join(parts) + return None + + if isinstance(value, dict): + priority_keys = ( + "output_text", + "text", + "content", + "message", + "response", + "answer", + "choices", + "output", + "delta", + ) + + for key in priority_keys: + if key not in value: + continue + extracted = extract_text_recursive(value.get(key), depth + 1) + if extracted: + return extracted + + for nested_value in value.values(): + extracted = extract_text_recursive(nested_value, depth + 1) + if extracted: + return extracted + + return None From d63fd5e0f70bd7e894933c86ac1dd7617d6cfd83 Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 06:16:02 +0100 Subject: [PATCH 10/13] feat: richer entity context, retry logic, config flow docs Tier 2 improvements from code review: - exposure.py: include area assignments, useful state attributes (brightness, temperature, volume, etc.), and current date/time in the entity context sent to OpenClaw - api.py: add async_send_message_with_retry() with 2 retries and 1s delay for transient connection failures (skips auth errors) - config_flow.py: document _find_addon_config_dir as blocking I/O (already correctly wrapped in async_add_executor_job by caller) Co-Authored-By: Claude Opus 4.6 (1M context) --- custom_components/openclaw/api.py | 19 +++++++++ custom_components/openclaw/config_flow.py | 4 +- custom_components/openclaw/exposure.py | 52 +++++++++++++++++++++-- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index edbc937..bdfd954 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -23,6 +23,10 @@ # Timeout for streaming chat completions (long-running) STREAM_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_read=120) +# Retry config for transient connection failures +_MAX_RETRIES = 2 +_RETRY_DELAY = 1.0 # seconds + class OpenClawApiError(Exception): """Base exception for OpenClaw API errors.""" @@ -290,6 +294,21 @@ async def async_send_message( f"Cannot connect to OpenClaw gateway: {err}" ) from err + async def async_send_message_with_retry(self, **kwargs: Any) -> dict[str, Any]: + """Send a message with automatic retry on transient connection failures.""" + last_err: Exception | None = None + for attempt in range(_MAX_RETRIES + 1): + try: + return await self.async_send_message(**kwargs) + except OpenClawConnectionError as err: + last_err = err + if attempt < _MAX_RETRIES: + _LOGGER.debug("Connection failed (attempt %d/%d), retrying in %ss", attempt + 1, _MAX_RETRIES + 1, _RETRY_DELAY) + await asyncio.sleep(_RETRY_DELAY) + except OpenClawAuthError: + raise # Don't retry auth errors + raise last_err # type: ignore[misc] + async def async_stream_message( self, message: str, diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index b40b6fe..9711610 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -84,7 +84,9 @@ # ── Filesystem helpers ──────────────────────────────────────────────────────── def _find_addon_config_dir() -> Path | None: - """Scan /addon_configs/ for the OpenClaw addon directory. + """Scan /addon_configs/ for the OpenClaw addon directory (blocking I/O). + + Must be called via hass.async_add_executor_job() from async code. The Supervisor prepends a repository-specific hash to the addon slug: /addon_configs/_/ diff --git a/custom_components/openclaw/exposure.py b/custom_components/openclaw/exposure.py index 8fd694e..ecc9505 100644 --- a/custom_components/openclaw/exposure.py +++ b/custom_components/openclaw/exposure.py @@ -3,9 +3,20 @@ from __future__ import annotations from collections import Counter +from datetime import datetime from homeassistant.components.homeassistant import async_should_expose from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er +from homeassistant.util import dt as dt_util + +# Attributes worth including in entity context (keeps prompt compact) +_USEFUL_ATTRIBUTES = frozenset({ + "brightness", "color_temp", "color_mode", "hvac_mode", "hvac_action", + "temperature", "current_temperature", "target_temp_high", "target_temp_low", + "battery_level", "battery", "media_title", "media_artist", "source", + "volume_level", "is_volume_muted", "preset_mode", "fan_mode", +}) def build_exposed_entities_context( @@ -16,6 +27,7 @@ def build_exposed_entities_context( """Build a compact prompt block of entities exposed to an assistant. Uses Home Assistant's built-in expose rules (Settings -> Voice assistants -> Expose). + Includes area assignments and useful state attributes for richer LLM context. """ assistant_id = assistant or "conversation" @@ -34,10 +46,28 @@ def _collect_for(assistant_value: str) -> list: if not exposed_states: return None + # Build area lookup + ent_reg = er.async_get(hass) + area_reg = ar.async_get(hass) + area_cache: dict[str | None, str] = {} + + def _get_area_name(entity_id: str) -> str | None: + entry = ent_reg.async_get(entity_id) + if not entry: + return None + area_id = entry.area_id + if area_id and area_id not in area_cache: + area_entry = area_reg.async_get_area(area_id) + area_cache[area_id] = area_entry.name if area_entry else "" + return area_cache.get(area_id) or None + exposed_states.sort(key=lambda state: state.entity_id) domain_counts = Counter(state.domain for state in exposed_states) + now = dt_util.now() lines: list[str] = [ + f"Current date and time: {now.strftime('%A %d %B %Y, %H:%M %Z')}", + "", "Home Assistant live context (entities exposed to this assistant):", f"- total_exposed_entities: {len(exposed_states)}", "- domain_counts:", @@ -47,9 +77,25 @@ def _collect_for(assistant_value: str) -> list: for state in exposed_states[:max_entities]: friendly_name = state.name or state.entity_id - lines.append( - f" - id: {state.entity_id}; name: {friendly_name}; state: {state.state}" - ) + area_name = _get_area_name(state.entity_id) + parts = [ + f"id: {state.entity_id}", + f"name: {friendly_name}", + f"state: {state.state}", + ] + if area_name: + parts.append(f"area: {area_name}") + + # Include useful attributes (skip empty/None) + useful_attrs = { + k: v for k, v in state.attributes.items() + if k in _USEFUL_ATTRIBUTES and v is not None + } + if useful_attrs: + attrs_str = ", ".join(f"{k}={v}" for k, v in useful_attrs.items()) + parts.append(f"attrs: {attrs_str}") + + lines.append(f" - {'; '.join(parts)}") if len(exposed_states) > max_entities: lines.append( From 2a69f63768d55bcfd77f4c18472e0f4d9cc2bfb3 Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 06:26:20 +0100 Subject: [PATCH 11/13] docs: update README for fork with room awareness and improvements Replace upstream README with fork-specific documentation covering room-aware voice, richer entity context, merged community PRs, and code quality improvements. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 376 +++++++++++++----------------------------------------- 1 file changed, 91 insertions(+), 285 deletions(-) diff --git a/README.md b/README.md index 3b7d950..9878959 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,112 @@ -# OpenClaw Integration for Home Assistant +# OpenClaw Integration for Home Assistant (Fork) -## [Join our Discord Server!](https://discord.gg/xeHeKu9jYp) -image -ChatGPT Image Feb 25, 2026, 11_37_02 PM - -_If you want to install OpenClaw as Add-On/App directly on your Home Assistant instance take a look here:_ https://github.com/techartdev/OpenClawHomeAssistant - - -OpenClaw is a Home Assistant custom integration that connects your HA instance to the OpenClaw assistant backend and provides: - -- A native conversation agent for Assist -- A Lovelace chat card with session history -- Service and event APIs for automations -- Optional voice mode in the card - ---- - -## What it includes - -- **Conversation agent** (`openclaw`) in Assist / Voice Assistants -- **Lovelace chat card** (`custom:openclaw-chat-card`) with: - - message history restore, - - typing indicator, - - optional voice input, - - wake-word handling for continuous mode -- **Services** - - `openclaw.send_message` - - `openclaw.clear_history` - - `openclaw.invoke_tool` -- **Event** - - `openclaw_message_received` - - `openclaw_tool_invoked` -- **Sensors / status entities** for model and connection state - - Includes tool telemetry sensors (`Last Tool`, `Last Tool Status`, `Last Tool Duration`, `Last Tool Invoked`) - ---- - -## Requirements - -- Home Assistant Core `2025.1.0+` (declared minimum) -- An **OpenClaw gateway** with `enable_openai_api` enabled — either: - - The [OpenClaw Assistant addon](https://github.com/techartdev/OpenClawHomeAssistant) running on the same HA instance (auto-discovery supported), **or** - - Any standalone [OpenClaw](https://github.com/openclaw/openclaw) installation reachable over the network (manual config) -- Supervisor is optional (used only for addon auto-discovery) - -> **No addon required.** If you have OpenClaw running anywhere — on a separate server, a VPS, a Docker container, or even another machine on your LAN — this integration can connect to it via the manual configuration flow. +> **Forked from [techartdev/OpenClawHomeAssistantIntegration](https://github.com/techartdev/OpenClawHomeAssistantIntegration)** with additional features for room-aware voice responses, improved entity context, and community PR merges. --- -## Connection modes +## Fork Changes -The integration supports connecting to OpenClaw in several ways: +This fork adds the following on top of the upstream integration: -### Local addon (auto-discovery) - -If the OpenClaw Assistant addon is installed on the **same** Home Assistant instance, the integration auto-discovers it: -- Reads token from the shared filesystem -- Detects `access_mode` and chooses the correct port automatically -- No manual config needed — just click **Submit** on the confirm step +### Room-Aware Voice Responses +- Resolves the originating voice satellite's area from the HA device/area registry +- Injects `[Voice command from: ]` into the system prompt so the agent knows which room you're in +- Sends `x-openclaw-area` and `x-openclaw-device-id` headers for structured access +- "Turn off the lights" and "what's the temperature in here?" target the correct room automatically -> **`lan_https` mode**: The integration automatically connects to the internal gateway port (plain HTTP on loopback), bypassing the HTTPS proxy entirely. No certificate setup required. +### Richer Entity Context +- Entity context now includes area assignments, useful state attributes (brightness, temperature, volume, media info), and current date/time +- Significantly improves device control accuracy for LLM-based agents -### Remote or standalone OpenClaw instance (manual config) +### Merged Community PRs +- **PR #9** (dalehamel) -- opt-in debug logging for API request tracing +- **PR #10** (dalehamel) -- sticky sessions and agent routing fix (resolves upstream Issue #8) +- **PR #11** (L0rz) -- `continue_conversation` for Voice PE follow-up dialog -You can connect to **any reachable OpenClaw gateway** — whether it's the HA addon on another machine, a standalone `openclaw` install on a VPS, or a Docker container on your LAN. The integration doesn't care how OpenClaw is installed; it only needs the `/v1/chat/completions` endpoint. - -**Prerequisites on the OpenClaw instance:** - -1. The OpenAI-compatible API must be **enabled**: - - **Addon users**: Set `enable_openai_api: true` in addon settings - - **Standalone users**: Set `gateway.http.endpoints.chatCompletions.enabled: true` in `openclaw.json`, or run: - ```sh - openclaw config set gateway.http.endpoints.chatCompletions.enabled true - ``` -2. The gateway must be **network-reachable** from your HA instance (not bound to loopback only) -3. You need the **gateway auth token**: - ```sh - openclaw config get gateway.auth.token - ``` - -**Setup steps:** - -1. Go to **Settings → Devices & Services → Add Integration → OpenClaw** -2. Auto-discovery will fail (no local addon) — you'll see the **Manual Configuration** form -3. Fill in: - - **Gateway Host**: IP or hostname of the remote machine (e.g. `192.168.1.50`) - - **Gateway Port**: The gateway port (default `18789`) - - **Gateway Token**: Auth token from the remote `openclaw.json` - - **Use SSL (HTTPS)**: Check if connecting to an HTTPS endpoint - - **Verify SSL certificate**: Uncheck for self-signed certificates (e.g. `lan_https` mode) - -### Common remote scenarios - -| Remote access mode | Host | Port | Use SSL | Verify SSL | Notes | -|---|---|---|---|---|---| -| Standalone OpenClaw (plain HTTP on LAN) | Remote IP | 18789 | ❌ | — | Default `openclaw gateway run` config | -| `lan_https` (addon built-in HTTPS proxy) | Remote IP | 18789 | ✅ | ❌ | Self-signed cert; disable verification | -| Behind reverse proxy (NPM/Caddy with Let's Encrypt) | Domain or IP | 443 | ✅ | ✅ | Trusted cert from a real CA | -| Plain HTTP addon on LAN | Remote IP | 18789 | ❌ | — | Addon `bind_mode` must be `lan` | -| Tailscale | Tailscale IP | 18789 | ❌ | — | Encrypted tunnel; plain HTTP is fine | - -> **Security note**: Avoid exposing plain HTTP gateways to the public internet. Use `lan_https`, a reverse proxy with TLS, or Tailscale for remote access. +### Code Quality +- Shared utility module (`utils.py`) -- extracted duplicated methods +- Granular error codes -- `FAILED_TO_HANDLE` for connection/auth errors instead of `UNKNOWN` +- API client retry logic for transient connection failures +- Improved session management logging --- -## Installation +## Installation via HACS -### Option A: HACS (recommended) - -1. Open **HACS → Integrations** -2. Click the **3 dots (⋮)** menu in the top-right -3. Select **Custom repositories** -4. Add repository URL: `https://github.com/techartdev/OpenClawHomeAssistantIntegration` -5. Category: **Integration** -6. Click **Add** -7. Go back to **Explore & Download Repositories** -8. Search for **OpenClaw** and install -9. Restart Home Assistant -10. Open **Settings → Devices & Services → Add Integration** -11. Add **OpenClaw** - -### Option B: Manual - -1. Copy `custom_components/openclaw` into your HA config directory: - - ``` - config/custom_components/openclaw - ``` - -2. Restart Home Assistant -3. Add **OpenClaw** from **Settings → Devices & Services** +1. Open **HACS -> Integrations** +2. Click the **three-dot menu** -> **Custom repositories** +3. Add repository URL: `https://github.com/DarrenBenson/OpenClawHomeAssistantIntegration` +4. Category: **Integration** +5. Click **Add**, then **Download** +6. Restart Home Assistant +7. Go to **Settings -> Devices & Services -> Add Integration -> OpenClaw** --- -## Dashboard card - -The card is registered automatically by the integration. +## What It Includes -The card header shows live gateway state (`Online` / `Offline`) using existing OpenClaw status entities. - -```yaml -type: custom:openclaw-chat-card -title: OpenClaw Chat -height: 500px -show_timestamps: true -show_voice_button: true -show_clear_button: true -session_id: default -``` - -Minimal config: - -```yaml -type: custom:openclaw-chat-card -``` +- **Conversation agent** (`openclaw`) in Assist / Voice Assistants +- **Lovelace chat card** (`custom:openclaw-chat-card`) with message history, typing indicator, optional voice input, wake-word handling +- **Services:** `openclaw.send_message`, `openclaw.clear_history`, `openclaw.invoke_tool` +- **Events:** `openclaw_message_received`, `openclaw_tool_invoked` +- **Sensors / status entities** for model and connection state, including tool telemetry --- -## Assist entity exposure context - -OpenClaw can include Home Assistant entity context based on Assist exposure. - -Configure exposure in: +## Requirements -**Settings → Voice assistants → Expose** +- Home Assistant Core `2025.1.0+` +- An **OpenClaw gateway** with `enable_openai_api` enabled -- either: + - The [OpenClaw Assistant addon](https://github.com/techartdev/OpenClawHomeAssistant) running on the same HA instance, **or** + - Any standalone [OpenClaw](https://github.com/openclaw/openclaw) installation reachable over the network -Only entities exposed there are included when this feature is enabled. +> **No addon required.** If you have OpenClaw running anywhere -- on a separate server, a VPS, a Docker container, or another machine on your LAN -- this integration can connect to it via the manual configuration flow. --- -## Integration options - -Open **Settings → Devices & Services → OpenClaw → Configure**. - -### Context options +## Connection Modes -- **Include exposed entities context** -- **Max context characters** -- **Context strategy** - - `truncate`: keep the first part up to max length - - `clear`: remove context when it exceeds max length - -### Tool call option - -- **Enable tool calls** +### Local addon (auto-discovery) -When enabled, OpenClaw tool-call responses can execute Home Assistant services. +If the OpenClaw Assistant addon is installed on the **same** Home Assistant instance, the integration auto-discovers it -- no manual config needed. -### Voice options +### Remote or standalone OpenClaw instance (manual config) -- **Wake word enabled** -- **Wake word** (default: `hey openclaw`) -- **Voice input provider** (`browser` or `assist_stt`) +Connect to **any reachable OpenClaw gateway**. You need: -### Voice provider usage +1. `enable_openai_api` enabled on the OpenClaw instance +2. Network reachability from HA +3. The gateway auth token (`openclaw config get gateway.auth.token`) -- **`browser`** - - Uses browser Web Speech recognition. - - Supports manual mic and continuous voice mode (wake word flow). - - Best when browser STT is stable in your environment. +| Scenario | Host | Port | SSL | Verify SSL | +|---|---|---|---|---| +| Standalone (LAN) | Remote IP | 18789 | No | -- | +| `lan_https` (addon HTTPS proxy) | Remote IP | 18789 | Yes | No | +| Reverse proxy (Let's Encrypt) | Domain | 443 | Yes | Yes | +| Tailscale | Tailscale IP | 18789 | No | -- | -- **`assist_stt`** - - Uses Home Assistant STT provider via `/api/stt/`. - - Intended for manual mic input (press mic, speak, auto-stop, transcribe, send). - - If continuous Voice Mode is enabled while this provider is selected, the card uses browser speech for continuous listening. +--- -For `assist_stt`, make sure an STT engine is configured in **Settings → Voice assistants**. +## Integration Options ---- +Open **Settings -> Devices & Services -> OpenClaw -> Configure**. -## Browser voice note (important) +### Context +- **Include exposed entities context** -- sends entity states to the agent +- **Max context characters** -- limit context size +- **Context strategy** -- `truncate` or `clear` when exceeding max length -Card voice input uses browser speech recognition APIs (`SpeechRecognition` / `webkitSpeechRecognition`). +### Agent Routing +- **Agent ID** -- default OpenClaw agent (e.g. `main`) +- **Voice agent ID** -- agent for voice pipeline requests (e.g. `voice`) +- **Assist session ID override** -- fixed session key for voice (e.g. `ha-voice-assist`) -- Behavior depends on browser support and provider availability -- In Brave, repeated `network` errors can occur even with mic permission -- The card now detects repeated backend failures and stops endless retries with a clear status message +### Debug +- **Debug logging** -- log agent ID, session ID, and area for each request -If voice is unreliable in Brave, use Chrome/Edge for card voice input or continue with typed chat. +### Voice (Lovelace card) +- **Wake word enabled/word** -- for continuous voice mode in the card +- **Voice input provider** -- `browser` (Web Speech) or `assist_stt` (HA STT) --- @@ -225,16 +114,6 @@ If voice is unreliable in Brave, use Chrome/Edge for card voice input or continu ### `openclaw.send_message` -Send a message to OpenClaw. - -Fields: - -- `message` (required) -- `session_id` (optional) -- `attachments` (optional) - -Example: - ```yaml service: openclaw.send_message data: @@ -244,14 +123,6 @@ data: ### `openclaw.clear_history` -Clear stored conversation history for a session. - -Fields: - -- `session_id` (optional; defaults to `default` session) - -Example: - ```yaml service: openclaw.clear_history data: @@ -260,20 +131,6 @@ data: ### `openclaw.invoke_tool` -Invoke a single OpenClaw Gateway tool directly. - -Fields: - -- `tool` (required) -- `action` (optional) -- `args` (optional object) -- `session_key` (optional) -- `dry_run` (optional) -- `message_channel` (optional) -- `account_id` (optional) - -Example: - ```yaml service: openclaw.invoke_tool data: @@ -285,20 +142,10 @@ data: --- -## Event +## Events ### `openclaw_message_received` -Fired when OpenClaw returns a response. - -Event data includes: - -- `message` -- `session_id` -- `timestamp` - -Automation example: - ```yaml trigger: - platform: event @@ -311,19 +158,6 @@ action: ### `openclaw_tool_invoked` -Fired when `openclaw.invoke_tool` completes. - -Event data includes: - -- `tool` -- `ok` -- `result` -- `error` -- `duration_ms` -- `timestamp` - -Automation example: - ```yaml trigger: - platform: event @@ -339,63 +173,35 @@ action: --- -## Troubleshooting +## Dashboard Card -### Card does not appear +Registered automatically by the integration. -- Restart Home Assistant after updating -- Hard refresh browser cache -- Confirm Integration is loaded in **Settings → Devices & Services** - -### Voice button is active but no transcript is sent - -- Check browser mic permission for your HA URL -- Confirm **Voice input provider** setting in integration options: - - `browser` for Web Speech recognition - - `assist_stt` for Home Assistant STT transcription -- For `browser`: open browser console for `OpenClaw: Speech recognition error`; repeated `network` usually means browser speech backend failure -- For `assist_stt`: check network calls to `/api/stt/` and verify Home Assistant Voice/STT provider is configured - -### Tool sensors show `Unknown` - -- `Last Tool*` sensors stay `Unknown` until at least one `openclaw.invoke_tool` service call has executed. -- `Session Count` remains `0` if gateway policy blocks `sessions_list` for `/tools/invoke`. - -### Responses do not appear after sending - -- Verify `openclaw_message_received` is being fired in Developer Tools → Events -- Confirm session IDs match between card and service calls - -### "400 Bad Request — plain HTTP request was sent to HTTPS port" - -- The gateway is running in `lan_https` mode (built-in HTTPS proxy) -- **Local addon**: Remove and re-add the integration — auto-discovery now detects `lan_https` and uses the correct internal port automatically -- **Remote connection**: Enable **Use SSL (HTTPS)** and disable **Verify SSL certificate** in the manual config +```yaml +type: custom:openclaw-chat-card +title: OpenClaw Chat +height: 500px +show_timestamps: true +show_voice_button: true +show_clear_button: true +session_id: default +``` --- -## Development notes - -- Main card source is: - - ``` - custom_components/openclaw/www/openclaw-chat-card.js - ``` +## Troubleshooting -- Root `www/openclaw-chat-card.js` is a loader shim that imports the packaged card script. +- **Card doesn't appear:** Restart HA, hard refresh browser cache +- **Voice not working:** Check browser mic permissions and voice provider setting +- **Tool sensors show Unknown:** Normal until first `openclaw.invoke_tool` call +- **400 Bad Request (HTTPS):** Enable "Use SSL" and disable "Verify SSL" for `lan_https` mode --- -## Star History +## Upstream -[![Star History Chart](https://api.star-history.com/svg?repos=techartdev/OpenClawHomeAssistantIntegration&type=date&legend=top-left)](https://www.star-history.com/#techartdev/OpenClawHomeAssistantIntegration&type=date&legend=top-left) +This fork tracks [techartdev/OpenClawHomeAssistantIntegration](https://github.com/techartdev/OpenClawHomeAssistantIntegration). Upstream PRs are merged when compatible. -## License +## Licence MIT. See [LICENSE](LICENSE). - -## Support / Donations - -If you find this useful and you want to bring me a coffee to make more nice stuff, or support the project, use the link below: -- https://revolut.me/vanyo6dhw - From 8edfc60e6777268fc99d0aaef42bf740da7e6a7e Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 06:34:08 +0100 Subject: [PATCH 12/13] fix: remove stale _log_request call in async_check_connection PR #9 debug logging added _log_request() but referenced undefined variables (agent_id, payload, session_id, headers) in the async_check_connection method, causing 500 errors during config flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- custom_components/openclaw/api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index bdfd954..ee8e5e8 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -424,8 +424,6 @@ async def async_check_connection(self) -> bool: session = await self._get_session() url = f"{self._base_url}{API_CHAT_COMPLETIONS}" - self._log_request("chat", agent_id, payload.get("model"), session_id, headers) - try: async with session.post( url, From fa64a25a5f4b7d210cc0620a231c0bba2e5b5daa Mon Sep 17 00:00:00 2001 From: Darren Benson Date: Sun, 29 Mar 2026 06:45:19 +0100 Subject: [PATCH 13/13] remove: assist_session_id option (superseded by PR #10 sticky sessions) The static session ID override is no longer needed now that PR #10 provides durable, agent-scoped sticky sessions persisted to disk. The old option caused confusion by routing all requests to whichever agent owned the named session, bypassing voice agent routing. Removed from: config_flow, conversation, strings, translations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../openclaw/__pycache__/api.cpython-312.pyc | Bin 23672 -> 24557 bytes .../__pycache__/config_flow.cpython-312.pyc | Bin 18228 -> 20671 bytes custom_components/openclaw/config_flow.py | 9 --------- custom_components/openclaw/conversation.py | 13 +------------ custom_components/openclaw/strings.json | 1 - .../openclaw/translations/en.json | 1 - 6 files changed, 1 insertion(+), 23 deletions(-) diff --git a/custom_components/openclaw/__pycache__/api.cpython-312.pyc b/custom_components/openclaw/__pycache__/api.cpython-312.pyc index fc8d26db84061265790e2a9f051c5dc6e50f13cf..144c32ab50e70be8643bed85e5a3d35bc37b3077 100644 GIT binary patch delta 4804 zcma)A3v5%@89vANUOy7sdDuyuhl#_ZaRNm^fReNjpswS42?ob@k8gkk zgDKE1TOX93721_*tyER*=!4eX`WPD}X}3z74CKiaS*Kn(fp_RG#gktv`T(f_WP?e2Ru2oYOMgY zU3o3MYc)7qK#+c|5Y*h-B+UtGMH6aTEhtq_3urEo7i)F!3gzw+xEH~_bV4sboKFll zSt|y0k2Xas0X6S{MV*>`R_T*zB`+*c>3rT;u$TJyYXxD?ELhpglr_LDMF9e=l20yF zE4z^41~^DYt)$p{^uUh}oVWX#k86dg%G*GoT7(swhno2M186&1IM3US90$Nb@_Bjn zko;&N^JN>INlIt(7hO}mGeD#|gmq4dg?scEYvg}+wSs$f?s+tn-RSP3bUA;ysH+Nf z8BUk8_9wNlp{t5HZ6o&;Pc2>v5|JDMykPz{#VU>Q-NhTIFZ)sP8H-yLwvkvOrE9E} zKjE2D62vhVqh)@@zxB+c>FhPnpp~x7rmFr$SKWg;7@VAq>G${RDT8f5)@)>1Q%2b6 zPl-Is$%eky5J{eMMiOyDj~gLlAgQw+)bHZinrXC}e^%4lgh9?(`*dSRLd(g?u(5+} z7OY)+!mKyN7UF}{&tf^5>8T{!jsx`a4gN}q%r3u5k7l3re=e1$kZ(u0X%P?CEv6T< zAJ!eV`d6T^9pJhnk<{anSa{Epmo2Oj+3UDozn}~QXL^V(Fnncf3xBn~+IKII*9(I? z!bU)6EWrXSgu?sy=k-4*6EPT%4YQ$SHE&dFGZ+}ABZw_1e8-(X76zoE2s;sWA;b`H zo2$iUF4+3?R4Ux7vvK!xuGB!v(ECD3mgq|wEQ2QIDv&w=kh3Mj1F?iZtTC}B3?fS+ z+>apqV0=`?7#7S}O8=6*bH?v2UHInn7l!g-Lg>}|fV-Mda)M}dzr`5WF~$lWA(~|n zluwOG+Q_y^gzT3b#6r?!r_7(9>)9qpNt(VuAESq1lzmWPj!P$`hZKcGER<{{GD%ww z(M9AD#}r~%^Day!Y}g!MyBF1N{@%&O)4Fj+B;6KPEKCo}GEkQ?f}> zVifVSEByR7D;$};+rlwWI4+}Oi--bC-i%9@56F)^WZw5s1Iw$pz&UXJ5ZOcaN}I_Z z`l_^ts>)#dMm??t!hx~37T6Otb_BxxMxrllL?Z#F8*CtuhzAT7j;Eq}JYxi4f5i2O z5e4q{a5UBrY2MJmFbae7gEz}`El?je;N2&Uz%*^nG)%x@+p+bnP^Yqwmks{7gO zr)R(7{Isg^qPOvaxA8yME_FmR{O?zQ9Hh_=@ z=w$nm94`dyAU-~Ta0uZrKnFX5Cr0?~MZ$@2(# zMLwl)*NRFB$`YTq#v9O--GmhyC3zLinlC(uXjRh$9eO*`Qtx43218hgY82yi&v6(r8$J+E8 z&b5g##GAH(7&{=ipb7e>wcYvzRu-MN1?O@e{GH7~JhQHi+IhHbSLSI@KICn;(Bm{% zDWC8Gb$W5IiJVhuu-bZVhJ^I&786WB7Lw#L3+8= zZprR!KOtqto090)uns16uG5UR*r2m8a6d4s+|KlaURBS20O z&GXH@=C3;Ez~=h0^WHm_gUSln33E_Yf;O4W6;w2Ch))CdYj|Yy5#B;rFitJxN4h3w zMBx)UO?gSq3WsMui|6EyL|lg*c@`z8qsljoDY)+=*U9iKGH1f^t`~qGfph@PMH|e{ zp?y81y&BMrdE5wNVRLA1Imb#K$R2>02#2qkVd!-(c8rFw8|UuIZWSSWA3h+gJoL z>;uqHtsNa5E5N^ECe9HNx`~yw;~>IkajXa`gFF#@5o~is$QwZrs}dv2xAZ;Yk&0Ik!w7nm;gdPep2}jz6fHfg!izRw{p(oB+;iD7oW*R9d@8WlD znU;Zd6NVj1&Sj=)KIz1o9!2R-5S~O8NBiE0p2P;-Je%1Ae3%Gev*X_-H39Ub- zFJpfIJqo|na{$mEQu-O0`~{i(H^Tq9rOP=Ae?llZ=)6=^deF&Rw$>Nzw$b`41X0B3 EzsUhMApigX delta 3874 zcma)9eQZb+k-s2dP>O{#aH1nbB0XcKdHrv`(XnrfKKg7u&!< z+Kc>q=bf*6&Uxpa`|`Js$hTLB^M=DwAi?#!_dbgyww!mCkihvRH^L@qgGB995_M>v z85tEMDeM(oCvXcjZ(0X=Z-G=W`~uya-7jf?$>)RhEHjAhKr542?1{TgGSQb+Lz;R-h?D?Y*Y0hAHy< z;FrA?vL@f`A(C`a(xfn$Z599@!?EvB1%xEJLVb3OvJ5LAn44RwKvd(5ORN?kP% zDWrs}1u9v}dkc;dKfhTJb$@{*1DQ@!>CA&q?2QRh)X zq~;6jw0SVPUmIjA_`e z?b9Z<0@*$MR6~mw3ui=#A+TKtVSb~byksws^S1T0o)z8nXZVl&v z>N5JmI;16&(SsVxrjm9f$CA1>6iKl7P(o+N(a^{Usbc_XYa%)}7>`mW(!+YPI6?wJ zEIh)Ujabfx#_y8bsRNBSO(~`MIqS|NsBskG2LK^9fn;`VHi?fX5S~Lg3DCn%AvuK* zMtB|ptH)kISg6_`S3N<-xw}p6`5|!CN_HBBkE$a(gVGlf&LUvDJ+yY?{7hRliSl2x zRk%7)sTU!HAd0z36w_zym6wp6OXkP;cz@;CIpjUGYJ7QnT?t3=1%z>g5rCu*fRD7h z&Bo4qp}mvTr#@@{vuv6(@s9#k{LaQQ{`Hy$-dyYRQIn1xD=u9ePe}4Vt?&lr8Qg|& z={Rr1rDK1HYsPHc35?hYGT>fd-*cNVO-KV?{<){D@t{N%YJSb6V`$<6QAvi*-7j1C z@Y-tFl~J#|+Im=GHoY<zxXrtbe#Ksdu`IfA572Bk!m|ib?g+jy z838iH_9Fw^Q!QZ+*~><2h=vf|C>lq!hUiOd-L$Q9>!wF-@B`>xL%_GV(342T_{*wu zrD#m6(U=eH(43`cFq|$p*5QK5zNlaNO zvyC?o-pYH1-|GI<>X6(`-_F_j>l+SDz6xGWdV;OwWzywPE_T|1{B2!ZSEF>Tl5`bX zu2soM*L0FDuY7$G=_;~ZuPsITNgD`et)$DX%;qUb+f86RTS$;DLj75{puLu^CDz$; z(p6=jt*|0pi%w@3lVCL%ED;Zl%Y!ZQCl$rPrSd0B%}BS%!PeCD#*1=$b_mD*2`Mr$ zD(uF~5$^y`BKC8H+(gK2_IgoM<(`@*P*oIB0cxRw?>@bne3<(C(;t{uVW&JWwZ@A> z@%UgMHhegKNV{+J+fn&hged=V`|^;8&XZR5gZ_h)43GW)XMTxS9Yy6_8h(+6aR9J4 zP&SS5CO_A^vhL9X3U!3L%xqUetI4fYN9d<=C7%9lJ3!hjJ^}7OG3USkIC1dCo%>d= z0hMmGm@P$B84i1fJ1;HAhIR3Q!`_2O){HQNusln(@asDlO|HOaq4PvWlD05SA7#U7 zr6)eD!M1!qqoXSK_!Qh43;iB`W>@*7SnvZ}&?&xemY$xTZivst#Ni}L zm&>dRorsvlStTfO-izXkVjCHu-iMhL4bNuq433?TaJ=MyJwMbRzY{L25w*fKAx_bd zB2LkjV*NLH`R?+`%h_muiIj-(tt{{DEG4{&>=&9ryd@7SMh!gKmwgHIj! zkkrRm8}e2oJcWQW*q9D56E;drd=ZW!^Ct*DLwFtG62dzODFh#aAE5-HDg#gyDY-9Y z+Veb-=fWm_Zf~Et&Fuf?Fu%K(H3$RLRZB&1>4He)z$o;#7Kt$YpNb72MEUuB)g;7! zwXc3sq|^!Aw8QACOpk~eb`}Ns!J~azLWfo}&I#Q7#uuUp>;6#Z^PU`v)6qd~4Vwi$ w0>9)1fH|2E@&{?rU!+A}NzU7n?Y89nQmW^X$Y%b2q@gfoB@O?O5JlSm1w1}I4gdfE diff --git a/custom_components/openclaw/__pycache__/config_flow.cpython-312.pyc b/custom_components/openclaw/__pycache__/config_flow.cpython-312.pyc index 943f45b350a7e3671692d1cf94e0e3c6fd7e3aa0..18ec48931f900600e2bd5c666d49a2bb9b851f54 100644 GIT binary patch delta 6685 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 diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index 9711610..daa4ce6 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -37,7 +37,6 @@ ADDON_SLUG_FRAGMENTS, CONF_ADDON_CONFIG_PATH, CONF_AGENT_ID, - CONF_ASSIST_SESSION_ID, CONF_GATEWAY_HOST, CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, @@ -59,7 +58,6 @@ CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_TRUNCATE, DEFAULT_AGENT_ID, - DEFAULT_ASSIST_SESSION_ID, DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, DEFAULT_CONTEXT_MAX_CHARS, @@ -481,13 +479,6 @@ async def async_step_init( 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/conversation.py b/custom_components/openclaw/conversation.py index 3d4cf45..b3c911d 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -26,14 +26,12 @@ ATTR_MODEL, ATTR_SESSION_ID, ATTR_TIMESTAMP, - CONF_ASSIST_SESSION_ID, CONF_AGENT_ID, CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, CONF_DEBUG_LOGGING, CONF_INCLUDE_EXPOSED_CONTEXT, CONF_VOICE_AGENT_ID, - DEFAULT_ASSIST_SESSION_ID, DEFAULT_AGENT_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, @@ -263,16 +261,7 @@ async def async_process( ) def _resolve_conversation_id(self, user_input: conversation.ConversationInput, agent_id: str | None) -> str: - """Return conversation id from HA or a stable Assist fallback session key.""" - configured_session_id = normalize_optional_text( - self.entry.options.get( - CONF_ASSIST_SESSION_ID, - DEFAULT_ASSIST_SESSION_ID, - ) - ) - if configured_session_id: - return configured_session_id - + """Return a stable, agent-scoped session key persisted across HA restarts.""" domain_store = self.hass.data.setdefault(DOMAIN, {}) session_cache = domain_store.setdefault(DATA_ASSIST_SESSIONS, {}) cache_key = agent_id or "main" diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 69909f9..f25fbfb 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -40,7 +40,6 @@ "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 3ccf002..b247a3f 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -42,7 +42,6 @@ "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",