feat(phase-04): Wyoming Satellite integration + OpenClaw HA components
## Voice Pipeline (P3) - Replace openWakeWord daemon with Wyoming Satellite approach - Add Wyoming Satellite service on port 10700 for HA voice pipeline - Update setup.sh with cross-platform sed compatibility (macOS/Linux) - Add version field to Kokoro TTS voice info - Update launchd service loader to use Wyoming Satellite ## Home Assistant Integration (P4) - Add custom conversation agent component (openclaw_conversation) - Fix: Use IntentResponse instead of plain strings (HA API requirement) - Support both HTTP API and CLI fallback modes - Config flow for easy HA UI setup - Add OpenClaw bridge scripts (Python + Bash) - Add ha-ctl utility for HA entity control - Fix: Use context manager for token file reading - Add HA configuration examples and documentation ## Infrastructure - Add mem0 backup automation (launchd + script) - Add n8n workflow templates (morning briefing, notification router) - Add VS Code workspace configuration - Reorganize model files into categorized folders: - lmstudio-community/ - mlx-community/ - bartowski/ - mradermacher/ ## Documentation - Update PROJECT_PLAN.md with Wyoming Satellite architecture - Update TODO.md with completed Wyoming integration tasks - Add OPENCLAW_INTEGRATION.md for HA setup guide ## Testing - Verified Wyoming services running (STT:10300, TTS:10301, Satellite:10700) - Verified OpenClaw CLI accessibility - Confirmed cross-platform compatibility fixes
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""Conversation agent for OpenClaw integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.conversation import (
|
||||
AbstractConversationAgent,
|
||||
ConversationInput,
|
||||
ConversationResult,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.intent import IntentResponse
|
||||
|
||||
from .const import (
|
||||
CONF_AGENT_NAME,
|
||||
CONF_OPENCLAW_HOST,
|
||||
CONF_OPENCLAW_PORT,
|
||||
CONF_TIMEOUT,
|
||||
DEFAULT_AGENT,
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
OPENCLAW_API_PATH,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Schema for configuration
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_OPENCLAW_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Optional(CONF_OPENCLAW_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Optional(CONF_AGENT_NAME, default=DEFAULT_AGENT): str,
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): int,
|
||||
})
|
||||
|
||||
|
||||
class OpenClawAgent(AbstractConversationAgent):
|
||||
"""OpenClaw conversation agent."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.host = config.get(CONF_OPENCLAW_HOST, DEFAULT_HOST)
|
||||
self.port = config.get(CONF_OPENCLAW_PORT, DEFAULT_PORT)
|
||||
self.agent_name = config.get(CONF_AGENT_NAME, DEFAULT_AGENT)
|
||||
self.timeout = config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
return ["en"] # OpenClaw primarily supports English
|
||||
|
||||
@property
|
||||
def attribution(self) -> dict[str, str] | None:
|
||||
"""Return attribution information."""
|
||||
return {
|
||||
"name": "OpenClaw",
|
||||
"url": "https://github.com/homeai/openclaw",
|
||||
}
|
||||
|
||||
async def async_process(
|
||||
self, user_input: ConversationInput
|
||||
) -> ConversationResult:
|
||||
"""Process a sentence and return a response."""
|
||||
text = user_input.text
|
||||
conversation_id = user_input.conversation_id
|
||||
|
||||
_LOGGER.debug("Processing message: %s", text)
|
||||
|
||||
try:
|
||||
response_text = await self._call_openclaw(text)
|
||||
|
||||
# Create proper IntentResponse for Home Assistant
|
||||
intent_response = IntentResponse(language=user_input.language or "en")
|
||||
intent_response.async_set_speech(response_text)
|
||||
|
||||
return ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error calling OpenClaw: %s", err)
|
||||
intent_response = IntentResponse(language=user_input.language or "en")
|
||||
intent_response.async_set_speech("I'm sorry, I encountered an error processing your request.")
|
||||
return ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
async def _call_openclaw(self, message: str) -> str:
|
||||
"""Call OpenClaw API and return the response."""
|
||||
url = f"http://{self.host}:{self.port}{OPENCLAW_API_PATH}"
|
||||
|
||||
payload = {
|
||||
"message": message,
|
||||
"agent": self.agent_name,
|
||||
}
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(self.timeout):
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
_LOGGER.error(
|
||||
"OpenClaw returned status %s: %s",
|
||||
response.status,
|
||||
await response.text(),
|
||||
)
|
||||
return "I'm sorry, I couldn't process that request."
|
||||
|
||||
data = await response.json()
|
||||
return data.get("response", "I didn't get a response.")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout calling OpenClaw")
|
||||
return "I'm sorry, the request timed out."
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Error connecting to OpenClaw: %s", err)
|
||||
return "I'm sorry, I couldn't connect to the OpenClaw service."
|
||||
|
||||
|
||||
class OpenClawCLIAgent(AbstractConversationAgent):
|
||||
"""OpenClaw conversation agent using CLI (fallback if HTTP API unavailable)."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.agent_name = config.get(CONF_AGENT_NAME, DEFAULT_AGENT)
|
||||
self.timeout = config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
return ["en"]
|
||||
|
||||
@property
|
||||
def attribution(self) -> dict[str, str] | None:
|
||||
"""Return attribution information."""
|
||||
return {
|
||||
"name": "OpenClaw",
|
||||
"url": "https://github.com/homeai/openclaw",
|
||||
}
|
||||
|
||||
async def async_process(
|
||||
self, user_input: ConversationInput
|
||||
) -> ConversationResult:
|
||||
"""Process a sentence using OpenClaw CLI."""
|
||||
text = user_input.text
|
||||
conversation_id = user_input.conversation_id
|
||||
|
||||
_LOGGER.debug("Processing message via CLI: %s", text)
|
||||
|
||||
try:
|
||||
response_text = await self._call_openclaw_cli(text)
|
||||
|
||||
# Create proper IntentResponse for Home Assistant
|
||||
intent_response = IntentResponse(language=user_input.language or "en")
|
||||
intent_response.async_set_speech(response_text)
|
||||
|
||||
return ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error calling OpenClaw CLI: %s", err)
|
||||
intent_response = IntentResponse(language=user_input.language or "en")
|
||||
intent_response.async_set_speech("I'm sorry, I encountered an error processing your request.")
|
||||
return ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
async def _call_openclaw_cli(self, message: str) -> str:
|
||||
"""Call OpenClaw CLI and return the response."""
|
||||
import subprocess
|
||||
|
||||
cmd = [
|
||||
"openclaw",
|
||||
"agent",
|
||||
"--message", message,
|
||||
"--agent", self.agent_name,
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
_LOGGER.error("OpenClaw CLI failed: %s", stderr.decode().strip())
|
||||
return "I'm sorry, I couldn't process that request."
|
||||
|
||||
return stdout.decode().strip()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout calling OpenClaw CLI")
|
||||
return "I'm sorry, the request timed out."
|
||||
except FileNotFoundError:
|
||||
_LOGGER.error("OpenClaw CLI not found")
|
||||
return "I'm sorry, OpenClaw is not available."
|
||||
Reference in New Issue
Block a user