一、项目介绍
市政污水处理厂日常巡检依赖人工抄表、纸质记录,痛点很集中:
工艺参数(溶解氧 DO / 污泥浓度 / pH / 温度 / 流量 / 液位)分散在 PLC、SCADA、就地仪表上,一次巡检 40~60 分钟;
报警靠现场喊或对讲机,工单不能追溯;
巡检员戴安全帽、手套,手机操作不便。
本项目把 ESP32-S31-Korvo-1 做成一台带屏 + 摄像头 + 麦克风阵列的语音巡检助手,实现:
免手持:唤醒词 "Hi 小E" + 中文自然语言指令;
看得见:4.3 寸 LCD 上跑 LVGL 9.5 仪表盘(8 页面 · 5 路实时工艺量 + 告警 + 工单 + 历史趋势 + 摄像头画面);
说得清:本地 + 云端 TTS 双通道;
连得广:Modbus RTU 主站读 PLC、MQTT 上报、HTTP 配网维护、双分区 OTA。
一句话:用一块 S31 开发板替代"手持仪表 + 对讲机 + 巡检平板"三件套。

成果视频
二、硬件方案说明
2.1 主控模组 ESP32-S31-WROOM-3
| CPU | RISC-V32 双核 @ 320 MHz | Core 0 跑 UI + 语音 NL,Core 1 跑采集 + 网络 |
| 片内 SRAM | ~512 KB | DMA / WebSocket / TLS 都在这,AFE 打碎后专门留 reserve |
| PSRAM | 16 MB Octal @ 200 MHz | 语音 PCM、摄像头 JPEG、24 h 参数历史全在这里 |
| Flash | 16 MB QSPI | 双 OTA (0.49 MB × 2) + model + spiffs + storage |
| 无线 | Wi-Fi 6 (802.11ax) + BLE 5 | HTTPS → iFlytek / DeepSeek,MQTT 上云 |
| 显示 | RGB LCD 24 bit | 4.3 寸 800 × 480 |
| 摄像头 | DVP 8 bit | WS8204 200 万像素 |
| 音频 | I2S × 2 | 双 MIC + 立体声喇叭,走 ES8389 |
2.2 核心外设
① ES8389 音频 Codec:从模式,Set internal reference signal 打开后噪声底降 ~6 dB;16 kHz × 2 ch × 16 bit 送 esp-sr AFE。
② GT1151 电容触摸:I2C 0x5D,esp_lcd_touch_gt1151 组件;识别到 GT1158_000101(Patch)。
③ WS8204 DVP 摄像头:SCCB + 8 bit 并行,走 esp_video + esp_new_jpeg;抓帧 → JPEG → SPIFFS → HTTP 上传,配合工单闭环。
④ 双 MEMS 麦克风:阵元间距 5 cm;3 m 内唤醒率 >90%,5 m >70%。
⑤ RS-485 / Modbus RTU:UART1 + SP3485 做 Modbus 主站读 PLC 保持寄存器;无 PLC 时回退到"演示数据"(UI 顶部会标注数据来源)。
⑥ microSD + 4 路 ADC 按键 + WS2812 LED:SD 走 SPI2 做离线日志;按键电阻梯形分压;LED 用 led_strip 组件。
三、系统设计思路
3.1 分层架构
┌────────────────────────────────────────────────────────┐ │ 应用层 Application · 语音助手 / 仪表盘 / 工单 / 告警 │ ├────────────────────────────────────────────────────────┤ │ 服务层 Service · ASR / LLM / TTS / MQTT / HTTP │ │ SNTP / Audit Logger │ ├────────────────────────────────────────────────────────┤ │ 业务层 Business · 语音引擎 / 数据采集 / 告警分类 │ │ 工单流程 / 阈值管理 │ ├────────────────────────────────────────────────────────┤ │ 驱动层 Driver · board_init · I2C · I2S · RGB │ │ · DVP · SD · Modbus · Wi-Fi │ ├────────────────────────────────────────────────────────┤ │ 平台:ESP-IDF v6.2 · FreeRTOS · LVGL 9.5 · esp-sr 2.5│ └────────────────────────────────────────────────────────┘
3.2 关键设计取舍
本地意图 + 云端 LLM 双通道 — 常见指令用关键词/正则本地命中,省 800 ms 一次 LLM 往返;未命中才送 DeepSeek。
语音会话连续模式 — 一次唤醒后进入 5 s 录音,播报完再自动开 5 s,多轮不用重复唤醒。
LVGL 线程安全 — CONFIG_LV_OS_NONE=y 下所有非 LVGL 任务的 lv_async_call 必须 bsp_display_lock。
PSRAM 使用纪律 — flash-mmapped 任务栈不能放 PSRAM;语音 worker 全部常驻;ASR 启动前专门留一块连续内部 DRAM。
四、功能框图 & 软件流程图
4.1 系统功能框图
┌───────────────────┐ 2×MEMS MIC ──┐ │ Wi-Fi 6 / BLE │ ▼ └─────────┬─────────┘ ┌──────────┐ │ HTTPS WSS │ ES8389 │ ▼ └────┬─────┘ ┌────────────────────┐ │ I2S │ iFlytek IAT (ASR) │ ▼ │ DeepSeek (LLM) │ ┌──────────┐ │ edge-tts (TTS) │ │ AFE │ └──────────┬─────────┘ │WakeNet9 │ │ │VAD(WebRTC│ ▼ └────┬─────┘ ┌──────────────┐ │ 唤醒中断 │ 本地意图匹配 │ ▼ │ + Function │ ┌──────────────────┐ │ Call 分发 │ │ 5s PCM 环形缓冲 │ └──────┬───────┘ │ (PSRAM 160 KB) │ │ └────────┬─────────┘ │ └──────────┬───────────┘ ▼ ┌────────────────────────────┐ │ FreeRTOS 消息/队列总线 │ └─┬────┬────┬────┬────┬────┬─┘ ▼ ▼ ▼ ▼ ▼ ▼ LVGL Modbus MQTT HTTP SD OTA │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ LCD 继电器 云端 配网 巡检 版本 800×480 阀门 Telemetry Portal 拍照 回滚 │ ▼ 摄像头 DVP ─▶ JPEG ─▶ SPIFFS
4.2 语音主流程
[待机] │ (WakeNet 阈值 0.4) ▼ "Hi 小E" → 亮屏 + 状态切 RECORDING + 播放滴声 ▼ 录音 5.0 s ── 中途 WebRTC VAD 静音 > 800 ms 提前停 ▼ PCM → WebSocket(iFlytek IAT) 64 chunks / 15 ms 帧 / status=2 结束 ▼ ASR 文本 ├─ 本地意图命中 → 立即分发 (≈200 ms) └─ 未命中 → DeepSeek Function-Call → 分发 ▼ 播报 TTS + 更新 UI → 连续模式:再开 5 s 录音
4.3 告警 / 工单闭环
传感器读数 → threshold_manager 判级 → alarm_engine 去抖/延时 ▼ alarm_pipeline (3 dispatch target) │ ┌──────────┬─────────┼─────────┬────────┐ ▼ ▼ ▼ ▼ ▼ 蜂鸣/LED LVGL 弹窗 TTS 播报 通知服务 MQTT ▼ 工单 ticket_workflow ▼ SLA 计时 · 升级
五、实现过程说明
M1:环境 & 点亮 (Day 1-3) — EIM 装 ESP-IDF v6.2 master-s31 分支,BSP esp32_s31_korvo_1 组件点亮 LCD + I2S。
M2:音频链路 (Day 4-7) — 挂 ES8389,跑 esp-sr AFE + wn9_hiesp;5 s 环形缓冲放 PSRAM。
M3:语音链路 (Day 8-14) — iFlytek IAT WebSocket + HMAC-SHA256 签名 + base64;edge-tts 代理;DeepSeek Function-Call 定义 15 个工具;本地关键词命中率 ≈ 70%。
M4:仪表盘 & 数据链路 (Day 15-20) — LVGL 9.5 八页面;gen_wwtp_cjk_font.py 生成中文字体子集;Modbus / MQTT / HTTP / SPIFFS 全通。
M5:稳定性 & OTA (Day 21-25) — 双 OTA 分区 4.6 MB × 2;修 SNTP GOT_IP 初始化顺序 bug、AFE 打碎 DRAM 导致 TLS 起不来、nl_task PSRAM 栈崩溃三个硬伤。
M6:现场化 & UI 差异化 (Day 26-28) — 实时页 vs 历史页去掉重复的 24 h 数据块,字体子集重生成。
六、关键代码解析
6.1 唤醒 → 录音 → ASR:voice_pipeline.cpp
void VoicePipeline::feed(const int16_t *pcm, size_t n) {
afe->feed(pcm, n);
if (state_ == IDLE) {
auto res = afe->fetch();
if (res.wakenet_model_name) { // WakeNet 命中
state_ = RECORDING;
rec_buffer_.clear();
}
} else if (state_ == RECORDING) {
rec_buffer_.insert(rec_buffer_.end(), pcm, pcm + n);
if (++rec_frames_ >= max_rec_frames_) // 5 s 上限
finish_recording();
}
}vad_eos 从 500 ms 提到 800 ms 显著减少"说话中间被截"。
6.2 iFlytek 签名 URL:asr_client.c
static char *build_signed_url(void) {
char date_str[64];
format_rfc1123(date_str, sizeof(date_str)); // GMT 秒级
char sig_origin[256];
snprintf(sig_origin, sizeof(sig_origin),
"host: %s\ndate: %s\nGET %s HTTP/1.1",
ASR_HOST, date_str, ASR_PATH);
uint8_t hmac_out[32];
crypto_engine_hmac_sha256((uint8_t*)sig_origin, strlen(sig_origin),
(uint8_t*)s_xf_api_secret,
strlen(s_xf_api_secret), hmac_out);
// base64 → url-encode → 拼 authorization/date/host
...
}date 必须是当前 UTC,服务器允许 ±5 分钟。SNTP 不同步 = 直接 403。
6.3 连续识别 & 意图分发:voice_assistant.cpp
static void nl_worker_task(void *) {
nl_task_arg_t msg;
for (;;) {
xQueueReceive(s_nl_queue, &msg, portMAX_DELAY);
char transcript[256] = {0};
if (asr_client_recognize(msg.pcm, msg.pcm_bytes,
transcript, sizeof(transcript),
12000) != ESP_OK) {
voice_say("没听清,请再说");
continue;
}
llm_function_call_t local_fcs[LLM_MAX_FUNCTION_CALLS] = {};
int local_n = local_intent_match_all(transcript, local_fcs,
LLM_MAX_FUNCTION_CALLS);
if (local_n > 0) {
for (int i = 0; i < local_n; i++)
dispatch_function_call(&local_fcs[i], ...);
} else {
llm_response_t resp;
llm_client_query(transcript, &resp, 12000);
for (int i = 0; i < resp.function_call_count; i++)
dispatch_function_call(&resp.function_calls[i], ...);
}
voice_continue_listening();
}
}Worker 常驻、栈静态分配在内部 DRAM(xTaskCreateStaticPinnedToCore),队列深度 1 天然限流。
6.4 SNTP 由 Wi-Fi GOT_IP 直接触发(本次修复)
// time_sync.h void time_sync_notify_got_ip(void); // 幂等入口 // wifi_manager.c · IP_EVENT_STA_GOT_IP 分支 s_connected = true; xEventGroupSetBits(s_wifi_events, WIFI_CONNECTED_BIT); time_sync_notify_got_ip(); // ← 关键:直接推 SNTP 起来
esp_event_handler_instance_register 在 esp_event_loop_create_default() 之前调用会静默失败(ESP_ERR_INVALID_STATE)。
6.5 中文字体子集生成:scripts/gen_wwtp_cjk_font.py
def collect_chars() -> str:
chars: set[str] = set(EXTRA)
for p in MAIN.rglob("*"):
if p.suffix.lower() not in {".c", ".h", ".cpp", ".hpp"}:
continue
text = p.read_text(encoding="utf-8")
for ch in text:
o = ord(ch)
if 0x4E00 <= o <= 0x9FFF:
chars.add(ch)
return "".join(sorted(chars, key=ord))新增 UI 中文文本后必须先跑这个脚本,否则上电就是方块。本次修复实时/历史页文案后子集从 418 → 443 字符。
七、技术难点与解决方案
难点 1:iFlytek 403 → "没听清"死循环
现象:唤醒后 ASR 一直返回 没听清。串口:
W asr: Time not synced, brief SNTP wait (max 2s)... W asr: Current epoch: 39 I asr: Signing with date: Thu, 01 Jan 1970 00:00:41 GMT E transport_ws: esp_ws_handshake_status_code=403 E asr: WebSocket connect timeout/error
根因:time_sync_init() 在 app_main 里比 esp_event_loop_create_default() 更早(default loop 藏在 wifi_manager.c 的 ensure_netif_init() 里)。esp_event_handler_instance_register 返回 ESP_ERR_INVALID_STATE 被吞了,GOT_IP 处理器根本没挂上,SNTP 从未启动。
修复:SNTP 启动改由 wifi_manager.c 的 GOT_IP 分支直接函数调用 time_sync_notify_got_ip(),幂等,不依赖 event bus 注册时机。同时把 ASR 里"拿 1970 时间硬签"改成"SNTP 4 秒仍未同步就直接 ESP_ERR_INVALID_STATE 返回"。
验证:重刷后:
I (6872) wifi_mgr: Got IP: 192.168.31.33 I (6872) time_sync: SNTP started I (11431) time_sync: NTP synced, local time: 2026-09-22 22:32:47 I (220375) asr: Signing with date: Tue, 22 Sep 2026 14:36:16 GMT I (221325) asr: WebSocket CONNECTED I (225569) asr: ASR result: 帮我查询溶解氧。 I (226450) tts_mgr: Local TTS: 溶解氧 6.23 毫克每升
难点 2:AFE 打碎内部 DRAM,TLS 建不起来
现象:开机正常,唤醒一次之后,第二次 ASR 就 esp_websocket_client_init 返回 NULL。
排查:Free internal heap: 14019 (largest 4864) — 总剩余够,最大块不够 TLS (~8 KB 连续)。
方案:asr_dram_reserve_hold/release 一对函数:语音 pipeline 一就绪立刻抢 4~12 KB 连续内部 DRAM 存起来,每次 ASR 前释放、握手结束再抢回来。
难点 3:nl_task 崩溃
现象:Guru Meditation Error: Core 0 panic'ed (LoadProhibited) 在 ASR 完成后。
根因:临时 spawn 任务栈放在 PSRAM,但访问了 flash-mmapped 代码。
方案:xTaskCreateStaticPinnedToCore + 静态内部 DRAM 栈 10 KB + 常驻 for-loop。任务永远不死,只吃一次栈开销。
难点 4:LVGL 中文方块
根因:字体子集是扫 main/** 得到的;新增页面文案没跑脚本就少字形。
方案:把 gen_wwtp_cjk_font.py 加入 pre-build 钩子;每次 UI 改动前后各跑一次。
难点 5:现场无 PLC 时的可演示性
data_acquisition.c 双源:Modbus 有响应走真数据 + UI 打"PLC 实时";超时回退仿真 + UI 打"演示数据"。没有现场也能演完整链路。
八、项目总结 & 后续计划
8.1 用下来这块板给我的直观感受
优点
RISC-V32 · 320 MHz · Wi-Fi 6 · 16 MB Octal PSRAM 这套组合在"带语音 + 屏幕"的项目里明显够用,我全部塞进这一块 S31,没外接协处理器;
ES8389 + 双 MIC + RGB LCD + DVP 摄像头 一板到位,硬件不用另做转接;
esp-sr WakeNet9 精度惊人,3 m 内唤醒率 >90%,误唤醒很低;
IDF v6.2 对 S31 支持完整,esp32_s31_korvo_1 BSP 开箱即用。
踩坑点
内部 SRAM 真的很紧张,Wi-Fi DMA + LVGL + TLS + FreeRTOS 一起抢,要精心安排"什么放 PSRAM / 什么必须留 DRAM";
事件循环初始化顺序敏感,一旦顺序错,静默失败极难发现;
RISC-V 上任务栈放 PSRAM + flash-mmapped 代码的组合仍然会炸,关键任务全用内部 DRAM 栈。
8.2 已达成指标
| 唤醒响应 | <500 ms | ~320 ms |
| 唤醒 → TTS 首字 | <6 s | ~4.8 s |
| 一次会话 ASR 字准率 | >90% | 93% |
| 连续无重启 | 24 h | 36 h 未死 |
| 单页 UI 帧率 | ≥15 fps | 20~25 fps |
8.3 后续迭代方向
双麦波束成形:esp-sr SE 通道打开,噪声现场 5 m 唤醒率提到 85%+;
本地命令词:multinet 加自定义命令集(停止/继续/重复),断网也能操作;
Modbus 从站回归测试:等真 PLC 到位;
摄像头 AI:esp-dl / esp-who 做人脸识别或表计读数 OCR;
多设备组网:MQTT + Matter 桥接,走 S31 的 Thread 支持。
我要赚赏金
