From e17bd0aa859d9dbf64eebc45daf768e4f43835f7 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Apr 2026 16:28:35 +0300 Subject: [PATCH] Fix model selection, docs links, cache-busting, and Assist routing --- README.md | 14 ++- custom_components/openclaw/__init__.py | 59 ++---------- custom_components/openclaw/api.py | 5 + custom_components/openclaw/conversation.py | 93 +++++++------------ custom_components/openclaw/event.py | 15 +-- custom_components/openclaw/helpers.py | 52 +++++++++++ custom_components/openclaw/manifest.json | 4 +- custom_components/openclaw/services.yaml | 7 -- custom_components/openclaw/strings.json | 4 - .../openclaw/translations/en.json | 4 - www/openclaw-chat-card.js | 2 +- 11 files changed, 121 insertions(+), 138 deletions(-) create mode 100644 custom_components/openclaw/helpers.py diff --git a/README.md b/README.md index 3b7d950..e5bc630 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ OpenClaw is a Home Assistant custom integration that connects your HA instance t - `openclaw.send_message` - `openclaw.clear_history` - `openclaw.invoke_tool` +- **Integration options** for model selection and voice-specific routing - **Event** - `openclaw_message_received` - `openclaw_tool_invoked` @@ -192,6 +193,13 @@ When enabled, OpenClaw tool-call responses can execute Home Assistant services. - **Wake word enabled** - **Wake word** (default: `hey openclaw`) - **Voice input provider** (`browser` or `assist_stt`) +- **Voice agent ID** (optional) + +### Model selection + +- **Active Model** select entity controls the model override used for chat-card and Assist requests. +- If no model is selected, the gateway default is used. +- Assist conversation IDs are conservatively namespaced by agent ID so different agents do not accidentally share the same conversation context. ### Voice provider usage @@ -227,11 +235,13 @@ If voice is unreliable in Brave, use Chrome/Edge for card voice input or continu Send a message to OpenClaw. +> Note: file attachments are not currently supported by this service. The old `attachments` field was removed because it was accepted by the schema but never sent to the gateway. + Fields: - `message` (required) - `session_id` (optional) -- `attachments` (optional) +- `agent_id` (optional) Example: @@ -313,6 +323,8 @@ action: Fired when `openclaw.invoke_tool` completes. +The integration also exposes native Event entities for both message-received and tool-invoked events so they can be selected directly in the automation UI. + Event data includes: - `tool` diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 7e4d656..ce58698 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -31,7 +31,6 @@ from .api import OpenClawApiClient, OpenClawApiError from .const import ( ATTR_AGENT_ID, - ATTR_ATTACHMENTS, ATTR_MESSAGE, ATTR_MODEL, ATTR_OK, @@ -90,6 +89,7 @@ ) from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context +from .helpers import extract_text_recursive _LOGGER = logging.getLogger(__name__) @@ -98,6 +98,7 @@ _VOICE_REQUEST_HEADERS = { "x-openclaw-source": "voice", "x-ha-voice": "true", + "x-openclaw-message-channel": "voice", } # Path to the chat card JS inside the integration package (custom_components/openclaw/www/) @@ -106,7 +107,7 @@ # 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.60" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.61" OpenClawConfigEntry = ConfigEntry @@ -117,7 +118,6 @@ 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, } ) @@ -447,6 +447,8 @@ async def handle_send_message(call: ServiceCall) -> None: ) system_prompt = apply_context_policy(raw_context, max_chars, strategy) + active_model = _normalize_optional_text(options.get("active_model")) + _append_chat_history(hass, session_id, "user", message) response = await client.async_send_message( @@ -454,6 +456,7 @@ async def handle_send_message(call: ServiceCall) -> None: session_id=session_id, system_prompt=system_prompt, agent_id=resolved_agent_id, + model=active_model, extra_headers=extra_headers, ) @@ -469,6 +472,7 @@ async def handle_send_message(call: ServiceCall) -> None: session_id=session_id, system_prompt=system_prompt, agent_id=resolved_agent_id, + model=active_model, extra_headers=extra_headers, ) @@ -635,53 +639,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: @@ -703,7 +660,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/api.py b/custom_components/openclaw/api.py index 65f4c69..bd277d8 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -104,6 +104,11 @@ def _headers( async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" if self._session is None or self._session.closed: + if self._session is not None and self._session.closed: + _LOGGER.warning( + "Primary aiohttp session unavailable — creating fallback session. " + "This may bypass HA connection management" + ) self._session = aiohttp.ClientSession() return self._session diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 895312c..633a7d1 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -24,11 +24,13 @@ 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, @@ -38,6 +40,7 @@ ) from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context +from .helpers import extract_text_recursive _LOGGER = logging.getLogger(__name__) @@ -117,12 +120,20 @@ 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) + active_model = self._normalize_optional_text(options.get("active_model")) include_context = options.get( CONF_INCLUDE_EXPOSED_CONTEXT, DEFAULT_INCLUDE_EXPOSED_CONTEXT, @@ -149,8 +160,9 @@ async def async_process( client, message, conversation_id, - voice_agent_id, + resolved_agent_id, system_prompt, + active_model, ) except OpenClawApiError as err: _LOGGER.error("OpenClaw conversation error: %s", err) @@ -165,8 +177,9 @@ async def async_process( client, message, conversation_id, - voice_agent_id, + resolved_agent_id, system_prompt, + active_model, ) except OpenClawApiError as retry_err: return self._error_result( @@ -205,8 +218,12 @@ async def async_process( continue_conversation=self._should_continue(full_response), ) - def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -> str: - """Return conversation id from HA or a stable Assist fallback session key.""" + def _resolve_conversation_id( + self, + user_input: conversation.ConversationInput, + agent_id: str | None, + ) -> str: + """Return conversation id from HA with conservative agent namespacing.""" configured_session_id = self._normalize_optional_text( self.entry.options.get( CONF_ASSIST_SESSION_ID, @@ -216,19 +233,25 @@ def _resolve_conversation_id(self, user_input: conversation.ConversationInput) - if configured_session_id: return configured_session_id + agent_suffix = self._normalize_optional_text(agent_id) + if user_input.conversation_id: + if agent_suffix: + return f"{user_input.conversation_id}:{agent_suffix}" return user_input.conversation_id context = getattr(user_input, "context", None) user_id = getattr(context, "user_id", None) if user_id: - return f"assist_user_{user_id}" + base_id = f"assist_user_{user_id}" + return f"{base_id}:{agent_suffix}" if agent_suffix else base_id device_id = getattr(user_input, "device_id", None) if device_id: - return f"assist_device_{device_id}" + base_id = f"assist_device_{device_id}" + return f"{base_id}:{agent_suffix}" if agent_suffix else base_id - return "assist_default" + return f"assist_default:{agent_suffix}" if agent_suffix else "assist_default" def _normalize_optional_text(self, value: Any) -> str | None: """Return a stripped string or None for blank values.""" @@ -244,13 +267,14 @@ async def _get_response( conversation_id: str, agent_id: str | None = None, system_prompt: str | None = None, + model: str | None = None, ) -> str: """Get a response from OpenClaw, trying streaming first.""" - # Try streaming (lower TTFB for voice pipeline) full_response = "" async for chunk in client.async_stream_message( message=message, session_id=conversation_id, + model=model, system_prompt=system_prompt, agent_id=agent_id, extra_headers=_VOICE_REQUEST_HEADERS, @@ -260,62 +284,15 @@ async def _get_response( if full_response: return full_response - # Fallback to non-streaming response = await client.async_send_message( message=message, session_id=conversation_id, + model=model, system_prompt=system_prompt, agent_id=agent_id, extra_headers=_VOICE_REQUEST_HEADERS, ) - extracted = self._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 + return extract_text_recursive(response) or "" @staticmethod def _should_continue(response: str) -> bool: diff --git a/custom_components/openclaw/event.py b/custom_components/openclaw/event.py index 5c85a40..0dfa20c 100644 --- a/custom_components/openclaw/event.py +++ b/custom_components/openclaw/event.py @@ -14,7 +14,7 @@ from homeassistant.components.event import EventEntity, EventEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -53,10 +53,6 @@ async def async_setup_entry( ] async_add_entities(entities) - # Wire HA bus events → entity triggers - for entity in entities: - entity.async_start_listening(hass) - class OpenClawEventEntity(EventEntity): """Event entity that mirrors HA bus events into the entity registry.""" @@ -78,11 +74,10 @@ def __init__( "model": "OpenClaw Gateway", } self._entry_id = entry.entry_id - self._unsub: callback | None = None + self._unsub = None - @callback - def async_start_listening(self, hass: HomeAssistant) -> None: - """Subscribe to the matching HA bus event.""" + async def async_added_to_hass(self) -> None: + """Subscribe to the matching HA bus event after the entity is added.""" key = self.entity_description.key if key == "message_received": @@ -103,7 +98,7 @@ def _handle_event(event) -> None: self._trigger_event(event_type, data) self.async_write_ha_state() - self._unsub = hass.bus.async_listen(bus_event, _handle_event) + self._unsub = self.hass.bus.async_listen(bus_event, _handle_event) async def async_will_remove_from_hass(self) -> None: """Unsubscribe when entity is removed.""" diff --git a/custom_components/openclaw/helpers.py b/custom_components/openclaw/helpers.py new file mode 100644 index 0000000..a6c3c2c --- /dev/null +++ b/custom_components/openclaw/helpers.py @@ -0,0 +1,52 @@ +"""Shared helper utilities for the OpenClaw integration.""" + +from __future__ import annotations + +from typing import Any + + +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 diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 9d6a209..40a6807 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -3,10 +3,10 @@ "name": "OpenClaw", "codeowners": ["@techartdev"], "config_flow": true, - "documentation": "https://github.com/techartdev/OpenClawHomeAssistant", + "documentation": "https://github.com/techartdev/OpenClawHomeAssistantIntegration", "integration_type": "hub", "iot_class": "local_polling", - "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", + "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues", "requirements": [], "version": "0.1.61", "dependencies": ["conversation"], diff --git a/custom_components/openclaw/services.yaml b/custom_components/openclaw/services.yaml index 6d47b5e..9b9f255 100644 --- a/custom_components/openclaw/services.yaml +++ b/custom_components/openclaw/services.yaml @@ -17,13 +17,6 @@ send_message: example: "my-automation-session" selector: text: - attachments: - name: Attachments - description: Optional list of file paths to attach to the message. - required: false - selector: - text: - multiline: true agent_id: name: Agent ID description: > diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 69909f9..8246532 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -125,10 +125,6 @@ "name": "Session ID", "description": "Optional session ID for conversation context." }, - "attachments": { - "name": "Attachments", - "description": "Optional file attachments." - }, "agent_id": { "name": "Agent ID", "description": "Optional OpenClaw agent ID to route this message to. Overrides the configured default. Defaults to \"main\"." diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index b14609f..fc8df9d 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -127,10 +127,6 @@ "name": "Session ID", "description": "Optional session ID for conversation context." }, - "attachments": { - "name": "Attachments", - "description": "Optional file attachments." - }, "agent_id": { "name": "Agent ID", "description": "Optional OpenClaw agent ID to route this message to. Overrides the configured default. Defaults to \"main\"." diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js index 4b5630a..c71f0a5 100644 --- a/www/openclaw-chat-card.js +++ b/www/openclaw-chat-card.js @@ -1,7 +1,7 @@ (async () => { try { if (!customElements.get("openclaw-chat-card")) { - const src = "/openclaw/openclaw-chat-card.js?v=0.1.55"; + const src = "/openclaw/openclaw-chat-card.js?v=0.1.61"; console.info("OpenClaw loader importing", src); await import(src); }