从 0 做一个 AI 语音相机:拍照、扫码、看懂画面,一条 4G 链路全打通

这篇写给做过一点 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,测试时灯亮了,很开心。然后发现一个诡异现象:

  1. 语音会话期间,灯必然亮着,关不掉;
  2. 长按结束会话,灯自己灭了;
  3. 我发“关灯”指令,返回成功,但灯还亮着。

查了两小时才想明白:这颗 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,而且没有任何异常栈。

根因链是这样的,值得逐字看一遍:

  1. 回调是在 umqtt 的 wait_msg() 里被调用的;
  2. 我在回调里等回传,等回传就得收消息,于是又调了一次 check_msg();
  3. wait_msg 重入,umqtt 的 __loop_forever 心跳超时;
  4. 心跳超时触发 base_reconnect,而重入锁还被我占着 → 互锁;
  5. 锁死之后驱动层抛出 KeyboardInterrupt;
  6. KeyboardInterrupt 继承自 BaseException,不是 Exception —— 我外层那个 except Exception 一个字节都没接住;
  7. 线程静默死亡,主循环还在跑,但 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/ 剩多少。

最后:三句话

  1. 摄像头不通,别碰网络。 LCD 初始化 → open 返回 0 → 回调能进,这三关过掉再谈上传。顺序反了,你会在网络层查一个根本不在网络层的问题。
  2. 回调里只发不等。 所有“等一个结果”的操作都要挪到主循环或独立线程。KeyboardInterrupt 继承自 BaseException,你的 except Exception 抓不住它。
  3. 给每一条异步链路都准备一个“超时也要回一条”。 静默失败比崩溃更难查——崩溃至少有栈,静默失败只有用户的一句“它没反应”。

诚实交代两件事:阿里云的应用 OTA 包(camera_app_1.1.0.zip,18 KB)已经生成并在控制台走通了推送流程,但还没在真机上完成一次完整的升级验证;另外,照片只走 EMQX + Gitee,阿里云侧只有 URL,不做图片传输。这两点在答辩和文档里我都照实写了。

代码是完整的,引脚和阈值都能直接抄。卡住的地方把串口日志贴到评论区——有 9950、KeyboardInterrupt、local variable referenced、LCD init ret 这几个关键字,我大概能判断卡在哪一层。