feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调

- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 11:21:16 +08:00
parent 4c3f9a367b
commit 6b3adb4ad8
36 changed files with 1194 additions and 162 deletions

View File

@@ -0,0 +1,143 @@
"""WebSocket接收服务端 voice_assignment开录/停录自动启停监控)。"""
from __future__ import annotations
import json
import os
import threading
import time
from collections.abc import Callable
from typing import Any
from urllib.parse import quote, urlparse, urlunparse
from loguru import logger
def http_base_to_ws_root(http_base: str) -> str:
p = urlparse(http_base.strip())
scheme = "wss" if p.scheme == "https" else "ws"
return urlunparse((scheme, p.netloc, "", "", "", ""))
class VoiceAssignmentListener:
"""后台线程:仅 WebSocket断线后等待一小段时间再重连。"""
def __init__(
self,
*,
http_base_url: str,
terminal_id: str,
on_start: Callable[[str], None],
on_end: Callable[[str], None],
reconnect_delay_sec: float = 2.0,
) -> None:
self._http_base = http_base_url.rstrip("/")
self._terminal_id = terminal_id.strip()
self._on_start = on_start
self._on_end = on_end
self._reconnect_delay = reconnect_delay_sec
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._last_assignment: str | None = None
@property
def terminal_id(self) -> str:
return self._terminal_id
def start(self) -> None:
if not self._terminal_id:
logger.warning("未配置 terminal_id跳过语音终端指派监听")
return
if self._thread and self._thread.is_alive():
logger.debug("指派监听线程已在运行,忽略重复 start")
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name="VoiceAssignment", daemon=True)
self._thread.start()
logger.info(
"已启动指派监听(仅 WebSocketterminal_id={!r} base={!r}",
self._terminal_id,
self._http_base,
)
def stop(self) -> None:
self._stop.set()
logger.debug("已请求停止指派监听线程")
def _run(self) -> None:
import websocket
ws_root = http_base_to_ws_root(self._http_base)
path = f"/client/voice-terminals/ws?terminal_id={quote(self._terminal_id)}"
ws_url = ws_root.rstrip("/") + path
logger.info("WebSocket 目标: {}", ws_url)
while not self._stop.is_set():
try:
def _on_open(_ws: Any) -> None:
logger.info("WebSocket 已连接,等待服务端 voice_assignment 消息")
def _on_close(_ws: Any, close_status_code: Any, close_msg: Any) -> None:
logger.warning(
"WebSocket 断开 code={} msg={!r}",
close_status_code,
close_msg,
)
def _on_error(_ws: Any, err: Any) -> None:
if err is None:
return
if type(err).__name__ == "ABNF":
logger.debug("WebSocket 内部帧回调(已忽略): {}", type(err).__name__)
return
logger.warning("WebSocket 错误: {}", err)
ws = websocket.WebSocketApp(
ws_url,
on_open=_on_open,
on_close=_on_close,
on_message=self._ws_on_message,
on_error=_on_error,
)
ws.run_forever(ping_interval=None, ping_timeout=None)
except Exception as exc:
logger.exception("WebSocket run_forever 异常: {}", exc)
if self._stop.is_set():
break
logger.info(
"{:.1f}s 后重连 WebSocket…",
self._reconnect_delay,
)
time.sleep(self._reconnect_delay)
def _ws_on_message(self, _ws: Any, message: str) -> None:
try:
data = json.loads(message)
except json.JSONDecodeError:
logger.debug("WebSocket 非 JSON 消息(已忽略): {!r}", message[:200])
return
if data.get("type") != "voice_assignment":
logger.debug("WebSocket 非 voice_assignment 消息 type={!r}", data.get("type"))
return
action = data.get("action")
sid = str(data.get("surgery_id") or "")
if not sid:
return
if action == "start":
logger.info("收到 voice_assignment start surgery_id={!r}", sid)
self._last_assignment = sid
self._on_start(sid)
elif action == "end":
logger.info("收到 voice_assignment end surgery_id={!r}", sid)
if self._last_assignment == sid:
self._last_assignment = None
self._on_end(sid)
else:
logger.debug("忽略 voice_assignment action={!r}", action)
def default_terminal_id_from_env() -> str:
return (os.environ.get("VOICE_TERMINAL_ID") or "").strip()

View File

@@ -9,6 +9,8 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from voice_confirmation_client.core.api import ConfirmationApiClient
from voice_confirmation_client.core.playback import play_mp3_from_base64
from voice_confirmation_client.core.record import record_wav_16k_mono
@@ -100,9 +102,13 @@ class MonitorWorker:
def set_monitoring(self, active: bool) -> None:
if active:
with self._settings_lock:
sid = self._settings.surgery_id
logger.info("监控已开启 surgery_id={!r}", sid)
self._monitoring.set()
self._wake.set()
else:
logger.info("监控已关闭")
self._monitoring.clear()
with self._state_lock:
self._state.generation += 1
@@ -135,6 +141,7 @@ class MonitorWorker:
self._emit_state("待机")
def _log(self, msg: str) -> None:
logger.info("{}", msg)
if self._on_log:
self._on_log(msg)