146 lines
6.4 KiB
Python
146 lines
6.4 KiB
Python
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()
|