Loiter 踩坑记:Cardputer 生存指南

Loiter 踩坑记:Cardputer 生存指南

接上篇:你已经知道 Loiter 的 AI 协作故事了。这篇是纯技术踩坑——写给那些也在玩 ESP32 / Cardputer / IoT 的开发者。

环境搭建:PlatformIO 的两个大坑

坑 1:包下载慢 100 倍

首次 pio run 要拉 ESP32-S3 的 framework(235MB)、toolchain、esptoolpy 等包。用 PlatformIO 默认的单连接下载:

方式速度
代理单线程~33 KB/s
直连单线程~19 KB/s
aria2 -x16 直连1.6–2.1 MB/s

上游镜像限速单连接,开不开代理都没用。多线程并发才是解药,而且要关掉代理(代理反而更慢)。

手动下包套路:

# 1. 查 download_url + sha256
curl -s "https://api.registry.platformio.org/v3/packages/platformio/tool/<包名>" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);v=d['version'];f=v['files'][0];print(f['download_url']);print(f['checksum']['sha256'])"

# 2. aria2 多线程(务必 unset 代理)
cd ~/.platformio/.cache/downloads
env -u https_proxy -u http_proxy -u all_proxy \
  aria2c -x16 -s16 -k1M -o pkg.tar.gz "<download_url>"

# 3. 校验 + 解压到 packages/ + 手写 .piopm
echo "<sha256>  pkg.tar.gz" | shasum -a 256 -c -

解压后需要在包目录下放一个 .piopm JSON 文件,格式参考已装包,让 PlatformIO 认为已安装并跳过重下。

坑 2:No module named pip

uv tool install platformio 装出来的 pio,其内嵌 Python 没有 pip。当 esptoolpy 尝试安装 cryptography、ecdsa 等依赖时直接炸。

PIOPY=~/.local/share/uv/tools/platformio/bin/python
"$PIOPY" -m ensurepip --upgrade    # 补 pip
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
  env -u https_proxy pio run       # PyPI 走清华镜像

PubSubClient 的 256 字节陷阱

PubSubClient 是 Arduino 上最常用的 MQTT 客户端库,但它有一个默认值会在生产中炸:单包最大 256 字节。

Loiter 的 avatar bitmap JSON 约 200 字节,加上 topic 和 MQTT header 就超限了。消息直接被 PubSubClient 静默丢弃,没有任何报错。

修复:在引入头文件之前 define。

#define MQTT_MAX_PACKET_SIZE 1024
#include <PubSubClient.h>

一定是之前。如果放在 #include 后面,宏不生效,PubSubClient 内部已经用 256 编译了。

MQTT 重连的循环踢连

WiFi 微抖 → mqtt.connected() 返回 false → 立刻 mqtt.connect() → broker 认为旧连接还活着 → 踢旧连接 → 旧连接的 LWT(Last Will and Testament)触发 → 新连接被干扰 → 又断了 → 循环。

这个 bug 在稳定的局域网里永远不会出现,只有上了公网才暴露。

修复三板斧:

// 1. 重连前先断旧连接
mqtt.disconnect();
delay(100);
mqtt.connect(uid, MQTT_USER, MQTT_PASS, willTopic, 0, false, willPayload);

// 2. keepalive 放宽
mqtt.setKeepAlive(60);  // 默认 15 太激进,公网抖动容易误判

// 3. 重连间隔拉长
if (millis() - lastReconnect > 5000) { ... }  // 5s 而不是 1s

Speaker.tone() 会杀死 MQTT

这是整个项目最痛的坑。

Cardputer-Adv 搭载 ES8311 音频芯片,通过 I2S 总线驱动。调用 M5Cardputer.Speaker.tone(freq, duration) 会启动一个 FreeRTOS I2S task 来输出音频。

问题是:这个 I2S task 和 PubSubClient 的网络处理争抢 CPU。在 ESP32-S3 上,如果 I2S task 持有总线的时间过长,loop() 里的 mqtt.loop() 就来不及处理心跳和消息,看门狗超时,设备卡死或 MQTT 掉线。

试过的方案:

  • 把音效放到独立 task 的队列里异步播放 → 还是卡
  • 降低 I2S task 优先级 → 音频断断续续,MQTT 仍偶发掉线
  • 缩短 tone duration 到 50ms → 不够稳定
  • 在 mqtt.loop() 前后加保护 → 无效

根因是 I2S DMA 和 WiFi 的 RF 模块在硬件层面有资源竞争,软件层面绕不过去。

最终决策:彻底删除音效功能。 6 种精心设计的事件音效全部移除。等未来有精力做硬件隔离测试再考虑。

教训:在资源受限的嵌入式平台上,任何外设的初始化都要先和网络栈做兼容性测试,确认不冲突再集成到主循环里。

BMI270 加速度计:从原始数据到体感操控

Cardputer-Adv 内置 BMI270 六轴 IMU。我们用它做了两件事:

体感移动

500ms 采样一次加速度计,把倾斜角映射为大屏上角色的移动方向:

float ax, ay, az;
M5Cardputer.Imu.getAccelData(&ax, &ay, &az);

// 死区 0.15g:平放时微小抖动不触发移动
float dx = (fabsf(ax) > 0.15f) ? constrain(-ax, -1.0f, 1.0f) : 0.0f;
float dy = (fabsf(ay) > 0.15f) ? constrain(ay, -1.0f, 1.0f) : 0.0f;

// 量化步进 0.05:避免微小变化频繁发包
dx = roundf(dx / 0.05f) * 0.05f;
dy = roundf(dy / 0.05f) * 0.05f;

Cardputer 横屏、键盘朝自己时:左倾 = ax 正 → 角色左移,前倾 = ay 负 → 角色上移。服务端对 move 消息做 250ms 限流中继,大屏以 2px/帧平滑插值。

关键参数调了好几轮:

  • 死区太小(0.05g)→ 平放桌上角色也在抖
  • 死区太大(0.3g)→ 明显倾斜才有反应,手感迟钝
  • 量化步进太细(0.01)→ MQTT 发包太频繁
  • 量化步进太粗(0.1)→ 移动不流畅
  • 0.15g 死区 + 0.05 步进是实测最舒服的手感

摇一摇触发 emote

检测加速度矢量模:

float mag = sqrtf(ax * ax + ay * ay + az * az);
if (mag > 2.5f && millis() - lastShake > 3000) {
    int idx = esp_random() % 5;
    // 发送随机 emote
    lastShake = millis();
}

2.5g 阈值是"明确在摇"和"走路时手晃"之间的分界线。3 秒冷却和服务端的 rate-limit 对齐。

240×135 上的终端 UI

在 Cardputer 的 240×135 像素屏幕上,textSize(1) 下每个字符约 6×8 像素,能放下 40 列 × 16 行。

屏幕被分成三段:

区域高度内容
状态栏12pxnick@channel + 连接灯(绿/红圆点)+ 在线人数
消息区13 行 × 8px环形缓冲 LogRow[13],每行截断到 40 列
输入行8px> + 光标 + 当前输入,前景色跟随频道

6 种颜色区分不同来源的消息:

颜色来源
黄色自己发的消息
暖金 0xC560#main 频道
青绿 0x0EC6#fishing 频道
紫色 0x7A5F#help 频道
琥珀 0xFDA0Vix 狐灵回复
浅灰 0x7BEF匿名告白

颜色用的是 RGB565(16-bit),不是常见的 RGB888。这是 TFT LCD 驱动的原生格式。

非阻塞是生命线

ESP32 的 loop() 必须无阻塞——任何 delay() 或 while(等待) 都会导致 MQTT 心跳超时。

所有需要"等一会儿再做"的事情,都用 millis() 节流:

void loop() {
    M5Cardputer.update();
    
    // MQTT loop —— 必须每次都调,不能被任何东西挡住
    if (mqtt.connected()) mqtt.loop();
    
    // WiFi 重连 —— 5s 节流
    if (!WiFi.isConnected() && millis() - lastWifiRetry > 5000) { ... }
    
    // MQTT 重连 —— 5s 节流
    if (!mqtt.connected() && millis() - lastMqttRetry > 5000) { ... }
    
    // IMU 采样 —— 500ms 节流
    if (millis() - lastImu > 500) { ... }
    
    // 状态栏刷新 —— 1s 节流
    if (millis() - lastStatusRedraw > 1000) { ... }
}

整个 loop() 里没有一个 delay(),没有一个阻塞等待。WiFi 断了?记一下时间,下次再试。MQTT 断了?同理。输入处理、屏幕刷新、IMU 采样,全靠 millis() 差值驱动。

WiFi 配网:在 240×135 上做交互

Cardputer 固件硬编码了一个默认 WiFi SSID。但带去公司或活动现场,那个 SSID 不存在——而且 ESP32-S3 只支持 2.4GHz,iPhone 默认开的 5GHz 热点根本搜不到。

于是做了 /wifi 设备端配网:

  1. WiFi.scanNetworks() 扫描附近 SSID
  2. 在 240×135 的屏幕上渲染列表,Tab/Backspace 上下选择
  3. 选中后进入密码输入界面(QWERTY 键盘直接打)
  4. 8 秒超时连接
  5. 成功后 Preferences NVS 持久化——下次开机自动用这个 WiFi

启动时 5 秒连不上默认 WiFi,自动弹出配网界面。随时输入 /wifi 也能重新配。

32 字节头像的完整链路

AI 头像从生成到显示在 Cardputer 上,经过这条路径:

  1. 用户输入 /face cat warrior
  2. 固件发 MQTT:loiter/hall/avatar/request → {uid, keywords:["cat","warrior"]}
  3. 服务端 avatar.py 拼 prompt → 调 Azure gpt-image-1.5 → 拿回 1024×1024 PNG
  4. Pillow 缩到 16×16 灰度 → Floyd-Steinberg 抖动 → 1-bit 位图 → 32 字节 base64
  5. 服务端发 MQTT QoS1:loiter/hall/avatar/<uid> → {bitmap_b64: "..."}
  6. 固件 base64_decode → 按 bit 遍历 → M5Cardputer.Display.fillRect() 逐像素绘制
  7. 放大 4 倍显示为 64×64

QoS1 确保头像不丢。线程池确保 AI 生成不阻塞 MQTT 线程。32 字节是 MQTT 单包 1024 限制下的舒适区间。

给同路人的清单

如果你也打算用 Cardputer 做项目:

  • PlatformIO 下包用 aria2,不要傻等单线程
  • MQTT_MAX_PACKET_SIZE 放在 #include 前面,否则不生效
  • 不要在 MQTT 回调里调 Speaker.tone(),I2S 会杀死网络栈
  • loop() 里零 delay(),用 millis() 节流一切
  • BMI270 死区 0.15g,低了桌上会抖,高了手感迟钝
  • WiFi 只支持 2.4GHz,iPhone 热点记得关 5GHz
  • 重连前先 disconnect(),否则 broker 循环踢连
  • NVS (Preferences) 存 WiFi 凭据,别让用户每次开机重配
  • RGB565 不是 RGB888,颜色转换公式别搞反

这些都不难。但每一条都是一个下午的调试。


这是 Loiter 系列的第三篇。第一篇讲 Loiter 是什么,第二篇讲 AI 协作故事。

Comments