Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
## 变更内容
### 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>
252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# hw_service_go 本地硬件通讯测试计划
|
||
|
||
> 目标:用浏览器模拟 ESP32 硬件,验证 `hw_service_go` WebSocket 服务能否正常接收指令、获取故事、推送 Opus 音频。
|
||
|
||
---
|
||
|
||
## 一、协议对比分析
|
||
|
||
### 1.1 小智(xiaozhi-server)vs 我们的服务
|
||
|
||
| 维度 | xiaozhi-server | hw_service_go(本服务) |
|
||
|------|---------------|------------------------|
|
||
| **WebSocket URL** | `ws://host:port/xiaozhi/v1/?device-id=&client-id=` | 完全相同 |
|
||
| **连接参数** | `device-id`(MAC)、`client-id`(UUID)| 完全相同 |
|
||
| **握手消息** | 需要发送 `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 |
|
||
| 编码器 | libopus(WASM) |
|
||
|
||
---
|
||
|
||
## 二、前置条件检查
|
||
|
||
在开始测试之前,需要满足以下条件:
|
||
|
||
### 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.js`(WASM)做解码。
|
||
|
||
### 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 核心实现逻辑
|
||
|
||
#### 连接流程
|
||
```javascript
|
||
const ws = new WebSocket(
|
||
`ws://localhost:8888/xiaozhi/v1/?device-id=${deviceId}&client-id=${clientId}`
|
||
);
|
||
ws.binaryType = 'arraybuffer';
|
||
```
|
||
|
||
#### 触发故事
|
||
```javascript
|
||
ws.send(JSON.stringify({ type: 'story' }));
|
||
```
|
||
|
||
#### 接收消息处理
|
||
```javascript
|
||
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.js`(WASM)初始化解码器:16000Hz,单声道
|
||
- 解码:`Int16Array` → `Float32Array`
|
||
- 使用 `AudioContext` + `AudioBufferSourceNode` 按时序排队播放
|
||
- 使用 `BlockingQueue` 缓冲帧,避免播放卡顿
|
||
|
||
---
|
||
|
||
## 四、测试用例
|
||
|
||
### Case 1:基础连接测试
|
||
- 输入正确的 `device-id` 和 `client-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**
|
||
```bash
|
||
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.js` 和 `BlockingQueue.js` 的逻辑
|
||
- 去掉录音/编码部分(我们只需解码)
|
||
- 保留 Opus 解码 + AudioContext 播放部分
|
||
- 添加连接配置 UI 和消息日志面板
|
||
|
||
3. **浏览器打开测试**
|
||
```
|
||
直接用浏览器打开 test.html(file:// 协议即可)
|
||
```
|
||
> 注意: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 秒 |
|