接上篇:你已经知道 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 行。
屏幕被分成三段:
| 区域 | 高度 | 内容 |
|---|---|---|
| 状态栏 | 12px | nick@channel + 连接灯(绿/红圆点)+ 在线人数 |
| 消息区 | 13 行 × 8px | 环形缓冲 LogRow[13],每行截断到 40 列 |
| 输入行 | 8px | > + 光标 + 当前输入,前景色跟随频道 |
6 种颜色区分不同来源的消息:
| 颜色 | 来源 |
|---|---|
| 黄色 | 自己发的消息 |
暖金 0xC560 | #main 频道 |
青绿 0x0EC6 | #fishing 频道 |
紫色 0x7A5F | #help 频道 |
琥珀 0xFDA0 | Vix 狐灵回复 |
浅灰 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 设备端配网:
WiFi.scanNetworks()扫描附近 SSID- 在 240×135 的屏幕上渲染列表,Tab/Backspace 上下选择
- 选中后进入密码输入界面(QWERTY 键盘直接打)
- 8 秒超时连接
- 成功后
PreferencesNVS 持久化——下次开机自动用这个 WiFi
启动时 5 秒连不上默认 WiFi,自动弹出配网界面。随时输入 /wifi 也能重新配。
32 字节头像的完整链路
AI 头像从生成到显示在 Cardputer 上,经过这条路径:
- 用户输入
/face cat warrior - 固件发 MQTT:
loiter/hall/avatar/request→{uid, keywords:["cat","warrior"]} - 服务端
avatar.py拼 prompt → 调 Azure gpt-image-1.5 → 拿回 1024×1024 PNG - Pillow 缩到 16×16 灰度 → Floyd-Steinberg 抖动 → 1-bit 位图 → 32 字节 base64
- 服务端发 MQTT QoS1:
loiter/hall/avatar/<uid>→{bitmap_b64: "..."} - 固件
base64_decode→ 按 bit 遍历 →M5Cardputer.Display.fillRect()逐像素绘制 - 放大 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,颜色转换公式别搞反
这些都不难。但每一条都是一个下午的调试。

