Release OpenClaw HA integration 0.1.66

This commit is contained in:
2026-05-06 13:08:57 +02:00
parent 8221098ebc
commit 2a02c18b86
6 changed files with 301 additions and 136 deletions
+163 -88
View File
@@ -91,6 +91,7 @@ from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive
from .routing import (
build_scoped_session_id,
build_scoped_session_prefix,
normalize_ha_user_id,
normalize_optional_text,
resolve_agent_id,
@@ -111,9 +112,11 @@ _VOICE_REQUEST_HEADERS = {
_CARD_FILENAME = "openclaw-chat-card.js"
_CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
# URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.63"
_CARD_STATIC_URL = "/openclaw/openclaw-chat-card-0.1.66.js"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache.
# The path itself carries the version because HA/Lovelace can keep old custom
# card modules alive more aggressively than ordinary query-string cache busting.
_CARD_URL = _CARD_STATIC_URL
OpenClawConfigEntry = ConfigEntry
@@ -324,10 +327,10 @@ async def _async_register_static_path(hass: HomeAssistant) -> str | None:
from homeassistant.components.http import StaticPathConfig # noqa: PLC0415
await hass.http.async_register_static_paths(
[StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=True)]
[StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=False)]
)
except (ImportError, AttributeError):
hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), True)
hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), False)
hass.data[static_key] = True
_LOGGER.debug("Registered static path: %s", _CARD_STATIC_URL)
@@ -358,7 +361,9 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool:
desired_path = urlsplit(url).path
legacy_paths = {
_CARD_STATIC_URL,
"/openclaw/openclaw-chat-card.js",
"/openclaw/openclaw-chat-card-0.1.65.js",
"/local/openclaw-chat-card.js",
"/hacsfiles/openclaw/openclaw-chat-card.js",
}
@@ -402,6 +407,112 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool:
# ── Service registration ──────────────────────────────────────────────────────
async def _async_send_openclaw_chat(
hass: HomeAssistant,
*,
message: str,
source: str | None,
raw_session_id: str,
ha_user_id: str,
call_agent_id: str | None = None,
) -> dict[str, Any]:
"""Send one chat message to OpenClaw and return the routed response payload."""
extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None
entry_data = _get_first_entry_data(hass)
if not entry_data:
raise OpenClawApiError("No OpenClaw integration configured")
client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
options = _get_entry_options(hass, entry_data)
resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id)
scoped_session_id = build_scoped_session_id(
raw_session_id,
ha_user_id,
resolved_agent_id,
)
_LOGGER.debug(
"OpenClaw chat routing: context_user=%s call_agent=%s "
"resolved_agent=%s raw_session=%s scoped_session=%s source=%s",
ha_user_id,
call_agent_id,
resolved_agent_id,
raw_session_id,
scoped_session_id,
source,
)
include_context = options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
)
max_chars = int(options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS))
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}:
strategy = DEFAULT_CONTEXT_STRATEGY
raw_context = (
build_exposed_entities_context(hass, assistant="conversation")
if include_context
else None
)
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
active_model = resolve_model_override(options.get("active_model"))
_append_chat_history(hass, scoped_session_id, "user", message)
response = await client.async_send_message(
message=message,
session_id=scoped_session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)
if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS):
tool_results = await _async_execute_tool_calls(hass, response)
if tool_results:
response = await client.async_send_message(
message=(
"Tool execution results:\n"
+ "\n".join(f"- {line}" for line in tool_results)
+ "\nRespond to the user based on these results."
),
session_id=scoped_session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)
assistant_message = _extract_assistant_message(response)
model_used = response.get("model", "unknown")
if not assistant_message:
assistant_message = "Response received, but no readable message content was found."
_LOGGER.warning(
"OpenClaw response had no parseable assistant message. Keys: %s",
list(response.keys()),
)
_append_chat_history(hass, scoped_session_id, "assistant", assistant_message)
coordinator.update_last_activity()
return {
ATTR_MESSAGE: assistant_message,
ATTR_SESSION_ID: raw_session_id,
ATTR_SESSION_KEY: scoped_session_id,
ATTR_HA_USER_ID: ha_user_id,
ATTR_AGENT_ID: resolved_agent_id,
ATTR_MODEL: model_used,
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
"messages": _get_chat_history_store(hass).get(scoped_session_id, []),
}
@callback
def _async_register_services(hass: HomeAssistant) -> None:
"""Register openclaw.send_message and openclaw.clear_history services."""
@@ -413,16 +524,6 @@ def _async_register_services(hass: HomeAssistant) -> None:
raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
ha_user_id = normalize_ha_user_id(call.context.user_id)
call_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID))
extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None
entry_data = _get_first_entry_data(hass)
if not entry_data:
_LOGGER.error("No OpenClaw integration configured")
return
client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
options = _get_entry_options(hass, entry_data)
resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id)
scoped_session_id = build_scoped_session_id(
raw_session_id,
@@ -431,77 +532,15 @@ def _async_register_services(hass: HomeAssistant) -> None:
)
try:
include_context = options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
)
max_chars = int(
options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS)
)
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}:
strategy = DEFAULT_CONTEXT_STRATEGY
raw_context = (
build_exposed_entities_context(hass, assistant="conversation")
if include_context
else None
)
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
active_model = resolve_model_override(options.get("active_model"))
_append_chat_history(hass, scoped_session_id, "user", message)
response = await client.async_send_message(
result = await _async_send_openclaw_chat(
hass,
message=message,
session_id=scoped_session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
source=source,
raw_session_id=raw_session_id,
ha_user_id=ha_user_id,
call_agent_id=call_agent_id,
)
if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS):
tool_results = await _async_execute_tool_calls(hass, response)
if tool_results:
response = await client.async_send_message(
message=(
"Tool execution results:\n"
+ "\n".join(f"- {line}" for line in tool_results)
+ "\nRespond to the user based on these results."
),
session_id=scoped_session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)
assistant_message = _extract_assistant_message(response)
model_used = response.get("model", "unknown")
if not assistant_message:
assistant_message = "Response received, but no readable message content was found."
_LOGGER.warning(
"OpenClaw response had no parseable assistant message. Keys: %s",
list(response.keys()),
)
_append_chat_history(hass, scoped_session_id, "assistant", assistant_message)
hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED,
{
ATTR_MESSAGE: assistant_message,
ATTR_SESSION_ID: raw_session_id,
ATTR_SESSION_KEY: scoped_session_id,
ATTR_HA_USER_ID: ha_user_id,
ATTR_AGENT_ID: resolved_agent_id,
ATTR_MODEL: model_used,
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
},
)
coordinator.update_last_activity()
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, result)
except OpenClawApiError as err:
_LOGGER.error("Failed to send message to OpenClaw: %s", err)
@@ -545,10 +584,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
)
store.pop(scoped_session_id, None)
else:
scoped_prefix = build_scoped_session_id("", ha_user_id, agent_id).rsplit(
"__session_",
1,
)[0] + "__session_"
scoped_prefix = build_scoped_session_prefix(ha_user_id, agent_id)
for key in list(store):
if key.startswith(scoped_prefix):
store.pop(key, None)
@@ -848,6 +884,45 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_get_history)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/send_message",
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_SOURCE): cv.string,
vol.Optional(ATTR_SESSION_ID): cv.string,
vol.Optional(ATTR_AGENT_ID): cv.string,
}
)
@websocket_api.async_response
async def websocket_send_message(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Send a chat message and return the assistant reply to the caller."""
raw_session_id = msg.get(ATTR_SESSION_ID) or "default"
source = normalize_optional_text(msg.get(ATTR_SOURCE))
requested_agent_id = normalize_optional_text(msg.get(ATTR_AGENT_ID))
connection_user = getattr(connection, "user", None)
ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None))
try:
result = await _async_send_openclaw_chat(
hass,
message=msg[ATTR_MESSAGE],
source=source,
raw_session_id=raw_session_id,
ha_user_id=ha_user_id,
call_agent_id=requested_agent_id,
)
except OpenClawApiError as err:
connection.send_error(msg["id"], "openclaw_error", str(err))
return
connection.send_result(msg["id"], result)
websocket_api.async_register_command(hass, websocket_send_message)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_settings",