2025-03-12 10:57:25 +08:00
|
|
|
|
import sys
|
2025-06-01 13:34:32 +08:00
|
|
|
|
import uuid
|
2025-03-12 10:57:25 +08:00
|
|
|
|
import signal
|
2025-05-30 02:05:34 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
from aioconsole import ainput
|
2026-07-27 02:05:26 +08:00
|
|
|
|
from config.config_loader import load_config
|
2025-04-27 13:07:14 +08:00
|
|
|
|
from config.logger import setup_logging
|
2025-07-05 19:46:04 +08:00
|
|
|
|
from core.utils.util import get_local_ip, validate_mcp_endpoint
|
2025-06-01 02:26:19 +08:00
|
|
|
|
from core.http_server import SimpleHttpServer
|
2026-07-27 02:05:26 +08:00
|
|
|
|
from core.xiaozhi_server_facade import XiaozhiServerFacade
|
2025-05-30 02:05:34 +08:00
|
|
|
|
from core.utils.util import check_ffmpeg_installed
|
2025-11-28 16:12:19 +08:00
|
|
|
|
from core.utils.gc_manager import get_gc_manager
|
2026-07-27 02:05:26 +08:00
|
|
|
|
from config.manage_api_client import manage_api_http_close
|
2025-03-05 23:13:24 +08:00
|
|
|
|
|
|
|
|
|
|
TAG = __name__
|
2025-04-27 13:07:14 +08:00
|
|
|
|
logger = setup_logging()
|
2025-03-05 23:13:24 +08:00
|
|
|
|
|
2025-04-12 17:36:04 +08:00
|
|
|
|
|
2025-05-04 22:58:43 +08:00
|
|
|
|
async def wait_for_exit() -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
阻塞直到收到 Ctrl‑C / SIGTERM。
|
|
|
|
|
|
- Unix: 使用 add_signal_handler
|
|
|
|
|
|
- Windows: 依赖 KeyboardInterrupt
|
|
|
|
|
|
"""
|
2025-03-12 10:57:25 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
stop_event = asyncio.Event()
|
|
|
|
|
|
|
2025-05-04 22:58:43 +08:00
|
|
|
|
if sys.platform != "win32": # Unix / macOS
|
|
|
|
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
|
|
|
|
loop.add_signal_handler(sig, stop_event.set)
|
2025-03-12 10:57:25 +08:00
|
|
|
|
await stop_event.wait()
|
2025-05-04 22:58:43 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# Windows:await一个永远pending的fut,
|
|
|
|
|
|
# 让 KeyboardInterrupt 冒泡到 asyncio.run,以此消除遗留普通线程导致进程退出阻塞的问题
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.Future()
|
|
|
|
|
|
except KeyboardInterrupt: # Ctrl‑C
|
|
|
|
|
|
pass
|
2025-03-05 23:13:24 +08:00
|
|
|
|
|
2025-04-12 17:36:04 +08:00
|
|
|
|
|
2025-05-07 16:20:43 +08:00
|
|
|
|
async def monitor_stdin():
|
|
|
|
|
|
"""监控标准输入,消费回车键"""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
await ainput() # 异步等待输入,消费回车
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-03-05 23:13:24 +08:00
|
|
|
|
async def main():
|
|
|
|
|
|
check_ffmpeg_installed()
|
2026-07-09 10:29:41 +08:00
|
|
|
|
config = await load_config()
|
2025-03-05 23:13:24 +08:00
|
|
|
|
|
2025-10-20 21:25:37 +08:00
|
|
|
|
# auth_key优先级:配置文件server.auth_key > manager-api.secret > 自动生成
|
|
|
|
|
|
# auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证
|
|
|
|
|
|
# 获取配置文件中的auth_key
|
|
|
|
|
|
auth_key = config["server"].get("auth_key", "")
|
2026-07-27 02:05:26 +08:00
|
|
|
|
|
2025-10-20 21:25:37 +08:00
|
|
|
|
# 验证auth_key,无效则尝试使用manager-api.secret
|
2025-06-02 21:24:26 +08:00
|
|
|
|
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
2025-10-20 21:25:37 +08:00
|
|
|
|
auth_key = config.get("manager-api", {}).get("secret", "")
|
|
|
|
|
|
# 验证secret,无效则生成随机密钥
|
|
|
|
|
|
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
2025-10-20 11:57:18 +08:00
|
|
|
|
auth_key = str(uuid.uuid4().hex)
|
2026-07-27 02:05:26 +08:00
|
|
|
|
|
2025-10-20 21:25:37 +08:00
|
|
|
|
config["server"]["auth_key"] = auth_key
|
2025-06-01 13:34:32 +08:00
|
|
|
|
|
2025-05-07 16:20:43 +08:00
|
|
|
|
# 添加 stdin 监控任务
|
|
|
|
|
|
stdin_task = asyncio.create_task(monitor_stdin())
|
|
|
|
|
|
|
2025-11-28 16:12:19 +08:00
|
|
|
|
# 启动全局GC管理器(5分钟清理一次)
|
|
|
|
|
|
gc_manager = get_gc_manager(interval_seconds=300)
|
|
|
|
|
|
await gc_manager.start()
|
|
|
|
|
|
|
2026-07-27 02:05:26 +08:00
|
|
|
|
# 启动小智服务器门面
|
|
|
|
|
|
xiaozhi_server = XiaozhiServerFacade(config)
|
|
|
|
|
|
ota_server = None
|
|
|
|
|
|
ota_task = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Facade.start() returns after every listener is ready. Await it so bind
|
|
|
|
|
|
# or configuration failures stop startup instead of leaving a partial process.
|
|
|
|
|
|
await xiaozhi_server.start()
|
|
|
|
|
|
ota_server = SimpleHttpServer(config)
|
|
|
|
|
|
ota_task = asyncio.create_task(
|
|
|
|
|
|
ota_server.start(), name="xiaozhi-http-server"
|
|
|
|
|
|
)
|
|
|
|
|
|
await ota_server.wait_started(
|
|
|
|
|
|
ota_task,
|
|
|
|
|
|
timeout=float(config.get("server_startup_timeout", 10)),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
if ota_server:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await ota_server.stop()
|
|
|
|
|
|
except Exception as cleanup_error:
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"停止HTTP服务器失败: {cleanup_error}")
|
|
|
|
|
|
if ota_task:
|
|
|
|
|
|
if not ota_task.done():
|
|
|
|
|
|
ota_task.cancel()
|
|
|
|
|
|
await asyncio.gather(ota_task, return_exceptions=True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await xiaozhi_server.stop()
|
|
|
|
|
|
except Exception as cleanup_error:
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"停止协议服务器失败: {cleanup_error}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
await gc_manager.stop()
|
|
|
|
|
|
except Exception as cleanup_error:
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"停止GC管理器失败: {cleanup_error}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
await manage_api_http_close()
|
|
|
|
|
|
except Exception as cleanup_error:
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"关闭管理API客户端失败: {cleanup_error}")
|
|
|
|
|
|
stdin_task.cancel()
|
|
|
|
|
|
await asyncio.gather(stdin_task, return_exceptions=True)
|
|
|
|
|
|
raise
|
2025-03-05 23:13:24 +08:00
|
|
|
|
|
2025-04-27 13:07:14 +08:00
|
|
|
|
read_config_from_api = config.get("read_config_from_api", False)
|
2025-06-01 02:26:19 +08:00
|
|
|
|
port = int(config["server"].get("http_port", 8003))
|
2025-04-27 13:07:14 +08:00
|
|
|
|
if not read_config_from_api:
|
|
|
|
|
|
logger.bind(tag=TAG).info(
|
|
|
|
|
|
"OTA接口是\t\thttp://{}:{}/xiaozhi/ota/",
|
|
|
|
|
|
get_local_ip(),
|
2025-06-01 02:26:19 +08:00
|
|
|
|
port,
|
2025-04-27 13:07:14 +08:00
|
|
|
|
)
|
2025-06-01 02:26:19 +08:00
|
|
|
|
logger.bind(tag=TAG).info(
|
|
|
|
|
|
"视觉分析接口是\thttp://{}:{}/mcp/vision/explain",
|
|
|
|
|
|
get_local_ip(),
|
|
|
|
|
|
port,
|
|
|
|
|
|
)
|
2025-07-05 19:46:04 +08:00
|
|
|
|
mcp_endpoint = config.get("mcp_endpoint", None)
|
|
|
|
|
|
if mcp_endpoint is not None and "你" not in mcp_endpoint:
|
|
|
|
|
|
# 校验MCP接入点格式
|
|
|
|
|
|
if validate_mcp_endpoint(mcp_endpoint):
|
|
|
|
|
|
logger.bind(tag=TAG).info("mcp接入点是\t{}", mcp_endpoint)
|
|
|
|
|
|
# 将mcp计入点地址转成调用点
|
|
|
|
|
|
mcp_endpoint = mcp_endpoint.replace("/mcp/", "/call/")
|
|
|
|
|
|
config["mcp_endpoint"] = mcp_endpoint
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.bind(tag=TAG).error("mcp接入点不符合规范")
|
|
|
|
|
|
config["mcp_endpoint"] = "你的接入点 websocket地址"
|
2025-04-27 13:07:14 +08:00
|
|
|
|
|
2026-07-27 02:05:26 +08:00
|
|
|
|
# 显示协议连接信息
|
|
|
|
|
|
connection_info = xiaozhi_server.get_connection_info()
|
2025-04-27 13:07:14 +08:00
|
|
|
|
|
2026-07-27 02:05:26 +08:00
|
|
|
|
# WebSocket信息
|
|
|
|
|
|
websocket_info = connection_info.get("websocket", {})
|
|
|
|
|
|
if websocket_info.get("enabled", False):
|
|
|
|
|
|
websocket_port = websocket_info.get("port", 8000)
|
|
|
|
|
|
logger.bind(tag=TAG).info(
|
|
|
|
|
|
"WebSocket地址是\tws://{}:{}/xiaozhi/v1/",
|
|
|
|
|
|
get_local_ip(),
|
|
|
|
|
|
websocket_port,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 显示启用的协议
|
|
|
|
|
|
enabled_protocols = xiaozhi_server.config.get("enabled_protocols", [])
|
|
|
|
|
|
logger.bind(tag=TAG).info(f"启用的协议: {', '.join(enabled_protocols)}")
|
|
|
|
|
|
|
|
|
|
|
|
if "websocket" in enabled_protocols:
|
|
|
|
|
|
logger.bind(tag=TAG).info(
|
|
|
|
|
|
"=======上面的WebSocket地址请勿用浏览器访问======="
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.bind(tag=TAG).info(
|
|
|
|
|
|
"如想测试WebSocket请启动digital-human模块,打开浏览器交互测试"
|
|
|
|
|
|
)
|
2025-04-27 13:07:14 +08:00
|
|
|
|
|
|
|
|
|
|
logger.bind(tag=TAG).info(
|
|
|
|
|
|
"=============================================================\n"
|
|
|
|
|
|
)
|
2025-04-26 21:56:41 +08:00
|
|
|
|
|
2025-03-05 23:13:24 +08:00
|
|
|
|
try:
|
2025-05-07 16:20:43 +08:00
|
|
|
|
await wait_for_exit() # 阻塞直到收到退出信号
|
2025-03-12 10:57:25 +08:00
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
print("任务被取消,清理资源中...")
|
2025-03-05 23:13:24 +08:00
|
|
|
|
finally:
|
2026-07-27 02:05:26 +08:00
|
|
|
|
shutdown_errors = []
|
|
|
|
|
|
if ota_server:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await ota_server.stop()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutdown_errors.append(("HTTP服务器", e))
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"停止HTTP服务器失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 停止小智服务器
|
|
|
|
|
|
try:
|
|
|
|
|
|
await xiaozhi_server.stop()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutdown_errors.append(("协议服务器", e))
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"停止小智服务器失败: {e}")
|
|
|
|
|
|
|
2025-11-28 16:12:19 +08:00
|
|
|
|
# 停止全局GC管理器
|
2026-07-27 02:05:26 +08:00
|
|
|
|
try:
|
|
|
|
|
|
await gc_manager.stop()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutdown_errors.append(("GC管理器", e))
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"停止GC管理器失败: {e}")
|
2025-11-28 16:12:19 +08:00
|
|
|
|
|
2026-07-27 02:05:26 +08:00
|
|
|
|
try:
|
|
|
|
|
|
await manage_api_http_close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutdown_errors.append(("管理API客户端", e))
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"关闭管理API客户端失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# stdin 可立即取消;HTTP task 先获得窗口完成 runner.cleanup()。
|
2025-05-07 16:20:43 +08:00
|
|
|
|
stdin_task.cancel()
|
2026-07-27 02:05:26 +08:00
|
|
|
|
await asyncio.gather(stdin_task, return_exceptions=True)
|
2025-05-07 16:20:43 +08:00
|
|
|
|
|
2026-07-27 02:05:26 +08:00
|
|
|
|
if ota_task:
|
|
|
|
|
|
done, pending = await asyncio.wait({ota_task}, timeout=3.0)
|
|
|
|
|
|
if pending:
|
|
|
|
|
|
ota_task.cancel()
|
|
|
|
|
|
await asyncio.gather(ota_task, return_exceptions=True)
|
|
|
|
|
|
elif done and not ota_task.cancelled():
|
|
|
|
|
|
try:
|
|
|
|
|
|
ota_task.result()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutdown_errors.append((ota_task.get_name(), e))
|
|
|
|
|
|
logger.bind(tag=TAG).error(
|
|
|
|
|
|
f"后台任务退出失败: {ota_task.get_name()}: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# If task cancellation or its first cleanup attempt retained the
|
|
|
|
|
|
# runner, retry after the task has fully relinquished ownership.
|
|
|
|
|
|
if ota_server:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await ota_server.stop()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutdown_errors.append(("HTTP服务器重试清理", e))
|
|
|
|
|
|
logger.bind(tag=TAG).error(f"重试停止HTTP服务器失败: {e}")
|
2025-03-12 10:57:25 +08:00
|
|
|
|
print("服务器已关闭,程序退出。")
|
2026-07-27 02:05:26 +08:00
|
|
|
|
if shutdown_errors:
|
|
|
|
|
|
details = ", ".join(
|
|
|
|
|
|
f"{owner}: {error}" for owner, error in shutdown_errors
|
|
|
|
|
|
)
|
|
|
|
|
|
raise RuntimeError(f"服务器清理失败: {details}")
|
2025-03-05 23:13:24 +08:00
|
|
|
|
|
2025-04-12 17:36:04 +08:00
|
|
|
|
|
2025-03-05 23:13:24 +08:00
|
|
|
|
if __name__ == "__main__":
|
2025-03-12 10:57:25 +08:00
|
|
|
|
try:
|
|
|
|
|
|
asyncio.run(main())
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
print("手动中断,程序终止。")
|