mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 18:23:59 +08:00
refactor: introduce transport-neutral connection runtime
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
|
||||
class TransportInterface(ABC):
|
||||
"""
|
||||
传输层抽象接口。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, data: Any) -> None:
|
||||
"""发送一条消息。"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def send_json(self, message: Any) -> None:
|
||||
"""Send a control message without exposing transport framing to callers."""
|
||||
payload = message if isinstance(message, str) else json.dumps(message)
|
||||
await self.send(payload)
|
||||
|
||||
async def send_audio(self, audio: bytes, timestamp: int = 0) -> None:
|
||||
"""Send one encoded audio frame over the transport's audio channel."""
|
||||
await self.send(audio)
|
||||
|
||||
async def prepare_audio_channel(self, audio_params=None, version: int = 3) -> None:
|
||||
"""Prepare transport-specific audio negotiation when required."""
|
||||
|
||||
async def wait_audio_ready(self, timeout: float = 0) -> bool:
|
||||
"""Wait until encoded audio can be delivered to the peer."""
|
||||
return True
|
||||
|
||||
async def mark_business_ready(self) -> None:
|
||||
"""Allow a transport handshake to proceed after runtime initialization."""
|
||||
|
||||
async def mark_session_ready(self, session_id: str = None) -> None:
|
||||
"""Release a logical-session handshake after its runtime is ready."""
|
||||
|
||||
async def end_session(self, session_id: str) -> None:
|
||||
"""Tell the device to return to Idle without closing the connection."""
|
||||
await self.send_json({"type": "goodbye", "session_id": session_id})
|
||||
|
||||
@abstractmethod
|
||||
async def receive(self) -> AsyncGenerator[Any, None]:
|
||||
"""异步消息流。"""
|
||||
yield # pragma: no cover
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""关闭底层连接。"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""连接是否存活。"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def transport_type(self) -> str:
|
||||
"""Stable transport identifier used by shared connection logic."""
|
||||
return "unknown"
|
||||
|
||||
@property
|
||||
def has_datagram_audio(self) -> bool:
|
||||
"""Whether audio is carried by a channel separate from control messages."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def requires_audio_tail_grace(self) -> bool:
|
||||
"""Whether control can overtake the last audio frames of a turn."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def keeps_connection_between_sessions(self) -> bool:
|
||||
"""Whether ending a conversation should leave the transport connected."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_protocol_authenticated(self) -> bool:
|
||||
"""Whether the transport already authenticated the peer during handshake."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def raw_connection(self):
|
||||
"""Underlying connection for temporary compatibility with legacy code."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def session_id(self):
|
||||
return None
|
||||
@@ -0,0 +1,154 @@
|
||||
import struct
|
||||
import time
|
||||
from typing import Any, AsyncGenerator
|
||||
from .transport_interface import TransportInterface
|
||||
|
||||
|
||||
class WebSocketTransport(TransportInterface):
|
||||
"""
|
||||
WebSocket 传输实现:包装 websockets 库的协议对象,
|
||||
提供统一的 send/receive/close 接口。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websocket,
|
||||
from_mqtt_gateway: bool = False,
|
||||
protocol_version: int = 1,
|
||||
):
|
||||
self._ws = websocket
|
||||
self._from_mqtt_gateway = from_mqtt_gateway
|
||||
self._protocol_version = protocol_version if protocol_version in (1, 2, 3) else 1
|
||||
self._gateway_sequence = 0
|
||||
|
||||
async def send(self, data: Any) -> None:
|
||||
if isinstance(data, bytes):
|
||||
await self.send_audio(data)
|
||||
return
|
||||
|
||||
if isinstance(data, (str, bytes)):
|
||||
await self._ws.send(data)
|
||||
else:
|
||||
await self._ws.send(str(data))
|
||||
|
||||
async def send_audio(self, audio: bytes, timestamp: int = 0) -> None:
|
||||
if self._from_mqtt_gateway:
|
||||
await self._ws.send(self._frame_gateway_audio(audio, timestamp))
|
||||
return
|
||||
await self._ws.send(self._frame_websocket_audio(audio, timestamp))
|
||||
|
||||
def _frame_websocket_audio(self, audio: bytes, timestamp: int = 0) -> bytes:
|
||||
if self._protocol_version == 2:
|
||||
return struct.pack("!HHIII", 2, 0, 0, timestamp, len(audio)) + audio
|
||||
if self._protocol_version == 3:
|
||||
return struct.pack("!BBH", 0, 0, len(audio)) + audio
|
||||
return audio
|
||||
|
||||
def _parse_websocket_audio(self, message: bytes):
|
||||
if self._protocol_version == 2:
|
||||
if len(message) < 16:
|
||||
return None
|
||||
version, message_type, _, timestamp, audio_length = struct.unpack(
|
||||
"!HHIII", message[:16]
|
||||
)
|
||||
if (
|
||||
version != 2
|
||||
or message_type != 0
|
||||
or audio_length <= 0
|
||||
or len(message) != 16 + audio_length
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"type": "audio",
|
||||
"data": message[16:],
|
||||
"timestamp": timestamp,
|
||||
"_transport_type": "websocket",
|
||||
}
|
||||
if self._protocol_version == 3:
|
||||
if len(message) < 4:
|
||||
return None
|
||||
message_type, _, audio_length = struct.unpack("!BBH", message[:4])
|
||||
if (
|
||||
message_type != 0
|
||||
or audio_length <= 0
|
||||
or len(message) != 4 + audio_length
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"type": "audio",
|
||||
"data": message[4:],
|
||||
"timestamp": 0,
|
||||
"_transport_type": "websocket",
|
||||
}
|
||||
return message
|
||||
|
||||
def _frame_gateway_audio(self, audio: bytes, timestamp: int = 0) -> bytes:
|
||||
self._gateway_sequence += 1
|
||||
if timestamp <= 0:
|
||||
timestamp = int(time.time() * 1000) % (2 ** 32)
|
||||
header = bytearray(16)
|
||||
header[0] = 1
|
||||
header[2:4] = len(audio).to_bytes(2, "big")
|
||||
header[4:8] = self._gateway_sequence.to_bytes(4, "big")
|
||||
header[8:12] = timestamp.to_bytes(4, "big")
|
||||
header[12:16] = len(audio).to_bytes(4, "big")
|
||||
return bytes(header) + audio
|
||||
|
||||
async def receive(self) -> AsyncGenerator[Any, None]:
|
||||
async for message in self._ws:
|
||||
if self._from_mqtt_gateway and isinstance(message, bytes):
|
||||
if len(message) < 16:
|
||||
continue
|
||||
if message[:8] != b"\x00" * 8:
|
||||
continue
|
||||
timestamp = int.from_bytes(message[8:12], "big")
|
||||
audio_length = int.from_bytes(message[12:16], "big")
|
||||
if audio_length <= 0 or len(message) != 16 + audio_length:
|
||||
continue
|
||||
message = {
|
||||
"type": "audio",
|
||||
"data": message[16:],
|
||||
"timestamp": timestamp,
|
||||
"_transport_type": "gateway",
|
||||
}
|
||||
elif isinstance(message, bytes):
|
||||
message = self._parse_websocket_audio(message)
|
||||
if message is None:
|
||||
continue
|
||||
yield message
|
||||
|
||||
async def close(self) -> None:
|
||||
try:
|
||||
if hasattr(self._ws, "closed") and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
elif hasattr(self._ws, "state") and self._ws.state.name != "CLOSED":
|
||||
await self._ws.close()
|
||||
else:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
raise RuntimeError("WebSocket close failed")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
try:
|
||||
if hasattr(self._ws, "closed"):
|
||||
return not self._ws.closed
|
||||
if hasattr(self._ws, "state"):
|
||||
return getattr(self._ws.state, "name", "CLOSED") != "CLOSED"
|
||||
except Exception:
|
||||
raise RuntimeError("WebSocket connection check failed")
|
||||
return False
|
||||
|
||||
@property
|
||||
def transport_type(self) -> str:
|
||||
return "gateway" if self._from_mqtt_gateway else "websocket"
|
||||
|
||||
@property
|
||||
def requires_audio_tail_grace(self) -> bool:
|
||||
# The gateway receives MQTT control and UDP audio independently before
|
||||
# serializing both streams onto this WebSocket.
|
||||
return self._from_mqtt_gateway
|
||||
|
||||
@property
|
||||
def raw_connection(self):
|
||||
return self._ws
|
||||
Reference in New Issue
Block a user