135×240 屏幕里的固件工程:VibeStick 的 LVGL、音频与按键状态机

摘要:本文深入解析 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"));

下面是状态判断的完整流程图:

成功

失败

开始请求 /state

发送 HTTP 请求

解析 JSON 响应

显示正常状态
(provider, quota, alert)

Wi-Fi 连接正常?

显示 NO WIFI
(检查路由器)

HTTP 状态码 == 401?

显示 AUTH FAILED
(核对 Token)

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
}

设备处理流程

  1. 当 HTTP 请求连续失败且当前 Bridge IP 无效时,触发发现流程;
  2. 发送上述发现请求广播报文;
  3. 等待最多 2 秒接收响应;
  4. 收到有效响应后,更新 Bridge 地址并立即重试 HTTP 请求;
  5. 若超时或无响应,标记发现失败。

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 根动画波形柱,以及 LISTENINGUPLOADINGTRANSCRIBINGSENT、失败与重试提示;
  • 配网覆盖层展示 VibeStick-XXXX192.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 引脚功能说明
LRCKGPIO_NUM_17I2S 左右声道时钟(帧同步)
BCLKGPIO_NUM_18I2S 位时钟(串行时钟)
DINGPIO_NUM_19I2S 数据输入(麦克风 → ES8311)
DOUTGPIO_NUM_20I2S 数据输出(ES8311 → 扬声器)
MCLKGPIO_NUM_21主时钟(可选,供 codec 内部 PLL 参考)
SCLGPIO_NUM_8I2C 时钟(ES8311 配置接口)
SDAGPIO_NUM_9I2C 数据(ES8311 配置接口)
MICBIASGPIO_NUM_10麦克风偏置电压使能
HP_POWERGPIO_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_failedpaste_failedaudio_failedretry_exhausted 等。固件同时读取 retryable,不会凭一串错误文案猜测状态。成功后才调用 vibe_audio_clear() 释放录音。

这体现了一个值得复用的原则:用户输入比中间状态更贵。网络请求可以重发,模型可以重跑,但用户刚才那段自然语言一旦丢了,就只能请人类再次朗诵。

七、固件下一步该怎么拆

当前 main.c 已超过“随手看看就能全记住”的范围。继续演进时,可以按以下方向拆分:

  1. 将 Bridge HTTP/UDP、鉴权头和响应解析抽成 transport 模块;
  2. 将 provider display state 与告警去重抽成 state reducer;
  3. 将录音阶段变成显式有限状态机,统一按钮可用性;
  4. 将 UI 创建与状态渲染移到独立 view 层;
  5. 给板级接口补统一 HAL,为其他 ESP32-S3 设备预留入口。

但现在不必为了“看起来架构很高级”强行拆成二十个文件。原型阶段最重要的是先识别稳定边界,而不是让目录树先实现微服务化。

下一篇转向电脑端:没有官方状态 API 时,Bridge 如何从本地 JSONL 和进程活动中判断 Agent 是 RUNNING、DONE 还是在等审批。


本文基于 VibeStick 当前工作区 0.1.4 源码。硬件目前仅明确支持 M5Stack StickS3。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值