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:
Aodhan Collins
2026-03-08 02:06:37 +00:00
parent 9eb5633115
commit 6a0bae2a0b
119 changed files with 780808 additions and 64 deletions

View File

@@ -0,0 +1,114 @@
# OpenClaw Conversation - Home Assistant Custom Component
A custom conversation agent for Home Assistant that routes all voice/text queries to OpenClaw for processing.
## Features
- **Direct OpenClaw Integration**: Routes all conversation requests to OpenClaw
- **CLI-based Communication**: Uses the `openclaw` CLI command (fallback if HTTP API unavailable)
- **Configurable**: Set host, port, agent name, and timeout via UI
- **Voice Pipeline Compatible**: Works with Home Assistant's voice assistant pipeline
## Installation
### Method 1: Manual Copy
1. Copy the entire `openclaw_conversation` folder to your Home Assistant `custom_components` directory:
```bash
# On the HA host (if using HA OS or Container, use the File Editor add-on)
cp -r homeai-agent/custom_components/openclaw_conversation \
/config/custom_components/
```
2. Restart Home Assistant
3. Go to **Settings → Devices & Services → Add Integration**
4. Search for "OpenClaw Conversation"
5. Configure the settings:
- **OpenClaw Host**: `localhost` (or IP of Mac Mini)
- **OpenClaw Port**: `8080`
- **Agent Name**: `main` (or your configured agent)
- **Timeout**: `30` seconds
### Method 2: Using HACS (if available)
1. Add this repository to HACS as a custom repository
2. Install "OpenClaw Conversation"
3. Restart Home Assistant
## Configuration
### Via UI (Recommended)
After installation, configure via **Settings → Devices & Services → OpenClaw Conversation → Configure**.
### Via YAML (Alternative)
Add to your `configuration.yaml`:
```yaml
openclaw_conversation:
openclaw_host: localhost
openclaw_port: 8080
agent_name: main
timeout: 30
```
## Usage
Once configured, the OpenClaw agent will be available as a conversation agent in Home Assistant.
### Setting as Default Agent
1. Go to **Settings → Voice Assistants**
2. Edit your voice assistant pipeline
3. Set **Conversation Agent** to "OpenClaw Conversation"
4. Save
### Testing
1. Open the **Assist** panel in Home Assistant
2. Type a query like: "Turn on the reading lamp"
3. OpenClaw will process the request and return a response
## Architecture
```
[Voice Input] → [HA Voice Pipeline] → [OpenClaw Conversation Agent]
[OpenClaw CLI/API]
[Ollama LLM + Skills]
[HA Actions + TTS Response]
```
## Troubleshooting
### Agent Not Responding
1. Check OpenClaw is running: `pgrep -f openclaw`
2. Test CLI directly: `openclaw agent --message "Hello" --agent main`
3. Check HA logs: **Settings → System → Logs**
### Connection Errors
1. Verify OpenClaw host/port settings
2. Ensure OpenClaw is accessible from HA container/host
3. Check network connectivity: `curl http://localhost:8080/status`
## Files
| File | Purpose |
|------|---------|
| `manifest.json` | Component metadata |
| `__init__.py` | Component setup and registration |
| `config_flow.py` | Configuration UI flow |
| `const.py` | Constants and defaults |
| `conversation.py` | Conversation agent implementation |
| `strings.json` | UI translations |
## See Also
- [OpenClaw Integration Guide](../../skills/home-assistant/OPENCLAW_INTEGRATION.md)
- [Voice Pipeline Implementation](../../../plans/ha-voice-pipeline-implementation.md)

View File

@@ -0,0 +1,98 @@
"""OpenClaw Conversation integration for Home Assistant."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_AGENT_NAME,
CONF_OPENCLAW_HOST,
CONF_OPENCLAW_PORT,
CONF_TIMEOUT,
DEFAULT_AGENT,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_TIMEOUT,
DOMAIN,
)
from .conversation import OpenClawCLIAgent
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CONVERSATION]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_OPENCLAW_HOST, default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_OPENCLAW_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_AGENT_NAME, default=DEFAULT_AGENT): cv.string,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the OpenClaw Conversation component."""
hass.data.setdefault(DOMAIN, {})
if DOMAIN not in config:
return True
conf = config[DOMAIN]
# Store config
hass.data[DOMAIN] = {
"config": conf,
}
# Register the conversation agent
agent = OpenClawCLIAgent(hass, conf)
# Add to conversation agent registry
from homeassistant.components import conversation
conversation.async_set_agent(hass, DOMAIN, agent)
_LOGGER.info("OpenClaw Conversation agent registered")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up OpenClaw Conversation from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Store entry data
hass.data[DOMAIN][entry.entry_id] = entry.data
# Register the conversation agent
agent = OpenClawCLIAgent(hass, entry.data)
from homeassistant.components import conversation
conversation.async_set_agent(hass, DOMAIN, agent)
_LOGGER.info("OpenClaw Conversation agent registered from config entry")
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unregister the conversation agent
from homeassistant.components import conversation
conversation.async_unset_agent(hass, DOMAIN)
hass.data[DOMAIN].pop(entry.entry_id, None)
return True

View File

@@ -0,0 +1,134 @@
"""Config flow for OpenClaw Conversation integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
from .const import (
CONF_AGENT_NAME,
CONF_OPENCLAW_HOST,
CONF_OPENCLAW_PORT,
CONF_TIMEOUT,
DEFAULT_AGENT,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_TIMEOUT,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPENCLAW_HOST, default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_OPENCLAW_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_AGENT_NAME, default=DEFAULT_AGENT): cv.string,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenClaw Conversation."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# Validate the input
try:
# Test connection to OpenClaw
await self._test_openclaw_connection(
user_input[CONF_OPENCLAW_HOST],
user_input[CONF_OPENCLAW_PORT],
)
return self.async_create_entry(
title="OpenClaw Conversation",
data=user_input,
)
except ConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def _test_openclaw_connection(self, host: str, port: int) -> None:
"""Test if OpenClaw is reachable."""
import asyncio
try:
# Try to connect to OpenClaw
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=5,
)
writer.close()
await writer.wait_closed()
except Exception as err:
raise ConnectionError(f"Cannot connect to OpenClaw: {err}")
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> OptionsFlow:
"""Create the options flow."""
return OptionsFlow(config_entry)
class OptionsFlow(config_entries.OptionsFlow):
"""Handle options flow for OpenClaw Conversation."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = {
vol.Optional(
CONF_AGENT_NAME,
default=self.config_entry.options.get(
CONF_AGENT_NAME, DEFAULT_AGENT
),
): cv.string,
vol.Optional(
CONF_TIMEOUT,
default=self.config_entry.options.get(
CONF_TIMEOUT, DEFAULT_TIMEOUT
),
): cv.positive_int,
}
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(options),
errors=errors,
)

View File

@@ -0,0 +1,26 @@
"""Constants for OpenClaw Conversation integration."""
DOMAIN = "openclaw_conversation"
# Configuration keys
CONF_OPENCLAW_HOST = "openclaw_host"
CONF_OPENCLAW_PORT = "openclaw_port"
CONF_AGENT_NAME = "agent_name"
CONF_TIMEOUT = "timeout"
# Defaults
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8080
DEFAULT_AGENT = "main"
DEFAULT_TIMEOUT = 30
# API endpoints
OPENCLAW_API_PATH = "/api/agent/message"
# Service names
SERVICE_PROCESS = "process"
# Attributes
ATTR_MESSAGE = "message"
ATTR_RESPONSE = "response"
ATTR_AGENT = "agent"

View File

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

View File

@@ -0,0 +1,11 @@
{
"domain": "openclaw_conversation",
"name": "OpenClaw Conversation",
"codeowners": ["@homeai"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://github.com/homeai/homeai-agent",
"iot_class": "local_push",
"requirements": [],
"version": "1.0.0"
}

View File

@@ -0,0 +1,35 @@
{
"title": "OpenClaw Conversation",
"config": {
"step": {
"user": {
"title": "OpenClaw Conversation Setup",
"description": "Configure the OpenClaw conversation agent.",
"data": {
"openclaw_host": "OpenClaw Host",
"openclaw_port": "OpenClaw Port",
"agent_name": "Agent Name",
"timeout": "Timeout (seconds)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to OpenClaw. Please check the host and port.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "OpenClaw Conversation is already configured."
}
},
"options": {
"step": {
"init": {
"title": "OpenClaw Conversation Options",
"data": {
"agent_name": "Agent Name",
"timeout": "Timeout (seconds)"
}
}
}
}
}