diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 7e4d656..dac6df9 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__) @@ -106,7 +106,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 +117,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, } ) @@ -446,6 +445,7 @@ async def handle_send_message(call: ServiceCall) -> None: else 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) @@ -454,6 +454,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 +470,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 +637,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 +658,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..635863c 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -104,6 +104,10 @@ def _headers( async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" if self._session is None or 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 6baa134..cfb9175 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -37,6 +37,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__) @@ -121,6 +122,7 @@ async def async_process( voice_agent_id = self._normalize_optional_text( options.get(CONF_VOICE_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,6 +151,7 @@ async def async_process( conversation_id, voice_agent_id, system_prompt, + active_model, ) except OpenClawApiError as err: _LOGGER.error("OpenClaw conversation error: %s", err) @@ -165,6 +168,7 @@ async def async_process( conversation_id, voice_agent_id, system_prompt, + active_model, ) except OpenClawApiError as retry_err: return self._error_result( @@ -241,13 +245,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, @@ -257,62 +262,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 "" def _error_result( self, diff --git a/custom_components/openclaw/event.py b/custom_components/openclaw/event.py index 5c85a40..c652a17 100644 --- a/custom_components/openclaw/event.py +++ b/custom_components/openclaw/event.py @@ -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.""" @@ -80,9 +76,9 @@ def __init__( self._entry_id = entry.entry_id self._unsub: callback | None = 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 when entity is added.""" + await super().async_added_to_hass() key = self.entity_description.key if key == "message_received": @@ -103,7 +99,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..e9ff683 --- /dev/null +++ b/custom_components/openclaw/helpers.py @@ -0,0 +1,56 @@ +"""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. + + Walks OpenAI-compatible and custom response structures to find the + first usable text content, checking high-priority keys first. + """ + 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/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\"."