feat(hass): 新增小爱音箱接入 Home Assistant Assist 的完整示例
- 新增完整的 Home Assistant 集成示例,支持文本对话与上下文会话 - 实现小爱音箱事件解析与 TTS 播报,支持实时打断功能 - 添加连续会话管理,支持区域上下文注入和会话超时结束 - 提供完整的配置系统、类型定义和错误处理 - 包含 Rust 扩展模块用于小爱音箱通信,支持音频流处理 - 添加单元测试、集成测试脚本和 Docker 部署支持 - 提供详细的使用文档和配置说明
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] == "书房"
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user