refactor: introduce transport-neutral connection runtime

This commit is contained in:
caixypromise
2026-07-27 02:05:26 +08:00
parent f5ed1aaec8
commit 0c582ed3b6
56 changed files with 9931 additions and 237 deletions
@@ -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