diff --git a/CHANGELOG.md b/CHANGELOG.md index 0453f2e..65d2fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 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 ### Added diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index da0d4bc..777cf9e 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -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) _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.55" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.56" OpenClawConfigEntry = ConfigEntry diff --git a/custom_components/openclaw/button.py b/custom_components/openclaw/button.py new file mode 100644 index 0000000..d9754e8 --- /dev/null +++ b/custom_components/openclaw/button.py @@ -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() diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index 15cb33a..909174c 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -106,7 +106,7 @@ DATA_LAST_TOOL_ERROR = "last_tool_error" DATA_LAST_TOOL_RESULT_PREVIEW = "last_tool_result_preview" # Platforms -PLATFORMS = ["sensor", "binary_sensor", "conversation"] +PLATFORMS = ["sensor", "binary_sensor", "conversation", "event", "button", "select"] # Events EVENT_MESSAGE_RECEIVED = f"{DOMAIN}_message_received" diff --git a/custom_components/openclaw/coordinator.py b/custom_components/openclaw/coordinator.py index c12afbe..344235e 100644 --- a/custom_components/openclaw/coordinator.py +++ b/custom_components/openclaw/coordinator.py @@ -66,6 +66,7 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.client = client self._last_activity: datetime | None = None self._model_cache: dict[str, Any] = {} + self._available_models: list[str] = [] self._consecutive_failures = 0 self._last_tool_state: dict[str, Any] = { DATA_LAST_TOOL_NAME: None, @@ -147,6 +148,9 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]): DATA_PROVIDER: current.get("owned_by"), 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: _LOGGER.warning("Gateway auth failed during poll: %s", err) await self._try_refresh_token() @@ -199,6 +203,11 @@ class OpenClawCoordinator(DataUpdateCoordinator[dict[str, Any]]): """ 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( self, *, diff --git a/custom_components/openclaw/event.py b/custom_components/openclaw/event.py new file mode 100644 index 0000000..5c85a40 --- /dev/null +++ b/custom_components/openclaw/event.py @@ -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 diff --git a/custom_components/openclaw/icon.png b/custom_components/openclaw/icon.png index 4e37bbf..1d95d95 100644 Binary files a/custom_components/openclaw/icon.png and b/custom_components/openclaw/icon.png differ diff --git a/custom_components/openclaw/logo.png b/custom_components/openclaw/logo.png index 4e37bbf..1d95d95 100644 Binary files a/custom_components/openclaw/logo.png and b/custom_components/openclaw/logo.png differ diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index cbdff64..dcae5c3 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "requirements": [], - "version": "0.1.55", + "version": "0.1.56", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/select.py b/custom_components/openclaw/select.py new file mode 100644 index 0000000..b330a92 --- /dev/null +++ b/custom_components/openclaw/select.py @@ -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() diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 582ccb7..745dc5f 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -82,6 +82,30 @@ "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": { diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index 9ada55a..c046e00 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -84,6 +84,30 @@ "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": {