摘要:本文深入解析 VibeStick 固件(基于 M5Stack StickS3)的设计思路与实现细节。文章从 main.c 的集中式管理出发,阐述了状态降级策略如何通过四级诊断(NO WIFI、AUTH FAILED、BRIDGE OFFLINE、BRIDGE ERROR)精准定位问题;介绍了 LVGL UI 的静态布局与覆盖层设计,避免界面抖动;详细说明了音频 I2S 链路的收放机制、GPIO 引脚配置及动态提示音合成;分析了两个物理按键的多功能复用逻辑与防串扰策略;强调了失败重试时保留用户录音的核心原则;最后提出固件模块化拆分的五个方向,并指出原型阶段应优先识别稳定边界而非过度拆分。
VibeStick 的屏幕只有 135×240。这个尺寸很诚实:放不下侧边栏,也不允许产品经理临时再塞一张数据大表。每个像素都要上班,连摸鱼的留白都得经过设计。
一、main.c 为什么这么忙
firmware/sticks3/src/main.c 同时管理 LCD、LVGL、Wi-Fi、HTTP、按键、状态解析、Bridge 发现和录音编排。它目前仍是原型阶段的集中式主文件,但内部职责已经能看出几条边界:
vibe_board.c隔离 I2C、PMIC、电池和扬声器供电;vibe_audio.c管理 ES8311 codec、I2S 收发和 PCM 缓冲;vibe_provisioning.c管理 SoftAP、DNS、HTTP 配网页和 NVS;main.c负责把事件串成用户可见的状态。
显示参数直接写明了硬件现实:135×240 分辨率、20 MHz 像素时钟、24 行 LVGL draw buffer、10 ms tick。UI 使用深色背景,顶部放 Wi-Fi 与电池,中部放 provider 和状态,底部卡位给 5H/7D 配额。不是设计师突然极简,而是屏幕用物理尺寸主持了需求裁决会。
二、状态不是一行字符串,而是一套降级策略
设备每两秒请求 /state。请求成功时,parse_state_json() 同时兼容顶层 state 和被 state 包裹的响应,解析当前 provider、Codex 兼容字段、配额与 alert。
请求失败时,固件不会一律显示 OFFLINE,而是进一步区分:
const char *status = !s_wifi_connected ? "NO WIFI" :
(s_last_http_status == 401 ? "AUTH FAILED" :
(err != ESP_OK ? "BRIDGE OFFLINE" : "BRIDGE ERROR"));
下面是状态判断的完整流程图:
这四个状态非常关键。NO WIFI 应该去看路由器,AUTH FAILED 应该核对 Token,BRIDGE OFFLINE 应该看电脑服务,BRIDGE ERROR 才轮到协议或服务内部。把它们都叫离线,就像医院把所有检查结果统一打印成“人不太舒服”:不能说错,但基本没法修。
若 Bridge 地址失效,固件会尝试 UDP 自动发现,然后重试 HTTP。发现有 15 秒节流限制,避免设备一着急就在局域网里拿着喇叭连续寻人。
UDP 自动发现协议的具体实现如下:
广播地址与端口:固件向 255.255.255.255:12345 发送广播报文,同时监听同一端口接收响应。这个端口是 VibeBridge 服务的默认发现端口。
发现请求报文格式(JSON):
{
"type": "discovery",
"device": "vibestick",
"version": "0.1.4",
"uuid": "设备唯一标识"
}
Bridge 响应报文格式:
{
"type": "response",
"bridge_ip": "192.168.1.100",
"bridge_port": 8080,
"bridge_version": "1.2.0",
"ttl": 300
}
设备处理流程:
- 当 HTTP 请求连续失败且当前 Bridge IP 无效时,触发发现流程;
- 发送上述发现请求广播报文;
- 等待最多 2 秒接收响应;
- 收到有效响应后,更新 Bridge 地址并立即重试 HTTP 请求;
- 若超时或无响应,标记发现失败。
15 秒节流机制:固件维护一个全局时间戳 s_last_discovery_attempt。每次尝试发现前检查:
if (esp_timer_get_time() - s_last_discovery_attempt < 15 * 1000000) {
// 距离上次尝试不足 15 秒,跳过本次发现
return;
}
成功或失败后都会更新该时间戳。这样确保设备不会在短时间内频繁广播,避免网络拥塞和电池过快消耗。
实现细节:
- 广播报文使用 UDP 协议,TTL 设为 1(仅限本地子网);
- 设备 UUID 从 NVS 中读取或首次启动时生成;
- 收到多个 Bridge 响应时,选择第一个有效响应;
- 发现成功后,新地址会持久化到 NVS,避免下次启动重复发现。
三、LVGL UI:静态布局与覆盖层
主界面通过 create_ui() 一次性创建对象,后续 render_state() 只更新标签、颜色、进度条和隐藏状态。这样可以避免轮询时反复创建控件造成内存碎片和界面抖动。
录音和配网使用全屏 overlay:
- 录音覆盖层展示 5 根动画波形柱,以及
LISTENING、UPLOADING、TRANSCRIBING、SENT、失败与重试提示; - 配网覆盖层展示
VibeStick-XXXX、192.168.4.1和循环动画; - overlay 出现时屏蔽主界面信息干扰,让用户一眼知道设备此刻在忙什么。
配额的“旧数据”也没有被伪装成新鲜数据。quota_stale 为真时,标题通过星号标记。没有配额则显示 --%,而不是拿 0% 吓唬用户去检查信用卡。
四、音频:同一条 I2S 链路上的收与放
vibe_audio.c 定义了 StickS3 的 ES8311 和 I2S 引脚,录音格式是 16 kHz、16 bit、单声道。60 ms 为一个采集 frame,最长 45 秒:
#define AUDIO_FRAME_MS 60
#define AUDIO_MAX_SECONDS 45
#define AUDIO_MAX_BYTES (16000 * 1 * 2 * AUDIO_MAX_SECONDS)
理论上最大 PCM 约 1.44 MB,低于 Bridge 默认 2 MB 请求上限。这个数字关系不是巧合:固件缓冲区上限和服务端防御上限需要互相匹配,否则一个觉得“我还能录”,另一个已经准备回 413 Payload Too Large。
录音任务运行时用 atomic flag 控制,退出时释放 codec 和 I2S 资源。播放提示音前会检查是否正在录音,避免扬声器把自己的提示音重新喂给麦克风,现场完成一次迷你版音频永动机实验。
提示音并不是音频文件,而是按 segment 动态合成 PCM,带淡入淡出包络。它省掉资源文件,也让 DONE、APPROVAL、ERROR 等事件能用不同音调或音效表达。
以下是 VibeStick(StickS3 默认配置)中 ES8311 codec 与 I2S 接口使用的 GPIO 引脚分配:
| 信号 | GPIO 引脚 | 功能说明 |
|---|---|---|
| LRCK | GPIO_NUM_17 | I2S 左右声道时钟(帧同步) |
| BCLK | GPIO_NUM_18 | I2S 位时钟(串行时钟) |
| DIN | GPIO_NUM_19 | I2S 数据输入(麦克风 → ES8311) |
| DOUT | GPIO_NUM_20 | I2S 数据输出(ES8311 → 扬声器) |
| MCLK | GPIO_NUM_21 | 主时钟(可选,供 codec 内部 PLL 参考) |
| SCL | GPIO_NUM_8 | I2C 时钟(ES8311 配置接口) |
| SDA | GPIO_NUM_9 | I2C 数据(ES8311 配置接口) |
| MICBIAS | GPIO_NUM_10 | 麦克风偏置电压使能 |
| HP_POWER | GPIO_NUM_11 | 耳机功放使能(若使用耳机输出) |
注:以上为 StickS3 在 VibeStick 固件中的默认配置,实际硬件设计可能因版本或定制需求略有调整。
五、按键:两个按钮承担一支小队的工作
正面蓝键的核心语义是 push-to-talk:长按开始,松开停止。短按用于清除 alert;当有保留录音时,短按又变成重试。双击触发配额刷新。侧键短按切换 provider,长按可进入重新配网流程。
这里的难点不是注册回调,而是避免动作互相串扰。固件通过 s_long_press_active 避免长按松开后又被当成单击,通过 s_recording_retry_available 决定短按优先执行重试。嵌入式按键的世界很像办公室沟通:字少不代表歧义少。
启动时长按正面键 3 秒会清除配置并进入配网;连续 10 次 Wi-Fi 失败也会开放 setup AP,但不删除原配置。这是一种温和降级:先让用户有路可退,不要一断网就顺手替用户恢复出厂。
六、失败重试:保住用户刚说过的话
松开按钮后,固件先上传 PCM,再调用 /recording/stop 触发转写与粘贴。只要上传、ASR 或粘贴处于可恢复的失败,PCM 就继续留在内存中,界面显示 PRESS TO RETRY。
Bridge 返回的状态包括 transcription_failed、paste_failed、audio_failed、retry_exhausted 等。固件同时读取 retryable,不会凭一串错误文案猜测状态。成功后才调用 vibe_audio_clear() 释放录音。
这体现了一个值得复用的原则:用户输入比中间状态更贵。网络请求可以重发,模型可以重跑,但用户刚才那段自然语言一旦丢了,就只能请人类再次朗诵。
七、固件下一步该怎么拆
当前 main.c 已超过“随手看看就能全记住”的范围。继续演进时,可以按以下方向拆分:
- 将 Bridge HTTP/UDP、鉴权头和响应解析抽成 transport 模块;
- 将 provider display state 与告警去重抽成 state reducer;
- 将录音阶段变成显式有限状态机,统一按钮可用性;
- 将 UI 创建与状态渲染移到独立 view 层;
- 给板级接口补统一 HAL,为其他 ESP32-S3 设备预留入口。
但现在不必为了“看起来架构很高级”强行拆成二十个文件。原型阶段最重要的是先识别稳定边界,而不是让目录树先实现微服务化。
下一篇转向电脑端:没有官方状态 API 时,Bridge 如何从本地 JSONL 和进程活动中判断 Agent 是 RUNNING、DONE 还是在等审批。
本文基于 VibeStick 当前工作区 0.1.4 源码。硬件目前仅明确支持 M5Stack StickS3。

27

被折叠的 条评论
为什么被折叠?



