mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 18:13:52 +08:00
feat: add optional native mqtt and udp transport
This commit is contained in:
@@ -229,6 +229,23 @@ class ConnectionService:
|
||||
await initialization_task
|
||||
initialization_task = None
|
||||
|
||||
is_native_hello = (
|
||||
transport.keeps_connection_between_sessions
|
||||
and is_hello
|
||||
)
|
||||
if is_native_hello:
|
||||
try:
|
||||
# Every Native Hello is a logical-session boundary.
|
||||
# Always query Agent config here; the refresh helper
|
||||
# cheaply retains an unchanged healthy runtime.
|
||||
component_manager = await self._refresh_private_runtime(
|
||||
context,
|
||||
component_manager,
|
||||
bind_completed_event,
|
||||
)
|
||||
finally:
|
||||
await transport.mark_session_ready(transport.session_id)
|
||||
|
||||
if getattr(context, "init_error", None):
|
||||
# 配置错误时,允许hello/listen触发默认语音(节流)
|
||||
msg_type = msg_json.get("type") if isinstance(msg_json, dict) else None
|
||||
@@ -337,7 +354,9 @@ class ConnectionService:
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"会话清理失败: {e}")
|
||||
|
||||
# Clean the manager currently owned by the context.
|
||||
# A cancelled Native runtime refresh may have already installed a
|
||||
# healthy replacement before this local variable was reassigned.
|
||||
# Always clean the manager currently owned by the context.
|
||||
active_component_manager = (
|
||||
getattr(context, "component_manager", None) or component_manager
|
||||
)
|
||||
@@ -785,6 +804,201 @@ class ConnectionService:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _refresh_private_runtime(
|
||||
self,
|
||||
context: SessionContext,
|
||||
component_manager,
|
||||
bind_completed_event: asyncio.Event,
|
||||
):
|
||||
"""Refresh Agent config at a Native MQTT logical-session boundary."""
|
||||
if not context.read_config_from_api:
|
||||
return component_manager
|
||||
|
||||
try:
|
||||
private_config = await get_private_config_from_api(
|
||||
context.common_config,
|
||||
context.device_id,
|
||||
context.headers.get("client-id", context.device_id),
|
||||
)
|
||||
except DeviceNotFoundException:
|
||||
context.need_bind = True
|
||||
return component_manager
|
||||
except DeviceBindException as exc:
|
||||
context.need_bind = True
|
||||
context.bind_code = getattr(exc, "bind_code", None)
|
||||
return component_manager
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).warning(f"刷新差异化配置失败,继续使用当前配置: {exc}")
|
||||
return component_manager
|
||||
|
||||
if not private_config:
|
||||
return component_manager
|
||||
|
||||
private_config["delete_audio"] = bool(
|
||||
context.common_config.get("delete_audio", True)
|
||||
)
|
||||
config_changed = private_config != context.private_config
|
||||
if not config_changed and getattr(context, "init_error", None) is None:
|
||||
context.need_bind = False
|
||||
context.bind_code = None
|
||||
bind_completed_event.set()
|
||||
return component_manager
|
||||
|
||||
await self._finalize_conversation_session(context, context.session_id)
|
||||
await context.cancel_conversation_tasks()
|
||||
|
||||
try:
|
||||
staged_context = copy.copy(context)
|
||||
staged_context.config = copy.deepcopy(context.common_config)
|
||||
staged_context.private_config = copy.deepcopy(private_config)
|
||||
self._reset_config_derived_state(staged_context)
|
||||
staged_context.config.update(copy.deepcopy(private_config))
|
||||
self._reset_config_derived_state(staged_context)
|
||||
self._merge_private_modules(
|
||||
staged_context, copy.deepcopy(private_config)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"物化刷新配置失败,继续使用旧运行时: {exc}"
|
||||
)
|
||||
return component_manager
|
||||
|
||||
try:
|
||||
new_manager = ComponentRegistry.create_component_manager(
|
||||
staged_context.config
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).error(f"创建刷新业务组件失败,保留旧运行时: {exc}")
|
||||
return component_manager
|
||||
|
||||
runtime_fields = (
|
||||
"config",
|
||||
"private_config",
|
||||
"component_manager",
|
||||
"asr",
|
||||
"tts",
|
||||
"func_handler",
|
||||
"voiceprint_provider",
|
||||
"prompt",
|
||||
"intent_type",
|
||||
"load_function_plugin",
|
||||
"inject_tool_call_fewshot",
|
||||
"dialogue",
|
||||
"max_output_size",
|
||||
"chat_history_conf",
|
||||
"cmd_exit",
|
||||
"need_bind",
|
||||
"bind_code",
|
||||
"init_error",
|
||||
"init_error_notified",
|
||||
"_init_error_last_audio_ts",
|
||||
)
|
||||
missing = object()
|
||||
previous = {
|
||||
field: getattr(context, field, missing) for field in runtime_fields
|
||||
}
|
||||
previous_callbacks = list(context._cleanup_callbacks)
|
||||
old_func_handler = context.func_handler
|
||||
|
||||
context.config = staged_context.config
|
||||
context.private_config = staged_context.private_config
|
||||
context.max_output_size = staged_context.max_output_size
|
||||
context.chat_history_conf = staged_context.chat_history_conf
|
||||
context.cmd_exit = staged_context.cmd_exit
|
||||
context.component_manager = new_manager
|
||||
context.asr = None
|
||||
context.tts = None
|
||||
context.func_handler = None
|
||||
context.voiceprint_provider = None
|
||||
context.prompt = None
|
||||
context.intent_type = "nointent"
|
||||
context.load_function_plugin = False
|
||||
context.inject_tool_call_fewshot = None
|
||||
context.dialogue = Dialogue()
|
||||
context.init_error = None
|
||||
|
||||
try:
|
||||
await self._initialize_components(context, new_manager)
|
||||
except (asyncio.CancelledError, Exception) as exc:
|
||||
was_cancelled = isinstance(exc, asyncio.CancelledError)
|
||||
retry_callbacks = []
|
||||
new_callbacks = [
|
||||
callback
|
||||
for callback in context._cleanup_callbacks
|
||||
if callback not in previous_callbacks
|
||||
]
|
||||
for callback in reversed(new_callbacks):
|
||||
try:
|
||||
result = callback()
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except (asyncio.CancelledError, Exception) as cleanup_exc:
|
||||
retry_callbacks.append(callback)
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"回滚刷新工具处理器失败: {cleanup_exc}"
|
||||
)
|
||||
try:
|
||||
await new_manager.cleanup_all()
|
||||
except (asyncio.CancelledError, Exception) as cleanup_exc:
|
||||
retry_callbacks.append(new_manager.cleanup_all)
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"回滚刷新业务组件失败: {cleanup_exc}"
|
||||
)
|
||||
for field, value in previous.items():
|
||||
if value is missing:
|
||||
if hasattr(context, field):
|
||||
delattr(context, field)
|
||||
else:
|
||||
setattr(context, field, value)
|
||||
context._cleanup_callbacks = previous_callbacks + retry_callbacks
|
||||
if was_cancelled:
|
||||
logger.bind(tag=TAG).info(
|
||||
"刷新业务组件被取消,已回滚并保留旧运行时"
|
||||
)
|
||||
raise
|
||||
logger.bind(tag=TAG).error(
|
||||
f"刷新业务组件失败,继续使用旧运行时: {exc}"
|
||||
)
|
||||
return component_manager
|
||||
|
||||
# Only retire the previous runtime after the replacement is healthy.
|
||||
# Register the old manager before the first await so cancellation at
|
||||
# any point leaves a cleanup owner for handle_connection.finally.
|
||||
if (
|
||||
component_manager
|
||||
and component_manager.cleanup_all not in context._cleanup_callbacks
|
||||
):
|
||||
context.register_cleanup(component_manager.cleanup_all)
|
||||
if old_func_handler and hasattr(old_func_handler, "cleanup"):
|
||||
try:
|
||||
await old_func_handler.cleanup()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).warning(f"清理旧工具处理器失败: {exc}")
|
||||
else:
|
||||
context.unregister_cleanup(old_func_handler.cleanup)
|
||||
if component_manager:
|
||||
try:
|
||||
await component_manager.cleanup_all()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).warning(f"清理旧业务组件失败: {exc}")
|
||||
else:
|
||||
context.unregister_cleanup(component_manager.cleanup_all)
|
||||
|
||||
context.init_error = None
|
||||
context.init_error_notified = False
|
||||
context._init_error_last_audio_ts = 0.0
|
||||
context.need_bind = False
|
||||
context.bind_code = None
|
||||
bind_completed_event.set()
|
||||
logger.bind(tag=TAG).info(
|
||||
"Native MQTT逻辑会话已原子刷新私有配置和业务组件"
|
||||
)
|
||||
return new_manager
|
||||
|
||||
@staticmethod
|
||||
def _reset_config_derived_state(context: SessionContext) -> None:
|
||||
"""Reset fields derived from config before applying a replacement."""
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
def normalize_device_id(device_id: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(device_id, str):
|
||||
return None
|
||||
normalized = device_id.strip().lower().replace("-", ":")
|
||||
return normalized or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingCall:
|
||||
caller_mac: str
|
||||
target_mac: str
|
||||
caller_nickname: str
|
||||
created_at: float
|
||||
generation: int
|
||||
|
||||
|
||||
class NativeMqttCallManager:
|
||||
def __init__(
|
||||
self,
|
||||
connection_registry,
|
||||
timeout_seconds: float = 60,
|
||||
silence_frame: Optional[bytes] = None,
|
||||
clock=time.monotonic,
|
||||
):
|
||||
self.connection_registry = connection_registry
|
||||
self.timeout_seconds = max(1.0, float(timeout_seconds))
|
||||
self.silence_frame = silence_frame
|
||||
self.clock = clock
|
||||
self.pending_calls: Dict[str, PendingCall] = {}
|
||||
self.active_calls: Dict[str, str] = {}
|
||||
self.call_session_ids: Dict[str, str] = {}
|
||||
self.call_generations: Dict[str, int] = {}
|
||||
self._next_generation = 1
|
||||
self._generation_end_events: Dict[int, asyncio.Event] = {}
|
||||
self._end_tasks: Dict[tuple[str, int], asyncio.Task] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def request_call(
|
||||
self,
|
||||
caller_mac: str,
|
||||
target_mac: str,
|
||||
caller_nickname: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
caller = normalize_device_id(caller_mac)
|
||||
target = normalize_device_id(target_mac)
|
||||
if not caller or not target or caller == target:
|
||||
return {"status": "error", "message": "呼叫设备参数无效"}
|
||||
|
||||
caller_entry = self.connection_registry.resolve_device_now(caller)
|
||||
target_entry = self.connection_registry.resolve_device_now(target)
|
||||
if target_entry is None:
|
||||
return {"status": "offline", "message": "对方设备不在线,请稍后重试"}
|
||||
if caller_entry is None:
|
||||
return {"status": "error", "message": "主叫设备不在线"}
|
||||
|
||||
async with self._lock:
|
||||
if caller in self.active_calls or target in self.active_calls:
|
||||
return {"status": "error", "message": "设备已在通话中"}
|
||||
caller_pending = self._pending_owner_locked(caller)
|
||||
target_pending = self._pending_owner_locked(target)
|
||||
reverse = (
|
||||
caller_pending == target
|
||||
and target_pending == target
|
||||
and self.pending_calls[target].target_mac == caller
|
||||
)
|
||||
if reverse:
|
||||
pending = self.pending_calls.pop(target)
|
||||
self.active_calls[caller] = target
|
||||
self.active_calls[target] = caller
|
||||
generation = pending.generation
|
||||
status = "bridged"
|
||||
elif caller_pending or target_pending:
|
||||
return {"status": "error", "message": "设备已有等待中的通话"}
|
||||
else:
|
||||
generation = self._allocate_generation_locked()
|
||||
self.pending_calls[caller] = PendingCall(
|
||||
caller_mac=caller,
|
||||
target_mac=target,
|
||||
caller_nickname=caller_nickname or "",
|
||||
created_at=self.clock(),
|
||||
generation=generation,
|
||||
)
|
||||
status = "pending"
|
||||
self.call_generations[caller] = generation
|
||||
self.call_generations[target] = generation
|
||||
self._capture_session(caller, caller_entry)
|
||||
self._capture_session(target, target_entry)
|
||||
self._set_call_state(caller_entry, True)
|
||||
if status == "bridged":
|
||||
self._set_call_state(target_entry, True)
|
||||
|
||||
try:
|
||||
await self._stop_ai_session(
|
||||
caller_entry, self.call_session_ids.get(caller)
|
||||
)
|
||||
if status == "bridged":
|
||||
await self._stop_ai_session(
|
||||
target_entry, self.call_session_ids.get(target)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
caller,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
caller,
|
||||
notify_device=True,
|
||||
notify_peer=status == "bridged",
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT停止AI会话失败: caller={}, target={}, error={}",
|
||||
caller,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "停止AI会话失败"}
|
||||
|
||||
async with self._lock:
|
||||
if status == "pending":
|
||||
valid = self._pending_matches_locked(
|
||||
caller, target, generation
|
||||
)
|
||||
else:
|
||||
valid = self._active_matches_locked(
|
||||
caller, target, generation
|
||||
)
|
||||
if not valid:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
|
||||
if status == "pending":
|
||||
try:
|
||||
sent = await self._send_while_generation_active(
|
||||
target_entry.transport,
|
||||
{
|
||||
"type": "mcp",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9999,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "self.remote_wakeup",
|
||||
"arguments": {
|
||||
"reason": (
|
||||
"[device_call]您收到来自"
|
||||
f"{caller_nickname or '未知'}的来电,是否接听?"
|
||||
),
|
||||
"action": "listen",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
generation,
|
||||
)
|
||||
if not sent:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
caller,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
caller,
|
||||
"发送来电通知失败",
|
||||
notify_device=True,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT来电通知发送失败: caller={}, target={}, error={}",
|
||||
caller,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "发送来电通知失败"}
|
||||
return {"status": status}
|
||||
|
||||
async def accept_call(self, callee_mac: str) -> Dict[str, Any]:
|
||||
callee = normalize_device_id(callee_mac)
|
||||
if not callee:
|
||||
return {"status": "error", "message": "接听设备参数无效"}
|
||||
|
||||
callee_entry = self.connection_registry.resolve_device_now(callee)
|
||||
if callee_entry is None:
|
||||
return {"status": "offline", "message": "接听设备不在线"}
|
||||
|
||||
async with self._lock:
|
||||
if callee in self.active_calls:
|
||||
return {"status": "error", "message": "设备已在通话中"}
|
||||
pending = next(
|
||||
(
|
||||
entry
|
||||
for entry in self.pending_calls.values()
|
||||
if entry.target_mac == callee
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pending is None:
|
||||
return {"status": "no_pending", "message": "没有等待中的通话"}
|
||||
|
||||
caller = pending.caller_mac
|
||||
caller_entry = self.connection_registry.resolve_device_now(caller)
|
||||
if caller_entry is None:
|
||||
self._remove_call_locked(caller)
|
||||
self._set_call_state(callee_entry, False)
|
||||
return {
|
||||
"status": "caller_gone",
|
||||
"message": "主叫方已离开或通话已超时",
|
||||
}
|
||||
|
||||
self.pending_calls.pop(caller, None)
|
||||
self.active_calls[caller] = callee
|
||||
self.active_calls[callee] = caller
|
||||
generation = pending.generation
|
||||
self.call_generations[caller] = generation
|
||||
self.call_generations[callee] = generation
|
||||
self._capture_session(caller, caller_entry)
|
||||
self._capture_session(callee, callee_entry)
|
||||
self._set_call_state(caller_entry, True)
|
||||
self._set_call_state(callee_entry, True)
|
||||
|
||||
try:
|
||||
await self._stop_ai_session(
|
||||
callee_entry, self.call_session_ids.get(callee)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
callee,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
callee,
|
||||
notify_device=True,
|
||||
notify_peer=True,
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT停止接听方AI会话失败: caller={}, callee={}, error={}",
|
||||
caller,
|
||||
callee,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "停止AI会话失败"}
|
||||
|
||||
async with self._lock:
|
||||
valid = self._active_matches_locked(
|
||||
caller, callee, generation
|
||||
)
|
||||
if not valid:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
|
||||
try:
|
||||
sent = await self._send_while_generation_active(
|
||||
caller_entry.transport,
|
||||
{"type": "call_accepted", "from": callee},
|
||||
generation,
|
||||
)
|
||||
if not sent:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
callee,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
callee,
|
||||
"发送接听确认失败",
|
||||
notify_device=True,
|
||||
notify_peer=True,
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT接听确认发送失败: caller={}, callee={}, error={}",
|
||||
caller,
|
||||
callee,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "发送接听确认失败"}
|
||||
return {"status": "bridged", "peerMac": caller}
|
||||
|
||||
def route_audio(
|
||||
self, source_device_id: str, payload: bytes, timestamp: int
|
||||
) -> bool:
|
||||
source = normalize_device_id(source_device_id)
|
||||
if not source:
|
||||
return False
|
||||
|
||||
peer = self.active_calls.get(source)
|
||||
if peer:
|
||||
target = self.connection_registry.resolve_device_now(peer)
|
||||
if target is None:
|
||||
self._schedule_end(source, "对方已离开")
|
||||
return True
|
||||
handler = getattr(target.transport, "_udp_handler", None)
|
||||
try:
|
||||
sent = (
|
||||
handler is not None
|
||||
and handler.send_audio_nowait(payload, 0)
|
||||
)
|
||||
except Exception:
|
||||
sent = False
|
||||
if not sent:
|
||||
self._schedule_end(source, "对方音频通道不可用")
|
||||
return True
|
||||
|
||||
if source in self.pending_calls:
|
||||
source_entry = self.connection_registry.resolve_device_now(source)
|
||||
handler = (
|
||||
getattr(source_entry.transport, "_udp_handler", None)
|
||||
if source_entry
|
||||
else None
|
||||
)
|
||||
if handler is not None and self.silence_frame:
|
||||
try:
|
||||
handler.send_audio_nowait(self.silence_frame, 0)
|
||||
except Exception:
|
||||
self._schedule_end(source, "主叫音频通道不可用")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def end_call(
|
||||
self,
|
||||
device_id: str,
|
||||
reason: str = "",
|
||||
notify_device: bool = False,
|
||||
notify_peer: bool = True,
|
||||
expected_session_id: Optional[str] = None,
|
||||
expected_generation: Optional[int] = None,
|
||||
) -> bool:
|
||||
device = normalize_device_id(device_id)
|
||||
if not device:
|
||||
return False
|
||||
|
||||
observed_generation = self.call_generations.get(device)
|
||||
generation = (
|
||||
expected_generation
|
||||
if expected_generation is not None
|
||||
else observed_generation
|
||||
)
|
||||
if observed_generation is None or observed_generation != generation:
|
||||
return False
|
||||
if (
|
||||
expected_session_id is not None
|
||||
and self.call_session_ids.get(device) != expected_session_id
|
||||
):
|
||||
return False
|
||||
end_event = self._generation_end_events.get(generation)
|
||||
if end_event is not None:
|
||||
end_event.set()
|
||||
|
||||
async with self._lock:
|
||||
current_generation = self.call_generations.get(device)
|
||||
if current_generation != generation:
|
||||
return False
|
||||
if expected_session_id is not None:
|
||||
current_session_id = self.call_session_ids.get(device)
|
||||
if current_session_id != expected_session_id:
|
||||
return False
|
||||
related = self._remove_call_locked(device)
|
||||
if related is None:
|
||||
return False
|
||||
|
||||
peer = related.get("peer")
|
||||
device_session = related.get("device_session")
|
||||
peer_session = related.get("peer_session")
|
||||
device_entry = self.connection_registry.resolve_device_now(device)
|
||||
peer_entry = (
|
||||
self.connection_registry.resolve_device_now(peer) if peer else None
|
||||
)
|
||||
self._set_call_state(device_entry, False)
|
||||
self._set_call_state(peer_entry, False)
|
||||
|
||||
notifications = []
|
||||
if notify_device and device_entry is not None:
|
||||
notifications.append(
|
||||
self._notify_idle(device_entry, device_session, reason)
|
||||
)
|
||||
if notify_peer and peer_entry is not None:
|
||||
notifications.append(
|
||||
self._notify_idle(peer_entry, peer_session, reason)
|
||||
)
|
||||
if notifications:
|
||||
results = await asyncio.gather(
|
||||
*notifications, return_exceptions=True
|
||||
)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Native MQTT通话结束通知失败: error={}", result
|
||||
)
|
||||
return True
|
||||
|
||||
async def cleanup_timeouts(self) -> int:
|
||||
expired = []
|
||||
now = self.clock()
|
||||
async with self._lock:
|
||||
for caller, pending in list(self.pending_calls.items()):
|
||||
if now - pending.created_at >= self.timeout_seconds:
|
||||
expired.append((caller, pending.generation))
|
||||
for caller, generation in expired:
|
||||
await self.end_call(
|
||||
caller,
|
||||
"呼叫等待超时",
|
||||
notify_device=True,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
return len(expired)
|
||||
|
||||
async def clear(self) -> None:
|
||||
tasks = list(self._end_tasks.values())
|
||||
self._end_tasks.clear()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
async with self._lock:
|
||||
devices = set(self.pending_calls)
|
||||
devices.update(self.active_calls)
|
||||
devices.update(self.call_generations)
|
||||
for device in devices:
|
||||
await self.end_call(
|
||||
device,
|
||||
"服务停止",
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
)
|
||||
async with self._lock:
|
||||
remaining = set(self.call_generations)
|
||||
remaining.update(self.call_session_ids)
|
||||
self.pending_calls.clear()
|
||||
self.active_calls.clear()
|
||||
self.call_generations.clear()
|
||||
self.call_session_ids.clear()
|
||||
end_events = list(self._generation_end_events.values())
|
||||
self._generation_end_events.clear()
|
||||
for end_event in end_events:
|
||||
end_event.set()
|
||||
for device in remaining:
|
||||
self._set_call_state(
|
||||
self.connection_registry.resolve_device_now(device),
|
||||
False,
|
||||
)
|
||||
|
||||
def contains(self, device_id: str) -> bool:
|
||||
device = normalize_device_id(device_id)
|
||||
return bool(
|
||||
device
|
||||
and (
|
||||
self._pending_owner_now(device) is not None
|
||||
or device in self.active_calls
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
devices = set(self.call_generations)
|
||||
devices.update(self.call_session_ids)
|
||||
devices.update(self.active_calls)
|
||||
devices.update(self.pending_calls)
|
||||
devices.update(
|
||||
pending.target_mac for pending in self.pending_calls.values()
|
||||
)
|
||||
generations = set(self.call_generations.values())
|
||||
orphan_events = set(self._generation_end_events) - generations
|
||||
return len(devices) + len(orphan_events)
|
||||
|
||||
@property
|
||||
def background_task_count(self) -> int:
|
||||
return sum(not task.done() for task in self._end_tasks.values())
|
||||
|
||||
async def handle_logical_hello(
|
||||
self, device_id: str, session_id: Optional[str]
|
||||
) -> bool:
|
||||
device = normalize_device_id(device_id)
|
||||
if not device:
|
||||
return False
|
||||
async with self._lock:
|
||||
pending_owner = self._pending_owner_locked(device)
|
||||
if (
|
||||
pending_owner is not None
|
||||
and pending_owner != device
|
||||
and device not in self.active_calls
|
||||
):
|
||||
if session_id:
|
||||
self.call_session_ids[device] = session_id
|
||||
return False
|
||||
generation = self.call_generations.get(device)
|
||||
if generation is None:
|
||||
return False
|
||||
return await self.end_call(
|
||||
device,
|
||||
"设备重新进入AI会话",
|
||||
notify_device=False,
|
||||
notify_peer=True,
|
||||
expected_session_id=session_id,
|
||||
expected_generation=generation,
|
||||
)
|
||||
|
||||
def _capture_session(self, device_id: str, entry) -> None:
|
||||
session_id = getattr(entry.transport, "session_id", None)
|
||||
if session_id:
|
||||
self.call_session_ids[device_id] = session_id
|
||||
|
||||
def _drop_session(self, device_id: str) -> Optional[str]:
|
||||
return self.call_session_ids.pop(device_id, None)
|
||||
|
||||
def _remove_call_locked(self, device: str) -> Optional[Dict[str, Any]]:
|
||||
generation = self.call_generations.get(device)
|
||||
end_event = self._generation_end_events.get(generation)
|
||||
if end_event is not None:
|
||||
end_event.set()
|
||||
pending = self.pending_calls.pop(device, None)
|
||||
if pending:
|
||||
self._drop_generation(device)
|
||||
self._drop_generation(pending.target_mac)
|
||||
self._generation_end_events.pop(pending.generation, None)
|
||||
return {
|
||||
"peer": pending.target_mac,
|
||||
"device_session": self._drop_session(device),
|
||||
"peer_session": self._drop_session(pending.target_mac),
|
||||
}
|
||||
|
||||
pending_owner = next(
|
||||
(
|
||||
caller
|
||||
for caller, entry in self.pending_calls.items()
|
||||
if entry.target_mac == device
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pending_owner:
|
||||
pending = self.pending_calls.pop(pending_owner)
|
||||
self._drop_generation(device)
|
||||
self._drop_generation(pending_owner)
|
||||
self._generation_end_events.pop(pending.generation, None)
|
||||
return {
|
||||
"peer": pending_owner,
|
||||
"device_session": self._drop_session(device),
|
||||
"peer_session": self._drop_session(pending_owner),
|
||||
}
|
||||
|
||||
peer = self.active_calls.pop(device, None)
|
||||
if peer:
|
||||
self.active_calls.pop(peer, None)
|
||||
self._drop_generation(device)
|
||||
self._drop_generation(peer)
|
||||
if generation is not None:
|
||||
self._generation_end_events.pop(generation, None)
|
||||
return {
|
||||
"peer": peer,
|
||||
"device_session": self._drop_session(device),
|
||||
"peer_session": self._drop_session(peer),
|
||||
}
|
||||
return None
|
||||
|
||||
def _schedule_end(self, device_id: str, reason: str) -> None:
|
||||
device = normalize_device_id(device_id)
|
||||
generation = self.call_generations.get(device) if device else None
|
||||
if not device or generation is None:
|
||||
return
|
||||
key = (device, generation)
|
||||
existing = self._end_tasks.get(key)
|
||||
if existing is not None and not existing.done():
|
||||
return
|
||||
task = asyncio.create_task(
|
||||
self.end_call(
|
||||
device,
|
||||
reason,
|
||||
notify_device=True,
|
||||
notify_peer=True,
|
||||
expected_generation=generation,
|
||||
)
|
||||
)
|
||||
self._end_tasks[key] = task
|
||||
task.add_done_callback(
|
||||
lambda completed, task_key=key: self._discard_end_task(
|
||||
task_key, completed
|
||||
)
|
||||
)
|
||||
|
||||
def _discard_end_task(
|
||||
self, key: tuple[str, int], task: asyncio.Task
|
||||
) -> None:
|
||||
if self._end_tasks.get(key) is task:
|
||||
self._end_tasks.pop(key, None)
|
||||
if not task.cancelled():
|
||||
task.exception()
|
||||
|
||||
def _allocate_generation_locked(self) -> int:
|
||||
generation = self._next_generation
|
||||
self._next_generation += 1
|
||||
self._generation_end_events[generation] = asyncio.Event()
|
||||
return generation
|
||||
|
||||
async def _send_while_generation_active(
|
||||
self, transport, message: Dict[str, Any], generation: int
|
||||
) -> bool:
|
||||
end_event = self._generation_end_events.get(generation)
|
||||
if end_event is None or end_event.is_set():
|
||||
return False
|
||||
send_task = asyncio.create_task(transport.send_json(message))
|
||||
end_task = asyncio.create_task(end_event.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{send_task, end_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if end_task in done:
|
||||
send_task.cancel()
|
||||
await asyncio.gather(send_task, return_exceptions=True)
|
||||
return False
|
||||
await send_task
|
||||
return not end_event.is_set()
|
||||
finally:
|
||||
if not send_task.done():
|
||||
send_task.cancel()
|
||||
if not end_task.done():
|
||||
end_task.cancel()
|
||||
await asyncio.gather(
|
||||
send_task, end_task, return_exceptions=True
|
||||
)
|
||||
|
||||
def _pending_owner_locked(self, device: str) -> Optional[str]:
|
||||
if device in self.pending_calls:
|
||||
return device
|
||||
return next(
|
||||
(
|
||||
caller
|
||||
for caller, pending in self.pending_calls.items()
|
||||
if pending.target_mac == device
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _pending_owner_now(self, device: str) -> Optional[str]:
|
||||
return self._pending_owner_locked(device)
|
||||
|
||||
def _pending_matches_locked(
|
||||
self, caller: str, target: str, generation: int
|
||||
) -> bool:
|
||||
pending = self.pending_calls.get(caller)
|
||||
return bool(
|
||||
pending
|
||||
and pending.target_mac == target
|
||||
and pending.generation == generation
|
||||
and self.call_generations.get(caller) == generation
|
||||
and self.call_generations.get(target) == generation
|
||||
)
|
||||
|
||||
def _active_matches_locked(
|
||||
self, caller: str, target: str, generation: int
|
||||
) -> bool:
|
||||
return (
|
||||
self.active_calls.get(caller) == target
|
||||
and self.active_calls.get(target) == caller
|
||||
and self.call_generations.get(caller) == generation
|
||||
and self.call_generations.get(target) == generation
|
||||
)
|
||||
|
||||
def _drop_generation(self, device_id: str) -> Optional[int]:
|
||||
return self.call_generations.pop(device_id, None)
|
||||
|
||||
@staticmethod
|
||||
async def _stop_ai_session(entry, session_id: Optional[str]) -> None:
|
||||
if entry is None:
|
||||
return
|
||||
end_conversation = getattr(entry.context, "end_conversation", None)
|
||||
if callable(end_conversation):
|
||||
await end_conversation(session_id)
|
||||
return
|
||||
cancel_tasks = getattr(entry.context, "cancel_conversation_tasks", None)
|
||||
if callable(cancel_tasks):
|
||||
await cancel_tasks()
|
||||
|
||||
@staticmethod
|
||||
def _set_call_state(entry, active: bool) -> None:
|
||||
if entry is None:
|
||||
return
|
||||
entry.context.calling = active
|
||||
if not active:
|
||||
entry.context.incoming_call = None
|
||||
|
||||
@staticmethod
|
||||
async def _notify_idle(entry, session_id: Optional[str], reason: str) -> None:
|
||||
raw_connection = getattr(entry.transport, "raw_connection", None)
|
||||
if raw_connection is None:
|
||||
return
|
||||
try:
|
||||
await raw_connection.notify_device_idle(session_id)
|
||||
finally:
|
||||
end_conversation = getattr(entry.context, "end_conversation", None)
|
||||
if callable(end_conversation):
|
||||
await end_conversation(session_id)
|
||||
if reason:
|
||||
logger.info(
|
||||
"Native MQTT通话结束: device_id={}, reason={}",
|
||||
entry.context.device_id,
|
||||
reason,
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeMqttConnection:
|
||||
client_id: str
|
||||
device_id: Optional[str]
|
||||
connection_id: int
|
||||
context: Any
|
||||
transport: Any
|
||||
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
return bool(getattr(self.transport, "is_connected", False))
|
||||
|
||||
|
||||
class NativeMqttConnectionRegistry:
|
||||
def __init__(self):
|
||||
self._connections: Dict[str, NativeMqttConnection] = {}
|
||||
self._devices: Dict[str, NativeMqttConnection] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, context: Any, transport: Any) -> bool:
|
||||
client_id = getattr(transport, "client_id", None)
|
||||
raw_connection = getattr(transport, "raw_connection", None)
|
||||
connection_id = getattr(raw_connection, "connection_id", None)
|
||||
if not client_id or connection_id is None:
|
||||
return False
|
||||
|
||||
entry = NativeMqttConnection(
|
||||
client_id=client_id,
|
||||
device_id=self._normalize_device_id(
|
||||
getattr(context, "device_id", None)
|
||||
),
|
||||
connection_id=connection_id,
|
||||
context=context,
|
||||
transport=transport,
|
||||
)
|
||||
async with self._lock:
|
||||
previous_client = self._connections.get(client_id)
|
||||
previous_device = (
|
||||
self._devices.get(entry.device_id)
|
||||
if entry.device_id
|
||||
else None
|
||||
)
|
||||
for previous in (previous_client, previous_device):
|
||||
if previous is None or previous is entry:
|
||||
continue
|
||||
if self._connections.get(previous.client_id) is previous:
|
||||
self._connections.pop(previous.client_id, None)
|
||||
if (
|
||||
previous.device_id
|
||||
and self._devices.get(previous.device_id) is previous
|
||||
):
|
||||
self._devices.pop(previous.device_id, None)
|
||||
self._connections[client_id] = entry
|
||||
if entry.device_id:
|
||||
self._devices[entry.device_id] = entry
|
||||
return True
|
||||
|
||||
async def unregister(self, context: Any, transport: Any) -> bool:
|
||||
client_id = getattr(transport, "client_id", None)
|
||||
if not client_id:
|
||||
return False
|
||||
|
||||
async with self._lock:
|
||||
entry = self._connections.get(client_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.context is not context
|
||||
or entry.transport is not transport
|
||||
):
|
||||
return False
|
||||
self._connections.pop(client_id, None)
|
||||
if (
|
||||
entry.device_id
|
||||
and self._devices.get(entry.device_id) is entry
|
||||
):
|
||||
self._devices.pop(entry.device_id, None)
|
||||
return True
|
||||
|
||||
async def resolve(self, client_id: str) -> Optional[NativeMqttConnection]:
|
||||
async with self._lock:
|
||||
entry = self._connections.get(client_id)
|
||||
if entry is None or not entry.is_alive:
|
||||
return None
|
||||
return entry
|
||||
|
||||
async def status(self, client_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
result = {}
|
||||
for client_id in client_ids:
|
||||
entry = self._connections.get(client_id)
|
||||
exists = entry is not None
|
||||
result[client_id] = {
|
||||
"isAlive": bool(entry and entry.is_alive),
|
||||
"exists": exists,
|
||||
"backend": "native",
|
||||
}
|
||||
return result
|
||||
|
||||
async def resolve_device(
|
||||
self, device_id: str
|
||||
) -> Optional[NativeMqttConnection]:
|
||||
async with self._lock:
|
||||
return self.resolve_device_now(device_id)
|
||||
|
||||
def resolve_device_now(
|
||||
self, device_id: str
|
||||
) -> Optional[NativeMqttConnection]:
|
||||
normalized = self._normalize_device_id(device_id)
|
||||
entry = self._devices.get(normalized) if normalized else None
|
||||
if entry is None or not entry.is_alive:
|
||||
return None
|
||||
return entry
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._connections.clear()
|
||||
self._devices.clear()
|
||||
|
||||
async def size(self) -> int:
|
||||
async with self._lock:
|
||||
return len(self._connections)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._connections)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_device_id(device_id: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(device_id, str):
|
||||
return None
|
||||
normalized = device_id.strip().lower().replace("-", ":")
|
||||
return normalized or None
|
||||
Reference in New Issue
Block a user