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:
@@ -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