- auth from Authorization/Device-Id headers (firmware v2.5.0), frame fallback kept
- server hello carries transport=websocket + session_id (firmware ParseServerHello)
- /ota GET+POST returns {websocket:{url,token,version}, server_time:{timestamp,timezone_offset}, firmware:{version}}
- config.yaml: device registered by MAC, ota block added
- 21 tests pass (added OTA + header-auth coverage)
73 lines
2.4 KiB
Python
73 lines
2.4 KiB
Python
"""Xiaozhi WebSocket handshake frame models (PoC).
|
|
|
|
CON-004: the exact field set of the Xiaozhi ``hello`` frame is a firmware
|
|
detail. These models capture the fields the plan names (Device-Id,
|
|
Client-Id, Authorization, audio_params) plus the standard handshake fields.
|
|
Every constant that must match the firmware is marked ``# FIRMWARE``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class AudioParams(BaseModel):
|
|
# FIRMWARE: Opus params the device reports/accepts.
|
|
format: str = "opus"
|
|
sample_rate: int = 16000
|
|
channels: int = 1
|
|
frame_duration: int = 20 # ms
|
|
|
|
|
|
class Hello(BaseModel):
|
|
"""Device -> Bridge greeting (first text frame)."""
|
|
type: str = "hello"
|
|
device_id: str = ""
|
|
client_id: Optional[str] = None
|
|
authorization: Optional[str] = None # FIRMWARE: token field name
|
|
protocol_version: int = 1 # FIRMWARE
|
|
audio_params: AudioParams = Field(default_factory=AudioParams)
|
|
|
|
|
|
class ServerHello(BaseModel):
|
|
"""Bridge -> Device acknowledgement, firmware v2.5.0-compatible.
|
|
|
|
FIRMWARE: ParseServerHello requires ``transport == "websocket"`` and
|
|
reads ``session_id`` + ``audio_params`` (sample_rate, frame_duration).
|
|
"""
|
|
type: str = "hello"
|
|
transport: str = "websocket"
|
|
session_id: str = ""
|
|
audio_params: AudioParams = Field(default_factory=AudioParams)
|
|
server_time: int = 0
|
|
|
|
|
|
def build_hello(payload: dict) -> Hello:
|
|
"""Parse a raw hello dict; tolerate firmware field-name drift.
|
|
|
|
Tolerant parsing is intentional: firmware builds differ, and the PoC must
|
|
connect, not reject, on a missing optional field.
|
|
"""
|
|
data: dict[str, Any] = dict(payload)
|
|
data.setdefault("type", "hello")
|
|
# Common firmware aliases.
|
|
for src, dst in (("deviceId", "device_id"), ("Device-Id", "device_id")):
|
|
if src in data and "device_id" not in data:
|
|
data["device_id"] = data[src]
|
|
if "audio" in data and "audio_params" not in data:
|
|
data["audio_params"] = data.pop("audio")
|
|
return Hello.model_validate(data)
|
|
|
|
|
|
def build_server_hello(device_hello: Hello) -> ServerHello:
|
|
"""Firmware-compatible server hello (transport=websocket + session_id)."""
|
|
import time
|
|
|
|
return ServerHello(
|
|
session_id=str(uuid.uuid4()),
|
|
audio_params=device_hello.audio_params,
|
|
server_time=int(time.time() * 1000),
|
|
)
|