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:
143
voice_confirmation_client/core/assignment_listener.py
Normal file
143
voice_confirmation_client/core/assignment_listener.py
Normal 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(
|
||||
"已启动指派监听(仅 WebSocket)terminal_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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user