做语音控制 AI 相机(EC600M-CNLE + GC032A + ST7789 + 小智语音)断断续续几个月,专题的坑在之前的帖子里拆过几篇(稳定性防线、MCP 工具集),这篇把它们串起来,站在“整个项目”的高度把账算全:硬件怎么选、576KB 怎么花、三朵云怎么分工、一句话怎么变成一张照片、OTA 怎么在 64KB 的夹缝里做。数据都是实测,欢迎对账。
一、先看全景:五层各司其职

分层的目的只有一个:让“换任何一层”都不牵动其余。OTA 只更新固件层+能力层;把小智换成别的语音平台只动通道层;摄像头换型号只动能力层里十几个函数。设备端 14 个 .py 平铺在 /usr,全部代码 ≈256KB——这不是炫技,是 576KB 分区逼出来的纪律。
二、硬件账:两个“文档没写”的坑
GC032A 走 SPI,且 camCapture 隐式依赖 LCD。 相机驱动按官方 camera 库走,但 AI 固件上 open() 即开启预览流,预览依赖 LCD——LCD 不初始化,拍照回调永远不来,现象是“命令发了、文件没有”。ST7789 的 init 序列每段延时都要按屏规格书来,我按官方 drivers 版抄却漏了尾巴段(0x36/2A/2B/2C),整屏发白——这两个坑都只在真机上现形。
三、资源账:一张图决定敢做什么

Flash 侧:customer_fs(/usr)576KB,代码 256KB,剩 ~290KB 给照片——所以照片“循环保留最近 N 张 + 上传即删”。fota_pkg 只有 64KB,FOTA 整包想都别想,只能走 SOTA(脚本包),且全量包 ≤255KB 还必须含 main.py,日常只发 50~80KB 增量。
RAM 侧:两条水位线写死在主循环:
free = gc.mem_free()
if free < 120 * 1024: # 熔断线
end_session() # 结束视觉/语音会话,回待机
elif free < 200 * 1024: # 警戒线
skip_frame() # 本帧不拍不传,只保流程
另一条容易忽视的纪律:GC 和 DMA 会打架——摄像头帧缓冲正被 DMA 读的时候 gc.collect() 可能直接 dump。收集只放在“拍照后、上传后”这些硬件不持锁的间隙。
四、三朵云:按“报文大小和时效”分工
| 通道 | 承载 | 为什么是它 |
|---|---|---|
| EMQX | 照片/视频流 | 公网 broker 自由定 topic,负载不受物模型限制 |
| 阿里云 IoT | 属性/指令/RRPC | 平台规约下行主题+物模型,控制台可视 |
| Gitee | 照片归档 | raw 外链给网页直接 <img>,302→200 image/jpeg |
| 小智 WS | 语音流+MCP call | 实时性最好的通道,专供语音交互 |
照片不走阿里云:物模型报文有负载限制,塞图必然撞墙——这是踩过墙之后的分工。topic 设计也极简:
emqx: cam/photo/upload (二进制帧)
cam/photo/result (gitee raw url 回传)
ali: property/set (cmd + cmd_args,平台规约)
/rrpc/request/+ (同步指令)
五、语音链路:每一段的毫秒都有出处

| 环节 | 耗时 | 备注 |
|---|---|---|
| PTT → 首帧 Opus 上行 | ≈100~150ms | 板端录音启动+上行 |
| 语音流速率 | Opus 60kbps ≈7.5KB/s | 4G 无压力 |
| STT + LLM 决策 | 1~3s | 不可控,靠 TTS 兜体验 |
| MCP call 下发→解析 | <500ms | JSON-RPC 经 WS |
| 拍照执行(含回调) | ≈2s | camCapture 回调+落盘 |
| 上传 + raw 回传 | 2~5s | 看网络 |
| 端到端 | ≈4~9s | 网络决定 |
两个关键取舍:设备端只做执行器(JSON-RPC 复杂度全在云端桥);先应答后交付(回执先行,照片异步补传,上传失败不影响“已为你拍好”)。LLM 的 tools 有个文档不写的坑:
"inputSchema": {"type": "object", "properties": {}}
写成空 dict {} 会被 schema 校验器推断成 null,报 schema must be 'type: object', got 'type: null'——必须显式声明。
六、稳定性:三条防线一句话版
超时熔断(≥5s/帧,丢帧保流程)、RAM 水位(见第三节)、回调禁令——最后这条是用一次“静默掉线”换的:MQTT 回调里直接调视觉 API,与心跳线程重入同一 socket,底层抛 KeyboardInterrupt(继承 BaseException,except Exception 抓不住),接收线程无声死亡、设备“假在线”。铁律:
def _on_mqtt_cmd(topic, msg): # 回调线程只做三件事
with _cmd_lock:
_cmd_queue.append(msg) # ① 入队
_cmd_flag = True # ② 置标志
# ③ 重活回主循环 process_cmd() 消费,绝不碰 socket/外设
七、OTA:在 64KB 夹缝里做增量
fota_pkg 分区只有 64KB,整包 FOTA 没戏。方案是 SOTA 脚本包:打包工具做白名单+py_compile+强制 main.py+版本递增,增量包 50~80KB。两个坑:progress.params.step 必须字符串(传 int 部分账号静默丢进度);升级目标路径在云端 data.extData._package_udi 的嵌套 JSON 里。最要命的是入口:
# main.py —— 全部两行
import example
example.exec('/usr/_main.py')
_main.py 下划线开头上电不自启,OTA 重启后板上没程序 = LCD 冻结。这两行垫片是“重启变砖”和“重启继续跑”的全部差别。
八、工程方法:让 AI 提效,让真机说话
315 项设备端测试全绿、71 项 TSL 一致性校验脚本、tools/ 下一套串口上传/日志监听/端到端拍照工具链。编码大量用了 AI 提效,但AI 生成不了真机踩坑:GC032A 的时序、4G 传图断流、回调里的静默死亡、PSM 不支持 M 系列——这些只有板子会教你。把每一条都记下来,比多写十个功能更值钱。
小结
- 资源账先行:Flash/RAM 两张表决定功能取舍,不先算账的功能都是空中楼阁;
- 分层是为了可替换:通道层换平台、能力层换摄像头、OTA 只碰两层;
- 先应答后交付:交互体验的关键是“闭环的回执”,不是“完整的交付”;
- 真机踩坑记录是最值钱的资产。
专题细节在我之前几篇(稳定性防线 / MCP 工具集 / 多线程笔记)里,欢迎交流。