Update version to 0.1.40 and add OpenClaw Gateway tools endpoint integration with new service and event support

This commit is contained in:
techartdev
2026-02-20 23:18:37 +02:00
parent 367541044c
commit faa962d8bc
16 changed files with 526 additions and 18 deletions
+11
View File
@@ -2,6 +2,17 @@
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
## [0.1.40] - 2026-02-20
### Added
- Added OpenClaw Gateway tools endpoint integration (`POST /tools/invoke`) in the API client.
- Added new Home Assistant service `openclaw.invoke_tool` with support for tool/action/args/session routing fields.
- Added new event `openclaw_tool_invoked` for automation hooks on tool execution results.
- Added tool telemetry sensors: last tool name, status, duration, and invocation timestamp.
### Changed
- Coordinator now performs a best-effort `sessions_list` tool invocation to populate session count/list when available.
## [0.1.39] - 2026-02-20
### Fixed
+55
View File
@@ -23,8 +23,10 @@ OpenClaw is a Home Assistant custom integration that connects your HA instance t
- **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
---
@@ -191,6 +193,31 @@ data:
session_id: "living-room-session"
```
### `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:
tool: sessions_list
action: json
args: {}
session_key: main
```
---
## Event
@@ -217,6 +244,34 @@ action:
message: "{{ trigger.event.data.message }}"
```
### `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
event_type: openclaw_tool_invoked
condition:
- condition: template
value_template: "{{ trigger.event.data.ok == false }}"
action:
- service: notify.mobile_app_phone
data:
message: "Tool {{ trigger.event.data.tool }} failed: {{ trigger.event.data.error }}"
```
---
## Troubleshooting
+134 -16
View File
@@ -10,6 +10,7 @@ import json
from datetime import datetime, timezone
import logging
from pathlib import Path
from time import perf_counter
from typing import Any
from urllib.parse import urlsplit
@@ -32,7 +33,18 @@ from .const import (
ATTR_ATTACHMENTS,
ATTR_MESSAGE,
ATTR_MODEL,
ATTR_OK,
ATTR_RESULT,
ATTR_ERROR,
ATTR_DURATION_MS,
ATTR_SESSION_ID,
ATTR_SESSION_KEY,
ATTR_TOOL,
ATTR_ACTION,
ATTR_ARGS,
ATTR_DRY_RUN,
ATTR_MESSAGE_CHANNEL,
ATTR_ACCOUNT_ID,
ATTR_TIMESTAMP,
CONF_ADDON_CONFIG_PATH,
CONF_GATEWAY_HOST,
@@ -60,9 +72,11 @@ from .const import (
DEFAULT_VOICE_PROVIDER,
DOMAIN,
EVENT_MESSAGE_RECEIVED,
EVENT_TOOL_INVOKED,
OPENCLAW_CONFIG_REL_PATH,
PLATFORMS,
SERVICE_CLEAR_HISTORY,
SERVICE_INVOKE_TOOL,
SERVICE_SEND_MESSAGE,
)
from .coordinator import OpenClawCoordinator
@@ -78,7 +92,7 @@ _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.39"
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.40"
OpenClawConfigEntry = ConfigEntry
@@ -98,6 +112,18 @@ CLEAR_HISTORY_SCHEMA = vol.Schema(
}
)
INVOKE_TOOL_SCHEMA = vol.Schema(
{
vol.Required(ATTR_TOOL): cv.string,
vol.Optional(ATTR_ACTION): cv.string,
vol.Optional(ATTR_ARGS, default={}): dict,
vol.Optional(ATTR_SESSION_KEY): cv.string,
vol.Optional(ATTR_DRY_RUN, default=False): cv.boolean,
vol.Optional(ATTR_MESSAGE_CHANNEL): cv.string,
vol.Optional(ATTR_ACCOUNT_ID): cv.string,
}
)
async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) -> bool:
"""Set up OpenClaw from a config entry.
@@ -348,9 +374,6 @@ 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."""
if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
return
async def handle_send_message(call: ServiceCall) -> None:
"""Handle the openclaw.send_message service call."""
message: str = call.data[ATTR_MESSAGE]
@@ -451,18 +474,94 @@ def _async_register_services(hass: HomeAssistant) -> None:
else:
store.clear()
hass.services.async_register(
DOMAIN,
SERVICE_SEND_MESSAGE,
handle_send_message,
schema=SEND_MESSAGE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_HISTORY,
handle_clear_history,
schema=CLEAR_HISTORY_SCHEMA,
)
async def handle_invoke_tool(call: ServiceCall) -> None:
"""Handle the openclaw.invoke_tool service call."""
tool_name: str = call.data[ATTR_TOOL]
action: str | None = call.data.get(ATTR_ACTION)
args: dict[str, Any] = call.data.get(ATTR_ARGS) or {}
session_key: str | None = call.data.get(ATTR_SESSION_KEY)
dry_run: bool = bool(call.data.get(ATTR_DRY_RUN, False))
message_channel: str | None = call.data.get(ATTR_MESSAGE_CHANNEL)
account_id: str | None = call.data.get(ATTR_ACCOUNT_ID)
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"]
started = perf_counter()
ok = False
result: Any = None
error_message: str | None = None
try:
response = await client.async_invoke_tool(
tool=tool_name,
action=action,
args=args,
session_key=session_key,
dry_run=dry_run,
message_channel=message_channel,
account_id=account_id,
)
ok = bool(response.get("ok", True)) if isinstance(response, dict) else True
result = response.get("result") if isinstance(response, dict) else response
if isinstance(response, dict) and response.get("error"):
error_message = str(response.get("error"))
except OpenClawApiError as err:
ok = False
error_message = str(err)
duration_ms = int((perf_counter() - started) * 1000)
result_preview = _summarize_tool_result(result)
coordinator.record_tool_invocation(
tool_name=tool_name,
ok=ok,
duration_ms=duration_ms,
error_message=error_message,
result_preview=result_preview,
)
event_payload = {
ATTR_TOOL: tool_name,
ATTR_ACTION: action,
ATTR_SESSION_KEY: session_key,
ATTR_DRY_RUN: dry_run,
ATTR_OK: ok,
ATTR_RESULT: result,
ATTR_ERROR: error_message,
ATTR_DURATION_MS: duration_ms,
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
}
hass.bus.async_fire(EVENT_TOOL_INVOKED, event_payload)
if not ok:
raise OpenClawApiError(error_message or "Tool invocation failed")
if not hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
hass.services.async_register(
DOMAIN,
SERVICE_SEND_MESSAGE,
handle_send_message,
schema=SEND_MESSAGE_SCHEMA,
)
if not hass.services.has_service(DOMAIN, SERVICE_CLEAR_HISTORY):
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_HISTORY,
handle_clear_history,
schema=CLEAR_HISTORY_SCHEMA,
)
if not hass.services.has_service(DOMAIN, SERVICE_INVOKE_TOOL):
hass.services.async_register(
DOMAIN,
SERVICE_INVOKE_TOOL,
handle_invoke_tool,
schema=INVOKE_TOOL_SCHEMA,
)
def _get_first_entry_data(hass: HomeAssistant) -> dict[str, Any] | None:
@@ -521,6 +620,25 @@ def _extract_text_recursive(value: Any, depth: int = 0) -> str | None:
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:
return None
if isinstance(value, str):
text = value.strip()
else:
try:
text = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
text = str(value)
text = text.strip()
if not text:
return None
if len(text) > max_len:
return f"{text[:max_len]}"
return text
def _extract_assistant_message(response: dict[str, Any]) -> str | None:
"""Extract assistant text from modern/legacy OpenAI-compatible responses."""
return _extract_text_recursive(response)
+57
View File
@@ -12,6 +12,7 @@ import aiohttp
from .const import (
API_CHAT_COMPLETIONS,
API_MODELS,
API_TOOLS_INVOKE,
)
_LOGGER = logging.getLogger(__name__)
@@ -387,6 +388,62 @@ class OpenClawApiClient:
f"Cannot connect to OpenClaw gateway: {err}"
) from err
async def async_invoke_tool(
self,
tool: str,
action: str | None = None,
args: dict[str, Any] | None = None,
session_key: str | None = None,
dry_run: bool = False,
message_channel: str | None = None,
account_id: str | None = None,
) -> dict[str, Any]:
"""Invoke a single OpenClaw tool via gateway HTTP endpoint."""
payload: dict[str, Any] = {
"tool": tool,
"args": args or {},
"dryRun": bool(dry_run),
}
if action:
payload["action"] = action
if session_key:
payload["sessionKey"] = session_key
headers = self._headers()
if message_channel:
headers["x-openclaw-message-channel"] = message_channel
if account_id:
headers["x-openclaw-account-id"] = account_id
session = await self._get_session()
url = f"{self._base_url}{API_TOOLS_INVOKE}"
try:
async with session.post(
url,
headers=headers,
json=payload,
timeout=STREAM_TIMEOUT,
) as resp:
if resp.status == 401:
raise OpenClawAuthError("Authentication failed")
if resp.status >= 400:
text = await resp.text()
raise OpenClawApiError(f"Tool invoke error {resp.status}: {text[:300]}")
content_type = resp.content_type or ""
if "json" not in content_type:
text = await resp.text()
raise OpenClawApiError(
f"Unexpected tool response content type '{content_type}': {text[:300]}"
)
return await resp.json()
except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, asyncio.TimeoutError) as err:
raise OpenClawConnectionError(
f"Cannot connect to OpenClaw gateway: {err}"
) from err
async def async_close(self) -> None:
"""Close the HTTP session."""
if self._session and not self._session.closed:
+20
View File
@@ -58,16 +58,24 @@ DATA_GATEWAY_VERSION = "gateway_version"
DATA_UPTIME = "uptime"
DATA_PROVIDER = "provider"
DATA_CONTEXT_WINDOW = "context_window"
DATA_LAST_TOOL_NAME = "last_tool_name"
DATA_LAST_TOOL_STATUS = "last_tool_status"
DATA_LAST_TOOL_DURATION_MS = "last_tool_duration_ms"
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"
# Platforms
PLATFORMS = ["sensor", "binary_sensor", "conversation"]
# Events
EVENT_MESSAGE_RECEIVED = f"{DOMAIN}_message_received"
EVENT_TOOL_INVOKED = f"{DOMAIN}_tool_invoked"
# Services
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_CLEAR_HISTORY = "clear_history"
SERVICE_INVOKE_TOOL = "invoke_tool"
# Attributes
ATTR_MESSAGE = "message"
@@ -75,6 +83,17 @@ ATTR_SESSION_ID = "session_id"
ATTR_ATTACHMENTS = "attachments"
ATTR_MODEL = "model"
ATTR_TIMESTAMP = "timestamp"
ATTR_TOOL = "tool"
ATTR_ACTION = "action"
ATTR_ARGS = "args"
ATTR_SESSION_KEY = "session_key"
ATTR_DRY_RUN = "dry_run"
ATTR_MESSAGE_CHANNEL = "message_channel"
ATTR_ACCOUNT_ID = "account_id"
ATTR_OK = "ok"
ATTR_RESULT = "result"
ATTR_ERROR = "error"
ATTR_DURATION_MS = "duration_ms"
# API endpoints
# The OpenClaw gateway exposes only the OpenAI-compatible endpoints.
@@ -82,3 +101,4 @@ ATTR_TIMESTAMP = "timestamp"
# home page (text/html) for any unrecognised route.
API_MODELS = "/v1/models"
API_CHAT_COMPLETIONS = "/v1/chat/completions"
API_TOOLS_INVOKE = "/tools/invoke"
+66
View File
@@ -19,6 +19,12 @@ from .const import (
DATA_CONNECTED,
DATA_CONTEXT_WINDOW,
DATA_GATEWAY_VERSION,
DATA_LAST_TOOL_DURATION_MS,
DATA_LAST_TOOL_ERROR,
DATA_LAST_TOOL_INVOKED_AT,
DATA_LAST_TOOL_NAME,
DATA_LAST_TOOL_RESULT_PREVIEW,
DATA_LAST_TOOL_STATUS,
DATA_LAST_ACTIVITY,
DATA_MODEL,
DATA_PROVIDER,
@@ -61,6 +67,14 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._last_activity: datetime | None = None
self._model_cache: dict[str, Any] = {}
self._consecutive_failures = 0
self._last_tool_state: dict[str, Any] = {
DATA_LAST_TOOL_NAME: None,
DATA_LAST_TOOL_STATUS: None,
DATA_LAST_TOOL_DURATION_MS: None,
DATA_LAST_TOOL_INVOKED_AT: None,
DATA_LAST_TOOL_ERROR: None,
DATA_LAST_TOOL_RESULT_PREVIEW: None,
}
def _offline_data(self) -> dict[str, Any]:
"""Return a data dict representing the offline state."""
@@ -75,6 +89,12 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
DATA_UPTIME: None,
DATA_PROVIDER: self._model_cache.get(DATA_PROVIDER),
DATA_CONTEXT_WINDOW: self._model_cache.get(DATA_CONTEXT_WINDOW),
DATA_LAST_TOOL_NAME: self._last_tool_state.get(DATA_LAST_TOOL_NAME),
DATA_LAST_TOOL_STATUS: self._last_tool_state.get(DATA_LAST_TOOL_STATUS),
DATA_LAST_TOOL_DURATION_MS: self._last_tool_state.get(DATA_LAST_TOOL_DURATION_MS),
DATA_LAST_TOOL_INVOKED_AT: self._last_tool_state.get(DATA_LAST_TOOL_INVOKED_AT),
DATA_LAST_TOOL_ERROR: self._last_tool_state.get(DATA_LAST_TOOL_ERROR),
DATA_LAST_TOOL_RESULT_PREVIEW: self._last_tool_state.get(DATA_LAST_TOOL_RESULT_PREVIEW),
}
async def _async_update_data(self) -> dict[str, Any]:
@@ -134,7 +154,31 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# /v1/models not implemented — expected, not an error
pass
# ── Best-effort sessions_list via tools invoke ──────────────
try:
tool_resp = await self.client.async_invoke_tool(
tool="sessions_list",
action="json",
args={},
)
result = tool_resp.get("result") if isinstance(tool_resp, dict) else None
sessions: list[dict[str, Any]] = []
if isinstance(result, list):
sessions = [item for item in result if isinstance(item, dict)]
elif isinstance(result, dict):
candidates = result.get("sessions") or result.get("items") or result.get("data")
if isinstance(candidates, list):
sessions = [item for item in candidates if isinstance(item, dict)]
if sessions:
data[DATA_SESSIONS] = sessions
data[DATA_SESSION_COUNT] = len(sessions)
except OpenClawApiError:
# tools endpoint may be policy-limited; not fatal
pass
data.update(self._model_cache)
data.update(self._last_tool_state)
return data
async def _try_refresh_token(self) -> None:
@@ -154,3 +198,25 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
Called when a message is sent/received through the integration.
"""
self._last_activity = datetime.now(timezone.utc)
def record_tool_invocation(
self,
*,
tool_name: str,
ok: bool,
duration_ms: int,
error_message: str | None = None,
result_preview: str | None = None,
) -> None:
"""Store latest tool invocation metadata and update entities immediately."""
self._last_tool_state = {
DATA_LAST_TOOL_NAME: tool_name,
DATA_LAST_TOOL_STATUS: "ok" if ok else "error",
DATA_LAST_TOOL_DURATION_MS: duration_ms,
DATA_LAST_TOOL_INVOKED_AT: datetime.now(timezone.utc),
DATA_LAST_TOOL_ERROR: error_message,
DATA_LAST_TOOL_RESULT_PREVIEW: result_preview,
}
current = dict(self.data or self._offline_data())
current.update(self._last_tool_state)
self.async_set_updated_data(current)
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [],
"version": "0.1.39",
"version": "0.1.40",
"dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"]
}
+39
View File
@@ -17,6 +17,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DATA_GATEWAY_VERSION,
DATA_LAST_TOOL_DURATION_MS,
DATA_LAST_TOOL_ERROR,
DATA_LAST_TOOL_INVOKED_AT,
DATA_LAST_TOOL_NAME,
DATA_LAST_TOOL_RESULT_PREVIEW,
DATA_LAST_TOOL_STATUS,
DATA_LAST_ACTIVITY,
DATA_MODEL,
DATA_PROVIDER,
@@ -55,6 +61,32 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
name="OpenClaw Model",
icon="mdi:brain",
),
SensorEntityDescription(
key=DATA_LAST_TOOL_NAME,
translation_key="last_tool_name",
name="OpenClaw Last Tool",
icon="mdi:tools",
),
SensorEntityDescription(
key=DATA_LAST_TOOL_STATUS,
translation_key="last_tool_status",
name="OpenClaw Last Tool Status",
icon="mdi:check-decagram",
),
SensorEntityDescription(
key=DATA_LAST_TOOL_DURATION_MS,
translation_key="last_tool_duration_ms",
name="OpenClaw Last Tool Duration",
icon="mdi:speedometer",
native_unit_of_measurement="ms",
),
SensorEntityDescription(
key=DATA_LAST_TOOL_INVOKED_AT,
translation_key="last_tool_invoked_at",
name="OpenClaw Last Tool Invoked",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:clock-outline",
),
)
@@ -135,4 +167,11 @@ class OpenClawSensor(CoordinatorEntity[OpenClawCoordinator], SensorEntity):
"last_message_preview": None, # TODO: populate from last message
}
if key in {DATA_LAST_TOOL_NAME, DATA_LAST_TOOL_STATUS, DATA_LAST_TOOL_DURATION_MS}:
return {
"error": data.get(DATA_LAST_TOOL_ERROR),
"result_preview": data.get(DATA_LAST_TOOL_RESULT_PREVIEW),
"invoked_at": data.get(DATA_LAST_TOOL_INVOKED_AT),
}
return None
+50
View File
@@ -35,3 +35,53 @@ clear_history:
required: false
selector:
text:
invoke_tool:
name: Invoke Tool
description: Invoke a single OpenClaw Gateway tool via /tools/invoke.
fields:
tool:
name: Tool
description: Tool name to invoke.
required: true
example: "sessions_list"
selector:
text:
action:
name: Action
description: Optional action string passed to the tool endpoint.
required: false
example: "json"
selector:
text:
args:
name: Args
description: Tool-specific args object.
required: false
selector:
object:
session_key:
name: Session Key
description: Optional target OpenClaw session key.
required: false
example: "main"
selector:
text:
dry_run:
name: Dry Run
description: Optional dry-run flag (reserved by gateway).
required: false
selector:
boolean:
message_channel:
name: Message Channel
description: Optional channel hint header (e.g. slack, telegram).
required: false
selector:
text:
account_id:
name: Account ID
description: Optional account hint header for multi-account routing.
required: false
selector:
text:
+46
View File
@@ -63,6 +63,18 @@
},
"model": {
"name": "Model"
},
"last_tool_name": {
"name": "Last Tool"
},
"last_tool_status": {
"name": "Last Tool Status"
},
"last_tool_duration_ms": {
"name": "Last Tool Duration"
},
"last_tool_invoked_at": {
"name": "Last Tool Invoked"
}
},
"binary_sensor": {
@@ -99,6 +111,40 @@
"description": "Session to clear."
}
}
},
"invoke_tool": {
"name": "Invoke Tool",
"description": "Invoke a single OpenClaw gateway tool.",
"fields": {
"tool": {
"name": "Tool",
"description": "Tool name to invoke."
},
"action": {
"name": "Action",
"description": "Optional action string for the tool."
},
"args": {
"name": "Args",
"description": "Tool arguments object."
},
"session_key": {
"name": "Session Key",
"description": "Optional target session key."
},
"dry_run": {
"name": "Dry Run",
"description": "Optional dry-run flag."
},
"message_channel": {
"name": "Message Channel",
"description": "Optional channel context header."
},
"account_id": {
"name": "Account ID",
"description": "Optional account context header."
}
}
}
}
}
@@ -63,6 +63,18 @@
},
"model": {
"name": "Model"
},
"last_tool_name": {
"name": "Last Tool"
},
"last_tool_status": {
"name": "Last Tool Status"
},
"last_tool_duration_ms": {
"name": "Last Tool Duration"
},
"last_tool_invoked_at": {
"name": "Last Tool Invoked"
}
},
"binary_sensor": {
@@ -99,6 +111,40 @@
"description": "Session to clear."
}
}
},
"invoke_tool": {
"name": "Invoke Tool",
"description": "Invoke a single OpenClaw gateway tool.",
"fields": {
"tool": {
"name": "Tool",
"description": "Tool name to invoke."
},
"action": {
"name": "Action",
"description": "Optional action string for the tool."
},
"args": {
"name": "Args",
"description": "Tool arguments object."
},
"session_key": {
"name": "Session Key",
"description": "Optional target session key."
},
"dry_run": {
"name": "Dry Run",
"description": "Optional dry-run flag."
},
"message_channel": {
"name": "Message Channel",
"description": "Optional channel context header."
},
"account_id": {
"name": "Account ID",
"description": "Optional account context header."
}
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
(async () => {
try {
if (!customElements.get("openclaw-chat-card")) {
const src = "/openclaw/openclaw-chat-card.js?v=0.1.39";
const src = "/openclaw/openclaw-chat-card.js?v=0.1.40";
console.info("OpenClaw loader importing", src);
await import(src);
}