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:
70
app/api.py
70
app/api.py
@@ -2,7 +2,17 @@ import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
UploadFile,
|
||||
WebSocket,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -10,7 +20,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.baked import pipeline as bp
|
||||
from app.config import settings
|
||||
from app.database import check_database
|
||||
from app.dependencies import get_surgery_pipeline
|
||||
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
|
||||
from app.schemas import (
|
||||
HealthResponse,
|
||||
SurgeryApiResponse,
|
||||
@@ -20,9 +30,14 @@ from app.schemas import (
|
||||
SurgeryPendingConfirmationResponse,
|
||||
SurgeryResultResponse,
|
||||
SurgeryStartRequest,
|
||||
VoiceTerminalAssignmentResponse,
|
||||
build_consumption_summary,
|
||||
)
|
||||
from app.services.surgery_pipeline import SurgeryPipeline
|
||||
from app.services.voice_terminal_hub import (
|
||||
VoiceTerminalHub,
|
||||
assign_voice_terminal_after_recording_started,
|
||||
)
|
||||
from app.surgery_errors import SurgeryPipelineError
|
||||
|
||||
router = APIRouter()
|
||||
@@ -123,13 +138,13 @@ async def health() -> HealthResponse | JSONResponse:
|
||||
description="供 demo 页探测:是否启用 orchestrator、RTSP 文件配置等;此路由始终存在,不依赖 DEMO_ORCHESTRATOR_ENABLED。",
|
||||
)
|
||||
async def demo_orchestrator_status() -> dict:
|
||||
f = (settings.video_rtsp_urls_json_file or "").strip()
|
||||
f = (settings.or_site_config_json_file or "").strip()
|
||||
return {
|
||||
"orchestrator_enabled": bool(settings.demo_orchestrator_enabled),
|
||||
"orchestrate_method": "POST",
|
||||
"orchestrate_path": "/internal/demo/orchestrate-and-start",
|
||||
"video_rtsp_urls_json_file_set": bool(f),
|
||||
"video_rtsp_urls_json_file": f or None,
|
||||
"or_site_config_json_file_set": bool(f),
|
||||
"or_site_config_json_file": f or None,
|
||||
"orchestrator_rtsp_port": settings.demo_orchestrator_rtsp_port,
|
||||
"orchestrator_rtsp_json_host": settings.demo_orchestrator_rtsp_json_host,
|
||||
}
|
||||
@@ -157,6 +172,7 @@ async def demo_orchestrator_status() -> dict:
|
||||
async def start_surgery(
|
||||
payload: SurgeryStartRequest,
|
||||
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
|
||||
voice_hub: Annotated[VoiceTerminalHub, Depends(get_voice_terminal_hub)],
|
||||
) -> SurgeryApiResponse:
|
||||
logger.info(
|
||||
"Start surgery: surgery_id={}, cameras={}, candidates={}",
|
||||
@@ -180,6 +196,14 @@ async def start_surgery(
|
||||
)
|
||||
except SurgeryPipelineError as exc:
|
||||
_raise_surgery_pipeline_http(exc, payload.surgery_id)
|
||||
|
||||
await assign_voice_terminal_after_recording_started(
|
||||
voice_hub,
|
||||
surgery_id=payload.surgery_id,
|
||||
camera_ids=list(payload.camera_ids),
|
||||
set_voice_terminal_id=pipeline.set_voice_terminal_id,
|
||||
)
|
||||
|
||||
return SurgeryApiResponse(
|
||||
surgery_id=payload.surgery_id,
|
||||
status="accepted",
|
||||
@@ -209,11 +233,14 @@ async def start_surgery(
|
||||
async def end_surgery(
|
||||
payload: SurgeryEndRequest,
|
||||
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
|
||||
voice_hub: Annotated[VoiceTerminalHub, Depends(get_voice_terminal_hub)],
|
||||
) -> SurgeryApiResponse:
|
||||
logger.info("End surgery: surgery_id={}", payload.surgery_id)
|
||||
voice_terminal_id: str | None = None
|
||||
try:
|
||||
async def _stop() -> None:
|
||||
await pipeline.stop_recording(payload.surgery_id)
|
||||
nonlocal voice_terminal_id
|
||||
voice_terminal_id = await pipeline.stop_recording(payload.surgery_id)
|
||||
|
||||
await _call_recording_with_retries(
|
||||
_stop,
|
||||
@@ -223,6 +250,10 @@ async def end_surgery(
|
||||
)
|
||||
except SurgeryPipelineError as exc:
|
||||
_raise_surgery_pipeline_http(exc, payload.surgery_id)
|
||||
|
||||
if voice_terminal_id:
|
||||
await voice_hub.notify_end(voice_terminal_id, payload.surgery_id)
|
||||
|
||||
return SurgeryApiResponse(
|
||||
surgery_id=payload.surgery_id,
|
||||
status="accepted",
|
||||
@@ -230,6 +261,33 @@ async def end_surgery(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/client/voice-terminals/{terminal_id}/assignment",
|
||||
response_model=VoiceTerminalAssignmentResponse,
|
||||
tags=["client"],
|
||||
summary="查询语音终端当前指派的手术",
|
||||
description="供桌面客户端在 WebSocket 不可用时的轮询兜底;与 WS 推送的 assignment 状态一致。",
|
||||
)
|
||||
async def get_voice_terminal_assignment(
|
||||
terminal_id: Annotated[str, Path(min_length=1, max_length=256)],
|
||||
hub: Annotated[VoiceTerminalHub, Depends(get_voice_terminal_hub)],
|
||||
) -> VoiceTerminalAssignmentResponse:
|
||||
tid = terminal_id.strip()
|
||||
return VoiceTerminalAssignmentResponse(
|
||||
voice_terminal_id=tid,
|
||||
active_surgery_id=hub.get_assignment(tid),
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/client/voice-terminals/ws")
|
||||
async def voice_terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
terminal_id: Annotated[str, Query(..., min_length=1, max_length=256)],
|
||||
) -> None:
|
||||
container = websocket.app.state.container
|
||||
await container.voice_terminal_hub.handle_websocket(websocket, terminal_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/client/surgeries/{surgery_id}/result",
|
||||
response_model=SurgeryResultResponse,
|
||||
|
||||
@@ -15,8 +15,9 @@ def default_consumable_classifier_labels_yaml_path() -> str:
|
||||
return str(_PACKAGE_DIR / "resources" / "consumable_classifier_labels.yaml")
|
||||
|
||||
|
||||
def default_camera_rtsp_urls_sample_path() -> str:
|
||||
return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json")
|
||||
def default_or_site_config_sample_path() -> str:
|
||||
"""站点配置示例:video_rtsp_urls + voice_or_room_bindings。"""
|
||||
return str(_PACKAGE_DIR / "resources" / "or_site_config.sample.json")
|
||||
|
||||
|
||||
# --- 耗材分类(YOLO-cls)---
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.services.surgery_pipeline import SurgeryPipeline
|
||||
from app.services.video.hikvision_runtime import HikvisionRuntime
|
||||
from app.services.video.session_manager import CameraSessionManager
|
||||
from app.services.voice_resolution import VoiceConfirmationService
|
||||
from app.services.voice_terminal_hub import VoiceTerminalHub
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,6 +43,7 @@ class AppContainer:
|
||||
tear_segment_model_bundle: TearGatedSegmentModelBundle | None
|
||||
voice_confirmation_service: VoiceConfirmationService
|
||||
surgery_pipeline: SurgeryPipeline
|
||||
voice_terminal_hub: VoiceTerminalHub
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.camera_session_manager.start_archive_retry_loop()
|
||||
@@ -96,6 +98,7 @@ def build_container(
|
||||
voice_confirmation=voice,
|
||||
session_factory=sf,
|
||||
)
|
||||
voice_hub = VoiceTerminalHub(s)
|
||||
return AppContainer(
|
||||
settings=s,
|
||||
consumable_vision_algorithm_service=vision,
|
||||
@@ -108,6 +111,7 @@ def build_container(
|
||||
tear_segment_model_bundle=tear_bundle,
|
||||
voice_confirmation_service=voice,
|
||||
surgery_pipeline=pipeline,
|
||||
voice_terminal_hub=voice_hub,
|
||||
)
|
||||
|
||||
|
||||
@@ -140,3 +144,7 @@ def get_surgery_result_repository(request: Request) -> SurgeryResultRepository:
|
||||
|
||||
def get_voice_confirmation_service(request: Request) -> VoiceConfirmationService:
|
||||
return get_container(request).voice_confirmation_service
|
||||
|
||||
|
||||
def get_voice_terminal_hub(request: Request) -> VoiceTerminalHub:
|
||||
return get_container(request).voice_terminal_hub
|
||||
|
||||
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)
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"or-cam-01": "rtsp://admin:ChangeMe@192.168.1.101:554/Streaming/Channels/101",
|
||||
"or-cam-02": "rtsp://admin:ChangeMe@192.168.1.102:554/Streaming/Channels/101"
|
||||
}
|
||||
15
app/resources/or_site_config.sample.json
Normal file
15
app/resources/or_site_config.sample.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"video_rtsp_urls": {
|
||||
"or-cam-01": "rtsp://127.0.0.1:18554/demo1"
|
||||
},
|
||||
"voice_or_room_bindings": [
|
||||
{
|
||||
"camera_ids": [
|
||||
"or-cam-01",
|
||||
"or-cam-02"
|
||||
],
|
||||
"or_room_id": "OR-DEMO",
|
||||
"voice_terminal_id": "desktop-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,10 +13,15 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_surgery_pipeline
|
||||
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
|
||||
from app.schemas import SurgeryApiResponse, SurgeryStartRequest
|
||||
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager, write_rtsp_url_json_file
|
||||
from app.or_site_config import merge_video_rtsp_urls_into_file
|
||||
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager
|
||||
from app.services.surgery_pipeline import SurgeryPipeline
|
||||
from app.services.voice_terminal_hub import (
|
||||
VoiceTerminalHub,
|
||||
assign_voice_terminal_after_recording_started,
|
||||
)
|
||||
from app.surgery_errors import SurgeryPipelineError
|
||||
|
||||
router = APIRouter(prefix="/internal/demo", tags=["demo"])
|
||||
@@ -39,7 +44,8 @@ def _orchestrate_write_rtsp_host() -> str:
|
||||
summary="一键联调:上传 1–4 路视频并开录",
|
||||
description=(
|
||||
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存一路或多路视频、启动 MediaMTX+ffmpeg、"
|
||||
"将 RTSP 映射写入 VIDEO_RTSP_URLS_JSON_FILE,再执行与 /client/surgeries/start 相同的开录逻辑。"
|
||||
"将 RTSP 映射合并写入 OR_SITE_CONFIG_JSON_FILE 的 video_rtsp_urls,再执行与 /client/surgeries/start 相同的开录逻辑"
|
||||
"(含按 voice_or_room_bindings 解析并 WebSocket 推送语音终端指派)。"
|
||||
),
|
||||
)
|
||||
async def orchestrate_and_start(
|
||||
@@ -58,6 +64,7 @@ async def orchestrate_and_start(
|
||||
rtsp_path_4: Annotated[str, Form()] = "demo4",
|
||||
candidate_consumables_json: Annotated[str, Form()] = "[]",
|
||||
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
|
||||
voice_hub: VoiceTerminalHub = Depends(get_voice_terminal_hub),
|
||||
) -> SurgeryApiResponse:
|
||||
logger.info(
|
||||
"demo orchestrate-and-start: surgery_id={} cameras={} rpaths={}",
|
||||
@@ -70,12 +77,13 @@ async def orchestrate_and_start(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
|
||||
)
|
||||
path_raw = (settings.video_rtsp_urls_json_file or "").strip()
|
||||
path_raw = (settings.or_site_config_json_file or "").strip()
|
||||
if not path_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"VIDEO_RTSP_URLS_JSON_FILE must be set to a writable path; "
|
||||
"OR_SITE_CONFIG_JSON_FILE must be set to a writable path "
|
||||
"(strict site JSON with video_rtsp_urls + voice_or_room_bindings); "
|
||||
"in Docker, bind-mount a host file to this path."
|
||||
),
|
||||
)
|
||||
@@ -195,7 +203,7 @@ async def orchestrate_and_start(
|
||||
try:
|
||||
|
||||
def _write() -> None:
|
||||
write_rtsp_url_json_file(
|
||||
merge_video_rtsp_urls_into_file(
|
||||
json_path,
|
||||
url_map_host,
|
||||
replace_host=host_for_json,
|
||||
@@ -224,6 +232,13 @@ async def orchestrate_and_start(
|
||||
detail={"code": exc.code, "message": exc.message, "surgery_id": body.surgery_id},
|
||||
) from exc
|
||||
|
||||
await assign_voice_terminal_after_recording_started(
|
||||
voice_hub,
|
||||
surgery_id=body.surgery_id,
|
||||
camera_ids=list(body.camera_ids),
|
||||
set_voice_terminal_id=pipeline.set_voice_terminal_id,
|
||||
)
|
||||
|
||||
return SurgeryApiResponse(
|
||||
surgery_id=body.surgery_id,
|
||||
status="accepted",
|
||||
|
||||
@@ -61,6 +61,13 @@ class SurgeryApiResponse(BaseModel):
|
||||
message: str = Field(description="返回说明。")
|
||||
|
||||
|
||||
class VoiceTerminalAssignmentResponse(BaseModel):
|
||||
"""语音桌面终端当前被指派的手术(HTTP 轮询兜底,与 WebSocket 推送一致)。"""
|
||||
|
||||
voice_terminal_id: str
|
||||
active_surgery_id: str | None = None
|
||||
|
||||
|
||||
class SurgeryClientErrorDetail(BaseModel):
|
||||
"""与 `HTTPException(detail={...})` 对应;最终 JSON 为 `{"detail": {...}}`。"""
|
||||
|
||||
|
||||
@@ -77,13 +77,16 @@ class SurgeryPipeline:
|
||||
f"开录未能确认:{exc}",
|
||||
) from exc
|
||||
|
||||
async def stop_recording(self, surgery_id: str) -> None:
|
||||
"""停止该手术关联的摄像头录制。仅在确认已全部停录时返回。"""
|
||||
async def stop_recording(self, surgery_id: str) -> str | None:
|
||||
"""停止该手术关联的摄像头录制。仅在确认已全部停录时返回。返回绑定的语音终端 ID(若有)。"""
|
||||
try:
|
||||
await self._sessions.stop_surgery(surgery_id, require_active=True)
|
||||
return await self._sessions.stop_surgery(surgery_id, require_active=True)
|
||||
except SurgeryPipelineError:
|
||||
raise
|
||||
|
||||
def set_voice_terminal_id(self, surgery_id: str, terminal_id: str | None) -> None:
|
||||
self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
|
||||
|
||||
async def get_consumption_details_for_client(
|
||||
self,
|
||||
surgery_id: str,
|
||||
|
||||
@@ -6,7 +6,6 @@ process is gone — reconnect or re-orchestrate for another playthrough.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
@@ -222,25 +221,3 @@ class SyntheticRtspManager:
|
||||
|
||||
self._active = run
|
||||
return run, url_map
|
||||
|
||||
|
||||
def write_rtsp_url_json_file(
|
||||
path: Path,
|
||||
url_map: dict[str, str],
|
||||
*,
|
||||
replace_host: str,
|
||||
) -> None:
|
||||
"""Write JSON map; replace 127.0.0.1 in values with `replace_host` (e.g. host.docker.internal)."""
|
||||
if replace_host in ("", "127.0.0.1"):
|
||||
out = url_map
|
||||
else:
|
||||
out = {
|
||||
k: v.replace("127.0.0.1", replace_host, 1)
|
||||
for k, v in url_map.items()
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(out, 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("Wrote RTSP map to {}", path)
|
||||
|
||||
@@ -54,7 +54,7 @@ class BackendResolver:
|
||||
return VideoBackendKind.RTSP
|
||||
|
||||
def rtsp_url_for_camera(self, camera_id: str) -> str:
|
||||
# Re-read on each use so VIDEO_RTSP_URLS_JSON_FILE can be hot-updated (e.g. dev orchestrator).
|
||||
# Re-read on each use so OR_SITE_CONFIG_JSON_FILE can be hot-updated (e.g. dev orchestrator).
|
||||
m = self._s.video_rtsp_url_map()
|
||||
if camera_id in m:
|
||||
return m[camera_id]
|
||||
@@ -67,8 +67,8 @@ class BackendResolver:
|
||||
f"video_rtsp_url_template missing placeholder: {exc}"
|
||||
) from exc
|
||||
raise ValueError(
|
||||
f"No RTSP URL for camera_id={camera_id!r}: set VIDEO_RTSP_URLS_JSON_FILE, "
|
||||
f"VIDEO_RTSP_URLS_JSON, or VIDEO_RTSP_URL_TEMPLATE"
|
||||
f"No RTSP URL for camera_id={camera_id!r}: set OR_SITE_CONFIG_JSON_FILE "
|
||||
f"(video_rtsp_urls) or VIDEO_RTSP_URL_TEMPLATE"
|
||||
)
|
||||
|
||||
def rtsp_url_after_hikvision_login(self, camera_id: str) -> str:
|
||||
|
||||
@@ -223,7 +223,17 @@ class CameraSessionManager:
|
||||
await self.stop_surgery(surgery_id, require_active=True)
|
||||
raise
|
||||
|
||||
async def stop_surgery(self, surgery_id: str, *, require_active: bool = True) -> None:
|
||||
def set_voice_terminal_id(self, surgery_id: str, terminal_id: str | None) -> None:
|
||||
"""开录成功后写入,供停录时向对应桌面终端推送 end。"""
|
||||
run = self._registry.get_running(surgery_id)
|
||||
if run is None:
|
||||
return
|
||||
tid = (terminal_id or "").strip()
|
||||
run.state.voice_terminal_id = tid or None
|
||||
|
||||
async def stop_surgery(
|
||||
self, surgery_id: str, *, require_active: bool = True
|
||||
) -> str | None:
|
||||
run = await self._registry.unregister(surgery_id)
|
||||
if run is None:
|
||||
if require_active:
|
||||
@@ -231,8 +241,9 @@ class CameraSessionManager:
|
||||
"RECORDING_NOT_STOPPED",
|
||||
"停录未能完成:当前没有该手术的活跃录制会话。",
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
voice_tid = run.state.voice_terminal_id
|
||||
run.stop_event.set()
|
||||
results = await asyncio.gather(*run.tasks, return_exceptions=True)
|
||||
for res in results:
|
||||
@@ -255,6 +266,7 @@ class CameraSessionManager:
|
||||
append_consumption_log_summary(surgery_id, totals)
|
||||
print_consumption_summary_markdown(totals)
|
||||
await self._archive.persist_or_archive(surgery_id, details)
|
||||
return voice_tid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PendingConfirmationStore 协议委托
|
||||
|
||||
@@ -85,6 +85,8 @@ class SurgerySessionState:
|
||||
last_voice_error: str | None = None
|
||||
#: ``start_surgery`` 创建会话时的 ``time.time()``,用于日志中「相对开录的流逝时间」。
|
||||
surgery_started_wall: float | None = None
|
||||
#: 术间绑定配置解析出的语音桌面终端 ID;停录时用于推送 end。
|
||||
voice_terminal_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
84
app/services/voice_terminal_binding.py
Normal file
84
app/services/voice_terminal_binding.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""术间配置:camera_ids 集合与语音桌面终端 ID 绑定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrRoomBinding:
|
||||
or_room_id: str
|
||||
camera_ids: frozenset[str]
|
||||
voice_terminal_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceTerminalBindingIndex:
|
||||
"""由 ``or_site_config.voice_or_room_bindings`` 数组构建。"""
|
||||
|
||||
rooms: tuple[OrRoomBinding, ...]
|
||||
|
||||
def resolve_terminal(self, camera_ids: list[str]) -> str | None:
|
||||
"""精确匹配 camera 集合;否则开录路数为术间子集时匹配最小超集术间。"""
|
||||
key = frozenset(str(x).strip() for x in camera_ids if str(x).strip())
|
||||
if not key:
|
||||
return None
|
||||
for r in self.rooms:
|
||||
if r.camera_ids == key:
|
||||
return r.voice_terminal_id
|
||||
candidates = [r for r in self.rooms if key <= r.camera_ids]
|
||||
if not candidates:
|
||||
return None
|
||||
if len(candidates) == 1:
|
||||
return candidates[0].voice_terminal_id
|
||||
candidates.sort(key=lambda r: (len(r.camera_ids), r.or_room_id, r.voice_terminal_id))
|
||||
return candidates[0].voice_terminal_id
|
||||
|
||||
@staticmethod
|
||||
def from_binding_list(data: list[Any]) -> VoiceTerminalBindingIndex | None:
|
||||
rows: list[OrRoomBinding] = []
|
||||
seen_terminals: set[str] = set()
|
||||
seen_camera_sets: set[frozenset[str]] = set()
|
||||
for i, item in enumerate(data):
|
||||
if not isinstance(item, dict):
|
||||
logger.warning("voice_or_room_bindings[{}] must be an object", i)
|
||||
return None
|
||||
rid = str(item.get("or_room_id") or "").strip()
|
||||
tid = str(item.get("voice_terminal_id") or "").strip()
|
||||
cams = item.get("camera_ids")
|
||||
if not rid or not tid or not isinstance(cams, list):
|
||||
logger.warning(
|
||||
"voice_or_room_bindings[{}] missing or invalid fields", i
|
||||
)
|
||||
return None
|
||||
cam_set = frozenset(str(x).strip() for x in cams if str(x).strip())
|
||||
if not cam_set:
|
||||
logger.warning(
|
||||
"voice_or_room_bindings[{}] camera_ids must be non-empty", i
|
||||
)
|
||||
return None
|
||||
if tid in seen_terminals:
|
||||
logger.warning(
|
||||
"voice_or_room_bindings: duplicate voice_terminal_id {!r}",
|
||||
tid,
|
||||
)
|
||||
return None
|
||||
if cam_set in seen_camera_sets:
|
||||
logger.warning(
|
||||
"voice_or_room_bindings: duplicate camera_ids set for room {!r}",
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
seen_terminals.add(tid)
|
||||
seen_camera_sets.add(cam_set)
|
||||
rows.append(
|
||||
OrRoomBinding(
|
||||
or_room_id=rid,
|
||||
camera_ids=cam_set,
|
||||
voice_terminal_id=tid,
|
||||
)
|
||||
)
|
||||
return VoiceTerminalBindingIndex(rooms=tuple(rows))
|
||||
152
app/services/voice_terminal_hub.py
Normal file
152
app/services/voice_terminal_hub.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""语音桌面终端:assignment 状态、WebSocket 推送与 HTTP 轮询兜底。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from asyncio import Lock
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.config import Settings
|
||||
from app.services.voice_terminal_binding import VoiceTerminalBindingIndex
|
||||
|
||||
|
||||
async def assign_voice_terminal_after_recording_started(
|
||||
hub: VoiceTerminalHub,
|
||||
*,
|
||||
surgery_id: str,
|
||||
camera_ids: list[str],
|
||||
set_voice_terminal_id: Callable[[str, str | None], None],
|
||||
) -> None:
|
||||
"""开录成功后:按站点绑定解析终端、写入会话、并 WebSocket 推送 start(与 HTTP 开录一致)。"""
|
||||
voice_tid = hub.resolve_terminal(list(camera_ids))
|
||||
if voice_tid:
|
||||
set_voice_terminal_id(surgery_id, voice_tid)
|
||||
await hub.notify_start(voice_tid, surgery_id)
|
||||
elif hub.bindings is not None:
|
||||
logger.warning(
|
||||
"voice or room bindings have no camera set matching start "
|
||||
"surgery_id={} camera_ids={}",
|
||||
surgery_id,
|
||||
camera_ids,
|
||||
)
|
||||
|
||||
|
||||
class VoiceTerminalHub:
|
||||
"""进程内终端连接与当前手术分配(多 worker 需另行同步)。"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
cfg = settings.load_or_site_config()
|
||||
self._bindings = cfg.voice_bindings if cfg else None
|
||||
self._assignments: dict[str, str] = {}
|
||||
self._lock = Lock()
|
||||
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
|
||||
@property
|
||||
def bindings(self) -> VoiceTerminalBindingIndex | None:
|
||||
return self._bindings
|
||||
|
||||
def resolve_terminal(self, camera_ids: list[str]) -> str | None:
|
||||
if self._bindings is None:
|
||||
return None
|
||||
return self._bindings.resolve_terminal(camera_ids)
|
||||
|
||||
def get_assignment(self, terminal_id: str) -> str | None:
|
||||
return self._assignments.get(terminal_id.strip())
|
||||
|
||||
async def notify_start(self, terminal_id: str, surgery_id: str) -> None:
|
||||
tid = terminal_id.strip()
|
||||
if not tid:
|
||||
return
|
||||
payload = {
|
||||
"type": "voice_assignment",
|
||||
"action": "start",
|
||||
"surgery_id": surgery_id,
|
||||
}
|
||||
async with self._lock:
|
||||
self._assignments[tid] = surgery_id
|
||||
await self._broadcast(tid, payload)
|
||||
logger.info(
|
||||
"Voice terminal {} assigned surgery {} (start push)",
|
||||
tid,
|
||||
surgery_id,
|
||||
)
|
||||
|
||||
async def notify_end(self, terminal_id: str | None, surgery_id: str) -> None:
|
||||
if not terminal_id:
|
||||
return
|
||||
tid = terminal_id.strip()
|
||||
if not tid:
|
||||
return
|
||||
payload = {
|
||||
"type": "voice_assignment",
|
||||
"action": "end",
|
||||
"surgery_id": surgery_id,
|
||||
}
|
||||
async with self._lock:
|
||||
if self._assignments.get(tid) == surgery_id:
|
||||
del self._assignments[tid]
|
||||
await self._broadcast(tid, payload)
|
||||
logger.info(
|
||||
"Voice terminal {} released surgery {} (end push)",
|
||||
tid,
|
||||
surgery_id,
|
||||
)
|
||||
|
||||
async def handle_websocket(self, websocket: WebSocket, terminal_id: str) -> None:
|
||||
tid = terminal_id.strip()
|
||||
if not tid:
|
||||
await websocket.close(code=4400)
|
||||
return
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
self._connections[tid].add(websocket)
|
||||
try:
|
||||
# 连接后立即推送当前 assignment,避免错过 start
|
||||
sid = self._assignments.get(tid)
|
||||
if sid:
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "voice_assignment",
|
||||
"action": "start",
|
||||
"surgery_id": sid,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
# 不能用 receive_text():桌面端 websocket-client 会发 ping/二进制控制帧,
|
||||
# ASGI 可能呈现为无 "text" 的 websocket.receive,receive_text 会 KeyError 并掐断连接。
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
async with self._lock:
|
||||
conns = self._connections.get(tid)
|
||||
if conns:
|
||||
conns.discard(websocket)
|
||||
if not conns:
|
||||
del self._connections[tid]
|
||||
|
||||
async def _broadcast(self, terminal_id: str, payload: dict) -> None:
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
async with self._lock:
|
||||
targets = list(self._connections.get(terminal_id, ()))
|
||||
dead: list[WebSocket] = []
|
||||
for ws in targets:
|
||||
try:
|
||||
await ws.send_text(text)
|
||||
except Exception as exc:
|
||||
logger.debug("voice terminal ws send failed: {}", exc)
|
||||
dead.append(ws)
|
||||
if dead:
|
||||
async with self._lock:
|
||||
for ws in dead:
|
||||
self._connections[terminal_id].discard(ws)
|
||||
Reference in New Issue
Block a user