feat(hass): 新增小爱音箱接入 Home Assistant Assist 的完整示例

- 新增完整的 Home Assistant 集成示例,支持文本对话与上下文会话
- 实现小爱音箱事件解析与 TTS 播报,支持实时打断功能
- 添加连续会话管理,支持区域上下文注入和会话超时结束
- 提供完整的配置系统、类型定义和错误处理
- 包含 Rust 扩展模块用于小爱音箱通信,支持音频流处理
- 添加单元测试、集成测试脚本和 Docker 部署支持
- 提供详细的使用文档和配置说明
This commit is contained in:
2026-03-04 17:11:04 +08:00
parent 0f92b0a26d
commit aafdf024d3
32 changed files with 1785 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+5
View File
@@ -0,0 +1,5 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -0,0 +1,53 @@
import asyncio
import pytest
from hass_assistant import ConversationSession
@pytest.mark.asyncio
async def test_session_idle_timeout_triggers_end():
session = ConversationSession(
enabled=True,
idle_timeout_seconds=1,
end_keywords=["再见", "拜拜", "", "退下"],
)
triggered = {"value": False}
async def on_timeout():
triggered["value"] = True
session.restart_idle_timer(on_timeout)
await asyncio.sleep(1.2)
assert triggered["value"] is True
await session.close()
def test_session_end_keyword_contains_match():
session = ConversationSession(
enabled=True,
idle_timeout_seconds=15,
end_keywords=["再见", "拜拜", "", "退下"],
)
assert session.match_end_keyword("那就先这样吧,再见哈") == "再见"
assert session.match_end_keyword("你先退下吧") == "退下"
assert session.match_end_keyword("继续聊聊") is None
def test_session_area_mapping_hit_and_miss():
session = ConversationSession(
enabled=True,
idle_timeout_seconds=15,
end_keywords=["再见"],
)
mapping = {"192.168.1.101": "客厅"}
assert session.resolve_area("192.168.1.101", mapping) == "客厅"
assert session.resolve_area("192.168.1.102", mapping) is None
assert session.resolve_area(None, mapping) is None
prefix = session.build_area_prefix("客厅")
assert "当前用户对话所在区域:客厅" in prefix
+123
View File
@@ -0,0 +1,123 @@
import json
import pytest
from aioresponses import aioresponses
from hass.errors import HomeAssistantAuthError
from hass.ha_client import HomeAssistantClient
from hass.memory import ConversationMemory
from hass.typing import HomeAssistantConfig
@pytest.mark.asyncio
async def test_process_text_updates_conversation_id(tmp_path):
state_path = tmp_path / "state.json"
memory = ConversationMemory(str(state_path))
await memory.load()
config = HomeAssistantConfig(
url="http://ha.local:8123",
access_token="token",
assistant_entity_id="home_assistant",
language="zh-cn",
timeout_seconds=2.5,
)
client = HomeAssistantClient(config, memory, concurrency=2)
await client.start()
url = "http://ha.local:8123/api/conversation/process"
with aioresponses() as mocked:
mocked.post(
url,
payload={
"continue_conversation": True,
"conversation_id": "cid-1",
"response": {
"response_type": "action_done",
"speech": {"plain": {"speech": "好的"}},
},
},
status=200,
)
reply = await client.process_text("打开客厅灯")
assert reply.speech == "好的"
assert reply.continue_conversation is True
assert memory.conversation_id == "cid-1"
await client.close()
@pytest.mark.asyncio
async def test_process_text_unauthorized(tmp_path):
memory = ConversationMemory(str(tmp_path / "state.json"))
await memory.load()
config = HomeAssistantConfig(url="http://ha.local:8123", access_token="bad-token")
client = HomeAssistantClient(config, memory)
await client.start()
url = "http://ha.local:8123/api/conversation/process"
with aioresponses() as mocked:
mocked.post(url, payload={"message": "unauthorized"}, status=401)
with pytest.raises(HomeAssistantAuthError):
await client.process_text("hi")
await client.close()
@pytest.mark.asyncio
async def test_process_text_with_prefix_prompt(tmp_path):
memory = ConversationMemory(str(tmp_path / "state.json"))
await memory.load()
config = HomeAssistantConfig(url="http://ha.local:8123", access_token="token")
client = HomeAssistantClient(config, memory)
await client.start()
url = "http://ha.local:8123/api/conversation/process"
with aioresponses() as mocked:
mocked.post(
url,
payload={
"continue_conversation": False,
"conversation_id": "cid-2",
"response": {
"response_type": "query_answer",
"speech": {"plain": {"speech": "在客厅"}},
},
},
status=200,
)
await client.process_text(
"灯开了吗",
prefix_prompt="当前用户对话所在区域:客厅,如后续对话未明确指定区域,则默认为此区域",
)
request = mocked.requests[("POST", url)][0]
payload = json.loads(request.kwargs["data"].decode("utf-8"))
assert payload["text"].startswith("当前用户对话所在区域:客厅")
assert "用户请求:灯开了吗" in payload["text"]
await client.close()
@pytest.mark.asyncio
async def test_reset_conversation(tmp_path):
state_path = tmp_path / "state.json"
memory = ConversationMemory(str(state_path))
await memory.load()
memory.conversation_id = "cid-old"
await memory.save()
config = HomeAssistantConfig(url="http://ha.local:8123", access_token="token")
client = HomeAssistantClient(config, memory)
await client.start()
await client.reset_conversation()
assert memory.conversation_id is None
saved = json.loads(state_path.read_text(encoding="utf-8"))
assert saved["conversation_id"] is None
await client.close()
+21
View File
@@ -0,0 +1,21 @@
import json
import pytest
from hass.memory import ConversationMemory
@pytest.mark.asyncio
async def test_memory_persist_roundtrip(tmp_path):
path = tmp_path / "state.json"
m = ConversationMemory(str(path))
m.conversation_id = "abc"
await m.save()
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["conversation_id"] == "abc"
m2 = ConversationMemory(str(path))
await m2.load()
assert m2.conversation_id == "abc"
+38
View File
@@ -0,0 +1,38 @@
from hass.typing import AppConfig
def test_xiaoai_config_defaults():
cfg = AppConfig.model_validate(
{
"homeassistant": {
"url": "http://ha.local:8123",
"access_token": "token",
}
}
)
assert cfg.xiaoai.continuous_conversation_enabled is True
assert cfg.xiaoai.session_idle_timeout_seconds == 15
assert cfg.xiaoai.session_end_keywords == ["再见", "拜拜", "", "退下"]
assert cfg.xiaoai.client_ip_area_mapping == {}
assert cfg.xiaoai.interrupt_on_new_input is True
def test_xiaoai_config_area_mapping_loaded():
cfg = AppConfig.model_validate(
{
"homeassistant": {
"url": "http://ha.local:8123",
"access_token": "token",
},
"xiaoai": {
"client_ip_area_mapping": {
"192.168.1.10": "客厅",
"192.168.1.11": "书房",
}
},
}
)
assert cfg.xiaoai.client_ip_area_mapping["192.168.1.10"] == "客厅"
assert cfg.xiaoai.client_ip_area_mapping["192.168.1.11"] == "书房"
+41
View File
@@ -0,0 +1,41 @@
import math
import struct
import pytest
from hass.vad import VadConfig, VadSegmenter
def _sine_frame(sample_rate: int, freq_hz: float, frame_ms: int = 20) -> bytes:
samples = int(sample_rate * frame_ms / 1000)
values = [
int(10000 * math.sin(2 * math.pi * freq_hz * i / sample_rate))
for i in range(samples)
]
return struct.pack("<" + "h" * samples, *values)
def _silence_frame(sample_rate: int, frame_ms: int = 20) -> bytes:
samples = int(sample_rate * frame_ms / 1000)
return b"\x00\x00" * samples
@pytest.mark.parametrize("min_silence_ms", [40, 60])
def test_vad_emits_utterance(min_silence_ms: int):
config = VadConfig(
aggressiveness=1,
min_silence_ms=min_silence_ms,
max_utterance_ms=2000,
sample_rate=16000,
)
seg = VadSegmenter(config)
utterances = []
for _ in range(6):
utterances.extend(seg.feed(_sine_frame(16000, 440.0)))
for _ in range(6):
utterances.extend(seg.feed(_silence_frame(16000)))
assert len(utterances) >= 1
assert len(utterances[0]) > 0
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import json
import pytest
from hass.typing import XiaoAiConfig
from hass.xiaoai_bridge import XiaoAiBridge
class FakeServer:
def __init__(self):
self._fns = {}
self.shell_calls = []
def register_fn(self, name, fn):
self._fns[name] = fn
async def run_shell(self, script, timeout):
self.shell_calls.append((script, timeout))
return '{"stdout":"ok","stderr":"","exit_code":0}'
async def start_server(self):
return None
@pytest.mark.asyncio
async def test_bridge_interrupts_before_dispatch(monkeypatch):
fake_server = FakeServer()
import hass.xiaoai_bridge as module
monkeypatch.setattr(module, "_LOGGER", module._LOGGER)
cfg = XiaoAiConfig(interrupt_on_new_input=True, shell_timeout_ms=123)
bridge = XiaoAiBridge(cfg)
bridge._open_xiaoai_server = fake_server
bridge._loop = asyncio.get_running_loop()
called = []
async def on_text(input_data):
called.append(input_data)
bridge.set_on_text(on_text)
bridge._ready.set()
bridge._register_callbacks()
event = {
"event": "instruction",
"data": {
"ClientIP": "192.168.1.101",
"NewLine": json.dumps(
{
"header": {"namespace": "SpeechRecognizer", "name": "RecognizeResult"},
"payload": {
"is_final": True,
"results": [{"text": "打开客厅灯"}],
},
},
ensure_ascii=False,
),
},
}
fake_server._fns["on_event"](json.dumps(event, ensure_ascii=False))
await asyncio.sleep(0.05)
assert len(fake_server.shell_calls) == 1
assert "ubus call mibrain ai_service" in fake_server.shell_calls[0][0]
assert len(called) == 1
assert called[0].text == "打开客厅灯"
assert called[0].client_ip == "192.168.1.101"
@pytest.mark.asyncio
async def test_bridge_extracts_missing_ip_as_none():
fake_server = FakeServer()
cfg = XiaoAiConfig(interrupt_on_new_input=False)
bridge = XiaoAiBridge(cfg)
bridge._open_xiaoai_server = fake_server
bridge._loop = asyncio.get_running_loop()
captured = []
async def on_text(input_data):
captured.append(input_data)
bridge.set_on_text(on_text)
bridge._ready.set()
bridge._register_callbacks()
event = {
"event": "instruction",
"data": {
"NewLine": json.dumps(
{
"header": {"namespace": "SpeechRecognizer", "name": "RecognizeResult"},
"payload": {
"is_final": True,
"results": [{"text": "查询天气"}],
},
},
ensure_ascii=False,
),
},
}
fake_server._fns["on_event"](json.dumps(event, ensure_ascii=False))
await asyncio.sleep(0.05)
assert len(captured) == 1
assert captured[0].client_ip is None
assert fake_server.shell_calls == []