feat(hass): 新增小爱音箱接入 Home Assistant Assist 的完整示例
- 新增完整的 Home Assistant 集成示例,支持文本对话与上下文会话 - 实现小爱音箱事件解析与 TTS 播报,支持实时打断功能 - 添加连续会话管理,支持区域上下文注入和会话超时结束 - 提供完整的配置系统、类型定义和错误处理 - 包含 Rust 扩展模块用于小爱音箱通信,支持音频流处理 - 添加单元测试、集成测试脚本和 Docker 部署支持 - 提供详细的使用文档和配置说明
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Optional
|
||||
|
||||
from hass.config_loader import load_config
|
||||
from hass.errors import HomeAssistantRequestError
|
||||
from hass.ha_client import HomeAssistantClient
|
||||
from hass.memory import ConversationMemory
|
||||
from hass.typing import RecognizedSpeechInput
|
||||
from hass.xiaoai_bridge import XiaoAiBridge
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationSession:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
idle_timeout_seconds: int,
|
||||
end_keywords: list[str],
|
||||
) -> None:
|
||||
self._enabled = enabled
|
||||
self._idle_timeout_seconds = max(1, int(idle_timeout_seconds))
|
||||
self._end_keywords = [keyword.strip() for keyword in end_keywords if keyword and keyword.strip()]
|
||||
self._idle_task: Optional[asyncio.Task] = None
|
||||
|
||||
def match_end_keyword(self, text: str) -> Optional[str]:
|
||||
for keyword in self._end_keywords:
|
||||
if keyword in text:
|
||||
return keyword
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def build_area_prefix(area: str) -> str:
|
||||
return f"当前用户对话所在区域:{area},如后续对话未明确指定区域,则默认为此区域"
|
||||
|
||||
def resolve_area(self, client_ip: Optional[str], mapping: dict[str, str]) -> Optional[str]:
|
||||
if not client_ip:
|
||||
return None
|
||||
return mapping.get(client_ip)
|
||||
|
||||
def restart_idle_timer(self, on_timeout: Callable[[], Awaitable[None]]) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
self.cancel_idle_timer()
|
||||
|
||||
async def _timeout_worker() -> None:
|
||||
try:
|
||||
await asyncio.sleep(self._idle_timeout_seconds)
|
||||
await on_timeout()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
self._idle_task = asyncio.create_task(_timeout_worker())
|
||||
|
||||
def cancel_idle_timer(self) -> None:
|
||||
if self._idle_task and not self._idle_task.done():
|
||||
self._idle_task.cancel()
|
||||
self._idle_task = None
|
||||
|
||||
async def close(self) -> None:
|
||||
self.cancel_idle_timer()
|
||||
|
||||
|
||||
async def run(config_path: str) -> None:
|
||||
config = load_config(config_path)
|
||||
logging.basicConfig(level=getattr(logging, config.runtime.log_level.upper(), logging.INFO))
|
||||
|
||||
memory = ConversationMemory(config.runtime.persist_state_path)
|
||||
await memory.load()
|
||||
|
||||
ha = HomeAssistantClient(config.homeassistant, memory, concurrency=config.runtime.concurrency)
|
||||
await ha.start()
|
||||
|
||||
bridge = XiaoAiBridge(config.xiaoai)
|
||||
await bridge.start()
|
||||
|
||||
session = ConversationSession(
|
||||
enabled=config.xiaoai.continuous_conversation_enabled,
|
||||
idle_timeout_seconds=config.xiaoai.session_idle_timeout_seconds,
|
||||
end_keywords=config.xiaoai.session_end_keywords,
|
||||
)
|
||||
session_end_lock = asyncio.Lock()
|
||||
|
||||
async def end_session(reason: str) -> None:
|
||||
async with session_end_lock:
|
||||
await ha.reset_conversation()
|
||||
_LOGGER.info("session_ended reason=%s", reason)
|
||||
|
||||
async def on_idle_timeout() -> None:
|
||||
await end_session("idle_timeout")
|
||||
|
||||
async def on_text(recognized: RecognizedSpeechInput) -> None:
|
||||
text = recognized.text.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
session.restart_idle_timer(on_idle_timeout)
|
||||
|
||||
matched_keyword = session.match_end_keyword(text)
|
||||
if matched_keyword:
|
||||
await end_session(f"keyword:{matched_keyword}")
|
||||
await bridge.speak("好的,当前会话已结束")
|
||||
return
|
||||
|
||||
area = session.resolve_area(recognized.client_ip, config.xiaoai.client_ip_area_mapping)
|
||||
prefix_prompt = None
|
||||
if area:
|
||||
prefix_prompt = session.build_area_prefix(area)
|
||||
_LOGGER.info(
|
||||
"area_mapping_hit client_ip=%s area=%s",
|
||||
recognized.client_ip,
|
||||
area,
|
||||
)
|
||||
else:
|
||||
_LOGGER.info("area_mapping_miss client_ip=%s", recognized.client_ip or "")
|
||||
|
||||
try:
|
||||
if not config.xiaoai.continuous_conversation_enabled:
|
||||
await end_session("continuous_disabled_new_round")
|
||||
reply = await ha.process_text(text, prefix_prompt=prefix_prompt)
|
||||
await bridge.speak(reply.speech)
|
||||
except HomeAssistantRequestError as exc:
|
||||
_LOGGER.warning(
|
||||
"ha_process_failed text=%s client_ip=%s error=%s",
|
||||
text,
|
||||
recognized.client_ip or "",
|
||||
exc,
|
||||
)
|
||||
if prefix_prompt:
|
||||
try:
|
||||
_LOGGER.info("ha_process_retry_without_prefix client_ip=%s", recognized.client_ip)
|
||||
reply = await ha.process_text(text, prefix_prompt=None)
|
||||
await bridge.speak(reply.speech)
|
||||
return
|
||||
except HomeAssistantRequestError as retry_exc:
|
||||
_LOGGER.warning(
|
||||
"ha_process_retry_failed client_ip=%s error=%s",
|
||||
recognized.client_ip or "",
|
||||
retry_exc,
|
||||
)
|
||||
await bridge.speak("Home Assistant 连接异常")
|
||||
|
||||
bridge.set_on_text(on_text)
|
||||
try:
|
||||
await bridge.wait_forever()
|
||||
finally:
|
||||
await session.close()
|
||||
await ha.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config_path = os.environ.get("HASS_ASSISTANT_CONFIG", "config.json")
|
||||
if not Path(config_path).exists():
|
||||
config_path = "config.example.json"
|
||||
asyncio.run(run(config_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user