docs: 部署编排与运维文档
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
# ESP32 红外遥控控制大屏 —— 开发文档
|
||||
|
||||
> 通过红外遥控器(NEC 协议)直接控制园区大屏页面切换与操作。
|
||||
> 链路:红外遥控 → ESP32(红外接收 + 解码)→ MQTT → 前端调度器 → 页面动作
|
||||
|
||||
## 1. 系统架构
|
||||
|
||||
```
|
||||
┌──────────┐ 38kHz 红外 ┌─────────┐ WiFi/MQTT ┌──────────────┐ opc/display/command ┌────────┐
|
||||
│ 红外遥控器 │ ───────────► │ ESP32 │ ───────────► │ EMQX Broker │ ─────────────────────► │ 大屏前端 │
|
||||
│ (NEC协议) │ │ 接收+解码 │ │ 192.168.1.3 │ │ 调度器 │
|
||||
└──────────┘ └─────────┘ └──────────────┘ └────────┘
|
||||
```
|
||||
|
||||
- **红外遥控器**:任意 NEC 协议遥控器(电视/机顶盒/学习型均可),按键映射可自定义
|
||||
- **ESP32**:接收红外信号 → 解码键码 → 映射成指令 → MQTT 发布
|
||||
- **MQTT**:复用现有 `opc/display/command` 频道(见 `docs/mqtt-commands.md`)
|
||||
- **大屏前端**:`useMqttControl` 调度器接收指令执行(切页/媒体/语音/视觉)
|
||||
|
||||
## 2. 硬件清单与接线
|
||||
|
||||
| 硬件 | 型号/规格 | 数量 |
|
||||
|---|---|---|
|
||||
| 开发板 | ESP32(DevKitC / NodeMCU-32S) | 1 |
|
||||
| 红外接收头 | VS1838B / TL1838(38kHz,3 脚) | 1 |
|
||||
| 电阻 | 100Ω(限流,可选) | 1 |
|
||||
| 红外遥控器 | NEC 协议(电视/机顶盒遥控器) | 1 |
|
||||
|
||||
**VS1838B 接线**(面对接收头平面,引脚从左到右):
|
||||
|
||||
| 引脚 | 接 ESP32 |
|
||||
|---|---|
|
||||
| 1 (OUT/信号) | GPIO 15(可改,见代码 `IR_RX_PIN`) |
|
||||
| 2 (GND) | GND |
|
||||
| 3 (VCC) | 3.3V(或 5V,接收头宽压 2.7~5.5V) |
|
||||
|
||||
## 3. 红外协议(NEC)
|
||||
|
||||
- NEC 协议:38kHz 载波,地址码 + 命令码 + 反码
|
||||
- 本方案**只取命令码(command byte)**即可区分按键,地址码忽略
|
||||
- 任意 NEC 遥控器的按键都有固定键码;不同遥控器键码不同 → **首次需"学习模式"采集键码**(见 §6)
|
||||
|
||||
## 4. Arduino 固件
|
||||
|
||||
依赖库(Arduino IDE 库管理器安装):
|
||||
- `IRremote`(by Armin Joachimsmeyer,v4.x)
|
||||
- `PubSubClient`(by Nick O'Leary)
|
||||
|
||||
```cpp
|
||||
// esp32_ir_mqtt.ino —— 红外遥控 → MQTT 控制大屏
|
||||
#include <WiFi.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <IRremote.h>
|
||||
|
||||
// ── WiFi / MQTT 配置 ─────────────────────────────────────
|
||||
const char* WIFI_SSID = "你的WiFi";
|
||||
const char* WIFI_PASS = "你的WiFi密码";
|
||||
const char* MQTT_HOST = "192.168.1.3"; // EMQX Broker
|
||||
const int MQTT_PORT = 1883;
|
||||
const char* MQTT_USER = "mqtt用户名"; // backend/.env MQTT_USERNAME
|
||||
const char* MQTT_PASS = "mqtt密码"; // backend/.env MQTT_PASSWORD
|
||||
const char* MQTT_TOPIC = "opc/display/command";
|
||||
|
||||
// ── 红外引脚 ─────────────────────────────────────────────
|
||||
const uint8_t IR_RX_PIN = 15;
|
||||
|
||||
// ── 按键映射表:遥控器键码 → 大屏指令 ─────────────────────
|
||||
// 键码需先用「学习模式」采集(串口打印 decodedIRData.command),再填入
|
||||
struct CmdMap { uint32_t key; const char* payload; };
|
||||
const CmdMap MAP[] = {
|
||||
// 数字键 1~5:直接切页
|
||||
{ 0x16, R"({"action":"navigate","params":{"page":"/"}})" }, // 1 → 数据大屏
|
||||
{ 0x19, R"({"action":"navigate","params":{"page":"/twin"}})" }, // 2 → 数字孪生
|
||||
{ 0x0D, R"({"action":"navigate","params":{"page":"/ai"}})" }, // 3 → AI 助手
|
||||
{ 0x0C, R"({"action":"navigate","params":{"page":"/voice"}})" }, // 4 → 语音对话
|
||||
{ 0x18, R"({"action":"navigate","params":{"page":"/screen"}})" }, // 5 → 媒体轮播
|
||||
// CH+/CH-(或左右方向):向左/右切页
|
||||
{ 0x00, R"({"action":"navigate_rel","params":{"delta":-1}})" }, // CH- → 左切
|
||||
{ 0x01, R"({"action":"navigate_rel","params":{"delta":1}})" }, // CH+ → 右切
|
||||
// 音量+/−:媒体播放/暂停(或 next/prev)
|
||||
{ 0x02, R"({"action":"play","params":{}})" }, // VOL+ → 播放
|
||||
{ 0x03, R"({"action":"pause","params":{}})" }, // VOL- → 暂停
|
||||
// 电源键:语音对话开关
|
||||
{ 0x0B, R"({"action":"voice_start","params":{}})" }, // 电源 → 语音启动
|
||||
// OK/静音键:视觉识别开关(toggle 示例:直接开/关,可换 toggle 逻辑)
|
||||
{ 0x0A, R"({"action":"vision_set","params":{"enabled":true}})" }, // 静音 → 开识别
|
||||
};
|
||||
const int MAP_LEN = sizeof(MAP) / sizeof(MAP[0]);
|
||||
|
||||
// ── 全局 ─────────────────────────────────────────────────
|
||||
WiFiClient espClient;
|
||||
PubSubClient mqtt(espClient);
|
||||
unsigned long lastReconnect = 0;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("ESP32 IR → MQTT 启动");
|
||||
|
||||
IrReceiver.begin(IR_RX_PIN, ENABLE_LED_FEEDBACK);
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
Serial.println("\nWiFi 已连接: " + String(WiFi.localIP()));
|
||||
|
||||
mqtt.setServer(MQTT_HOST, MQTT_PORT);
|
||||
mqtt.setKeepAlive(30);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (!mqtt.connected()) reconnect();
|
||||
mqtt.loop();
|
||||
|
||||
// ── 红外解码 ──
|
||||
if (IrReceiver.decode()) {
|
||||
uint32_t cmd = IrReceiver.decodedIRData.command;
|
||||
Serial.printf("[IR] 地址=0x%02X 键码=0x%02X\n",
|
||||
IrReceiver.decodedIRData.address, cmd);
|
||||
IrReceiver.resume(); // 立即恢复接收
|
||||
|
||||
sendCommand(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 键码 → MQTT ──
|
||||
void sendCommand(uint32_t key) {
|
||||
for (int i = 0; i < MAP_LEN; i++) {
|
||||
if (MAP[i].key == key) {
|
||||
Serial.println("[MQTT] 发送: " + String(MAP[i].payload));
|
||||
mqtt.publish(MQTT_TOPIC, MAP[i].payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Serial.println("[IR] 未映射键码(可在学习模式后加入映射表)");
|
||||
}
|
||||
|
||||
// ── MQTT 重连 ──
|
||||
void reconnect() {
|
||||
if (millis() - lastReconnect < 3000) return;
|
||||
lastReconnect = millis();
|
||||
String id = "esp32-ir-" + String((uint32_t)ESP.getEfuseMac(), HEX);
|
||||
if (mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASS)) {
|
||||
Serial.println("MQTT 已连接");
|
||||
} else {
|
||||
Serial.printf("MQTT 连接失败 rc=%d\n", mqtt.state());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 键位映射建议
|
||||
|
||||
| 遥控器按键 | 键码(示例) | 指令 | 效果 |
|
||||
|---|---|---|---|
|
||||
| 1 ~ 5 | 0x16/0x19/0x0D/0x0C/0x18 | `navigate` | 直达各页面 |
|
||||
| CH− / CH+ | 0x00 / 0x01 | `navigate_rel` | 向左/右循环切页 |
|
||||
| VOL+ / VOL− | 0x02 / 0x03 | `play` / `pause` | 媒体播放/暂停 |
|
||||
| 电源 | 0x0B | `voice_start` | 启动语音对话 |
|
||||
| 静音 | 0x0A | `vision_set` | 开/关人物识别 |
|
||||
|
||||
> 键码因遥控器而异——上面的 0x16/0x19… 是常见 NEC 遥控器的值,**首次使用务必先跑学习模式实测**。
|
||||
|
||||
其他可用指令(自定义映射):`ai_input`(AI 页提问)、`ai_company`(切企业)、`ai_zone`(切分区)、`voice_stop`、`next`/`prev`(媒体)、`alert`(通知)——参数见 `docs/mqtt-commands.md`。
|
||||
|
||||
## 5.1 企业展示墙控制(/wall,39 家入驻企业滚动墙)
|
||||
|
||||
**MQTT 控制消息**(ESP32 解码红外键码后发布到 `opc/display/command`,JSON 载荷):
|
||||
|
||||
| 遥控器按键 | 建议指令 action | params | 效果 | 示例消息 |
|
||||
|---|---|---|---|---|
|
||||
| 1 / 2 / 3 | `wall_speed` | `speed: "slow" \| "normal" \| "fast"` | 慢速 150s / 标准 80s / 快速 40s 一圈 | `{"action":"wall_speed","params":{"speed":"slow"}}` |
|
||||
| CH− | `wall_pause` | `{}` | 暂停滚动 | `{"action":"wall_pause","params":{}}` |
|
||||
| CH+ | `wall_resume` | `{}` | 继续滚动 | `{"action":"wall_resume","params":{}}` |
|
||||
| 电源 | `navigate` | `page: "/wall"` | 切换到企业展示墙 | `{"action":"navigate","params":{"page":"/wall"}}` |
|
||||
|
||||
**ESP32 固件映射示例**(在 §4 固件的 `MAP[]` 键码映射表中追加):
|
||||
|
||||
```cpp
|
||||
// 键码 → MQTT 指令(payload 为 JSON 字符串,经 publishCommand 发布)
|
||||
{ 0x02, R"({"action":"wall_pause","params":{}})" }, // VOL− 暂停墙滚动
|
||||
{ 0x03, R"({"action":"wall_resume","params":{}})" }, // VOL+ 继续墙滚动
|
||||
{ 0x00, R"({"action":"wall_speed","params":{"speed":"fast"}})" }, // CH− 快速
|
||||
{ 0x01, R"({"action":"wall_speed","params":{"speed":"slow"}})" }, // CH+ 慢速
|
||||
{ 0x0B, R"({"action":"navigate","params":{"page":"/wall"}})" }, // 电源 切到展示墙
|
||||
```
|
||||
|
||||
> 前端 `useMqttControl` 收到 `wall_*` 指令后派发 `dpm:wall-control` 事件,展示墙页据此暂停/继续/变速;未在展示墙页时 `wall_*` 指令不产生副作用(安全幂等)。
|
||||
|
||||
## 6. 学习模式(首次必须)
|
||||
|
||||
键码采集固件(只需串口打印,不用连 MQTT):
|
||||
|
||||
```cpp
|
||||
#include <IRremote.h>
|
||||
const uint8_t IR_RX_PIN = 15;
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
IrReceiver.begin(IR_RX_PIN, ENABLE_LED_FEEDBACK);
|
||||
Serial.println("按遥控器按键,串口会打印键码(学习模式)");
|
||||
}
|
||||
void loop() {
|
||||
if (IrReceiver.decode()) {
|
||||
Serial.printf("address=0x%02X command=0x%02X\n",
|
||||
IrReceiver.decodedIRData.address,
|
||||
IrReceiver.decodedIRData.command);
|
||||
IrReceiver.resume();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
步骤:
|
||||
1. 烧录学习固件,打开串口监视器(115200)
|
||||
2. 逐个按遥控器按键,记录每个键的 `command` 十六进制值
|
||||
3. 把键码填入正式固件的 `MAP[]` 映射表
|
||||
4. 烧录正式固件
|
||||
|
||||
## 7. 调试与故障排查
|
||||
|
||||
| 现象 | 排查 |
|
||||
|---|---|
|
||||
| 串口无任何 IR 输出 | 接线检查(OUT→GPIO15、GND、VCC);接收头正面对遥控器;换遥控器电池 |
|
||||
| 有 IR 输出但 MQTT 不发送 | 打印显示"未映射键码"→ 键码表没配对该遥控器;WiFi/MQTT 未连接 |
|
||||
| MQTT 连接失败 rc=5 | 用户名/密码错误(对照 backend/.env) |
|
||||
| 前端无反应但 MQTT 收到 | `mosquitto_sub -t opc/display/command` 验证消息到达 Broker;前端需在运行且 MQTT 已连(页面右上角在线状态) |
|
||||
| 连按无效 | NEC 重复码(0xFFFFFFFF)被忽略属正常;长按只发一次指令 |
|
||||
|
||||
## 8. 进阶扩展(可选)
|
||||
|
||||
- **学习型配置**:ESP32 加按钮 + OLED,现场学习键码并存入 NVS,免改代码
|
||||
- **多键组合**:短按/长按区分(记录按下时长)→ 同一键多指令
|
||||
- **OTA 升级**:ArduinoOTA 远程更新固件
|
||||
- **多屏控制**:指令包带 `client_id` 过滤,指定某台大屏执行
|
||||
@@ -0,0 +1,175 @@
|
||||
# MQTT 标准操作手册(OPC 智能园区大屏系统)
|
||||
|
||||
> 通过 MQTT 对前端大屏进行集中控制:切页、页面操作、媒体控制、视觉开关、状态上报。
|
||||
|
||||
## 1. Broker 连接
|
||||
|
||||
| 项 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| 地址 | `192.168.1.3` | 局域网 EMQX Broker |
|
||||
| TCP 端口 | `1883` | MQTT 标准端口(后端/命令行用) |
|
||||
| WebSocket 端口 | `8083` | 前端浏览器用(`ws://192.168.1.3:8083/mqtt`) |
|
||||
| 用户名/密码 | `backend/.env` 的 `MQTT_USERNAME` / `MQTT_PASSWORD` | 连接鉴权 |
|
||||
|
||||
## 2. 频道(Topic)
|
||||
|
||||
| Topic | 方向 | 载荷 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `opc/display/command` | 服务端 → 前端 | 指令信封 | **控制指令**(切页/页面操作/媒体/视觉) |
|
||||
| `opc/dashboard/tick` | 服务端 → 前端 | 园区数据快照 | 数据大屏实时刷新(2.2s) |
|
||||
| `opc/display/heartbeat` | 前端 → 服务端 | `{client_id, page, ts}` | 大屏在线心跳(10s) |
|
||||
| `opc/frontend/state` | 前端 → 服务端 | `{client_id, page, ts}` | **页面状态即时上报**(路由变化即发) |
|
||||
| `opc/display/ack` | 前端 → 服务端 | 指令回执 | 指令确认(可选) |
|
||||
|
||||
## 3. 统一指令信封
|
||||
|
||||
所有控制指令发到 `opc/display/command`,JSON 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"cmd_id": "uuid", // 指令 ID(可选)
|
||||
"ts": 1755490000000, // 时间戳 ms(可选)
|
||||
"action": "navigate", // 命令名(必填)
|
||||
"params": { "page": "/ai" } // 参数(按命令)
|
||||
}
|
||||
```
|
||||
|
||||
后端 `hub.publish_command(action, params)` 自动生成信封;命令行手动发送时只需 `action` + `params`。
|
||||
|
||||
## 4. 命令全集
|
||||
|
||||
### 4.1 页面切换
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/wall" \| "/screen"`(或别名 home/twin/ai/voice/wall/screen/数据大屏/企业展示墙…) | 切换到指定页面 |
|
||||
| `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 |
|
||||
|
||||
### 4.2 全局视觉识别
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `vision_set` | `enabled: true \| false` | 开启 / 关闭全局人物识别(YOLO 人脸/姿态/手势) |
|
||||
|
||||
### 4.3 AI 助手页(/ai)
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `ai_input` | `text: "介绍一下入驻政策"` | 输入问题并自动发送 |
|
||||
| `ai_preset` | `index: 0 \| 1 \| 2` | 选择预设问题并发送 |
|
||||
| `ai_company` | `action: "next" \| "prev"` | 切换展示企业 |
|
||||
| `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 |
|
||||
|
||||
### 4.4 语音对话页(/voice)
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `voice_start` | — | 启动语音对话 |
|
||||
| `voice_stop` | — | 关闭语音对话 |
|
||||
| `voice_refresh` | — | 刷新页面 |
|
||||
|
||||
### 4.5 媒体轮播页(/screen)
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `play` / `pause` | — | 播放 / 暂停 |
|
||||
| `next` / `prev` | — | 下一项 / 上一项 |
|
||||
| `set_mode` | `mode: sequential \| shuffle \| loop` | 播放模式 |
|
||||
| `play_target` | `path: "媒体路径"` | 播放指定媒体 |
|
||||
| `settings_changed` | 设置对象 | 应用设置变更 |
|
||||
| `playlist_changed` | — | 重新加载播放列表 |
|
||||
|
||||
### 4.6 企业展示墙页(/wall)
|
||||
|
||||
> 39 家入驻企业信息缓慢滚动展示墙(数据来源:《入驻企业信息表》)
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `wall_pause` | — | 暂停滚动 |
|
||||
| `wall_resume` | — | 继续滚动 |
|
||||
| `wall_speed` | `speed: slow \| normal \| fast` | 滚动速度:慢速 150s / 标准 80s / 快速 40s 一圈 |
|
||||
|
||||
### 4.7 全局
|
||||
|
||||
| action | params | 效果 |
|
||||
|---|---|---|
|
||||
| `alert` | `text: "通知内容"` | 全局通知弹窗 |
|
||||
| `show_card` | 卡片数据 | 展示信息卡片 |
|
||||
| `minimize` | — | 最小化窗口(Tauri) |
|
||||
|
||||
## 5. 命令行发送
|
||||
|
||||
### mosquitto_pub(推荐,需安装 `mosquitto-clients`)
|
||||
|
||||
```bash
|
||||
# 切页到 AI 助手
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command \
|
||||
-m '{"action":"navigate","params":{"page":"/ai"}}'
|
||||
|
||||
# 向左切页
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"navigate_rel","params":{"delta":-1}}'
|
||||
|
||||
# 关闭人物识别
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"vision_set","params":{"enabled":false}}'
|
||||
|
||||
# AI 页提问
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"ai_input","params":{"text":"介绍一下入驻政策"}}'
|
||||
|
||||
# 切换企业分区
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"ai_zone","params":{"zone":"国际区"}}'
|
||||
|
||||
# 语音启动 / 媒体下一项
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"voice_start","params":{}}'
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"next","params":{}}'
|
||||
|
||||
# 切到企业展示墙 / 暂停滚动
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"navigate","params":{"page":"/wall"}}'
|
||||
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
|
||||
-t opc/display/command -m '{"action":"wall_pause","params":{}}'
|
||||
```
|
||||
|
||||
### Python(paho-mqtt,后端 venv 自带)
|
||||
|
||||
```bash
|
||||
cd /Volumes/Pine/mycode/DPM/dpm/backend
|
||||
.venv/bin/python - <<'EOF'
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
def send(action, params=None):
|
||||
c = mqtt.Client()
|
||||
c.username_pw_set("用户名", "密码")
|
||||
c.connect("192.168.1.3", 1883, 30)
|
||||
c.publish("opc/display/command", str({"action": action, "params": params or {}}))
|
||||
c.disconnect()
|
||||
|
||||
send("navigate", {"page": "/voice"})
|
||||
send("ai_company", {"action": "next"})
|
||||
send("voice_stop")
|
||||
EOF
|
||||
```
|
||||
|
||||
### MQTTX(图形界面)
|
||||
|
||||
1. 新建连接:`mqtt://192.168.1.3:1883`,填用户名/密码
|
||||
2. 发布到 `opc/display/command`,消息体如上 JSON
|
||||
|
||||
## 6. 订阅调试
|
||||
|
||||
```bash
|
||||
# 监听前端页面上报(验证"当前页面")
|
||||
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/frontend/state'
|
||||
|
||||
# 监听心跳
|
||||
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/display/heartbeat'
|
||||
|
||||
# 监听全部指令
|
||||
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/display/command' -v
|
||||
```
|
||||
@@ -0,0 +1,168 @@
|
||||
# DPM 实时语音对话整合方案(复用 live-avatar 云化栈)
|
||||
|
||||
> 目标:砍掉数字人/口型/wav2lip 等全部视觉部分,只保留「语音识别 + 对话 + 实时交互」链路;
|
||||
> 前端在 DPM React 应用中新增对话页(复用 live-avatar demo 前端的样式与逻辑);
|
||||
> 后端并入 DPM `backend/pyproject.toml`。
|
||||
> 基线:live-avatar 云化栈已在 `/Volumes/Pine/mycode/opc/live` 跑通(s2s v0.2.11 + cloud-migration 补丁 + demo 前端)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 现状对比
|
||||
|
||||
| | live-avatar(已跑通) | DPM(现状) |
|
||||
|---|---|---|
|
||||
| 前端 | 原生 JS demo(index.html / main.js / s2s-ws-client.js / 两个 worklet) | React 19 + Vite + Tailwind;`/ai` 页已有 `AiChatPanel`(文字 + **录音上传式**语音) |
|
||||
| 后端 | s2s 独立进程 :8765(VAD 本地 + 云 ASR/LLM/TTS) | FastAPI :8000;已有 `app/asr.py`(dashscope paraformer)、`app/llm.py`(qwen + 工具)、`SSL_CERT_FILE=certifi` 同款处理 |
|
||||
| 语音体验 | **实时全双工**:本地 VAD → 流式云 ASR → 流式 LLM → 流式 TTS → 音频回放 | 录音 → 上传 → 转写 → 文字回复(无流式、无 TTS 回音) |
|
||||
|
||||
**DPM 缺的正是 live-avatar 栈的实时交互能力**;两边的云凭据/CA 处理已一致(dashscope + certifi),合并成本低。
|
||||
|
||||
---
|
||||
|
||||
## 1. 两个关键决策点
|
||||
|
||||
### 1.1 后端:内嵌进 DPM FastAPI(推荐) vs 独立 s2s 进程
|
||||
|
||||
- **推荐:内嵌**。s2s v0.2.11 的 `api/openai_realtime/websocket_router.create_app(pool, stop_event)` 本身就返回一个 FastAPI 应用,
|
||||
可在 DPM `app.main` 里构建管线池后 `app.mount("/v1", s2s_app)`,与现有 `/api/*` 共存、单进程单端口。
|
||||
Tauri 桌面打包只需一个后端进程,无子进程管理。
|
||||
- 备选:s2s 独立跑 :8765,DPM 负责拉起/守护。改动最小,但两进程两端口,打包与部署更重。
|
||||
|
||||
### 1.2 前端:iframe 内嵌(推荐 v1) vs React 组件移植(v2)
|
||||
|
||||
- **推荐 v1:iframe 内嵌**。把 live-avatar `demo/` 目录拷入 DPM `public/voice-demo/`(去掉数字人 iframe/视觉元素),
|
||||
React 新增 `VoiceAssistant.jsx`(路由 `/voice`)里 `<iframe src="/voice-demo/">` 全屏。
|
||||
**100% 复用现有样式与逻辑、零 React 改写、风险最低**;WS 地址由 `voice-demo/config.js` 按 `VITE_API_BASE` 派生。
|
||||
- v2:React 组件移植。把 `s2s-ws-client.js` + 两个 worklet 拷入 `src/voice/`,用 hooks 驱动、JSX 重绘圆球/气泡,
|
||||
与 DPM 布局/Tailwind 完全融合;工作量较大,建议先 v1 跑通再决定。
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端合并清单
|
||||
|
||||
### 2.1 `backend/pyproject.toml`
|
||||
|
||||
```toml
|
||||
dependencies = [
|
||||
# ...现有依赖(fastapi/uvicorn/dashscope/certifi 已覆盖)...
|
||||
# s2s 云化栈(path 依赖,指向补丁后的 s2s-cloud;或 vendored 到 backend/vendor/)
|
||||
"speech-to-speech @ file:///Volumes/Pine/mycode/opc/live/s2s-cloud",
|
||||
# s2s 运行所需(base 依赖里 Darwin 专用的 mlx/transformers 等不要全装)
|
||||
"torch>=2.4", # VAD(silero) 本地必需;CPU 亦可
|
||||
"numpy>=1.26",
|
||||
"scipy>=1.10",
|
||||
"httpx>=0.28",
|
||||
"websockets>=12",
|
||||
"nltk==3.9.4",
|
||||
"openai==2.28.0",
|
||||
"soundfile>=0.13",
|
||||
]
|
||||
```
|
||||
|
||||
> 安装用 `pip install -e "backend[voice]" --no-deps` + 手动补依赖,避免 s2s base 依赖把 mlx/transformers 等大件拖进来。
|
||||
> `torch` 是唯一的大依赖(VAD 本地必需);若部署机无 GPU,CPU torch 跑 silero 足够。
|
||||
|
||||
### 2.2 配置(`app/config.py` + `backend/.env`)
|
||||
|
||||
```env
|
||||
DASHSCOPE_API_KEY=sk-... # 已有(与 app/asr.py、app/llm.py 共用)
|
||||
S2S_ENABLED=1
|
||||
S2S_NUM_PIPELINES=4 # 并发会话数
|
||||
S2S_WS_PATH=/v1/realtime
|
||||
S2S_STT_MODEL=qwen3-asr-flash-realtime
|
||||
S2S_LLM_MODEL=qwen-plus
|
||||
S2S_TTS_MODEL=qwen3-tts-flash-realtime
|
||||
S2S_TTS_VOICE=Cherry
|
||||
```
|
||||
|
||||
### 2.3 代码(新 `app/s2s_bridge.py` + 挂载)
|
||||
|
||||
1. `s2s_bridge.py`:读取 Settings → 用与 `start_backend.sh` 等价的参数构建管线池
|
||||
(`--stt dashscope-asr --llm_backend chat-completions --tts qwen3-cloud --enable_live_transcription false`),
|
||||
返回 `websocket_router.create_app(pool, stop_event)`。
|
||||
2. `app/main.py` lifespan:`ENABLED` 时启动池 → `app.mount(settings.S2S_WS_PATH, s2s_app)`;关闭时 stop。
|
||||
3. 现有 `/api/ai/chat`、`/api/ai/asr` 保留(旧 AiChatPanel 流程不破坏)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端合并清单
|
||||
|
||||
1. `src/pages/VoiceAssistant.jsx`(新):
|
||||
- 全屏 `<iframe src="/voice-demo/" />`(v1),或未来 React 移植版。
|
||||
- 加入 `App.jsx` 路由:`<Route path="/voice" element={<VoiceAssistant />} />`(建议放在 ScreenLayout 内或独立全屏)。
|
||||
2. `public/voice-demo/`:拷贝 live-avatar `demo/`(index.html / main.js / style.css / ws/ / worklets/ / textchat.html),
|
||||
并删除数字人 iframe(`index.html` 里的 `#avatar-frame` → 背景改纯色/园区图)。
|
||||
3. `public/voice-demo/config.js`:暴露 `window.VOICE_WS_URL`(从 `VITE_API_BASE` 派生 `ws://<host>:<port>/v1/realtime`)。
|
||||
4. 麦克风权限:
|
||||
- 浏览器开发模式:`localhost` 直接可用。
|
||||
- **Tauri v2 打包**:`src-tauri/tauri.conf.json` 需 `app.macOSPrivateApi: true`(macOS 麦克风)+ 系统隐私授权说明;
|
||||
Windows 需在 manifest/权限描述中声明 microphone。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
| 步骤 | 内容 | 工作量 |
|
||||
|---|---|---|
|
||||
| P1 | 后端内嵌:pyproject 加依赖 → s2s_bridge.py → mount → 冒烟(WS 连得上、说话→回复→出声) | 0.5–1 天 |
|
||||
| P2 | 前端:public/voice-demo 拷贝去视觉化 → VoiceAssistant.jsx + 路由 → WS 指向 DPM 后端 | 0.5 天 |
|
||||
| P3 | 联调:DPM 页面语音对话全链路 + 并发验证 | 0.5 天 |
|
||||
| P4(可选) | React 原生移植(hooks 驱动 client + worklets,Tailwind 重绘 UI) | 1–2 天 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与注意
|
||||
|
||||
- **torch 体积**:VAD 本地必需(s2s 管线结构固定 VAD 本地),CPU 版可接受;若部署机极简可评估 VAD 上云(不推荐,延迟差)。
|
||||
- **单管线槽**:`S2S_NUM_PIPELINES` 默认 1 → 并发对话会拒连;按需调到 4。
|
||||
- **WS 鉴权**:内网/桌面场景可无鉴权;如需,在 session.update 里带 token 校验。
|
||||
- **iframe 限制**:v1 iframe 内样式隔离、无法用 DPM 主题;v2 移植可解决。
|
||||
- **Tauri 麦克风**:打包前务必配置 macOSPrivateApi + 权限描述,否则桌面版无声/无权限。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 视觉触发子系统(USB 摄像头 + 桌面端检测)
|
||||
|
||||
> 目标:大屏前 USB 摄像头拍观众;检测有人**正对且停留 ≥10 秒** → 主动问候 + 全屏联动;问候后**静默 60 秒**。
|
||||
|
||||
### A.1 架构与数据通路
|
||||
|
||||
```
|
||||
大屏机(Tauri 应用,fullscreen)
|
||||
└─ USB 摄像头(大屏前,拍观众)
|
||||
└─ webview getUserMedia(一路视频流)
|
||||
├─ 持续检测:MediaPipe FaceMesh 1~2fps(本机、不出设备)
|
||||
│ ├─ 头姿 yaw/pitch 各 ±15° = 正对
|
||||
│ └─ 连续 10s 正对 → 触发(窗口内 ≥80% 帧正对,防抖)
|
||||
└─ 按需抓帧:LLM 决定时 canvas 取当前帧 → input_image → 云 qwen3-vl(人数/人物)
|
||||
触发动作:
|
||||
① 主动问候:client.sendUserText("检测到有人正对摄像头…请主动问候") + requestResponse()
|
||||
→ s2s 走 LLM 生成问候语 → TTS 播报(零后端改动,复用现有 WS 协议)
|
||||
② 全屏联动:调后端 → event_bus / mqtt.publish → 各屏响应
|
||||
静默窗口:问候触发后 60s 冷却,期内检测照跑但不触发
|
||||
```
|
||||
|
||||
### A.2 Tauri 摄像头权限(必须配置)
|
||||
|
||||
- **macOS**:`tauri.conf.json` → `app.macOSPrivateApi: true`;Info.plist 加
|
||||
`NSCameraUsageDescription` / `NSMicrophoneUsageDescription`(麦克风语音对话也需要)。
|
||||
- **Windows(NSIS 打包目标)**:WebView2 的 getUserMedia 跟随系统隐私设置
|
||||
(设置 → 隐私和安全性 → 相机/麦克风 → 允许该应用);安装包建议在应用清单声明设备能力。
|
||||
- 大屏机摄像头常开:确认系统隐私里该应用允许相机,否则检测与抓帧同时失效。
|
||||
|
||||
### A.3 隐私与合规
|
||||
|
||||
- 持续检测帧**仅在本机内存处理、即用即弃**,不上云;只有 LLM 决定抓的那一帧发往百炼 VLM。
|
||||
- 大屏前采集观众人脸属个人信息处理 → 大屏旁需公示"影像识别用于互动服务"。
|
||||
|
||||
### A.4 落地清单(并入 P2/P3)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 前端新增 `useCameraDetect` hook | getUserMedia + MediaPipe FaceMesh + 状态机(IDLE→FACING→TRIGGER→SILENT) |
|
||||
| 依赖 | `@mediapipe/tasks-vision`(face_detector + face_landmarker) |
|
||||
| 参数化 | 停留 10s / 静默 60s / 角度 ±15° / 频率 1~2fps → 配置文件或设置页 |
|
||||
| 触发动作 | 主动问候(sendUserText+create)+ MQTT 联动(后端 API) |
|
||||
| 首对话抓帧 | 与检测共用同一路视频流,canvas 取帧 → input_image → qwen3-vl |
|
||||
|
||||
> 注:视觉子系统是**可选增强**,不影响主语音对话方案;摄像头/检测不可用时语音链路照常工作。
|
||||
@@ -0,0 +1,78 @@
|
||||
# Windows 打包部署指南(免重打包改地址)
|
||||
|
||||
## 一、打包后在 exe 同目录放置 config.json(推荐)
|
||||
|
||||
打包安装后,在 exe 所在目录(如 `C:\Program Files\云超服昆创园OPC运营中心\`)新建 **`config.json`**,
|
||||
改地址**不需要重新打包**,重启应用即生效:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_base": "http://192.168.1.9:10085",
|
||||
"mqtt_url": "ws://192.168.1.3:8083/mqtt",
|
||||
"mqtt_username": "dpm",
|
||||
"mqtt_password": "123456",
|
||||
"voice_url": "ws://192.168.1.3:8765/v1/realtime"
|
||||
}
|
||||
```
|
||||
|
||||
- `api_base`:FastAPI 后端地址(API / 管理后台 / 媒体)
|
||||
- `mqtt_url`:EMQX Broker 的 **WebSocket** 地址(端口 8083)
|
||||
- `mqtt_username/password`:Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致)
|
||||
|
||||
地址解析优先级:**config.json > 后端 /api/config > 构建默认(192.168.1.9)**。
|
||||
|
||||
## 二、后端 /api/config 方式(同机部署自动生效)
|
||||
|
||||
后端 `.env`(backend/.env)配置 Broker 后,展播端启动时自动从
|
||||
`GET /api/config` 获取 MQTT 地址,**无需 config.json**:
|
||||
|
||||
```
|
||||
DPM_MQTT_HOST=192.168.1.3
|
||||
DPM_MQTT_PORT=1883
|
||||
DPM_MQTT_WS=ws://192.168.1.3:8083/mqtt # 展播端可访问的 WebSocket 地址
|
||||
DPM_VOICE_WS=ws://192.168.1.3:8765/v1/realtime # s2s 语音 WS(可选,默认按主机推导)
|
||||
MQTT_USERNAME=dpmserver
|
||||
MQTT_PASSWORD=你的密码
|
||||
```
|
||||
|
||||
> 若后端与 Broker 同机:后端默认 `MQTT_WS_URL=ws://localhost:8083/mqtt` 即可,
|
||||
> 展播端连 `192.168.1.9` 也通;跨机必须显式配置上面的 IP。
|
||||
|
||||
## 三、构建期固定地址(不推荐,需重新打包)
|
||||
|
||||
在项目根目录(**构建机器上**)配置 `.env.local`(已被 gitignore):
|
||||
|
||||
```
|
||||
VITE_API_BASE=http://192.168.1.9:10085
|
||||
VITE_MQTT_URL=ws://192.168.1.3:8083/mqtt
|
||||
VITE_MQTT_USERNAME=dpm
|
||||
VITE_MQTT_PASSWORD=123456
|
||||
```
|
||||
|
||||
然后 `yarn build:win`(脚本自动清理旧 exe 进程再打包)。
|
||||
|
||||
## 四、打包前必须确认
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|---|---|
|
||||
| exe 未被运行 | `taskkill /f /im "昆明大学生创业园展播系统.exe"`,或直接用 `yarn build:win` |
|
||||
| 后端已启动 | 浏览器打开 `http://<后端IP>:10085/api/health` 应返回 `{"ok":true,...}` |
|
||||
| Broker WebSocket 已开 | EMQX 需开启 8083 端口 WS 监听,且账号密码与 config.json 一致 |
|
||||
| Windows 防火墙 | 首次运行允许,或放行出站 10085/8083(管理端上传用 10085) |
|
||||
|
||||
## 五、常见排查
|
||||
|
||||
```
|
||||
# 测试后端连通(展播机 PowerShell)
|
||||
Invoke-WebRequest http://192.168.1.9:10085/api/health
|
||||
|
||||
# 测试 Broker WS 端口
|
||||
Test-NetConnection 192.168.1.3 -Port 8083
|
||||
|
||||
# 看前端启动日志(bootstrap 会打印 MQTT 地址来源)
|
||||
打开应用后按 F12(WebView2 开发者工具)→ Console
|
||||
```
|
||||
|
||||
- 应用日志打印 `[bootstrap] config.json MQTT -> ...` = config.json 生效
|
||||
- 打印 `[bootstrap] /api/config MQTT -> ...` = 后端下发
|
||||
- 两者都没有 = 两个来源都不可达,请检查 IP/防火墙
|
||||
Reference in New Issue
Block a user