Files
operating-room-monitor-server/app/or_site_config.py
Kevin 6b3adb4ad8 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
2026-04-27 11:21:16 +08:00

104 lines
3.6 KiB
Python

"""手术室站点配置:单一 JSON 文件,严格结构,无历史格式分支。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger
from app.services.voice_terminal_binding import VoiceTerminalBindingIndex
_ALLOWED_TOP_LEVEL = frozenset({"video_rtsp_urls", "voice_or_room_bindings"})
@dataclass(frozen=True)
class OrSiteConfig:
"""根对象须含 ``video_rtsp_urls`` 与 ``voice_or_room_bindings`` 两个键。"""
video_rtsp_urls: dict[str, str]
voice_bindings: VoiceTerminalBindingIndex
def parse_or_site_config_object(data: Any, *, source: str | Path = "") -> OrSiteConfig:
label = str(source) if source else "JSON"
if not isinstance(data, dict):
raise ValueError(f"{label}: OR site config must be a JSON object")
extra = set(data.keys()) - _ALLOWED_TOP_LEVEL
if extra:
raise ValueError(
f"{label}: unknown top-level keys {sorted(extra)}; "
f"allowed: {sorted(_ALLOWED_TOP_LEVEL)}"
)
if "video_rtsp_urls" not in data or "voice_or_room_bindings" not in data:
raise ValueError(
f"{label}: must include video_rtsp_urls and voice_or_room_bindings"
)
urls = data["video_rtsp_urls"]
if not isinstance(urls, dict):
raise ValueError(f"{label}: video_rtsp_urls must be a JSON object")
raw_bindings = data["voice_or_room_bindings"]
if not isinstance(raw_bindings, list):
raise ValueError(f"{label}: voice_or_room_bindings must be a JSON array")
for k, v in urls.items():
if not isinstance(v, str):
raise ValueError(
f"{label}: video_rtsp_urls[{k!r}] must be a string (RTSP URL)"
)
idx = VoiceTerminalBindingIndex.from_binding_list(raw_bindings)
if idx is None:
raise ValueError(f"{label}: invalid voice_or_room_bindings content")
return OrSiteConfig(
video_rtsp_urls={str(k): str(v) for k, v in urls.items()},
voice_bindings=idx,
)
def load_or_site_config_from_path(path: Path) -> OrSiteConfig:
p = path.expanduser()
try:
raw_text = p.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"Cannot read OR site config {p}: {exc}") from exc
try:
data: Any = json.loads(raw_text)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in OR site config {p}: {exc}") from exc
return parse_or_site_config_object(data, source=p)
def merge_video_rtsp_urls_into_file(
path: Path,
url_map: dict[str, str],
*,
replace_host: str,
) -> None:
"""写入/更新站点配置中的 ``video_rtsp_urls``,保留 ``voice_or_room_bindings``。"""
if replace_host in ("", "127.0.0.1"):
out_urls = dict(url_map)
else:
out_urls = {
k: v.replace("127.0.0.1", replace_host, 1)
for k, v in url_map.items()
}
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_file():
raw_text = path.read_text(encoding="utf-8")
data: Any = json.loads(raw_text)
parse_or_site_config_object(data, source=path)
bindings_list = data["voice_or_room_bindings"]
else:
bindings_list = []
doc = {
"video_rtsp_urls": out_urls,
"voice_or_room_bindings": bindings_list,
}
text = json.dumps(doc, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
temp = path.with_name(path.name + ".tmp")
temp.write_text(text, encoding="utf-8")
temp.replace(path)
logger.info("Updated video_rtsp_urls in OR site config {}", path)