这篇写给做过一点 Python、手里有 EC600M 开发板和一颗摄像头的同学。文中所有引脚、阈值、超时、耗时都是这个项目的真实值,可以直接照抄;踩过的坑都标了定位过程。
一、先看它能干什么
一块名片大小的板子,插上摄像头、屏、SIM 卡和天线,接个小喇叭,就是这个东西。它对着一句话做四个动作:
| 你说 | 它做 | 验收口径 |
|---|---|---|
| “拍张照” | 拍一张 640×480 JPEG,上传,回执 | 3 秒内返回确认 |
| “扫个码” | 切扫码模式,识别二维码/条形码 | 15 秒内返回内容 |
| “看看这是什么” | 拍照 → 视觉大模型 → 中文描述 | 30 秒内返回分析 |
| “开始监控” | 每 3 秒抓一帧上传,网页实时看 | 连续跑不断线 |
先说一句实话,这句话决定了整个项目的架构:这块板子不识别语音。它没有 NPU,可用堆只有几百 KB,跑不动任何像样的语音模型。它在这个系统里的角色是眼睛和手——拍照、扫码、上传、播报;“听懂你说什么”和“决定该干什么”这两件事,全在云端。
想清楚这一点,你的工作量就从“训一个模型”变成“接通一条链路”。后者一个周末能做完。
二、开工前的四个决定
这四个决定是我改了两周才定下来的,先看能省你很多时间。
决定 1:语音入口交给云端智能体,板子只做执行端。
按键说话 → 音频上行到小智云 → 云端 STT + LLM 判断意图 → 通过 MCP 调工具 → 工具把指令变成一条 MQTT 消息 → 板子执行 → 结果原路回传 → TTS 播报。
这条链有 6 跳,任何一跳断了都表现为“说了没反应”。所以我在第三节放了一张“现象 → 责任方”的对照表,那是排障时最省时间的东西。
决定 2:摄像头用 GC032A,而且 LCD 必须插上。
这是全篇最反直觉的一条:EC600M 上不初始化 LCD,拍照回调永远不会触发。
不是“看不到画面”,是 start() 返回 0,然后回调一声不响,10 秒后你收到一个超时。我第一次做的时候没插屏,在这上面花了半天,以为是摄像头坏了。
原因不复杂:这颗 SPI 摄像头的输出通路和 LCD 共用一套时序/时钟配置,驱动层拿不到 LCD 的时序参数就不启动采集。官方文档里有一句“拍照必须初始化 LCD”,字面很短,坑很深。
决定 3:命令走公网 MQTT,不走局域网 HTTP。
4G 蜂窝网下,板子和你的电脑不在同一个局域网,http://192.168.x.x:8080 必然不通。我一开始按“局域网调试”的思路写,板子联网成功、PING 也通,但 HTTP 一直超时——因为它根本到不了你电脑的内网地址。
改成 broker.emqx.io:1883 之后一次通。代价是公共 broker 不安全、也可能限流,正式产品要换自建 EMQX 或阿里云。
决定 4:照片不走阿里云物模型。
阿里云 IoT 的物模型属性有大小限制,一张 640×480 的 JPEG 转 base64 是 40 KB 量级,塞进属性里既超限又浪费流量。所以这个项目里:
- 阿里云:管开关、状态、服务调用、OTA(16 属性 / 12 服务 / 2 事件)
- EMQX:管照片和帧的实际传输
- Gitee:管照片归档,回一个 raw 链接
一句话:阿里云当“遥控器”,EMQX 当“传送带”。
三、接线:三个必须插,一根必须看
| 器件 | 接口 | 位置 | 说明 |
|---|---|---|---|
| GC032A 摄像头 | SPI 排针 | J4 | 必须插 |
| ST7789 240×240 | LCD 专用接口 | J3 | 必须插,否则拍照无回调 |
| SIM 卡 + 天线 | - | 板载卡座 | 必须 |
| 喇叭 | NS4160 OUT± | J7 2pin | 要接,语音播报靠它 |
| 摄像头板 LED | GPIO9 | 摄像头扩展板 | 和功放 CTRL 共脚 |
| 语音按键 | GPIO13 | S4(PIN60) | 板载,不用接 |
重点说 GPIO9 这个共脚。
我一开始想给摄像头加个补光灯,在 config.py 里把 LED_GPIO 设成 9,测试时灯亮了,很开心。然后发现一个诡异现象:
- 语音会话期间,灯必然亮着,关不掉;
- 长按结束会话,灯自己灭了;
- 我发“关灯”指令,返回成功,但灯还亮着。
查了两小时才想明白:这颗 LED 就接在 GPIO9 上,而 GPIO9 同时是 NS4160 功放的 CTRL。 功放要出声,CTRL 就必须保持高电平;CTRL 高,灯就亮。物理上没法分开。
所以代码里加了一条保护:语音会话进行中,拒绝执行关灯——因为“关灯”等于“关功放”,用户会以为板子哑了。
# _do_toggle_led 里的保护
if self.voice is not None and self.voice.in_session():
return {"status": -5, "msg": "语音会话中,LED 与功放共脚,不能关"}
这类“两个功能抢一个引脚”的事,原理图上不一定写在一起,只能靠现象反推。排查方法:把可疑 GPIO 一个个拉高,看哪个外设动了——这比翻原理图快。
四、第一步:让摄像头出图
只有三件事,但每件都能让你卡半天。
4.1 先初始化 LCD
from machine import LCD
lcd = LCD()
ret = lcd.lcd_init(
bytearray(_LCD_INIT), # 初始化序列,三元组 (类型, 长度, 值)
240, 240, # 宽、高
13000, # 时钟频率
1, 4, 0, # 总线/位宽/模式
bytearray(_LCD_INVALID), # 局部刷新窗口
bytearray(_LCD_DISPLAY_ON),
bytearray(_LCD_DISPLAY_OFF),
None,
)
print("[camera] LCD init ret:", ret) # 必须看到 0
那个初始化序列长这样,格式是“每 3 字节一组”:
_LCD_INIT = (
2, 0, 120, # 类型2 = 延时 120ms
0, 0, 0x11, # 类型0 = 写命令,0 字节数据
0, 1, 0x36, 1, 1, 0xC0, # 写 0x36,跟 1 字节数据 0xC0
...
)
换屏幕必须换这段序列,不同厂家不一样。照抄别人的序列,最常见的现象是“能亮但花屏”或“颜色反了”。
ret != 0 就别往下做了。后面所有“拍照超时”都会是白费功夫。
4.2 打开摄像头(AI 固件上 = 打开预览)
import camera
cam = camera.camCapture(
CAMERA_MODEL, # 0 = gc032a
CAM_W, CAM_H, # 640, 480
PREVIEW_LEVEL, # 1
LCD_W, LCD_H, # 240, 240
)
ret = cam.open()
cam.callback(_cap_cb) # 回调必须注册,否则拍照永远等不到结果
这里有个固件相关的行为,文档里没强调,但决定了整个架构:
在这版 AI 固件上,
camCapture.open()会开启摄像头预览流,LCD 持续显示实时画面;只要不close,预览就一直在。
这意味着两件事:
- 拍照不需要重建对象,直接对常开的对象调
start(),拍完预览自动继续; - 扫码必须先
close(),因为扫码驱动要独占摄像头(后面第八节细说)。
我中途犯过一个错:为了“干净的拍照”,每次拍完都 close() 再 open()。结果是屏幕频繁黑一下、偶尔卡死。驱动是单实例的,别折腾它。
4.3 顺手把画质调一下
默认参数下画面偏灰。这七行是实测下来观感最好的组合:
for name, args in (
("set_brightness", (1,)),
("set_contrast", (1,)),
("set_saturation", (0,)),
("set_gainceiling", (16,)),
("set_whitebal", (True,)),
("set_gain_ctrl", (True,)),
("set_exposure_ctrl", (True,)),
):
try:
getattr(cam, name)(*args)
except Exception:
pass # 不同固件方法名有差异,不支持就跳过
try/except 是必须的——这几个 setter 在不同固件版本里有的存在、有的不存在,缺一个就整段崩掉不值得。
验收标准:串口打出 LCD init ret: 0 和 camCapture.open ret: 0,屏幕上出现实时画面。看不到画面就停在这里,别往下走。
五、第二步:拍一张,并且知道它成功了
5.1 同步等待怎么写
摄像头是异步的:start() 只是“开始拍”,真正的结果在回调里。
def _cap_cb(self, para):
# para[0] == 0 成功,para[1] 是生成的文件名
if para[0] == 0:
self._cap_ret = (0, para[1])
else:
self._cap_ret = (1, None)
def take_photo(self, w=640, h=480, name=None, timeout=10):
self._cap_ret = None
ret = self.cam_cap.start(w, h, name)
if ret != 0:
return (-2, "start fail ret=%d" % ret)
wait = 0
while self._cap_ret is None and wait < timeout * 10:
time.sleep_ms(100) # 100ms 轮询一次
wait += 1
if self._cap_ret is None:
return (-3, "capture timeout(no callback)")
return (0, self._cap_ret[1])
5.2 为什么你的日志里会是 9950ms
timeout=10、轮询 100ms,100 次正好 9950ms(少了最后一次的 100ms)。所以以后你在串口里看到这个数字,不用怀疑,就是回调一次都没来。
这是一个很好用的判据:9950ms = 回调丢失;其他数字 = 逻辑问题。
那回调为什么会丢?我遇到两个原因,都是“文件写不进去”:
原因 1:/usr/ 写满了。
EC600M 的用户分区只有 576 KB,一张 640×480 JPEG 约 30 KB,我早期设的保留策略是 50 张——数学上就不可能。写满之后:JPEG 写文件失败 → 固件不触发回调 → 9950ms 超时 → 主循环卡住 → 心跳停 → 网页显示离线。
看起来是“板子卡死”,根因是“磁盘满了”。这条因果链我写在了 config.py 的注释里,因为它太反直觉:
# 本地照片保留策略:/usr/ 仅 ~576 KB,每张约 30 KB,实际最多约 15 张。
# 50 张的旧配置远超分区上限 → 写满 → 拍照失败 → 文件系统错乱
# → 摄像头回调永久丢失(9950ms 超时)。
PHOTO_MAX_KEEP = 2
原因 2:拍照前没检查空间。 修法是在 take_photo 之前主动看一眼剩余空间,不够就先清:
def _ensure_free_space(self):
st = uos.statvfs("/usr/")
free = st[0] * st[3]
if free < 60 * 1024: # 少于 60KB 就先删旧照片
self._trim_photos(keep=1)
现在这个项目的策略是:上传成功立刻删本地(PHOTO_DELETE_AFTER_UPLOAD = True),设备端只留最近 2 张做兜底。照片的“家”在电脑上,不在板子上。
5.3 路径名有个坑:U:/ 不是 /usr/
回调给的文件名是 DOS 风格:
U:/photo_2026-09-01_183012.jpg
但 QuecPython 的 uos 用 POSIX 路径。你不归一化,uos.stat() 直接抛异常:
if fname.startswith("U:/"):
fname = "/usr/" + fname[3:]
elif not fname.startswith("/"):
fname = "/usr/" + fname
5.4 实测耗时
我在代码里埋了计时,串口每次打印 拍照耗时 xxx ms。翻日志汇总了 7 次:
| 次数 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 耗时(ms) | 808 | 818 | 1052 | 1088 | 1267 | 1404 | 1411 |
0.8~1.4 秒,这是纯拍照(start() 到回调)的时间,不含上传。 所以 3 秒的验收口径里,大半预算花在采集本身——上传和回执必须够快,或者干脆异步。这也解释了为什么后面 AI 视觉必须做成异步(第七节)。
5.5 返回值约定
代码里全部用负数错误码,方便日志一眼定位:
| 返回 | 含义 | 首查 |
|---|---|---|
| 0 | 成功,返回路径 | - |
| -2 | start() 失败 |
摄像头是否被扫码占用 |
| -3 | 9950ms 超时 | Flash 满 / 回调没注册 |
| -4 | 摄像头忙 | 上一条指令还没结束 |
| -5 | 回调报失败 | 驱动/排线 |
| -6 | 文件不存在 | 路径归一化写错 |
六、第三步:把照片送出去
6.1 为什么是 base64 + MQTT
公网可达是第一需求。MQTT 只要 1883 端口通,不需要公网 IP、不需要内网穿透,代价是二进制要塞进文本协议——base64 会让体积涨 4/3。
算一下账:
| 项 | 大小 |
|---|---|
| 640×480 JPEG | 7~30 KB(实测,看画面复杂度) |
| base64 后 | 9~40 KB |
| JSON 包装 + 主题开销 | +0.3 KB |
40 KB 的 publish 在 Cat.1 上是可以接受的(一次几百毫秒到一两秒),但不能高频——所以监控帧才要 3 秒一张,而不是 30fps。
import ubinascii
buf = self.cam.read_photo(info) # 读成 bytes
b64 = ubinascii.b2a_base64(buf).decode().replace("\n", "")
# b2a_base64 会带换行,必须去掉,否则 JSON 里多一个 \n
那个 .replace("\n", "") 是必须的。ubinascii.b2a_base64 和 CPython 不一样,默认带行尾换行,服务端 JSON.parse 会直接失败,报一个“Unexpected token”。
6.2 三条通道,各管一段
| 通道 | 干什么 | 为什么不用它干别的 |
|---|---|---|
EMQX(broker.emqx.io) |
指令、回执、照片、帧 | 公共 broker,不适合正式产品 |
| 阿里云 IoT | 属性/服务/事件、OTA | 属性有大小限制,塞不下图片 |
| Gitee 仓库 | 照片归档,回 raw 链接 | API 限频,只归档“拍照”,监控帧不进 |
Gitee 那条是我加的“产品化”细节:拍完照顺手传一份到公开仓库,回一个可以直接在浏览器打开的 raw 链接。这样不管你在网页、阿里云控制台还是语音播报里,拿到的都是同一个 URL。
代价要提前知道:每次上传 = 一个 commit,Gitee API 有频率限制,所以模块里写了 3 秒最小间隔,且只归档拍照,不归档监控帧。
七、第四步:让云端“看懂”(这一段最坑)
7.1 设备端到底做了什么
很少:拍一张、读成 bytes、base64、发到 ai_camera/<dev>/vision、等回传。
真正调用视觉模型的是电脑端那个 Node.js 服务:
设备 ──MQTT(base64)──> monitor_server ──HTTPS──> 视觉大模型
<──MQTT(analysis)── <──JSON──
服务端默认走智谱 GLM-4V-Flash(有免费额度),接口是 OpenAI 兼容格式,改两个环境变量就能换成通义千问 VL:
# 默认
VISION_MODEL=glm-4v-flash
# 换千问
# base=https://dashscope.aliyuncs.com/compatible-mode/v1 model=qwen-vl-max
这个设计的好处:模型换了、额度没了、要换供应商,设备端一行都不用改。板子只认“发图片、收文字”这个契约。
7.2 prompt 决定输出质量
我用的提示词是这样的,逼模型分点、给位置、给光线、读文字:
请仔细观察这张图片并分点详细描述:
1) 画面的主体/主角是什么
2) 画面中有哪些物体,分别在什么位置
3) 这是什么样的场景或环境
4) 主要颜色和光线情况
5) 如果画面中有文字,请读出文字内容
请用中文分点详细描述,内容尽量丰富,不少于 200 字。
真实返回(节选,来自 data/vision/ 里的一条记录):
1. 画面的主体/主角:这是一台电脑显示器,屏幕上显示着一个游戏界面。
2. 画面中的物体及其位置:显示器位于画面中心偏左;屏幕上有多个彩色方块状图案…
4. 主要颜色和光线情况:主色调为黑色、绿色、橙色等鲜艳颜色;光线从上方照射…
加“位置”和“读文字”这两条,实用性会明显不一样。 只说“描述这张图”,模型会给你一段空泛的散文;要求它报方位、读文字,输出立刻变成可用的信息。
超时给 30 秒(_VISION_TIMEOUT_S = 30)。视觉模型排队时 5~20 秒很正常,给 5 秒会大面积误判超时。
7.3 ★ 这里有一个会把整个程序搞死的写法
最早我是这么写的:在 MQTT 消息回调里,发完图片就原地等结果。
def _mqtt_cb(topic, msg):
...
r = cloud.vision_analyze(payload) # ← 阻塞等 vision_resp
现象:偶尔能成,但拍几次之后板子直接离线,日志最后一行是 KeyboardInterrupt,而且没有任何异常栈。
根因链是这样的,值得逐字看一遍:
- 回调是在
umqtt的wait_msg()里被调用的; - 我在回调里等回传,等回传就得收消息,于是又调了一次
check_msg(); wait_msg重入,umqtt的__loop_forever心跳超时;- 心跳超时触发
base_reconnect,而重入锁还被我占着 → 互锁; - 锁死之后驱动层抛出
KeyboardInterrupt; KeyboardInterrupt继承自BaseException,不是Exception—— 我外层那个except Exception一个字节都没接住;- 线程静默死亡,主循环还在跑,但 MQTT 再也不通了。表现就是“板子还活着,但网页显示离线”。
第 6 步是最贵的教训。在嵌入式里,except Exception 不等于“兜住一切”。
改法只有一句:回调里只发不等。
# 回调里:发出去,记一个 pending,立刻返回
cloud.vision_upload(msg, b64, name, prompt)
self._vision_pending = {"id": msg["id"], "t": time.ticks_ms()}
# 主循环里:每 tick 看一眼有没有结果 / 超时
def check_vision_pending(self):
if not self._vision_pending:
return
if self._vision_result is not None:
self._report(self._vision_pending["id"], self._vision_result)
self._vision_pending = None
elif time.ticks_diff(time.ticks_ms(), self._vision_pending["t"]) > 30000:
self._report(self._vision_pending["id"], {"status": -9, "msg": "vision timeout"})
self._vision_pending = None
改完之后,同一条链路的稳定性完全不一样:回调耗时从“几秒到几十秒”变成“几十毫秒”,心跳再也不会被堵。
这条规则可以推广到所有 RTOS/嵌入式项目:中断和回调里只做三件事——置标志、入队、发消息。任何“等一个结果”的操作,都要搬到主循环或者独立线程。
7.4 还有一个隐形的坑:模块版本不同步
我为了“不依赖旧模块”,在 _main.py 里本地定义了 _VISION_TIMEOUT_S = 30。但有一版漏了这行,结果 check_vision_pending 每次都抛 NameError。
NameError 被主循环的 except 吞掉了,板子不崩、日志不显眼、但视觉结果永远回不来。用户看到的是“它拍了照,然后没下文”。
教训:异步链路一定要有“超时也要回一条”的兜底,否则失败是静默的。现在无论成功、失败、超时,_report() 一定会发一条 resp,前端永远不会转圈到天荒地老。
八、第五步:扫码(换一个驱动)
扫码是另一套 API:camera.camScandecode。它有两条硬约束。
8.1 驱动单实例:先让路,再上场
camCapture(拍照/预览)和 camScandecode(扫码)不能同时开。所以要按这个顺序:
cam_cap.close() # 1. 拍照对象让路
time.sleep_ms(200) # 2. 给驱动一点时间释放
scan = camera.camScandecode(0, 1, 640, 480, 1, 240, 240)
scan.open(); scan.callback(_scan_cb); scan.start()
# ... 等回调,最多 15 秒 ...
scan.stop(); scan.close() # 3. 用完销毁,释放解码器内存
8.2 decode_level 不要贪高
官方文档说“等级越高,图像越流畅,消耗资源越大”。我一开始设成 2,想要更顺的取景,结果:
25 秒持续解码把 RAM 吃干,整块板子卡住,连 Ctrl+C 都断不开(只能拔电或 AT+CFUN=1,1 复位)。
改回 1 之后,扫码慢一点(15~25 秒内能扫出来),但稳定。在 1MB RAM 的模组上,“流畅”永远排在“活着”后面。
8.3 扫码失败要留证据
扫码超时(status=-7)时,我让它自动拍一张诊断照上传:
SCAN_DEBUG_PHOTO = True # 超时自动拍一张,看看它到底“看到”了什么
这张图能直接区分四种失败原因:距离太远、角度歪了、光线太暗、屏幕摩尔纹。比猜快一百倍。诊断完可以关掉。
8.4 扫完别急着恢复预览
这里有一个很细的时序坑:扫码结束如果立刻 reset() 重建预览,紧接着的 MQTT 回传和照片上传会干扰刚建好的预览流——表现是“扫完了,但屏幕一直黑着”。
所以代码里不立刻 reset,只置一个标志:
self._need_restore = True # 等 MQTT 回传完成后,主流程再调 restore_preview()
self._mark_recovering() # 3 秒保护期,期间拒绝新指令,防雪崩
那 3 秒保护期也不是拍脑袋:扫码/拍照刚结束时,摄像头驱动最脆弱,此时并发来一条 ai_vision 很可能直接把回调打死。保护期内新指令直接返回 -4 busy,比“接了然后失败”体验好。
九、第六步:让板子说话
这一节是“能出声”和“能听懂”的分界线,两个坑都很硬。
9.1 功放不是“给个高电平就行”
NS4160 是一线脉冲控制的 D 类功放:给一个高电平不会响,得给两个上升沿,然后保持高。
def _pa_pulse(self):
"""0→1→0→1,两个上升沿 → D 类功放使能,最后保持高"""
self._pa.write(0); time.sleep_us(20)
self._pa.write(1); time.sleep_us(20)
self._pa.write(0); time.sleep_us(20)
self._pa.write(1) # 保持高,开始出声
为什么 aud.play() 不用手动脉冲,而 Opus 必须手动? 因为播放 wav/mp3/TTS 时固件内部会驱动 PA,而 Opus 解码走的是 opus.write() 这条裸 PCM 通路,固件不管 PA。你不知道这件事,就会得到“解码日志全对、喇叭一点声音没有”。
9.2 音量默认是 0
pcm = audio.Audio.PCM(0, 1, 16000, 2, 1, 15)
pcm.setVolume(9) # ★ 不设 = 静音(默认 0)
PCM 通道的默认音量是 0。不是“小”,是静音。我在这上面浪费了一个晚上,因为日志里“播放成功”四个字看起来毫无破绽。
9.3 语音是增量,挂了不能影响相机
xiao_voice.py 是一个完全独立的模块,_main.py 里启动它的时候是包在 try/except 里的。这意味着:小智云挂了、OTA 激活失败、WebSocket 断了,相机全部功能照常。
对一个要交付的东西来说,这个隔离比“功能多”重要得多。
十、这台机器真正的天花板:RAM 和 Flash
所有奇怪问题的最终解释,基本都能归到这两个数字:可用堆几百 KB、用户分区 576 KB。
所以我给系统设了三道“水位线”,全部是可配置的常量:
| 门槛 | 值 | 触发动作 | 防的是什么 |
|---|---|---|---|
MONITOR_MEM_FLOOR |
200 KB | 跳过本帧 | 抓帧 + base64 把堆挤爆 |
XIAO_MEM_EXIT_FLOOR |
120 KB | 自动结束语音会话 | Opus + WS + TLS 叠加 |
MONITOR_DISK_FLOOR |
60 KB | 跳过本帧 | JPEG 写满 Flash → 回调丢失 |
设计原则很简单:宁可少一帧,也不能把文件系统写满、把堆挤爆。 前者只是画面卡一下,后者会让整块板子失联。
10.1 一个只有 MicroPython 才会有的雷
def _monitor_tick(self):
if gc.mem_free() < MONITOR_MEM_FLOOR: # ← 这里
return
...
import gc # ← 这里
gc.collect()
在 CPython 里这没问题。在 MicroPython 里,import gc 会让 gc 变成整个函数的局部名,于是第一行的 gc.mem_free() 抛:
local variable referenced before assignment
这个异常被主循环吞掉,然后每帧抛一次,异常对象把 RAM 一路吃到 122KB,最后触发内存熔断把会话结束掉。表现出来是“语音一开监控就掉线”,看起来像语音模块的问题,其实是三行之外的一个 import。
现在项目里有一条硬规则:函数体内绝不 import gc,统一放模块顶层。
10.2 上传漏一个文件,能查一整天
QPYcom 上传时,子目录里的文件很容易漏(尤其 lib/umqtt.py)。漏了之后的症状千奇百怪:ImportError 还好,最怕的是“导入了但是旧版”,行为诡异且不报错。
所以我加了一个开机自检:逐个读文件,检查关键标记字符串在不在。
_BOOT_CHECKS = (
("/usr/_main.py", "异步化 + 摄像头重置", ["_VISION_TIMEOUT_S = 30", "def check_vision_pending"]),
("/usr/camera_srv.py","画质调优 + 重置", ["def reset(self):", "def set_led"]),
("/usr/cloud_srv.py", "异步 vision", ["def check_vision_pending"]),
("/usr/xiao_voice.py","语音客户端", ["class XiaoVoice", "def _pa_pulse"]),
)
开机串口会打出这样一张表:
[boot] ===== 设备端文件版本自检 =====
[boot] OK /usr/_main.py (异步化 + 摄像头重置)
[boot] OK /usr/camera_srv.py (画质调优 + 重置)
[boot] XX /usr/xiao_voice.py 缺标记: ['def _pa_pulse']
[boot] ===== ⚠ 有文件未更新,请重传上表 XX 的文件 =====
这二十行代码,省掉的是“我明明改了为什么没生效”的一整天。
顺便,自检里还查了 /usr/ 剩余空间,低于 100 KB 就自动清旧照片——不是只打印一句“建议清理”。这是我吃过亏的地方:只提示不执行,用户不会去清,然后拍照失败,然后回调丢失,然后卡死。能自动修的就别只告警。
十一、验收怎么测
只说“能跑”没有意义,得有判据。这个项目的测试项:
| # | 测什么 | 怎么测 | 通过判据 |
|---|---|---|---|
| 1 | 拍照时延 | 连续拍 20 次,看 拍照耗时 |
全部 < 3000 ms,无 -3 |
| 2 | 扫码 | 三种码各扫 5 次(近/远/斜) | 15 秒内出内容,失败有诊断照 |
| 3 | AI 视觉 | 换 5 个场景各 2 次 | 30 秒内出 200 字以上描述 |
| 4 | 长稳 | 每 5 秒拍一张,跑 100 次 | 无离线、无 9950ms、/usr/ 不涨 |
| 5 | 断网恢复 | 拔天线 30 秒再插回 | 自动重连,指令恢复 |
第 4 项是抓“慢性病”的:Flash 泄漏、内存碎片、回调丢失,短测都看不出来,跑一百次全暴露。
第 5 项要特别注意:重连之后必须重新订阅主题。我早期版本重连成功但没订阅,指令全丢,日志里只看到“连接成功”。
十二、代码清单
设备端一共 17 个文件、约 7000 行(wc -l 实测):
| 文件 | 行数 | 职责 |
|---|---|---|
_main.py |
1506 | 状态机、指令调度、定时任务、监控、开机自检 |
ali_cloud.py |
1035 | 阿里云 IoT:属性/服务/事件、OTA |
umqtt.py |
819 | MQTT 客户端(防重连风暴版,必传) |
camera_srv.py |
737 | LCD + 拍照 + 扫码 + 照片管理 |
threading.py |
574 | 官方 solution 线程库 |
xiao_voice.py |
561 | 小智语音:OTA 激活 / WebSocket / Opus |
cloud_srv.py |
376 | EMQX 指令 + 照片上传 + 视觉上行 |
gitee_uploader.py |
287 | 照片归档 |
config.py |
250 | 所有引脚、阈值、云平台参数 |
目录结构就一层,全部平铺在 /usr/ 下(QuecPython 的惯例)。入口叫 _main.py 不叫 main.py——因为 QPYcom 会把 main.py 当成开机自启入口,调试期一上电就跑,很干扰。量产前再改名。
入口最前面那三行不能省:
import sys
for _p in ("/", "/usr/usr", "/usr"):
if _p not in sys.path:
sys.path.insert(0, _p) # 逆序 insert,最终 /usr 排最前
example.exec() 不设置 __file__,sys.path 里可能只有空串。不补这三行,后面所有 from usr.xxx import 全部失败,报一个“找不到 usr.main”这种看起来像文件没传的错。
可以自己改的三个方向
① 换成你自己的视觉模型。
服务端 VISION_MODEL + base_url 改两个环境变量就行。想换成自建的,只要兼容 OpenAI 的 /chat/completions 格式。
② 加“定时巡检”。
已经有 set_timer(once / interval / daily 三种模式,最多 8 个任务)。挂在 interval 上,配一个视觉 prompt,就是一个“每隔一小时看一眼现场并汇报”的巡检相机。
③ 把照片存到自己的对象存储。
现在归档走 Gitee(限频、每次一个 commit),正式产品应该换 OSS/S3。改动只在 _do_take_photo 上传成功那一处,加一个 upload_to_oss(path) 就行。
十三、六个必踩的坑
| # | 现象 | 判据 | 修法 |
|---|---|---|---|
| 1 | 拍照永远 9950ms | 回调一次没来 | 查 /usr/ 空间;查 LCD 是否初始化 |
| 2 | 屏亮了但拍照无回调 | lcd_init 返回非 0 |
换屏幕初始化序列 |
| 3 | 板子还活着但网页离线 | 日志有 KeyboardInterrupt |
回调里别同步等,改成 pending |
| 4 | 扫码一开就卡死 | 解 25 秒,Ctrl+C 断不开 | decode_level 改回 1 |
| 5 | 解码正常但没声音 | 日志全绿 | PA 两上升沿 + setVolume(9) |
| 6 | 语音一开监控就掉线 | 刷 local variable referenced |
函数体内删掉 import gc |
第 1 条再强调一次,因为它的因果跨度最大:
Flash 满 → JPEG 写失败 → 固件不触发回调 → 9950ms 超时
→ 主循环卡在等待 → 心跳停发 → 服务端判离线 → “板子卡死了”
四个环节,起点是“磁盘满了”。所以现场排障第一件事不是看代码,是看 /usr/ 剩多少。
最后:三句话
- 摄像头不通,别碰网络。 LCD 初始化 →
open返回 0 → 回调能进,这三关过掉再谈上传。顺序反了,你会在网络层查一个根本不在网络层的问题。 - 回调里只发不等。 所有“等一个结果”的操作都要挪到主循环或独立线程。
KeyboardInterrupt继承自BaseException,你的except Exception抓不住它。 - 给每一条异步链路都准备一个“超时也要回一条”。 静默失败比崩溃更难查——崩溃至少有栈,静默失败只有用户的一句“它没反应”。
诚实交代两件事:阿里云的应用 OTA 包(camera_app_1.1.0.zip,18 KB)已经生成并在控制台走通了推送流程,但还没在真机上完成一次完整的升级验证;另外,照片只走 EMQX + Gitee,阿里云侧只有 URL,不做图片传输。这两点在答辩和文档里我都照实写了。
代码是完整的,引脚和阈值都能直接抄。卡住的地方把串口日志贴到评论区——有 9950、KeyboardInterrupt、local variable referenced、LCD init ret 这几个关键字,我大概能判断卡在哪一层。





