repair-agent c219ec2fcf
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
feat(hw-ws-service): 将 Go WebSocket 服务纳入 CI/CD 并通过 Traefik 统一入口
## 变更内容

### k8s/ingress.yaml
- 新增 /xiaozhi/v1/ 路径规则,将 WebSocket 流量路由到 hw-ws-svc:8888
- Traefik 最长前缀优先,/xiaozhi/v1/ 不影响 / 下的 Django 路由

### hw_service_go/k8s/service.yaml
- Service 类型由 LoadBalancer 改为 ClusterIP
- 移除阿里云 SLB 注解(通过 Traefik Ingress 统一暴露,不再需要独立公网 IP)

### hw_service_go/k8s/deployment.yaml
- 镜像地址改为 ${CI_REGISTRY_IMAGE}/hw-ws-service:latest 占位符
- CI/CD 部署时统一通过 sed 替换为华为云 SWR 实际地址

### hw_service_go/internal/server/server.go
- 新增 GET /xiaozhi/v1/healthz 接口,返回 {"status":"ok","active_connections":N}
- 用于部署后验证服务存活及当前连接数

### .gitea/workflows/deploy.yaml
- 新增 Build and Push HW WebSocket Service 步骤,构建并推送 hw_service_go 镜像
- 部署步骤新增 kubectl apply hw_service_go/k8s/deployment.yaml 和 service.yaml
- 新增 kubectl rollout restart deployment/hw-ws-service

### run.sh
- 本地同时启动 Django + hw_service_go 的开发脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:16:26 +08:00

12 KiB
Raw Blame History

hw_service_go 本地硬件通讯测试计划

目标:用浏览器模拟 ESP32 硬件,验证 hw_service_go WebSocket 服务能否正常接收指令、获取故事、推送 Opus 音频。


一、协议对比分析

1.1 小智xiaozhi-servervs 我们的服务

维度 xiaozhi-server hw_service_go本服务
WebSocket URL ws://host:port/xiaozhi/v1/?device-id=&client-id= 完全相同
连接参数 device-idMACclient-idUUID 完全相同
握手消息 需要发送 hello JSON 不需要,连上即用
触发指令 listen(语音输入) 只需发 {"type":"story"}
音频方向 双向(硬件上传语音 + 服务下发 TTS 单向下行(服务→硬件,推 Opus
Opus 编解码 需要编码(麦克风)+ 解码(播放) 只需解码(浏览器只播放)
认证 token 参数 无需认证(仅 device-id 校验)
消息复杂度 hello/listen/stt/llm/tts/mcp 只有 tts 系列

1.2 我们服务的完整消息流

浏览器(模拟硬件)                     hw_service_go              Django
    │                                       │                        │
    │── WS 连接 ──────────────────────────→│                        │
    │   ?device-id=AA:BB:CC:DD:EE:FF       │                        │
    │   &client-id=test-001                 │                        │
    │                                       │                        │
    │── {"type":"story"} ────────────────→ │                        │
    │                                       │── GET /api/v1/devices/ │
    │                                       │   stories/?mac_address │
    │                                       │   =AA:BB:CC:DD:EE:FF → │
    │                                       │                        │
    │← {"type":"tts","state":"start"} ───── │                        │
    │                                       │← {title, audio_url} ── │
    │                                       │                        │
    │                                       │── 下载 MP3 ──────────→ CDN
    │                                       │← MP3 二进制流 ─────── │
    │                                       │                        │
    │                                       │  ffmpeg 转码 PCM→Opus  │
    │                                       │                        │
    │← {"type":"tts","state":"sentence_start","text":"故事标题"} ─── │
    │                                       │                        │
    │← [Opus帧1 二进制] ─────────────────── │  60ms/帧前3帧预缓冲  │
    │← [Opus帧2 二进制] ─────────────────── │                        │
    │← [Opus帧3 二进制] ─────────────────── │                        │
    │← [Opus帧N 二进制] ─────────────────── │  按时序流控发送        │
    │                                       │                        │
    │← {"type":"tts","state":"stop"} ─────── │                        │
    │                                       │                        │

1.3 Opus 音频参数(与小智完全一致)

参数
采样率 16000 Hz
声道 1单声道
帧时长 60ms
每帧采样数 960
编码器 libopusWASM

二、前置条件检查

在开始测试之前,需要满足以下条件:

2.1 服务运行状态

  • Django 后端运行在 http://localhost:8000
  • hw_service_go 运行在 ws://localhost:8888
  • 健康检查通过:curl http://localhost:8888/healthz 返回 200

2.2 Django 数据准备(关键!)

测试必须使用一个在 Django 数据库中真实存在的设备 MAC 地址。

Django API 查询逻辑(GET /api/v1/devices/stories/?mac_address=<MAC>

  • 根据 MAC 查找设备 → 找到设备绑定的用户 → 查找该用户的故事
  • 任何一步缺失,服务返回 null,硬件不会播放任何内容

需要在 Django Admin 或 API 中准备:

  1. 注册一个设备,记下其 MAC 地址(格式:AA:BB:CC:DD:EE:FF
  2. 该设备需已绑定用户owner
  3. 该用户名下有至少一个故事(有 audio_url 字段)

快速验证curl "http://localhost:8000/api/v1/devices/stories/?mac_address=你的MAC" 应返回 {"code":0,"data":{"title":"...","audio_url":"..."}}


三、测试程序设计

3.1 技术选型

方案 优点 缺点
纯 HTML+JS推荐 零依赖,直接浏览器打开,与小智方案一致 -
Python 脚本 简单但无法播放音频 无法验证音频播放端到端
Go 命令行 需额外音频库 环境搭建复杂

选择方案:纯 HTML+JS 单文件,复用小智项目的 libopus.jsWASM做解码。

3.2 文件结构

hw_service_go/test/
├── PLAN.md          ← 本文件
├── test.html        ← 测试主页面(待实现)
└── libopus.js       ← 复制自小智项目Opus WASM 解码库)

libopus.js 来源:

/Users/maidong/Desktop/zyc/jikashe/xiaozhi-server/main/xiaozhi-server/test/libopus.js

3.3 测试页面功能

┌─────────────────────────────────────────────────────┐
│  hw_service_go 硬件通讯测试                          │
├─────────────────────────────────────────────────────┤
│  服务地址:  [ws://localhost:8888/xiaozhi/v1/      ] │
│  device-id: [AA:BB:CC:DD:EE:FF                   ] │
│  client-id: [test-browser-001          ] [随机生成] │
├─────────────────────────────────────────────────────┤
│  [连接]  [断开]    状态: ● 未连接                   │
├─────────────────────────────────────────────────────┤
│  [▶ 触发故事播放]   [⏹ 停止]                        │
├─────────────────────────────────────────────────────┤
│  消息日志                              [清空]        │
│  ┌───────────────────────────────────────────────┐  │
│  │ [10:23:01] → 已连接                           │  │
│  │ [10:23:02] → 发送: {"type":"story"}           │  │
│  │ [10:23:02] ← 收到: {"type":"tts","state":"start"} │
│  │ [10:23:03] ← 收到: {"type":"tts","state":"sentence_start","text":"..."} │
│  │ [10:23:03] ← 收到: [Binary] Opus帧 #1 (38 bytes) │
│  │ [10:23:03] 🔊 开始播放...                     │  │
│  │ [10:23:15] ← 收到: {"type":"tts","state":"stop"} │
│  │ [10:23:15] 🔊 播放完毕                        │  │
│  └───────────────────────────────────────────────┘  │
│                                                     │
│  统计: 已收到 85 个Opus帧 | 约 5.1s 音频           │
└─────────────────────────────────────────────────────┘

3.4 核心实现逻辑

连接流程

const ws = new WebSocket(
  `ws://localhost:8888/xiaozhi/v1/?device-id=${deviceId}&client-id=${clientId}`
);
ws.binaryType = 'arraybuffer';

触发故事

ws.send(JSON.stringify({ type: 'story' }));

接收消息处理

ws.onmessage = (event) => {
  if (event.data instanceof ArrayBuffer) {
    // 二进制Opus 音频帧
    const opusFrame = new Uint8Array(event.data);
    const pcm = opusDecoder.decode(opusFrame);   // Int16Array
    schedulePlay(pcm);                            // 排队播放
  } else {
    // 文本:控制消息
    const msg = JSON.parse(event.data);
    handleTtsControl(msg);  // 处理 start/sentence_start/stop
  }
};

Opus 解码 + 播放(与小智方案完全一致)

  • 使用 libopus.jsWASM初始化解码器16000Hz单声道
  • 解码:Int16ArrayFloat32Array
  • 使用 AudioContext + AudioBufferSourceNode 按时序排队播放
  • 使用 BlockingQueue 缓冲帧,避免播放卡顿

四、测试用例

Case 1基础连接测试

  • 输入正确的 device-idclient-id
  • 期望WebSocket 连接建立成功,状态变为"已连接"

Case 2故事触发测试

  • 发送 {"type":"story"}
  • 期望:
    1. 收到 {"type":"tts","state":"start"}
    2. 收到 {"type":"tts","state":"sentence_start","text":"<故事标题>"}
    3. 陆续收到多个二进制 Opus 帧
    4. 最终收到 {"type":"tts","state":"stop"}

Case 3音频播放验证

  • 期望:浏览器实际播放出故事音频,声音正常无杂音、无卡顿

Case 4设备不存在测试

  • 使用未注册的 MAC 地址
  • 期望:发送故事指令后立即收到 {"type":"tts","state":"stop"}(服务侧找不到故事,直接结束)

Case 5重复触发测试

  • 播放过程中再次点击"触发故事"
  • 期望旧播放被打断新故事从头开始hw_service_go 的 StartPlayback 会 close 旧 abortCh

Case 6断线重连测试

  • 连接后断开,再重新连接
  • 期望:可以正常重新发起故事请求

五、实现步骤

  1. 复制 libopus.js

    cp /Users/maidong/Desktop/zyc/jikashe/xiaozhi-server/main/xiaozhi-server/test/libopus.js \
       /Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend/hw_service_go/test/
    
  2. 编写 test.html(单文件,嵌入所有 JS

    • 参考小智 StreamingContext.jsBlockingQueue.js 的逻辑
    • 去掉录音/编码部分(我们只需解码)
    • 保留 Opus 解码 + AudioContext 播放部分
    • 添加连接配置 UI 和消息日志面板
  3. 浏览器打开测试

    直接用浏览器打开 test.htmlfile:// 协议即可)
    

    注意macOS Safari 对 WebSocket + file:// 可能有限制,建议用 Chrome

  4. 按测试用例逐项验证


六、已知限制与注意事项

问题 说明
device-id 必须真实存在 MAC 地址若未在 Django 数据库注册,服务会静默返回无故事
ffmpeg 必须安装 hw_service_go 的转码依赖系统 ffmpeg,需提前安装
audio_url 必须可访问 故事的 MP3 链接需要能从本机下载(阿里云 OSS 等)
浏览器 AudioContext 限制 需要用户交互(点击)后才能创建 AudioContext不能自动播放
WASM 加载 libopus.js 较大844KB首次加载需要等待约 1-2 秒