从一句“你好”开始:手把手做一个会聊天的 AI 语音机器人

这篇写给做过一点 Python、但没碰过语音和网络的同学。不讲大道理,从“拿到板子”一直做到“按下键能和它聊天”,中间每一步我都写清楚“你现在应该看到什么”。

如果你之前跟着我做过播放器,这篇会轻松很多——音频那套东西是同一个底座。如果没做过也没关系,该解释的地方我都会重新解释一遍。

一、先看成品:它到底长什么样


这是我做出来的东西:一块名片大小的板子、一个小喇叭、一块锂电池、一根天线。没有外壳,没有 3D 打印,所有东西摊在桌上就能跑。

按下板上的 S4 键,对着它说话,几秒后喇叭里就有人回答你。整个过程不需要连电脑,插上电池就能用。

这是它工作时的样子:


屏幕上那几行字是官方 AI 固件自带的状态页,逐行解释一下:

  • 左上角绿点 + ONLINE:WebSocket 已连上云端;
  • 中间那个对话气泡:正在听 / 正在说;
  • IDLE:当前空闲,等你按 S4;
  • Edge77%:端侧网络质量指示(不同固件版本显示不同);
  • 右上 CSQ 27:4G 信号强度,27 属于“良好”;
  • 下面 34.0C 和电量条:板载温度传感器与电池电量。

先诚实说一句:这块屏上的 UI 是官方固件提供的,不是我们写的。我们要写的代码在它后面跑。如果你想自己画一块状态屏,思路和我之前那篇 LVGL 播放器教程一样(disp_drv_t + flush_cb + 20ms 泵),文末我会说怎么接。

二、动手前的三个决定(想清楚再买器件)

第一次做语音机器人,最容易犯的错是“想自己训一个模型”。我用三个决定帮你避开这个坑:

决定 1:语音识别和对话,全部交给云端。
本地只做三件事:录音、上传、播放。原因很现实——EC600M 是 Cat.1 模组,RAM 只有几百 KB,跑不了任何像样的语音模型。硬要在端侧做,你会花两周得到一个识别率 60% 的玩具。

决定 2:用按键触发,不做唤醒词。
“小智小智”这种唤醒词需要固件支持 ovkws(离线关键词识别)。我实测烧的这版固件里 hasattr(rec, "ovkws_set_callback") 返回 False——根本没有这个能力。等一个不存在的功能是最浪费时间的,所以直接用板载按键。

决定 3:用官方 AI 固件,不自己编译。
移远官方有一版带 Opus 编解码和音频子系统的固件,是给这类项目专用的。自己编固件要处理 codec 时钟、DMA、功放时序,那是另一个项目的工作量。

这三个决定定下来,你的工作量就从“造一个机器人”变成了“接通一条链路”——后者一个周末能做完。

三、系统架构:其实就三段流水线


看这张图,把它拆成三段:

第一段:采集(你说话)
板载麦克风 → audio.Record(0) 采 PCM → Opus 编码(60kbps)→ WebSocket 发出去。

第二段:理解(云端干活)
云端收到 Opus 流 → STT 转文字 → LLM 想怎么回答 → TTS 合成语音。这一段你看不见,只能通过日志看到它返回了什么。

第三段:回话(它说话)
下行的 Opus 帧 → opus_write 解码 → PCM → NS4160 功放 → 喇叭。

这里有一个必须先记住的结论:设备端和云端的责任边界非常清晰。所以调试时你可以这样定位:

现象 责任方 你能做什么
识别出来的字不对 云端 STT 改不了,换个安静环境
它听不懂你说什么 云端 LLM 改不了,换说法
声音断断续续 你的网络/上行 查 CSQ、查丢帧
有字幕但没声音 你的功放 能改,见第八节
说完没反应 你的上行没发出去 能改,查 opus_read

这张表能帮你省掉大量“瞎改代码”的时间。

四、接线:真正要动手的只有两根线


这是这个项目最舒服的地方——麦克风是板载的,功放是板载的,按键是板载的,屏也是板载的。你要接的只有:

  1. 喇叭:插到 J7 的 2pin 座(NS4160 的 OUT+/OUT-)。就两根线,不分正负也能响(反相而已)。
  2. 电池(可选):3.7V 锂电插 VBAT 座,做便携用。调试期我建议只插 USB——少一个变量,少一类“是不是没电了”的怀疑。

剩下的引脚,你只需要知道它们在哪,不需要接线:

功能 引脚 位置 说明
对话按键 S4 GPIO13 PIN60(丝印 S4) 短按开始 / 打断
音量键 S5 GPIO12 PIN59(丝印 S5) 可选,也可当第二功能键
状态灯 D6 GPIO24 PIN52 低电平点亮(经三极管驱动)
功放使能 GPIO9 PIN40(接 NS4160 CTRL) 一线脉冲协议,第八节细讲
麦克风 MICP/MICN 板载 差分输入,不用管

三个“文档没写但会坑你”的点:

  • PA_CTRL 是 GPIO9,不是随便找个 IO 就行。原理图第 5 页写着 NS4160 CTRL ↔ PIN40_3V3,PIN40 就是 GPIO9。
  • 音频 device 只能是 0。audio.Audio() 的 0=听筒、1=耳机、2=喇叭,但实测只有 0 能出声——因为听筒通道经过板载 PA 驱动喇叭,1 和 2 是空的。
  • S5/GPIO12 和 SPI0 复用。你如果后面要挂 SPI 外设,就别再拿它做按键。

五、环境准备:烧固件 + 激活设备

5.1 烧固件

用 QPYcom 烧 AI 专用固件(文件名类似 QPY_OCPU_EC600MCN_LE_AI_FW.bin)。烧完在 REPL 里验证两件事:

import audio, opus
print(audio.Opus)                                        # 有输出 = Opus 编解码在
print(hasattr(audio.Record(0), "ovkws_set_callback"))    # False = 没有唤醒词,正常

第二行是我建议你第一件事就查的:如果它是 False,就别再花时间研究唤醒词了,直接走按键方案。

5.2 激活设备(这一步 90% 的人会卡)

小智平台要求设备先“注册”,才会给你 WebSocket 地址和 token。流程是:

  1. 设备联网后,向 https://api.tenclass.net/xiaozhi/ota/ 发一个 GET;
  2. 平台返回一个 6 位激活码(日志里能看到);
  3. 你拿这个码去 xiaozhi.me 控制台添加设备;
  4. 添加完再重启设备,OTA 就会返回 websocket.url 和 token,此时才算“已激活”。

我第一次做时在这里卡了半天,因为没激活时程序不会报错,只是 ws_url 是 None,然后连接时抛一个看不懂的异常。所以记住这条判据:

日志里出现“激活验证码”四个字 → 去网站添加设备,不要改代码。

六、第一段代码:先让它连上云端

别急着录音。先证明“网络通、能握手”,这是最容易被跳过、也最容易出问题的一步。

6.1 配置(config.py)

# ===== 按键与 LED =====
TALK_KEY_GPIO = 13          # S4
VOLUME_DOWN_GPIO = 12       # S5
LED_GPIO = 24               # D6,低电平点亮

# ===== 音频 =====
AUDIO_CHANNEL = 0           # 只能是 0(听筒)
AUDIO_VOLUME  = 7           # 普通播放音量 0~11
OPUS_VOLUME   = 9           # Opus/PCM 通道音量,必须是 9,默认可能是 0
PA_GPIO       = 9           # NS4160 CTRL
OPUS_BITRATE  = 60000       # 60kbps
PCM_SAMPLE_RATE = 16000     # 16kHz 单声道

# ===== 平台 =====
OTA_URL  = "https://api.tenclass.net/xiaozhi/ota/"
WSS_HOST = "wss://api.tenclass.net/xiaozhi/v1/"

# ===== 交互 =====
INTERACTION_MODE = "button"     # 没有 ovkws,只能用按键
CONTINUOUS_MODE  = True         # 说完自动继续听,不用反复按键
AUDIO_PLAY_MODE  = "opus"       # 下行用 Opus 解码播放

OPUS_VOLUME = 9 这行,我在原项目里特意标了星号。因为:PCM 通道的默认音量是 0,而 0 不是“很小声”,是彻底静音。我当初对着“解码成功但没声音”调了一整天,最后发现就是这个。

6.2 握手:hello 里到底发了什么

WebSocket 连上后,第一件事是发 hello。这个帧告诉云端“我是什么设备、我用什么音频格式”:

hello = {
    "type": "hello",
    "version": 1,
    "transport": "websocket",
    "audio_params": {
        "format": "opus",
        "sample_rate": 16000,
        "channels": 1,
        "frame_duration": 60,      # 一帧 60ms
    },
    "features": {
        "consistent_sample_rate": True,
        "mcp": True,               # 声明支持 MCP 工具调用
    },
}

云端会回一个同样结构的 hello,里面有 session_id。收到回复才算握手成功,日志里会打印 hello resp: {...}。

连接时要带四个 header,少一个都连不上:

headers = {
    "Authorization": "Bearer " + token,   # OTA 下发的 token
    "Protocol-Version": "1",
    "Device-Id": mac_address,             # 设备 MAC,云端用它认设备
    "Client-Id": uuid,                    # 本次连接的 UUID
}

6.3 判断“通没通”的最小脚本

from usr.protocol import WebSocketClient

ws = WebSocketClient(debug=True)
ws.ota_bootstrap()                 # 拿地址 + token,打印激活码
if not ws.is_registered():
    print("-> 没激活,先去 xiaozhi.me 添加设备")
else:
    with ws:                       # 内部会 connect + 起接收线程
        resp = ws.hello()
        print("握手结果:", resp)    # 有 session_id 就成了

跑通这一段,你就已经完成了整个项目最难的一半——因为它证明固件、网络、平台账号、token 全都是对的。剩下的都是本地音频问题。

七、第二段:把声音送上去

录音链路是这样的:audio.Record(0) 拿到 PCM,audio.Opus 编码成 Opus 帧,每帧 60ms。

import audio

class Recorder(object):
    def __init__(self):
        self.rec = audio.Record(0)          # 只能是 0
        try:
            self.rec.gain_set(3, 9)         # 麦克风增益(3=通道,9=档位)
        except Exception:
            pass                            # 部分固件没这个方法,忽略
        self.pcm = None
        self.opus = None

    def open(self):
        # PCM(通道, 模式, 采样率, 2, 1, 15) —— 用官方同款参数
        self.pcm = audio.Audio.PCM(0, 1, 16000, 2, 1, 15)
        self.pcm.setVolume(9)               # 这里也必须设,影响回放通道
        self.opus = audio.Opus(self.pcm, 0, 60000)   # (pcm, 0, 码率)

    def read(self):
        """读一帧编码后的 Opus 数据(60ms)"""
        if self.opus is None:
            return b""
        return self.opus.read(60)

    def close(self):
        try:
            if self.opus: self.opus.close()
        except Exception:
            pass
        try:
            if self.pcm: self.pcm.close()
        except Exception:
            pass
        self.opus = None
        self.pcm = None

上行循环非常简单,但它有一个必须遵守的节奏:

while chatting:
    if ai_speaking:                 # AI 正在说话,绝对不要读 PCM
        utime.sleep_ms(20)
        continue
    data = rec.read()               # 60ms 一帧,约 450 字节
    if data:
        ws.send(data)               # 二进制帧直接发,不加任何包头
    utime.sleep_ms(5)

三个细节:

  1. send(data) 发的是裸二进制,不要包 JSON。协议里约定:能解析成 JSON 的是控制消息,解析失败的就是音频帧。
  2. 60ms 一帧是 hello 里声明的 frame_duration,改这个值云端会按不同帧长解码,容易出杂音。老实跟着 60 走。
  3. ai_speaking 为真时必须停止读 PCM——这就是第八节要讲的通道独占问题。

八、第三段:把回话放出来(全篇最坑的一节)

8.1 先理解“通道独占”

EC600M 的 audio.Audio(0) 是一条独占通道。也就是说:

  • 你正在 Record 录音时,下行音频放不出来;
  • 你在放 TTS 时,录音也采不到。

这就是很多人的现象:“日志里明明有 AI 的回复文字,喇叭就是没声音。” 不是代码没跑,是通道被录音占着。

解决办法只有一个:播报前暂停录音,播报后恢复。这也是第九节状态机里 ai_speaking 这个布尔量存在的全部理由。

8.2 NS4160:一个不按常理出牌的功放

板载功放是 NS4160,它的 CTRL 脚不是电平使能,而是“一线脉冲协议”:

  • 给 1 个上升沿 → 进入 AB 类放大;
  • 给 2 个上升沿 → 进入 D 类放大(效率高,推荐);
  • CTRL 拉低超过 100us → 关断。

关键差异在这里:

播放方式 会不会自动开功放
aud.play(priority, breakin, path) 播文件 会(固件自动发脉冲)
audio.TTS 本地播报 会(同上)
opus_write() / PCM.write() 喂流 不会

第三条就是坑:你用 opus_write 播下行音频时,功放压根没开,于是“解码返回值 1920(正常)但一点声音都没有”。

修法是自己发这两个脉冲:

from machine import Pin
import utime

class Pa(object):
    def __init__(self, gpio=9):
        self.pin = Pin(getattr(Pin, "GPIO%d" % gpio), Pin.OUT, Pin.PULL_DISABLE, 0)
        self.enabled = False

    def enable(self, width_us=10):
        """发 2 个上升沿 -> D 类,之后保持高电平"""
        if self.enabled:
            return
        p = self.pin
        p.write(0); utime.sleep_us(30)         # 先稳定低电平(<100us 不会关断)
        p.write(1); utime.sleep_us(width_us)   # 上升沿 1 -> AB 类
        p.write(0); utime.sleep_us(width_us)
        p.write(1)                            # 上升沿 2 -> D 类,保持高
        self.enabled = True

    def disable(self):
        self.pin.write(0)
        utime.sleep_us(200)                   # >100us 才真正关断
        self.enabled = False

调用时机很重要:

  • 首次 opus_write 之前调一次(用 enabled 标志防重复);
  • 每次 close_opus() 之后重置标志,因为通道重新打开后功放状态会丢。

8.3 下行播放

def on_audio_message(raw):
    """收到下行音频帧(二进制)"""
    if not raw:
        return
    pa.enable()                    # 第一次写之前开功放
    ret = opus.write(raw)
    # 1920 = 一帧 60ms 解码成功;-1 = 失败
    print("opus_write: %dB -> ret=%d" % (len(raw), ret))

看到 ret=1920 就说明解码正常。如果这时还没声音,只可能是三件事:

  1. 功放没开(pa.enable() 没执行到);
  2. 音量是 0(pcm.setVolume(9) 没生效);
  3. 喇叭没插好。

按这个顺序查,三十秒能定位。

8.4 本地 TTS 兜底方案

如果哪天你换了个固件,opus_write 真的不出声(我遇到过一版固件这样),还有一个兜底:用云端返回的文字,走本地 TTS 播报。

响应里 tts 消息的 sentence_start 带着文本:

def handle_tts_message(msg):
    state = msg.get("state")
    if state == "sentence_start":
        text = msg.get("text", "")
        if text.startswith("%"):        # 过滤 MCP 工具调用标记
            return
        # tts 模式:必须先把录音通道关掉,否则 TTS 也出不来
        recorder.close()
        aud.set_pa(9)                   # TTS 走 audio 子系统,会自动发脉冲
        aud.tts_play(text, priority=4, breakin=1)

这就是配置里 AUDIO_PLAY_MODE 两个取值的含义:"opus" 走解码播放(音色是云端的,好听),"tts" 走本地合成(音色机械,但一定能出声)。

我的建议:先用 tts 模式跑通全流程,确认“能听清它在说什么”;再切 opus 模式调音色和功放。这样出问题时你知道是“链路问题”还是“功放问题”。

九、把三段串起来:三个布尔量管住整台机器


到这一步你会有三个线程:按键中断、WebSocket 接收线程、主对话线程。它们都会碰同一批状态,直接写 if 一定打架。

我的做法是只保留三个布尔量,所有逻辑都围绕它们转:

class ChatApp(object):
    def __init__(self):
        self.chatting = False        # 1 对话线程是否在跑
        self.ai_speaking = False     # 2 AI 是否正在播报
        self.need_resume = False     # 3 播报结束,该重开 listen
        self.continuous = False      # 是否持续对话模式

    # ---- 按键:短按进入 / 打断 ----
    def on_key_click(self):
        if self.chatting:
            self.exit_chat()
            return
        self.continuous = CONTINUOUS_MODE
        self.chatting = True
        Thread(target=self.chat_loop).start()

    def exit_chat(self):
        self.continuous = False
        self.ai_speaking = False
        self.need_resume = False
        self.chatting = False
        aud.stop()
        ws.abort()

    # ---- 对话主循环 ----
    def chat_loop(self):
        try:
            with ws:
                if ws.hello() is None:
                    return
                ws.wakeword_detected("_xiao_tian_xiao_tian")   # 告诉云端“用户开始说话了”
                ws.listen("start", mode="auto")
                while self.chatting:
                    if self.ai_speaking:
                        utime.sleep_ms(20)     # 3 AI 说话时什么都不做
                        continue
                    if self.need_resume:
                        self.need_resume = False
                        if self.continuous and self.chatting:
                            ws.listen("start", mode="auto")   # 4 继续听
                    data = rec.read()
                    if data:
                        ws.send(data)
                    if not ws.is_state_ok():
                        break
                    utime.sleep_ms(5)
        finally:
            self.chatting = False
            self.ai_speaking = False
            self.need_resume = False

三个提醒,都是踩过的:

  1. 播报中不要读 PCM。读了也没用(通道被占),还会让 opus_read 抛异常刷日志。
  2. 不要在回调里重开 listen。handle_tts_message 是在接收线程里跑的,直接在里面调 ws.listen() 会和主循环抢 socket。正确做法是只置 need_resume = True,让主循环去执行。
  3. 退出时三个量全部清零。少清一个,下次按键就会进到一个“看起来在对话但其实没连”的僵尸状态。

9.1 按键怎么写

官方示例用的是双边沿 + 硬件滤波:

from machine import ExtInt
import osTimer, utime

class Button(object):
    def __init__(self, gpio, long_ms=3000, on_short=None, on_long=None):
        self.pressed = False
        self.t0 = 0
        self.long_ms = long_ms
        self.on_short = on_short or (lambda: None)
        self.on_long = on_long or (lambda: None)
        self.timer = osTimer()
        self.key = ExtInt(getattr(ExtInt, "GPIO%d" % gpio),
                          ExtInt.IRQ_RISING_FALLING,   # 双边沿
                          ExtInt.PULL_PU,
                          self._cb, 150)               # 150 = 硬件滤波时间
        self.key.enable()

    def _cb(self, args):
        gpio, mode = args
        if not self.pressed:                  # 按下沿
            self.pressed = True
            self.t0 = utime.ticks_ms()
            self.timer.start(self.long_ms, 0, lambda a: self.on_long())
        else:                                 # 松开沿
            self.pressed = False
            self.timer.stop()
            if utime.ticks_diff(utime.ticks_ms(), self.t0) &lt; self.long_ms:
                self.on_short()

这里我要提醒一句:IRQ_RISING_FALLING 在 EC600 系列的部分 GPIO 上会 HardFault(表现是串口突然断开、板子重启)。我在播放器项目里就踩过,最后改成“只监听按下沿 + 线程轮询等释放”。

这个项目用双边沿能跑,可能是因为带了 filter_time=150 的硬件滤波,也可能和 GPIO13 所在的 bank 有关。如果你烧进去发现一按键就重启,别怀疑人生,把 IRQ_RISING_FALLING 换成 IRQ_FALLING,再用线程轮询判断松手即可。

十、一次完整对话,到底发生了什么


把这张图存下来,调不通的时候一行一行对日志:

步骤 你应该在串口看到
1 短按 S4 >>> 进入持续对话模式
2 OTA OTA response: {...}
3 hello hello resp: {...}
4 listen start >>> 请说话...
5 上行 (无日志,静默发送)
6 STT >>> [STT] 识别到: "你好"
7 TTS start >>> [TTS] state=start
8 下行 >>> opus_write: 450B -> ret=1920
9 TTS stop >>> [TTS] state=stop

哪一行没出现,问题就在那一步。 这是整个项目最值钱的一条经验——比任何“万能排错公式”都管用。

关于耗时,给你一个心理预期:按下到听见第一句话,大约 1.5~3 秒。其中 STT + LLM 占了 1~2 秒,这部分在云端,你改不了。想让它“感觉快一点”,唯一的办法是让云端先说一句“好的,我看看”——这是产品设计,不是代码优化。

十一、六个必踩的坑(每个都有人卡过)

# 现象 判据 修法
1 程序启动但连不上 日志有 6 位激活码 去 xiaozhi.me 添加设备,别改代码
2 连接抛异常 OTA 返回里没有 websocket.url 没激活,或 token 过期(重新 OTA)
3 有字幕没声音 opus_write 返回 1920 NS4160 没使能 → 手动 2 个上升沿
4 说完没反应 日志没有 [STT] 上行没发出去:查 opus_read 是否有数据、CSQ 是否 ≥18
5 播报被自己打断 ai_speaking 没置位 录音还在抢通道 → 先置标志再处理下行
6 一按键就重启 串口断在按键之后 双边沿 HardFault → 改单边沿 + 轮询

还有一个隐藏的坑:close_opus() 之后 enabled 标志没重置。表现是“第一轮对话有声音,第二轮就没了”。因为功放被关掉后,代码以为还开着。所以 close_opus() 里必须写一行 self.pa_enabled = False。

十二、完整代码清单与你可以改的地方

整个项目一共 7 个文件,加起来不到 1500 行:

/usr/
├─ main.py           入口:网络 -> OTA -> 按键 -> 对话
├─ config.py         所有引脚、音量、平台地址
├─ protocol.py       WebSocket 封装:OTA / hello / listen / MCP
├─ utils.py          AudioManager / Button / NetManager
├─ threading.py      线程与事件(官方提供)
├─ logging.py        日志
└─ uuid.py           Client-Id 生成

12.1 AudioManager 的核心(完整可抄)

import audio, utime
from machine import Pin

AUDIO_CHANNEL = 0
OPUS_VOLUME = 9
OPUS_BITRATE = 60000
PCM_SAMPLE_RATE = 16000
PA_GPIO = 9

class AudioManager(object):
    def __init__(self):
        # 1) 功放引脚(先拉低 = 关断态)
        self.pa = Pin(getattr(Pin, "GPIO%d" % PA_GPIO),
                      Pin.OUT, Pin.PULL_DISABLE, 0)
        self.pa_enabled = False

        # 2) 播放通道:只能 0
        self.aud = audio.Audio(AUDIO_CHANNEL)
        #    AI 固件是单参数,标准固件是双参数,做兼容
        try:
            self.aud.set_pa(PA_GPIO)
        except Exception:
            self.aud.set_pa(getattr(Pin, "GPIO%d" % PA_GPIO), 2)
        self.aud.setVolume(7)

        # 3) 录音
        self.rec = audio.Record(AUDIO_CHANNEL)
        try:
            self.rec.gain_set(3, 9)
        except Exception:
            pass

        self.pcm = None
        self.opus = None

    # ---- 功放:一线脉冲 ----
    def _pa_on(self, w=10):
        if self.pa_enabled:
            return
        self.pa.write(0); utime.sleep_us(30)
        self.pa.write(1); utime.sleep_us(w)
        self.pa.write(0); utime.sleep_us(w)
        self.pa.write(1)
        self.pa_enabled = True

    def _pa_off(self):
        self.pa.write(0)
        utime.sleep_us(200)
        self.pa_enabled = False

    # ---- Opus 编解码 ----
    def open_opus(self):
        self.pcm = audio.Audio.PCM(0, 1, PCM_SAMPLE_RATE, 2, 1, 15)
        self.pcm.setVolume(OPUS_VOLUME)      # 0 = 静音
        self.opus = audio.Opus(self.pcm, 0, OPUS_BITRATE)

    def close_opus(self):
        try:
            if self.opus: self.opus.close()
        except Exception:
            pass
        try:
            if self.pcm: self.pcm.close()
        except Exception:
            pass
        self.opus = None
        self.pcm = None
        self.pa_enabled = False               # 必须重置

    def read(self):
        return self.opus.read(60) if self.opus else b""

    def write(self, data):
        if self.opus is None:
            return -1
        self._pa_on()                          # 首次写前开功放
        return self.opus.write(data)

12.2 三个可以自己动手改的方向

跑通之后,这个项目就变成你的了。三个我认为性价比最高的改法:

1 加一块自己的状态屏。
官方固件已经在屏上显示了状态,但那是它画的。你可以用 LVGL 再叠一层自己的 UI(比如显示“正在听第 3 轮对话”)。思路和播放器那篇一样:lv.disp_drv_t 注册、flush_cb 里 lcd_write、主循环 20~50ms 泵一次 timer_handler。注意别和官方固件的刷屏打架。

2 接 MCP 工具。
小智支持 MCP(Model Context Protocol),云端可以在对话中调用设备能力。官方示例里已经给了音量控制的工具:

def handle_mcp_message(msg):
    payload = msg.get("payload", {})
    method = payload.get("method")
    if method == "tools/call":
        name = payload.get("params", {}).get("name")
        if name == "self.setvolume_up()":
            volume_up()
        # 回一个 result,否则云端会一直等
        ws.mcp_tools_call(tool_name=name, req_id=payload.get("id", 1))

顺着这个思路,你可以把“拍照”(相机项目那套)、“开灯”、“报时间”都挂上去——机器人就真能干活了。

3 改成本地唤醒。
如果你换到带 ovkws 的固件,把 INTERACTION_MODE 从 "button" 改成 "kws",注册 ovkws_set_callback 就能用唤醒词。但要先 hasattr 探测,别写死。

最后:给你的三句话

  1. 先接通,再出声,最后才谈“智能”。 顺序反了,你会在正常工作的环节里反复折腾。
  2. 每个失败现象都要有一个“能读出来的判据”(日志关键字、返回值、串口断点位置)。没有判据的调试就是玄学。
  3. AI 项目里 80% 的时间花在音频和网络上,不是花在模型上。 接受这一点,你会轻松很多。

代码全部在仓库里,能直接跑。遇到卡住的地方,把串口日志贴到评论区——有 [STT] 和 opus_write 那几行,我基本能判断卡在哪。