Files
operating-room-monitor-server/app/services/voice_terminal_hub.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

153 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""语音桌面终端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.receivereceive_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)