Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 6 additions & 51 deletions custom_components/openclaw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
from .api import OpenClawApiClient, OpenClawApiError
from .const import (
ATTR_AGENT_ID,
ATTR_ATTACHMENTS,
ATTR_MESSAGE,
ATTR_MODEL,
ATTR_OK,
Expand Down Expand Up @@ -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__)

Expand All @@ -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

Expand All @@ -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,
}
)
Expand Down Expand Up @@ -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)

Expand All @@ -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,
)

Expand All @@ -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,
)

Expand Down Expand Up @@ -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:
Expand All @@ -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]]:
Expand Down
4 changes: 4 additions & 0 deletions custom_components/openclaw/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 8 additions & 50 deletions custom_components/openclaw/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 4 additions & 8 deletions custom_components/openclaw/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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":
Expand All @@ -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."""
Expand Down
56 changes: 56 additions & 0 deletions custom_components/openclaw/helpers.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 0 additions & 7 deletions custom_components/openclaw/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand Down
4 changes: 0 additions & 4 deletions custom_components/openclaw/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"."
Expand Down
4 changes: 0 additions & 4 deletions custom_components/openclaw/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"."
Expand Down