import asyncio import time from dataclasses import dataclass from typing import Dict, Optional from uuid import uuid4 from config import settings from services.connection_manager import connection_manager from services.target_audio_cache import target_audio_cache from utils.logger import session_logger VOICE_TARGET_KIND_DEVICE = "device" VOICE_TARGET_KIND_PARENT = "parent" @dataclass class VoiceTarget: kind: str target_device_id: Optional[str] = None uuid: Optional[str] = None audio_cache_key: Optional[str] = None websocket: object = None session_id: Optional[str] = None expires_at: float = 0 class DeviceTargetCache: def __init__(self, *, timeout_seconds=None, clock=None): self.targets: Dict[str, VoiceTarget] = {} self.lock = asyncio.Lock() self.timeout_seconds = timeout_seconds if timeout_seconds is not None else settings.voice_target_timeout_seconds self.clock = clock or time.monotonic async def _remove_locked(self, device_id, target, reason, *, clear_audio=True): del self.targets[device_id] if clear_audio: await target_audio_cache.clear_audio_data(target.audio_cache_key) session_logger.info(device_id, target.session_id or "target", f"留言目标已清除: kind={target.kind}, reason={reason}") async def _get_locked(self, device_id): target = self.targets.get(device_id) if target and self.clock() >= target.expires_at: await self._remove_locked(device_id, target, "timeout") return None return target async def get_target(self, device_id: str) -> Optional[str]: target = await self.get_voice_target(device_id) return target.target_device_id if target and target.kind == VOICE_TARGET_KIND_DEVICE else None async def get_voice_target(self, device_id: str) -> Optional[VoiceTarget]: async with self.lock: return await self._get_locked(device_id) async def _set_target(self, device_id, *, kind, target_device_id=None, uuid=None): async with self.lock: old = self.targets.get(device_id) if old: await self._remove_locked(device_id, old, "replaced") self.targets[device_id] = VoiceTarget( kind=kind, target_device_id=target_device_id, uuid=uuid, audio_cache_key=f"voice:{device_id}:{uuid4().hex}", websocket=await connection_manager.get_connection(device_id), expires_at=self.clock() + self.timeout_seconds, ) session_logger.info(device_id, "target", f"留言目标已设置: kind={kind}, timeout={self.timeout_seconds}s") async def set_target(self, device_id: str, target_device_id: str): await self._set_target(device_id, kind=VOICE_TARGET_KIND_DEVICE, target_device_id=target_device_id) async def set_parent_target(self, device_id: str, uuid: str): await self._set_target(device_id, kind=VOICE_TARGET_KIND_PARENT, uuid=uuid) async def claim_target(self, device_id, websocket, session_id): """Bind one pending intent to one recording on one connection.""" async with self.lock: active = await connection_manager.get_connection(device_id) if active is not None and active is not websocket: return None target = await self._get_locked(device_id) if target is None: return None if target.websocket is not None and target.websocket is not websocket: await self._remove_locked(device_id, target, "connection_changed") return None if target.session_id is not None and target.session_id != session_id: await self._remove_locked(device_id, target, "new_recording") return None target.websocket, target.session_id = websocket, session_id target.expires_at = self.clock() + self.timeout_seconds session_logger.info(device_id, session_id, f"留言目标已关联录音: kind={target.kind}") return target async def bind_connection(self, device_id, websocket): """Adopt commands received before connect; discard older socket intents.""" async with self.lock: if await connection_manager.get_connection(device_id) is not websocket: return target = await self._get_locked(device_id) if target is None: return if target.websocket is not None and target.websocket is not websocket: await self._remove_locked(device_id, target, "connection_changed") else: target.websocket = websocket async def append_audio(self, device_id, target, websocket, session_id, audio): async with self.lock: current = await self._get_locked(device_id) if current is not target or target.websocket is not websocket or target.session_id != session_id: return False target.expires_at = self.clock() + self.timeout_seconds await target_audio_cache.add_audio_data(target.audio_cache_key, audio) return True async def take_target(self, device_id, target, websocket, session_id): """Consume the exact recording before slow upload/notification work.""" async with self.lock: current = await self._get_locked(device_id) if current is not target or target.websocket is not websocket or target.session_id != session_id: return None await self._remove_locked(device_id, target, "recording_ended", clear_audio=False) return target async def remove_target(self, device_id: str, *, expected=None, websocket=None, reason="cancelled"): async with self.lock: target = self.targets.get(device_id) if target is None or (expected is not None and target is not expected): return False if websocket is not None and target.websocket is not websocket: return False await self._remove_locked(device_id, target, reason) return True async def get_all_targets(self) -> list: async with self.lock: for device_id in list(self.targets): await self._get_locked(device_id) return list(self.targets.items()) # 单例实例 device_target_cache = DeviceTargetCache()