Files
Aodhan Collins 6a0bae2a0b 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
2026-03-08 02:06:37 +00:00

223 lines
7.7 KiB
Python

"""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."