Add event and button entities, select entity for model switching, and update changelog to version 0.1.56

This commit is contained in:
techartdev
2026-02-25 15:24:23 +02:00
parent 11959e3ce7
commit 0f5357e7c7
12 changed files with 406 additions and 3 deletions
+11
View File
@@ -2,6 +2,17 @@
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
## [0.1.56] - 2026-02-25
### Added
- **Event entities** (`event.openclaw_message_received`, `event.openclaw_tool_invoked`) — native HA EventEntity entities that fire on each assistant reply and tool invocation result. Selectable in the automation UI without YAML.
- **Button entities** — dashboard-friendly buttons for common actions:
- **Clear History** — clears in-memory conversation history
- **Sync History** — triggers a backend coordinator refresh
- **Run Diagnostics** — fires a connectivity check against the gateway
- **Select entity** (`select.openclaw_active_model`) — exposes the list of available models from the gateway's `/v1/models` endpoint, allowing model switching from the HA dashboard. Selection is persisted in config entry options.
- Coordinator now caches the full model list (not just the first model) and exposes it via `coordinator.available_models`.
## [0.1.55] - 2026-02-23 ## [0.1.55] - 2026-02-23
### Added ### Added
+1 -1
View File
@@ -95,7 +95,7 @@ _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
# URL at which the card JS is served (registered via register_static_path) # URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache # Versioned URL used for Lovelace resource registration to avoid stale browser cache
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.55" _CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.56"
OpenClawConfigEntry = ConfigEntry OpenClawConfigEntry = ConfigEntry
+115
View File
@@ -0,0 +1,115 @@
"""Button entities for the OpenClaw integration.
Provides dashboard-friendly buttons for common actions:
- Clear History — clears in-memory conversation history
- Sync History — triggers backend history re-sync
- Run Diagnostics — fires a connectivity check against the gateway
"""
from __future__ import annotations
import logging
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api import OpenClawApiClient
from .const import DOMAIN
from .coordinator import OpenClawCoordinator
_LOGGER = logging.getLogger(__name__)
BUTTON_DESCRIPTIONS: tuple[ButtonEntityDescription, ...] = (
ButtonEntityDescription(
key="clear_history",
translation_key="clear_history",
name="OpenClaw Clear History",
icon="mdi:delete-sweep",
),
ButtonEntityDescription(
key="sync_history",
translation_key="sync_history",
name="OpenClaw Sync History",
icon="mdi:sync",
),
ButtonEntityDescription(
key="run_diagnostics",
translation_key="run_diagnostics",
name="OpenClaw Run Diagnostics",
icon="mdi:stethoscope",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up OpenClaw button entities from a config entry."""
entry_data: dict = hass.data[DOMAIN][entry.entry_id]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
client: OpenClawApiClient = entry_data["client"]
entities = [
OpenClawButton(coordinator, client, description, entry, hass)
for description in BUTTON_DESCRIPTIONS
]
async_add_entities(entities)
class OpenClawButton(CoordinatorEntity[OpenClawCoordinator], ButtonEntity):
"""Button entity for OpenClaw actions."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: OpenClawCoordinator,
client: OpenClawApiClient,
description: ButtonEntityDescription,
entry: ConfigEntry,
hass: HomeAssistant,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._client = client
self._entry = entry
self._hass = hass
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": "OpenClaw Assistant",
"manufacturer": "OpenClaw",
"model": "OpenClaw Gateway",
}
async def async_press(self) -> None:
"""Handle button press."""
key = self.entity_description.key
if key == "clear_history":
store_key = f"{DOMAIN}_chat_history"
store = self._hass.data.get(store_key)
if isinstance(store, dict):
store.clear()
_LOGGER.info("OpenClaw chat history cleared via button")
elif key == "sync_history":
await self.coordinator.async_request_refresh()
_LOGGER.info("OpenClaw history sync triggered via button")
elif key == "run_diagnostics":
try:
alive = await self._client.async_check_alive()
if alive:
_LOGGER.info("OpenClaw diagnostics: gateway is reachable")
else:
_LOGGER.warning("OpenClaw diagnostics: gateway did not respond")
except Exception as err: # noqa: BLE001
_LOGGER.error("OpenClaw diagnostics failed: %s", err)
await self.coordinator.async_request_refresh()
+1 -1
View File
@@ -106,7 +106,7 @@ DATA_LAST_TOOL_ERROR = "last_tool_error"
DATA_LAST_TOOL_RESULT_PREVIEW = "last_tool_result_preview" DATA_LAST_TOOL_RESULT_PREVIEW = "last_tool_result_preview"
# Platforms # Platforms
PLATFORMS = ["sensor", "binary_sensor", "conversation"] PLATFORMS = ["sensor", "binary_sensor", "conversation", "event", "button", "select"]
# Events # Events
EVENT_MESSAGE_RECEIVED = f"{DOMAIN}_message_received" EVENT_MESSAGE_RECEIVED = f"{DOMAIN}_message_received"
@@ -66,6 +66,7 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.client = client self.client = client
self._last_activity: datetime | None = None self._last_activity: datetime | None = None
self._model_cache: dict[str, Any] = {} self._model_cache: dict[str, Any] = {}
self._available_models: list[str] = []
self._consecutive_failures = 0 self._consecutive_failures = 0
self._last_tool_state: dict[str, Any] = { self._last_tool_state: dict[str, Any] = {
DATA_LAST_TOOL_NAME: None, DATA_LAST_TOOL_NAME: None,
@@ -147,6 +148,9 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
DATA_PROVIDER: current.get("owned_by"), DATA_PROVIDER: current.get("owned_by"),
DATA_CONTEXT_WINDOW: current.get("context_window"), DATA_CONTEXT_WINDOW: current.get("context_window"),
} }
self._available_models = [
m.get("id") for m in models if m.get("id")
]
except OpenClawAuthError as err: except OpenClawAuthError as err:
_LOGGER.warning("Gateway auth failed during poll: %s", err) _LOGGER.warning("Gateway auth failed during poll: %s", err)
await self._try_refresh_token() await self._try_refresh_token()
@@ -199,6 +203,11 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]):
""" """
self._last_activity = datetime.now(timezone.utc) self._last_activity = datetime.now(timezone.utc)
@property
def available_models(self) -> list[str]:
"""Return the list of model IDs from the last successful poll."""
return list(self._available_models)
def record_tool_invocation( def record_tool_invocation(
self, self,
*, *,
+112
View File
@@ -0,0 +1,112 @@
"""Event entities for the OpenClaw integration.
Provides native HA EventEntity entities for:
- openclaw_message_received — fires on each assistant reply
- openclaw_tool_invoked — fires on each tool invocation result
These complement the raw HA bus events with proper entity-registry entries
that are selectable in the automation UI (no YAML needed).
"""
from __future__ import annotations
from typing import Any
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
EVENT_MESSAGE_RECEIVED,
EVENT_TOOL_INVOKED,
)
EVENT_DESCRIPTIONS: tuple[EventEntityDescription, ...] = (
EventEntityDescription(
key="message_received",
translation_key="message_received",
name="OpenClaw Message Received",
icon="mdi:message-text",
event_types=["message_received"],
),
EventEntityDescription(
key="tool_invoked",
translation_key="tool_invoked",
name="OpenClaw Tool Invoked",
icon="mdi:tools",
event_types=["tool_invoked_ok", "tool_invoked_error"],
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up OpenClaw event entities from a config entry."""
entities = [
OpenClawEventEntity(entry, description)
for description in EVENT_DESCRIPTIONS
]
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."""
_attr_has_entity_name = True
def __init__(
self,
entry: ConfigEntry,
description: EventEntityDescription,
) -> None:
"""Initialize the event entity."""
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": "OpenClaw Assistant",
"manufacturer": "OpenClaw",
"model": "OpenClaw Gateway",
}
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."""
key = self.entity_description.key
if key == "message_received":
bus_event = EVENT_MESSAGE_RECEIVED
elif key == "tool_invoked":
bus_event = EVENT_TOOL_INVOKED
else:
return
@callback
def _handle_event(event) -> None:
data: dict[str, Any] = dict(event.data or {})
if key == "message_received":
self._trigger_event("message_received", data)
elif key == "tool_invoked":
ok = data.get("ok", False)
event_type = "tool_invoked_ok" if ok else "tool_invoked_error"
self._trigger_event(event_type, data)
self.async_write_ha_state()
self._unsub = hass.bus.async_listen(bus_event, _handle_event)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe when entity is removed."""
if self._unsub:
self._unsub()
self._unsub = None
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 62 KiB

+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [], "requirements": [],
"version": "0.1.55", "version": "0.1.56",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
+108
View File
@@ -0,0 +1,108 @@
"""Select entity for the OpenClaw integration.
Provides a Select entity that exposes the list of available models
from the gateway's /v1/models endpoint, allowing the user to switch
the active model from the HA dashboard.
"""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DATA_MODEL, DOMAIN
from .coordinator import OpenClawCoordinator
_LOGGER = logging.getLogger(__name__)
SELECT_DESCRIPTIONS: tuple[SelectEntityDescription, ...] = (
SelectEntityDescription(
key="active_model",
translation_key="active_model",
name="OpenClaw Active Model",
icon="mdi:brain",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up OpenClaw select entities from a config entry."""
entry_data: dict = hass.data[DOMAIN][entry.entry_id]
coordinator: OpenClawCoordinator = entry_data["coordinator"]
entities = [
OpenClawModelSelect(coordinator, description, entry)
for description in SELECT_DESCRIPTIONS
]
async_add_entities(entities)
class OpenClawModelSelect(CoordinatorEntity[OpenClawCoordinator], SelectEntity):
"""Select entity for switching the active OpenClaw model."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: OpenClawCoordinator,
description: SelectEntityDescription,
entry: ConfigEntry,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self.entity_description = description
self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": "OpenClaw Assistant",
"manufacturer": "OpenClaw",
"model": "OpenClaw Gateway",
}
# Initialise from coordinator cache
models = coordinator.available_models
self._attr_options = models if models else ["unknown"]
current = (coordinator.data or {}).get(DATA_MODEL)
self._attr_current_option = current if current in self._attr_options else (
self._attr_options[0] if self._attr_options else None
)
@callback
def _handle_coordinator_update(self) -> None:
"""Update options and current selection when coordinator refreshes."""
models = self.coordinator.available_models
if models:
self._attr_options = models
current = (self.coordinator.data or {}).get(DATA_MODEL)
if current and current in self._attr_options:
self._attr_current_option = current
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Handle the user selecting a new model.
Persists the selection in the config entry's options so that the
conversation agent and card can read it via ``get_settings``.
Then triggers a coordinator refresh.
"""
_LOGGER.info("OpenClaw active model changed to: %s", option)
self._attr_current_option = option
# Store in config entry options so other components can read it
new_options = dict(self._entry.options)
new_options["active_model"] = option
self.hass.config_entries.async_update_entry(
self._entry, options=new_options
)
self.async_write_ha_state()
+24
View File
@@ -82,6 +82,30 @@
"connected": { "connected": {
"name": "Connected" "name": "Connected"
} }
},
"event": {
"message_received": {
"name": "Message Received"
},
"tool_invoked": {
"name": "Tool Invoked"
}
},
"button": {
"clear_history": {
"name": "Clear History"
},
"sync_history": {
"name": "Sync History"
},
"run_diagnostics": {
"name": "Run Diagnostics"
}
},
"select": {
"active_model": {
"name": "Active Model"
}
} }
}, },
"services": { "services": {
@@ -84,6 +84,30 @@
"connected": { "connected": {
"name": "Connected" "name": "Connected"
} }
},
"event": {
"message_received": {
"name": "Message Received"
},
"tool_invoked": {
"name": "Tool Invoked"
}
},
"button": {
"clear_history": {
"name": "Clear History"
},
"sync_history": {
"name": "Sync History"
},
"run_diagnostics": {
"name": "Run Diagnostics"
}
},
"select": {
"active_model": {
"name": "Active Model"
}
} }
}, },
"services": { "services": {