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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user