Update changelog for version 0.1.16, add configurable wake-word support, optional always-on voice mode, and websocket settings endpoint; enhance chat card voice recognition and context handling.

This commit is contained in:
techartdev
2026-02-20 17:21:14 +02:00
parent c0f9e5c42e
commit 0445c595ef
12 changed files with 691 additions and 24 deletions
+35
View File
@@ -2,6 +2,41 @@
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.16] - 2026-02-20
### Added
- Added configurable wake-word support in integration options.
- Added optional always-on voice mode in integration options.
- Added websocket settings endpoint (`openclaw/get_settings`) used by the chat card to apply integration-level voice settings.
### Changed
- Chat card voice recognition now supports two modes:
- Manual voice input without wake word
- Continuous listening with required wake word
## [0.1.15] - 2026-02-20
### Added
- Added integration options for prompt/context behavior:
- Include exposed-entities context
- Max context characters
- Context overflow strategy (`truncate` or `clear`)
- Enable tool calls (`execute_service` / `execute_services`)
### Changed
- Service and conversation requests now apply configurable context policy before sending prompts.
- Optional tool-call execution now mirrors Extended OpenAI-style service tool usage and feeds execution results back into a follow-up model response.
## [0.1.14] - 2026-02-20
### Fixed
- Fixed Assist exposure context lookup to use Home Assistant's conversation assistant identifier (`conversation`) instead of `assist`, which could result in empty exposed-entity context.
- Added backend in-memory chat history and websocket endpoint (`openclaw/get_history`) so card responses are recoverable after leaving and returning to the dashboard.
- Normalized default session handling to `default` for service calls/events/history, avoiding session mismatch drops.
### Changed
- Chat card now syncs history from backend on mount/initialization to restore missed assistant messages.
## [0.1.13] - 2026-02-20 ## [0.1.13] - 2026-02-20
### Added ### Added
+12
View File
@@ -44,6 +44,18 @@ Configure this in Home Assistant:
2. Open the **Expose** tab 2. Open the **Expose** tab
3. Select which entities are exposed to **Assist** 3. Select which entities are exposed to **Assist**
## Advanced options
Open **Settings → Devices & Services → OpenClaw → Configure** to tune:
- Include exposed entities context
- Max context characters
- Context overflow strategy (`truncate` or `clear`)
- Enable tool calls (execute HA services from model tool-call responses)
- Require wake word
- Wake word text (for example: `hey openclaw`)
- Always-on voice mode (continuous listening while the card is open)
## Chat Card ## Chat Card
The chat card is registered automatically when the integration loads — no The chat card is registered automatically when the integration loads — no
+232 -4
View File
@@ -6,12 +6,14 @@ Sets up the OpenClaw integration: API client, coordinator, platforms, and servic
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components import websocket_api
try: try:
from homeassistant.components.lovelace.const import LOVELACE_DATA from homeassistant.components.lovelace.const import LOVELACE_DATA
@@ -36,6 +38,21 @@ from .const import (
CONF_GATEWAY_PORT, CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN, CONF_GATEWAY_TOKEN,
CONF_USE_SSL, CONF_USE_SSL,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE,
CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_ENABLE_TOOL_CALLS,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE,
DOMAIN, DOMAIN,
EVENT_MESSAGE_RECEIVED, EVENT_MESSAGE_RECEIVED,
OPENCLAW_CONFIG_REL_PATH, OPENCLAW_CONFIG_REL_PATH,
@@ -44,10 +61,12 @@ from .const import (
SERVICE_SEND_MESSAGE, SERVICE_SEND_MESSAGE,
) )
from .coordinator import OpenClawCoordinator from .coordinator import OpenClawCoordinator
from .exposure import build_exposed_entities_context from .exposure import apply_context_policy, build_exposed_entities_context
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_MAX_CHAT_HISTORY = 200
# Path to the chat card JS inside the integration package (custom_components/openclaw/www/) # Path to the chat card JS inside the integration package (custom_components/openclaw/www/)
_CARD_FILENAME = "openclaw-chat-card.js" _CARD_FILENAME = "openclaw-chat-card.js"
_CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
@@ -98,6 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) ->
"client": client, "client": client,
"coordinator": coordinator, "coordinator": coordinator,
"addon_config_path": addon_config_path, "addon_config_path": addon_config_path,
"entry": entry,
} }
# First data fetch — if it fails the coordinator marks entities unavailable # First data fetch — if it fails the coordinator marks entities unavailable
@@ -107,6 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenClawConfigEntry) ->
# Register services (once, idempotent) # Register services (once, idempotent)
_async_register_services(hass) _async_register_services(hass)
_async_register_websocket_api(hass)
# Register the frontend card resource # Register the frontend card resource
hass.async_create_task(_async_register_frontend(hass)) hass.async_create_task(_async_register_frontend(hass))
@@ -303,7 +324,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
async def handle_send_message(call: ServiceCall) -> None: async def handle_send_message(call: ServiceCall) -> None:
"""Handle the openclaw.send_message service call.""" """Handle the openclaw.send_message service call."""
message: str = call.data[ATTR_MESSAGE] message: str = call.data[ATTR_MESSAGE]
session_id: str | None = call.data.get(ATTR_SESSION_ID) session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
entry_data = _get_first_entry_data(hass) entry_data = _get_first_entry_data(hass)
if not entry_data: if not entry_data:
@@ -312,15 +333,48 @@ def _async_register_services(hass: HomeAssistant) -> None:
client: OpenClawApiClient = entry_data["client"] client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"] coordinator: OpenClawCoordinator = entry_data["coordinator"]
entry: ConfigEntry = entry_data["entry"]
options = entry.options
try: try:
system_prompt = build_exposed_entities_context(hass, assistant="assist") 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)
_append_chat_history(hass, session_id, "user", message)
response = await client.async_send_message( response = await client.async_send_message(
message=message, message=message,
session_id=session_id, session_id=session_id,
system_prompt=system_prompt, system_prompt=system_prompt,
) )
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=session_id,
system_prompt=system_prompt,
)
assistant_message = _extract_assistant_message(response) assistant_message = _extract_assistant_message(response)
model_used = response.get("model", "unknown") model_used = response.get("model", "unknown")
@@ -331,6 +385,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
list(response.keys()), list(response.keys()),
) )
_append_chat_history(hass, session_id, "assistant", assistant_message)
hass.bus.async_fire( hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED, EVENT_MESSAGE_RECEIVED,
{ {
@@ -344,6 +399,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
except OpenClawApiError as err: except OpenClawApiError as err:
_LOGGER.error("Failed to send message to OpenClaw: %s", err) _LOGGER.error("Failed to send message to OpenClaw: %s", err)
_append_chat_history(hass, session_id, "assistant", f"OpenClaw error: {err}")
hass.bus.async_fire( hass.bus.async_fire(
EVENT_MESSAGE_RECEIVED, EVENT_MESSAGE_RECEIVED,
{ {
@@ -358,7 +414,11 @@ def _async_register_services(hass: HomeAssistant) -> None:
"""Handle the openclaw.clear_history service call.""" """Handle the openclaw.clear_history service call."""
session_id: str | None = call.data.get(ATTR_SESSION_ID) session_id: str | None = call.data.get(ATTR_SESSION_ID)
_LOGGER.info("Clear history requested (session=%s)", session_id or "all") _LOGGER.info("Clear history requested (session=%s)", session_id or "all")
# TODO: Implement when gateway exposes a session-clear endpoint store = _get_chat_history_store(hass)
if session_id:
store.pop(session_id, None)
else:
store.clear()
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
@@ -433,3 +493,171 @@ def _extract_text_recursive(value: Any, depth: int = 0) -> str | None:
def _extract_assistant_message(response: dict[str, Any]) -> str | None: def _extract_assistant_message(response: dict[str, Any]) -> str | None:
"""Extract assistant text from modern/legacy OpenAI-compatible responses.""" """Extract assistant text from modern/legacy OpenAI-compatible responses."""
return _extract_text_recursive(response) return _extract_text_recursive(response)
def _extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract OpenAI-style tool calls from a response payload."""
choices = response.get("choices")
if not isinstance(choices, list) or not choices:
return []
message = choices[0].get("message") if isinstance(choices[0], dict) else None
if not isinstance(message, dict):
return []
tool_calls = message.get("tool_calls")
if not isinstance(tool_calls, list):
return []
valid_calls: list[dict[str, Any]] = []
for call in tool_calls:
if isinstance(call, dict):
valid_calls.append(call)
return valid_calls
async def _async_execute_tool_calls(
hass: HomeAssistant,
response: dict[str, Any],
) -> list[str]:
"""Execute supported tool calls and return result lines."""
results: list[str] = []
tool_calls = _extract_tool_calls(response)
for call in tool_calls:
function_data = call.get("function")
if not isinstance(function_data, dict):
continue
function_name = function_data.get("name")
arguments = function_data.get("arguments")
if function_name not in {"execute_service", "execute_services"}:
results.append(f"Skipped unsupported tool '{function_name}'")
continue
if not isinstance(arguments, str):
results.append("Skipped tool call with invalid arguments format")
continue
try:
parsed = json.loads(arguments)
except json.JSONDecodeError:
results.append("Skipped tool call due to invalid JSON arguments")
continue
services_list = parsed.get("list") if isinstance(parsed, dict) else None
if not isinstance(services_list, list):
results.append("Skipped tool call without 'list' payload")
continue
for item in services_list:
if not isinstance(item, dict):
continue
domain = item.get("domain")
service = item.get("service")
service_data = item.get("service_data", {})
if not isinstance(domain, str) or not isinstance(service, str):
results.append("Skipped invalid service item (missing domain/service)")
continue
if not isinstance(service_data, dict):
service_data = {}
try:
await hass.services.async_call(
domain,
service,
service_data,
blocking=True,
)
results.append(f"Executed {domain}.{service}")
except Exception as err: # noqa: BLE001
results.append(f"Failed {domain}.{service}: {err}")
return results
def _get_chat_history_store(hass: HomeAssistant) -> dict[str, list[dict[str, str]]]:
"""Return in-memory per-session chat history store."""
store_key = f"{DOMAIN}_chat_history"
store = hass.data.get(store_key)
if store is None:
store = {}
hass.data[store_key] = store
return store
def _append_chat_history(hass: HomeAssistant, session_id: str, role: str, content: str) -> None:
"""Append a message to in-memory chat history."""
store = _get_chat_history_store(hass)
history = store.setdefault(session_id, [])
history.append(
{
"role": role,
"content": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
if len(history) > _MAX_CHAT_HISTORY:
del history[:-_MAX_CHAT_HISTORY]
@callback
def _async_register_websocket_api(hass: HomeAssistant) -> None:
"""Register websocket API for chat history retrieval."""
key = f"{DOMAIN}_ws_registered"
if hass.data.get(key):
return
hass.data[key] = True
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_history",
vol.Optional("session_id"): cv.string,
}
)
@callback
def websocket_get_history(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return chat history for a session."""
session_id = msg.get("session_id") or "default"
history = _get_chat_history_store(hass).get(session_id, [])
connection.send_result(msg["id"], {"session_id": session_id, "messages": history})
websocket_api.async_register_command(hass, websocket_get_history)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/get_settings",
}
)
@callback
def websocket_get_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return frontend-related integration settings."""
entry_data = _get_first_entry_data(hass)
options = entry_data.get("entry").options if entry_data and entry_data.get("entry") else {}
connection.send_result(
msg["id"],
{
CONF_WAKE_WORD_ENABLED: options.get(
CONF_WAKE_WORD_ENABLED,
DEFAULT_WAKE_WORD_ENABLED,
),
CONF_WAKE_WORD: options.get(CONF_WAKE_WORD, DEFAULT_WAKE_WORD),
CONF_ALWAYS_VOICE_MODE: options.get(
CONF_ALWAYS_VOICE_MODE,
DEFAULT_ALWAYS_VOICE_MODE,
),
},
)
websocket_api.async_register_command(hass, websocket_get_settings)
+95 -1
View File
@@ -14,7 +14,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -28,8 +28,24 @@ from .const import (
CONF_GATEWAY_PORT, CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN, CONF_GATEWAY_TOKEN,
CONF_USE_SSL, CONF_USE_SSL,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE,
CONTEXT_STRATEGY_CLEAR,
CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_HOST,
DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_PORT,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_ENABLE_TOOL_CALLS,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_WAKE_WORD,
DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE,
DOMAIN, DOMAIN,
OPENCLAW_CONFIG_REL_PATH, OPENCLAW_CONFIG_REL_PATH,
) )
@@ -237,6 +253,11 @@ class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
@staticmethod
def async_get_options_flow(config_entry: ConfigEntry) -> OpenClawOptionsFlow:
"""Get options flow for this handler."""
return OpenClawOptionsFlow(config_entry)
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
self._discovered: dict[str, Any] | None = None self._discovered: dict[str, Any] | None = None
@@ -364,3 +385,76 @@ class OpenClawConfigFlow(ConfigFlow, domain=DOMAIN):
), ),
errors=errors, errors=errors,
) )
class OpenClawOptionsFlow(OptionsFlow):
"""Handle OpenClaw options."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage integration options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = self._config_entry.options
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_INCLUDE_EXPOSED_CONTEXT,
default=options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
),
): bool,
vol.Optional(
CONF_CONTEXT_MAX_CHARS,
default=options.get(
CONF_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_MAX_CHARS,
),
): vol.All(int, vol.Range(min=1000, max=200000)),
vol.Optional(
CONF_CONTEXT_STRATEGY,
default=options.get(
CONF_CONTEXT_STRATEGY,
DEFAULT_CONTEXT_STRATEGY,
),
): vol.In([CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_CLEAR]),
vol.Optional(
CONF_ENABLE_TOOL_CALLS,
default=options.get(
CONF_ENABLE_TOOL_CALLS,
DEFAULT_ENABLE_TOOL_CALLS,
),
): bool,
vol.Optional(
CONF_WAKE_WORD_ENABLED,
default=options.get(
CONF_WAKE_WORD_ENABLED,
DEFAULT_WAKE_WORD_ENABLED,
),
): bool,
vol.Optional(
CONF_WAKE_WORD,
default=options.get(
CONF_WAKE_WORD,
DEFAULT_WAKE_WORD,
),
): str,
vol.Optional(
CONF_ALWAYS_VOICE_MODE,
default=options.get(
CONF_ALWAYS_VOICE_MODE,
DEFAULT_ALWAYS_VOICE_MODE,
),
): bool,
}
),
)
+20
View File
@@ -23,6 +23,26 @@ CONF_GATEWAY_TOKEN = "gateway_token"
CONF_USE_SSL = "use_ssl" CONF_USE_SSL = "use_ssl"
CONF_ADDON_CONFIG_PATH = "addon_config_path" CONF_ADDON_CONFIG_PATH = "addon_config_path"
# Options
CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context"
CONF_CONTEXT_MAX_CHARS = "context_max_chars"
CONF_CONTEXT_STRATEGY = "context_strategy"
CONF_ENABLE_TOOL_CALLS = "enable_tool_calls"
CONF_WAKE_WORD_ENABLED = "wake_word_enabled"
CONF_WAKE_WORD = "wake_word"
CONF_ALWAYS_VOICE_MODE = "always_voice_mode"
DEFAULT_INCLUDE_EXPOSED_CONTEXT = True
DEFAULT_CONTEXT_MAX_CHARS = 13000
DEFAULT_CONTEXT_STRATEGY = "truncate"
DEFAULT_ENABLE_TOOL_CALLS = False
DEFAULT_WAKE_WORD_ENABLED = False
DEFAULT_WAKE_WORD = "hey openclaw"
DEFAULT_ALWAYS_VOICE_MODE = False
CONTEXT_STRATEGY_TRUNCATE = "truncate"
CONTEXT_STRATEGY_CLEAR = "clear"
# Coordinator data keys # Coordinator data keys
DATA_STATUS = "status" DATA_STATUS = "status"
DATA_MODEL = "model" DATA_MODEL = "model"
+22 -3
View File
@@ -21,12 +21,18 @@ from .const import (
ATTR_MODEL, ATTR_MODEL,
ATTR_SESSION_ID, ATTR_SESSION_ID,
ATTR_TIMESTAMP, ATTR_TIMESTAMP,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
DATA_MODEL, DATA_MODEL,
DOMAIN, DOMAIN,
EVENT_MESSAGE_RECEIVED, EVENT_MESSAGE_RECEIVED,
) )
from .coordinator import OpenClawCoordinator from .coordinator import OpenClawCoordinator
from .exposure import build_exposed_entities_context from .exposure import apply_context_policy, build_exposed_entities_context
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -101,11 +107,24 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
message = user_input.text message = user_input.text
conversation_id = user_input.conversation_id or "default" conversation_id = user_input.conversation_id or "default"
assistant_id = getattr(user_input, "assistant", None) or "assist" assistant_id = "conversation"
exposed_context = build_exposed_entities_context( options = self.entry.options
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)
raw_context = (
build_exposed_entities_context(
self.hass, self.hass,
assistant=assistant_id, assistant=assistant_id,
) )
if include_context
else None
)
exposed_context = apply_context_policy(raw_context, max_chars, strategy)
extra_system_prompt = getattr(user_input, "extra_system_prompt", None) extra_system_prompt = getattr(user_input, "extra_system_prompt", None)
system_prompt = "\n\n".join( system_prompt = "\n\n".join(
part for part in (exposed_context, extra_system_prompt) if part part for part in (exposed_context, extra_system_prompt) if part
+35 -4
View File
@@ -10,21 +10,27 @@ from homeassistant.core import HomeAssistant
def build_exposed_entities_context( def build_exposed_entities_context(
hass: HomeAssistant, hass: HomeAssistant,
assistant: str | None = "assist", assistant: str | None = "conversation",
max_entities: int = 250, max_entities: int = 250,
) -> str | None: ) -> str | None:
"""Build a compact prompt block of entities exposed to an assistant. """Build a compact prompt block of entities exposed to an assistant.
Uses Home Assistant's built-in expose rules (Settings -> Voice assistants -> Expose). Uses Home Assistant's built-in expose rules (Settings -> Voice assistants -> Expose).
""" """
assistant_id = assistant or "assist" assistant_id = assistant or "conversation"
exposed_states = [ def _collect_for(assistant_value: str) -> list:
return [
state state
for state in hass.states.async_all() for state in hass.states.async_all()
if async_should_expose(hass, assistant_id, state.entity_id) if async_should_expose(hass, assistant_value, state.entity_id)
] ]
exposed_states = _collect_for(assistant_id)
if not exposed_states and assistant_id != "conversation":
exposed_states = _collect_for("conversation")
if not exposed_states: if not exposed_states:
return None return None
@@ -55,3 +61,28 @@ def build_exposed_entities_context(
) )
return "\n".join(lines) return "\n".join(lines)
def apply_context_policy(
context_text: str | None,
max_chars: int,
strategy: str,
) -> str | None:
"""Apply context truncation policy to an optional prompt block."""
if not context_text:
return None
if max_chars <= 0:
return None
if len(context_text) <= max_chars:
return context_text
if strategy == "clear":
return None
marker = "\n[Context truncated to fit configured max length]\n"
available = max_chars - len(marker)
if available <= 0:
return context_text[-max_chars:]
return marker + context_text[-available:]
+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.13", "version": "0.1.16",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
+17
View File
@@ -31,6 +31,23 @@
"already_configured": "OpenClaw is already configured." "already_configured": "OpenClaw is already configured."
} }
}, },
"options": {
"step": {
"init": {
"title": "OpenClaw Options",
"description": "Configure context and tool-calling behavior.",
"data": {
"include_exposed_context": "Include exposed entities context",
"context_max_chars": "Max context characters",
"context_strategy": "When context exceeds max",
"enable_tool_calls": "Enable tool calls (execute services)",
"wake_word_enabled": "Require wake word",
"wake_word": "Wake word",
"always_voice_mode": "Always-on voice mode"
}
}
}
},
"entity": { "entity": {
"sensor": { "sensor": {
"status": { "status": {
@@ -31,6 +31,23 @@
"already_configured": "OpenClaw is already configured." "already_configured": "OpenClaw is already configured."
} }
}, },
"options": {
"step": {
"init": {
"title": "OpenClaw Options",
"description": "Configure context and tool-calling behavior.",
"data": {
"include_exposed_context": "Include exposed entities context",
"context_max_chars": "Max context characters",
"context_strategy": "When context exceeds max",
"enable_tool_calls": "Enable tool calls (execute services)",
"wake_word_enabled": "Require wake word",
"wake_word": "Wake word",
"always_voice_mode": "Always-on voice mode"
}
}
}
},
"entity": { "entity": {
"sensor": { "sensor": {
"status": { "status": {
@@ -59,6 +59,9 @@ class OpenClawChatCard extends HTMLElement {
this._recognition = null; this._recognition = null;
this._eventUnsubscribe = null; this._eventUnsubscribe = null;
this._thinkingTimer = null; this._thinkingTimer = null;
this._wakeWordEnabled = false;
this._wakeWord = "hey openclaw";
this._alwaysVoiceMode = false;
} }
// ── HA card interface ─────────────────────────────────────────────── // ── HA card interface ───────────────────────────────────────────────
@@ -91,6 +94,8 @@ class OpenClawChatCard extends HTMLElement {
this._hass = hass; this._hass = hass;
if (firstSet) { if (firstSet) {
this._subscribeToEvents(); this._subscribeToEvents();
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
this._render(); this._render();
} }
} }
@@ -100,6 +105,8 @@ class OpenClawChatCard extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
this._render(); this._render();
} }
@@ -136,7 +143,7 @@ class OpenClawChatCard extends HTMLElement {
if (!data || !data.message) return; if (!data || !data.message) return;
// Check if this event is for our session // Check if this event is for our session
const sessionId = this._config.session_id || "default"; const sessionId = this._getSessionId();
if (data.session_id && data.session_id !== sessionId) return; if (data.session_id && data.session_id !== sessionId) return;
// If we're waiting for a response, update the last "thinking" message // If we're waiting for a response, update the last "thinking" message
@@ -179,10 +186,14 @@ class OpenClawChatCard extends HTMLElement {
// ── Message persistence ────────────────────────────────────────────── // ── Message persistence ──────────────────────────────────────────────
_getStorageKey() { _getStorageKey() {
const session = this._config.session_id || "default"; const session = this._getSessionId();
return `${STORAGE_PREFIX}${session}`; return `${STORAGE_PREFIX}${session}`;
} }
_getSessionId() {
return this._config.session_id || "default";
}
_persistMessages() { _persistMessages() {
try { try {
const toSave = this._messages.filter((m) => !m._thinking); const toSave = this._messages.filter((m) => !m._thinking);
@@ -206,6 +217,73 @@ class OpenClawChatCard extends HTMLElement {
} }
} }
async _syncHistoryFromBackend() {
if (!this._hass) return;
const sessionId = this._getSessionId();
try {
let result;
if (typeof this._hass.callWS === "function") {
result = await this._hass.callWS({
type: "openclaw/get_history",
session_id: sessionId,
});
} else {
result = await this._hass.connection.sendMessagePromise({
type: "openclaw/get_history",
session_id: sessionId,
});
}
const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
if (!serverMessages.length) return;
const validMessages = serverMessages.filter(
(m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string"
);
if (!validMessages.length) return;
if (validMessages.length > this._messages.length) {
this._messages = validMessages;
this._isProcessing = false;
this._clearThinkingTimer();
this._persistMessages();
this._render();
this._scrollToBottom();
}
} catch (err) {
console.debug("OpenClaw: history sync skipped:", err);
}
}
async _loadIntegrationSettings() {
if (!this._hass) return;
try {
let result;
if (typeof this._hass.callWS === "function") {
result = await this._hass.callWS({ type: "openclaw/get_settings" });
} else {
result = await this._hass.connection.sendMessagePromise({
type: "openclaw/get_settings",
});
}
this._wakeWordEnabled = !!result?.wake_word_enabled;
this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase();
this._alwaysVoiceMode = !!result?.always_voice_mode;
if (this._alwaysVoiceMode && !this._isVoiceMode) {
this._isVoiceMode = true;
this._startVoiceRecognition();
}
this._render();
} catch (err) {
console.debug("OpenClaw: settings sync skipped:", err);
}
}
// ── Thinking timer ────────────────────────────────────────────────── // ── Thinking timer ──────────────────────────────────────────────────
_startThinkingTimer() { _startThinkingTimer() {
@@ -302,7 +380,26 @@ class OpenClawChatCard extends HTMLElement {
this._recognition.onresult = (event) => { this._recognition.onresult = (event) => {
const result = event.results[event.results.length - 1]; const result = event.results[event.results.length - 1];
if (result.isFinal) { if (result.isFinal) {
const text = result[0].transcript; const text = result[0].transcript?.trim();
if (!text) return;
if (this._wakeWordEnabled) {
const wake = this._wakeWord || "hey openclaw";
const lower = text.toLowerCase();
const wakePos = lower.indexOf(wake);
if (wakePos < 0) {
return;
}
let command = text.slice(wakePos + wake.length).trim();
command = command.replace(/^[,:;.!?\-]+\s*/, "");
if (!command) {
return;
}
this._sendMessage(command);
return;
}
this._sendMessage(text); this._sendMessage(text);
} }
}; };
+100 -3
View File
@@ -59,6 +59,9 @@ class OpenClawChatCard extends HTMLElement {
this._recognition = null; this._recognition = null;
this._eventUnsubscribe = null; this._eventUnsubscribe = null;
this._thinkingTimer = null; this._thinkingTimer = null;
this._wakeWordEnabled = false;
this._wakeWord = "hey openclaw";
this._alwaysVoiceMode = false;
} }
// ── HA card interface ─────────────────────────────────────────────── // ── HA card interface ───────────────────────────────────────────────
@@ -91,6 +94,8 @@ class OpenClawChatCard extends HTMLElement {
this._hass = hass; this._hass = hass;
if (firstSet) { if (firstSet) {
this._subscribeToEvents(); this._subscribeToEvents();
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
this._render(); this._render();
} }
} }
@@ -100,6 +105,8 @@ class OpenClawChatCard extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this._syncHistoryFromBackend();
this._loadIntegrationSettings();
this._render(); this._render();
} }
@@ -136,7 +143,7 @@ class OpenClawChatCard extends HTMLElement {
if (!data || !data.message) return; if (!data || !data.message) return;
// Check if this event is for our session // Check if this event is for our session
const sessionId = this._config.session_id || "default"; const sessionId = this._getSessionId();
if (data.session_id && data.session_id !== sessionId) return; if (data.session_id && data.session_id !== sessionId) return;
// If we're waiting for a response, update the last "thinking" message // If we're waiting for a response, update the last "thinking" message
@@ -179,10 +186,14 @@ class OpenClawChatCard extends HTMLElement {
// ── Message persistence ────────────────────────────────────────────── // ── Message persistence ──────────────────────────────────────────────
_getStorageKey() { _getStorageKey() {
const session = this._config.session_id || "default"; const session = this._getSessionId();
return `${STORAGE_PREFIX}${session}`; return `${STORAGE_PREFIX}${session}`;
} }
_getSessionId() {
return this._config.session_id || "default";
}
_persistMessages() { _persistMessages() {
try { try {
const toSave = this._messages.filter((m) => !m._thinking); const toSave = this._messages.filter((m) => !m._thinking);
@@ -206,6 +217,73 @@ class OpenClawChatCard extends HTMLElement {
} }
} }
async _syncHistoryFromBackend() {
if (!this._hass) return;
const sessionId = this._getSessionId();
try {
let result;
if (typeof this._hass.callWS === "function") {
result = await this._hass.callWS({
type: "openclaw/get_history",
session_id: sessionId,
});
} else {
result = await this._hass.connection.sendMessagePromise({
type: "openclaw/get_history",
session_id: sessionId,
});
}
const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
if (!serverMessages.length) return;
const validMessages = serverMessages.filter(
(m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string"
);
if (!validMessages.length) return;
if (validMessages.length > this._messages.length) {
this._messages = validMessages;
this._isProcessing = false;
this._clearThinkingTimer();
this._persistMessages();
this._render();
this._scrollToBottom();
}
} catch (err) {
console.debug("OpenClaw: history sync skipped:", err);
}
}
async _loadIntegrationSettings() {
if (!this._hass) return;
try {
let result;
if (typeof this._hass.callWS === "function") {
result = await this._hass.callWS({ type: "openclaw/get_settings" });
} else {
result = await this._hass.connection.sendMessagePromise({
type: "openclaw/get_settings",
});
}
this._wakeWordEnabled = !!result?.wake_word_enabled;
this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase();
this._alwaysVoiceMode = !!result?.always_voice_mode;
if (this._alwaysVoiceMode && !this._isVoiceMode) {
this._isVoiceMode = true;
this._startVoiceRecognition();
}
this._render();
} catch (err) {
console.debug("OpenClaw: settings sync skipped:", err);
}
}
// ── Thinking timer ────────────────────────────────────────────────── // ── Thinking timer ──────────────────────────────────────────────────
_startThinkingTimer() { _startThinkingTimer() {
@@ -302,7 +380,26 @@ class OpenClawChatCard extends HTMLElement {
this._recognition.onresult = (event) => { this._recognition.onresult = (event) => {
const result = event.results[event.results.length - 1]; const result = event.results[event.results.length - 1];
if (result.isFinal) { if (result.isFinal) {
const text = result[0].transcript; const text = result[0].transcript?.trim();
if (!text) return;
if (this._wakeWordEnabled) {
const wake = this._wakeWord || "hey openclaw";
const lower = text.toLowerCase();
const wakePos = lower.indexOf(wake);
if (wakePos < 0) {
return;
}
let command = text.slice(wakePos + wake.length).trim();
command = command.replace(/^[,:;.!?\-]+\s*/, "");
if (!command) {
return;
}
this._sendMessage(command);
return;
}
this._sendMessage(text); this._sendMessage(text);
} }
}; };