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:
103
app/or_site_config.py
Normal file
103
app/or_site_config.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""手术室站点配置:单一 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)
|
||||
Reference in New Issue
Block a user