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

@@ -1,4 +1,5 @@
import json
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote_plus
from typing import Any, Literal
@@ -7,6 +8,7 @@ from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.baked import algorithm as baked_algorithm
from app.or_site_config import OrSiteConfig
class _SettingsGroup:
@@ -39,8 +41,7 @@ class _VideoGroup(_SettingsGroup):
"video_default_backend",
"video_camera_backend_overrides_json",
"video_rtsp_url_template",
"video_rtsp_urls_json",
"video_rtsp_urls_json_file",
"or_site_config_json_file",
)
@@ -135,8 +136,8 @@ class Settings(BaseSettings):
video_default_backend: Literal["rtsp", "hikvision_sdk", "auto"] = "rtsp"
video_camera_backend_overrides_json: str = ""
video_rtsp_url_template: str = ""
video_rtsp_urls_json: str = ""
video_rtsp_urls_json_file: str = ""
#: 手术室站点配置UTF-8 JSON须含 video_rtsp_urls 与 voice_or_room_bindings见 or_site_config.sample.json
or_site_config_json_file: str = ""
hikvision_lib_dir: str = "/opt/hikvision/lib"
hikvision_sdk_enabled: bool = False
@@ -236,45 +237,27 @@ class Settings(BaseSettings):
and self.minio_bucket.strip()
)
@staticmethod
def _parse_rtsp_urls_object(raw: str) -> dict[str, str]:
raw = (raw or "").strip()
if not raw:
return {}
try:
data: Any = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid VIDEO_RTSP_URLS_JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("VIDEO_RTSP_URLS_JSON must be a JSON object")
return {str(k): str(v) for k, v in data.items()}
def load_or_site_config(self) -> OrSiteConfig | None:
"""解析 ``or_site_config_json_file``;未配置路径时返回 ``None``。"""
from app.or_site_config import load_or_site_config_from_path
path_raw = (self.or_site_config_json_file or "").strip()
if not path_raw:
return None
path = Path(path_raw).expanduser()
if not path.is_file():
raise ValueError(f"OR_SITE_CONFIG_JSON_FILE is set but file not found: {path}")
return load_or_site_config_from_path(path)
def video_rtsp_url_map(self) -> dict[str, str]:
merged: dict[str, str] = {}
path_raw = (self.video_rtsp_urls_json_file or "").strip()
if path_raw:
path = Path(path_raw).expanduser()
if not path.is_file():
raise ValueError(
f"VIDEO_RTSP_URLS_JSON_FILE is set but file not found: {path}"
)
try:
file_obj: Any = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in VIDEO_RTSP_URLS_JSON_FILE {path}: {exc}"
) from exc
if not isinstance(file_obj, dict):
raise ValueError(
f"VIDEO_RTSP_URLS_JSON_FILE must contain a JSON object: {path}"
)
merged = {str(k): str(v) for k, v in file_obj.items()}
merged.update(self._parse_rtsp_urls_object(self.video_rtsp_urls_json))
return merged
cfg = self.load_or_site_config()
if cfg is None:
return {}
return dict(cfg.video_rtsp_urls)
@property
def camera_rtsp_urls_sample_path(self) -> str:
return baked_algorithm.default_camera_rtsp_urls_sample_path()
def or_site_config_sample_path(self) -> str:
return baked_algorithm.default_or_site_config_sample_path()
@property
def video(self) -> _VideoGroup: