diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bdf6b..4427c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.13] - 2026-02-20 + +### Added +- Added native Home Assistant Assist entity exposure support to OpenClaw requests. +- OpenClaw now includes context for entities exposed in **Settings → Voice assistants → Expose**. + +### Changed +- Service chat (`openclaw.send_message`) and conversation agent (streaming + fallback) now pass exposed-entities context as a system prompt. + ## [0.1.12] - 2026-02-20 ### Fixed diff --git a/README.md b/README.md index 6fa540f..0754143 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ A native Home Assistant integration for communicating with the The **OpenClaw Assistant** addon must be installed and running. The integration will auto-detect it — no manual configuration needed. +## Exposed entities (important) + +OpenClaw uses Home Assistant's native **Assist entity exposure** controls. +Only entities exposed to Assist are included in the context sent to OpenClaw. + +Configure this in Home Assistant: + +1. Go to **Settings → Voice assistants** +2. Open the **Expose** tab +3. Select which entities are exposed to **Assist** + ## Chat Card The chat card is registered automatically when the integration loads — no diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 933a22f..b8e061b 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -44,6 +44,7 @@ from .const import ( SERVICE_SEND_MESSAGE, ) from .coordinator import OpenClawCoordinator +from .exposure import build_exposed_entities_context _LOGGER = logging.getLogger(__name__) @@ -313,9 +314,11 @@ def _async_register_services(hass: HomeAssistant) -> None: coordinator: OpenClawCoordinator = entry_data["coordinator"] try: + system_prompt = build_exposed_entities_context(hass, assistant="assist") response = await client.async_send_message( message=message, session_id=session_id, + system_prompt=system_prompt, ) assistant_message = _extract_assistant_message(response) diff --git a/custom_components/openclaw/api.py b/custom_components/openclaw/api.py index b629128..b4777ca 100644 --- a/custom_components/openclaw/api.py +++ b/custom_components/openclaw/api.py @@ -165,6 +165,7 @@ class OpenClawApiClient: message: str, session_id: str | None = None, model: str | None = None, + system_prompt: str | None = None, stream: bool = False, ) -> dict[str, Any]: """Send a chat message (non-streaming). @@ -181,8 +182,13 @@ class OpenClawApiClient: if stream: raise ValueError("Use async_stream_message() for streaming") + messages: list[dict[str, str]] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": message}) + payload: dict[str, Any] = { - "messages": [{"role": "user", "content": message}], + "messages": messages, "stream": False, } if model: @@ -220,6 +226,7 @@ class OpenClawApiClient: message: str, session_id: str | None = None, model: str | None = None, + system_prompt: str | None = None, ) -> AsyncIterator[str]: """Send a chat message and stream the response via SSE. @@ -233,8 +240,13 @@ class OpenClawApiClient: Yields: Content delta strings from the streaming response. """ + messages: list[dict[str, str]] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": message}) + payload: dict[str, Any] = { - "messages": [{"role": "user", "content": message}], + "messages": messages, "stream": True, } if model: diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 0cbdfcc..b694df3 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -26,6 +26,7 @@ from .const import ( EVENT_MESSAGE_RECEIVED, ) from .coordinator import OpenClawCoordinator +from .exposure import build_exposed_entities_context _LOGGER = logging.getLogger(__name__) @@ -100,9 +101,23 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): message = user_input.text conversation_id = user_input.conversation_id or "default" + assistant_id = getattr(user_input, "assistant", None) or "assist" + exposed_context = build_exposed_entities_context( + self.hass, + assistant=assistant_id, + ) + extra_system_prompt = getattr(user_input, "extra_system_prompt", None) + system_prompt = "\n\n".join( + part for part in (exposed_context, extra_system_prompt) if part + ) or None try: - full_response = await self._get_response(client, message, conversation_id) + full_response = await self._get_response( + client, + message, + conversation_id, + system_prompt, + ) except OpenClawApiError as err: _LOGGER.error("OpenClaw conversation error: %s", err) @@ -113,7 +128,10 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): if refreshed: try: full_response = await self._get_response( - client, message, conversation_id + client, + message, + conversation_id, + system_prompt, ) except OpenClawApiError as retry_err: return self._error_result( @@ -156,6 +174,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): client: OpenClawApiClient, message: str, conversation_id: str, + system_prompt: str | None = None, ) -> str: """Get a response from OpenClaw, trying streaming first.""" # Try streaming (lower TTFB for voice pipeline) @@ -163,6 +182,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): async for chunk in client.async_stream_message( message=message, session_id=conversation_id, + system_prompt=system_prompt, ): full_response += chunk @@ -173,6 +193,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): response = await client.async_send_message( message=message, session_id=conversation_id, + system_prompt=system_prompt, ) extracted = self._extract_text_recursive(response) return extracted or "" diff --git a/custom_components/openclaw/exposure.py b/custom_components/openclaw/exposure.py new file mode 100644 index 0000000..c3b2bcb --- /dev/null +++ b/custom_components/openclaw/exposure.py @@ -0,0 +1,57 @@ +"""Helpers for Assist entity exposure context.""" + +from __future__ import annotations + +from collections import Counter + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.core import HomeAssistant + + +def build_exposed_entities_context( + hass: HomeAssistant, + assistant: str | None = "assist", + max_entities: int = 250, +) -> str | None: + """Build a compact prompt block of entities exposed to an assistant. + + Uses Home Assistant's built-in expose rules (Settings -> Voice assistants -> Expose). + """ + assistant_id = assistant or "assist" + + exposed_states = [ + state + for state in hass.states.async_all() + if async_should_expose(hass, assistant_id, state.entity_id) + ] + + if not exposed_states: + return None + + exposed_states.sort(key=lambda state: state.entity_id) + domain_counts = Counter(state.domain for state in exposed_states) + + lines: list[str] = [ + "Home Assistant live context (entities exposed to this assistant):", + f"- total_exposed_entities: {len(exposed_states)}", + "- domain_counts:", + ] + lines.extend(f" - {domain}: {count}" for domain, count in sorted(domain_counts.items())) + lines.append("- entities:") + + for state in exposed_states[:max_entities]: + friendly_name = state.name or state.entity_id + lines.append( + f" - id: {state.entity_id}; name: {friendly_name}; state: {state.state}" + ) + + if len(exposed_states) > max_entities: + lines.append( + f" - ... {len(exposed_states) - max_entities} additional exposed entities omitted" + ) + + lines.append( + "Use only these exposed entities when answering or controlling Home Assistant." + ) + + return "\n".join(lines) diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 73e1d86..223a001 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.12", + "version": "0.1.13", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] }