给 DFR1221 圆屏装上「小智 AI」:从零适配、触摸修复到会说话的三套动态角色

2026-11-085
AI 快速预览详细 收起
本文介绍了如何将开源项目 xiaozhi-esp32 适配到 DFR1221 圆屏开发板上,解决了触摸无响应的问题,并新增了语音定时提醒和三套会动的角色。提供了完整的源码和编译好的固件,适合 ESP32-S3 开发者参考。

一块 1.85 寸圆屏、ESP32-S3R8、PDM 麦克风 + PCM5100A,官方 Wiki 里找不到能直接跑小智 AI 的板级源码。
于是我从原理图和官方示例里把引脚一个个抠出来,给开源项目 xiaozhi-esp32 新增了一套完整板型,
顺手修好了「点屏没反应」,再加上语音定时提醒、一只能自动校时的圆形时钟和和三套会动嘴的原创角色。
文末提供编译好的固件,不想装 ESP-IDF 也能直接烧来玩。


DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_1.webp

一、背景:Wiki 很详细,但缺了最关键的那份源码

这块板子是 DFRobot 的 DFR1221 ESP32-S3 1.85 寸圆形触摸屏开发板,官方 Wiki:

硬件本身很讨喜:360×360 圆屏、电容触摸、板载 PDM 麦克风、PCM5100A 立体声 DAC、TF 卡座,甚至还有 Qi 无线充电。拿来做一个桌面 AI 小伙伴再合适不过。

但真动手把开源项目 小智 AI(xiaozhi-esp32) 移植上去时,遇到两个绕不开的问题:

  1. Wiki 没有提供适配小智 AI 所需的源码。 Wiki 和配套资料包给的是产品规格、原理图、Arduino / ESP-IDF 的屏幕显示示例,属于「让你把屏点亮」的层级;而小智 AI 是 ESP-IDF + LVGL + MCP 的完整固件,需要的是一个 Board 派生类:显示、触摸、音频编解码、背光、按键、唤醒词全部按框架接口接好。这份板级驱动并不在官方资料里,需要自己写。
  2. Wiki 页面的引脚说明不够完整。 QSPI 六根数据/时钟线、触摸的 RST/INT、DAC 的 XSMT 静音脚等关键信号,需要对照官方原理图以及示例工程 ST77916_LVGL_DEMO 里的 pincfg.h 才能补齐。少一根线,屏就可能不亮、触摸就永远没反应。

所以这个项目做的事情,本质上是把「官方硬件资料」翻译成「小智 AI 能直接编译的板型」,并把结果完整开源,让后面拿到同一块板子的朋友不用再啃一遍原理图。

在这里也感谢 DFRobot 提供了原理图和可运行的显示示例,屏的 184 条初始化命令正是从官方 scr_st77916.h 逐条比对来的。

最终成果一览

项目 结果
新增板型 dfrobot/df-s3-1.85-round,已接入 Kconfig 与构建脚本
显示 / 触摸 ST77916 QSPI 360×360、CST816S 电容触摸,点屏可唤醒对话
音频 PDM 麦克风采集 + PCM5100A I2S 播放,XSMT 静音控制
语音定时提醒 3 个 MCP 工具,支持每隔 N 分钟 / 重复 N 次 / 查询 / 取消,NVS 持久化,断网可用
自定义角色 3 套皮肤 × 8 种状态 = 24 个 GIF,对话时嘴型随真实音量张合
交互 左右滑动切换角色,选择写入 NVS,重启保持
固件 16MB 合并镜像 + 5 个分分区 bin,Flash Download Tool 可直接烧
最终 app 大小 2,802,192 B,app 分区剩余 32%

演示视频

二、硬件与准备

材料清单

序号 材料 说明
1 DFRobot DFR1221 开发板 ESP32-S3R8,16MB Flash,8MB OPI PSRAM
2 Type-C 数据线 必须支持数据,不能是纯充电线
3 3.5mm 有源音箱 / 耳机 板子不带功放和喇叭,不接就完全没声音
4 Windows PC + ESP-IDF v6.1 只想烧固件的话可跳过,直接用 Flash Download Tool
5 2.4GHz WiFi 小智 AI 需要联网激活与对话

重要提醒:这块板的音频输出是 Line out,不是扬声器输出。第一次调试「完全没声音」时,先确认外接的是有源音箱。

DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_2.webp

引脚总表(本次适配的核心产出)

依据官方原理图与示例 pincfg.h 整理,全部集中在 main/boards/dfrobot/df-s3-1.85-round/config.h:

功能 GPIO
LCD QSPI CS / SCK / D0 / D1 / D2 / D3 10 / 9 / 11 / 12 / 13 / 14
LCD RST / 背光 PWM 47 / 15(高电平亮)
触摸 CST816S SDA / SCL / RST / INT 7 / 8 / 40 / 41
PDM 麦克风 CLK / DATA 45 / 46
I2S DAC BCLK / WS / DIN 18 / 16 / 17
DAC XSMT(解除静音) 48
BOOT 按键 0

屏幕走 SPI2_HOST、50MHz QSPI;触摸 I2C 地址 0x15;音频输入 16kHz、输出 24kHz;TF 卡占用 GPIO 1~6,本适配未启用,与上述外设不冲突。


DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_3.webp

三、第一步:把板型接进小智 AI 工程

小智 AI 通过 Kconfig 选择板型,再由 main/CMakeLists.txt 把对应目录编进来。新增板型要做三件事。

1)在 main/Kconfig.projbuild 里注册选项:

config BOARD_TYPE_DF_S3_1_85_ROUND
    bool "DFRobot ESP32-S3 1.85inch Round Display (DFR1221)"
    depends on IDF_TARGET_ESP32S3

2)在 main/CMakeLists.txt 里加分支,指向 boards/dfrobot/df-s3-1.85-round,并把字体/emoji 资源一并加入。

3)写板型目录,config.h 定义上面的引脚,.cc 里完成初始化:

  • 显示:esp_lcd_st77916 组件 + 184 条厂商初始化命令(与官方 scr_st77916.h 逐字节一致),QSPI 32-bit 命令字;
  • 触摸:esp_lcd_touch_cst816s,通过 lvgl_port_add_touch() 挂到默认显示器;
  • 音频:NoAudioCodecSimplexPdm(PDM 采集 + 标准 I2S 播放),派生 Pcm5100aAudioCodec 管理 XSMT 静音脚;
  • 背光:PwmBacklight,亮度可从设置中恢复。

编译(ESP-IDF v6.0.1 以上,推荐 release/v6.1):

idf.py set-target esp32s3
python scripts/build.py dfrobot/df-s3-1.85-round --name df-s3-1.85-round --language zh-CN
idf.py -p COM10 -b 921600 flash monitor

也可以在 idf.py menuconfig → Xiaozhi Assistant -> Board Type 里选 DFRobot ESP32-S3 1.85inch Round Display (DFR1221) 后直接 idf.py build。项目默认就是 16MB Flash + partitions/v2/16m.csv + Octal PSRAM,不需要额外覆盖 sdkconfig。

配图建议:这里放一张 menuconfig 里能看到 DFR1221 选项的截图,或编译成功的终端截图。


四、踩坑一:屏亮了,但怎么点都没反应

第一次烧完,屏、触摸驱动、WiFi、唤醒词全都正常,可点击屏幕完全不触发对话,只能用 BOOT 键。

排查后发现问题不在硬件:lvgl_port_add_touch() 只是把 CST816S 注册成 LVGL 的输入设备,并不会自动产生「点击唤醒」这个业务动作。而小智 AI 通用 UI 里创建的屏幕、容器、聊天气泡默认都不是 clickable,也没有挂 LV_EVENT_CLICKED 回调。换句话说,触摸坐标进得来,但没有任何对象「接」这个事件。

修法是派生一个自己的 Display 类,在基类 SetupUI() 之后补上可点击标记和事件回调,并让后创建的聊天气泡把事件冒泡上来:

class DfrobotS3Round185Display : public PetAvatarDisplay {
public:
    using PetAvatarDisplay::PetAvatarDisplay;

    void SetupUI() override {
        PetAvatarDisplay::SetupUI();
        DisplayLockGuard lock(this);

        // 覆盖屏幕 / 容器 / 聊天区,保证点到哪里都能唤醒
        for (lv_obj_t* obj : {lv_screen_active(), container_, content_}) {
            if (obj == nullptr) continue;
            lv_obj_add_flag(obj, LV_OBJ_FLAG_CLICKABLE);
            lv_obj_add_event_cb(obj, PressCb,   LV_EVENT_PRESSED,  this);
            lv_obj_add_event_cb(obj, ReleaseCb, LV_EVENT_RELEASED, this);
            lv_obj_add_event_cb(obj, GestureCb, LV_EVENT_GESTURE,  this);
            lv_obj_add_event_cb(obj, TapCb,     LV_EVENT_CLICKED,  this);
        }

        // 聊天气泡是运行期逐条创建的,让它们把事件上抛
        lv_obj_add_event_cb(content_, [](lv_event_t* e) {
            lv_obj_t* chat = static_cast<lv_obj_t*>(lv_event_get_user_data(e));
            uint32_t n = lv_obj_get_child_cnt(chat);
            if (n > 0) {
                lv_obj_t* bubble = lv_obj_get_child(chat, n - 1);
                lv_obj_add_flag(bubble, LV_OBJ_FLAG_EVENT_BUBBLE);
                lv_obj_add_flag(bubble, LV_OBJ_FLAG_GESTURE_BUBBLE);
            }
        }, LV_EVENT_CHILD_CREATED, content_);
    }
};

修完做了 300 秒串口监听,点屏后日志打印 Touch press at (178, 144) / Touch tap at (178, 144),状态机 idle -> connecting -> listening,随后完整跑通一轮对话:录音识别 → MQTT 会话 → TTS 回复 → speaking -> listening → 收到 goodbye 回到 idle。触摸唤醒、录音、网络对话、播放全链路闭环,无 panic、无重启循环。


五、功能一:说一句话就能设个定时提醒

小智 AI 支持 MCP 工具,云端大模型可以直接调用设备端能力。我给这块板注册了 3 个工具:self.reminder.set / self.reminder.list / self.reminder.cancel。

于是配网激活后,直接说自然语言就行:

说什么 效果
每 10 分钟提醒我喝水 每 600 秒一次,一直重复到取消
10 分钟后提醒我开会 600 秒后提醒一次
每 5 分钟提醒我吃药,一共 3 次 300 秒一次,触发 3 次后自动结束
现在有哪些提醒 列出内容、间隔、剩余次数、距下次秒数
取消喝水提醒 / 取消所有提醒 按内容关键词或 all 全部取消

实现上有几个刻意的设计:

  • 纯相对时间:基于 esp_timer 的 1Hz 心跳,不依赖 RTC、SNTP,也不依赖服务端调度,断网照样提醒。
  • NVS 持久化:最多 8 条,用 Settings("reminder") 存 JSON,重启后按完整间隔重新计时。
  • 到点反馈:屏幕通知 + 本地 OGG 提示音 + 角色切到 happy 蹦跳,8 秒后由 Schedule(DismissAlert) 自动复位界面,不会把状态栏一直占着。
  • 离线可用:默认是文字 + 提示音;voice=true 时才额外请求云端 TTS 播报(设备端没有本地 TTS 引擎)。

端到端实测(12 分钟串口监听):

语音:「30秒之后提醒我站起来」
  → Reminder: set r0f3211221: '站起来活动一下' every 30s, repeat=1
  → 32 秒后 Reminder: FIRE: 站起来活动一下
语音:「取消提醒」
  → cancelled 1 reminder(s) with 'all'
语音:「现在有哪些提醒?」
  → self.reminder.list 返回空,小智回复「现在一个提醒都没有喔」

中间还修了两个静态审查发现的体验问题:提醒到点后 Application::Alert() 留下的消息一直挂着;有限次数提醒的 repeat_left 递减没写回 NVS,重启后会多送几次。现在每次递减都持久化。

配图建议:放一张提醒弹出时的屏幕实拍,以及串口日志截图。


六、功能二:三套会动的角色,说话时嘴真的会张

小智 AI 自带的表情是静态 Emoji 集合,我想给这块圆屏做一个「有生命感」的桌面伙伴,于是自己画了三套角色。

皮肤 形象 嘴型
小糯 Nuonuo 奶白云团小精灵,两根会摆的橙色触角、大眼睛、腮红、小短脚 深紫红圆形嘴
格罗克 Grok Bot 深色圆角机器人,单根发光天线、青蓝屏幕眼(带扫描线)、LED 指示灯 青蓝色圆角 LED 条
豆包 Doubao 浅蓝圆形徽章 + 紫/蓝/青绿三色丝带环,齐下巴短发、大眼睛、粉腮红、深色圆领上衣 豆沙粉圆形嘴

每套都有 8 种状态循环动画:

状态 表现
idle 待机 呼吸起伏、眨眼、偶尔冒小星星
listening 聆听 两侧声波 + 发光环 / 天线信号环
speaking 说话 脸颊声波线、音符
thinking 思考 眼睛上瞟、头顶思考泡泡
happy 弯月笑眼、放大腮红、星星与爱心
sleepy(配网中) 半闭眼、飘 Zzz
sad / surprised 泪滴小雨云 / 四角星光

DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_4.webp

口型是真的跟着声音动的

这部分我最想分享。做法是:GIF 里故意不画嘴,播放时在角色嘴部叠一个 LVGL 对象;在 audio_service.cc 的输出链路里加了一个轻量探针,统计送往 DAC 的 PCM 平均绝对值,再用 lv_timer 每 60ms 读一次幅度,经过指数平滑后映射到嘴的高度:

  • 静音 → U 形微笑弧;
  • 说话 → 声音大嘴张得大,停顿就闭上;
  • 切换皮肤 → 嘴的坐标、颜色、尺寸范围、圆角一起换。

所以它不是「播放固定动画」,而是真正跟 TTS 波形同步,观感差别很明显。探针是全局加在 OutputData() 前后的,对其它板型没有副作用。

DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_5.webp

待机画面:一只能自动校时的圆形时钟

角色再可爱,一直挂在桌面上也容易看腻;而且待机时我更想一眼看到时间。所以后来把 ​idle 画面整个换成圆形模拟时钟​:屏幕中央 260px 表盘,60 个刻度点(整点放大)、12/3/6/9 数字、时分秒三根指针、每秒推进的蓝色秒环。顶部状态栏本来就会显示 HH:MM,表盘里就不再重复;MM/DD WWW 日期星期单独放到​表盘下方的屏幕底部​,带深色圆角底衬。

设备状态 画面
待机 idle 圆形时钟,同时暂停 GIF 解码器
聆听 / 说话 / 思考 / 配网 隐藏时钟,回到角色 GIF 与口型
服务端下发表情、提醒庆祝、切换皮肤 临时显示角色约 2.2 秒,结束后自动回到时钟

「能正常计时」这件事比画表盘麻烦。 上游 OTA 响应里的 server_time 会写系统时间,但它是把本地墙上时间当作伪 UTC 存进 settimeofday() 的;一旦服务器没下发这个字段,设备就完全没有时间基准。于是 clock_face.h 里又加了一个 ClockTimeSync:WiFi 连上后用 SNTP(ntp.aliyun.com)兜底,取到标准 UTC 后同样加时区偏移再写入,保证时钟、顶部状态栏和提醒显示的时间一致;同步完立刻停掉 SNTP,避免周期性同步把时间改回纯 UTC。没校时时表盘显示 SYNCING、指针停在 12 点,不会拿开机秒数冒充真实时间。默认 UTC+8,改一个宏就能换时区。

指针复用原本驱动口型的 60ms lv_timer,只在 time(nullptr) 的秒值变化时才重设三根线和秒环,平时只做一次整数比较,待机几乎不增加 CPU 负担。

DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_6.webp

角色资源怎么来的

24 个 GIF(240×240、8 帧、带透明通道、共 214.9 KB)全部由 tools/generate_pet_assets.py 用 Pillow 程序化绘制,不依赖任何外部素材:

pip install pillow
python main/boards/dfrobot/df-s3-1.85-round/tools/generate_pet_assets.py

脚本会重写 pet_gifs/、pet_assets.h 和三张预览图。pet_avatar.h 里的 kPetSkins[] 表同时定义每个皮肤的 GIF 集合和嘴型参数,Python 生成器与 C++ 渲染器共用同一份定义,改一处两边同步。

两个实现细节值得记一下:

  • 角色自己管 LvglGif 播放器,不挂主题的 EmojiCollection —— 设备联网激活后 Assets::Apply() 会替换表情集合,挂在那里会被覆盖掉,开机就变回默认机器人。
  • 生成器共用一张 64 色调色板(031 是初版配色、3263 留给豆包),透明索引 64,GIF 全局色表 128 项;确认过 gifdec.h 的调色板支持 256 项,不需要改解码器。设备端日志也确认 128 项色表解码正常,无 failed to decode GIF。

左右滑动换角色

在 LV_EVENT_PRESSED / RELEASED / GESTURE / CLICKED 上做了双路径判定:LVGL 自带手势 + 按下/抬起坐标差(横向 ≥55px 且明显大于纵向),700ms 时间窗去重,避免一次滑动连切两个;滑动会抑制紧随其后的 CLICKED,所以换角色不会误唤醒对话。选择写入 NVS 的 pet/skin,重启后保持。


七、不想装 ESP-IDF?直接烧固件

我把编译产物打包成了 Espressif Flash Download Tool 能直接用的格式,两种方法等价,内容已做逐字节比对校验。

工具设置(关键):

项 值
ChipType ESP32-S3
SPI SPEED 80 MHz
SPI MODE DIO
Flash Size 16 MB
WorkMode / LoadMode Develop / UART

方式 A(推荐,只填一行):

merged/xiaozhi_dfr1221_16MB_merged.bin → 地址 0x0,文件 16,777,216 B(已按 16MB 全片补齐 0xFF)。如果界面上有 DoNotChgBin 就勾上——镜像头里已经写好了 DIO / 80MHz / 16MB,不需要工具改写。

方式 B(5 个分分区文件):

地址 文件 大小 (B) 内容
0x0 0x00000_bootloader.bin 16,544 二级 bootloader
0x8000 0x08000_partition-table.bin 3,072 分区表
0xd000 0x0d000_ota_data_initial.bin 8,192 OTA 初始数据
0x20000 0x20000_xiaozhi_app.bin 2,802,192 主程序(含全部新功能与角色 GIF)
0x800000 0x800000_generated_assets.bin 1,262,753 字体 / 表情 / 提示音资源

分区布局(partitions/v2/16m.csv):nvs 0x9000、otadata 0xd000、phy_init 0xf000、ota_0 0x20000 (0x3f0000)、ota_1、assets 0x800000 (8M)。

等价的 esptool 命令:

esptool.py --chip esp32s3 --port COM10 --baud 921600 \
  write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB \
  0x0 merged/xiaozhi_dfr1221_16MB_merged.bin

两个注意点:

  1. 整片 16MB 镜像会清空 NVS / WiFi 配网,烧完按小智标准流程重新配网即可;只想升级程序时,单独烧 0x20000 那一行,配网信息就不会丢。
  2. 角色 GIF 是编进 app 分区的,所以换形象不需要重烧 0x800000 资源分区。

配图建议:放 Flash Download Tool 配置界面截图(能看到 5 行地址和 80MHz / DIO 设置)与 FINISH 完成截图。


八、实测数据与当前状态

验证项 结果
启动日志 ST77916 初始化成功、CST816S 识别(IC id 182)、背光、PDM + PCM5100A、AFE 唤醒词、WiFi / MQTT 激活均正常
工具注册 14 tools registered: 7 visible to the model,3 个提醒工具对云端 LLM 可见
角色解码 PetAvatar: avatar ready: 3 skins x 8 state GIFs, active=豆包 Doubao,无解码失败
提醒链路 语音设置 → 到点触发 → 查询 → 取消,全部闭环
内存 空闲内存稳定在 141~145 KB
app 分区 2,802,192 B,剩余 32%
稳定性 长时间监听无 panic、无重启循环;仅出现过一次 MQTT 瞬时超时,设备自动重连恢复

九、Windows 编译踩坑速查

如果你打算自己改代码重新编译,这几个坑我都踩过:

现象 原因与解法
工具链 exe 无法启动 / 被 SmartScreen 拦截 下载包带 MOTW 标记,用 Unblock-File 批量解除;工具链版本要对齐 esp-15.2.0_20251204
CreateProcess failed IDF 的 ccache、ninja 目录没进 PATH,export PATH=...:$PATH 补上
命令行超过 32767 字符导致链接失败 给 build.ninja 打补丁清空超长 INCLUDES,或 cmake -D CMAKE_NINJA_FORCE_RESPONSE_FILE=ON -S . -B build,也可直接缩短工程路径
偶发 as.exe 报 DLL 加载失败、ldgen 调 objdump.exe 报文件被占用 Windows 瞬时 DLL / 文件占用,重试即过,与代码无关
烧录到一半 PermissionError 会留下半套固件导致反复复位,用重试循环 + --connect-attempts 20 完整重烧

还有一条设计上的取舍:reminder.h / pet_avatar.h 的实现刻意放在头文件里。因为板目录的 *.cc 由 CMake glob 收集,新增 .cc 会强制 CMake 重新配置,把上面那个 build.ninja 补丁覆盖掉。等用 CMAKE_NINJA_FORCE_RESPONSE_FILE=ON 重新配置过工程,就可以把它们拆成规范的 .cc。


十、源码与资源

  • 完整工程(含板型源码、角色生成脚本、构建配置):Gitee 仓库 yuntian365/xiaozhi-dfr1221
  • 可直接烧录的固件:仓库 firmware/flash_download_tool/ 目录,或打包好的 xiaozhi_dfr1221_flash_download_tool.zip(约 5.1 MB)
  • 目录速查:
    • main/boards/dfrobot/df-s3-1.85-round/ — 板型全部源码、引脚表、角色资源与生成脚本
    • main/audio/audio_service.h/.cc — 音频播放幅度探针
    • main/Kconfig.projbuild / main/CMakeLists.txt — 板型注册
    • firmware/flash_download_tool/ — 合并镜像、分分区 bin、flash_args.txt、sha256.txt、烧录说明
  • 仓库里保留了 sdkconfig 与 dependencies.lock,克隆后无需重新 menuconfig 就能复现本次固件;build/ 和 managed_components/ 不入库。
  • 仓库内不含任何 WiFi 账号、API Key 或账号口令。

DFR1221 圆屏 · 从零适配小智 AI:触摸修复与动态角色_image_7.webp

十一、总结与下一步

从「Wiki 里没有能直接用的源码」到「一块能语音聊天、会提醒我喝水、还会跟着声音张嘴的圆屏小伙伴」,这个项目把 DFR1221 在小智 AI 生态里的最后一块拼图补上了。整个适配没有改动任何已有板型的引脚配置,避免 OTA 串版,其它板子照常编译。

回头看,最花时间的不是写驱动,而是三件小事:把引脚从原理图里抠全、想明白「触摸输入设备 ≠ 点击唤醒事件」、以及让 24 个 GIF 在 8MB PSRAM 的约束下既好看又不炸色表。

接下来还想在几处继续打磨:

  • 实拍确认滑动手感、屏幕方向与外接音箱听感,把参数调到更顺手;
  • 板上的 TF 卡和 Qi 无线充电目前没启用,可以做本地提示音/表情扩展和真正的桌面无线供电;
  • 若能接一个轻量本地 TTS,提醒就能完全离线语音播报;
  • 角色生成器可以做成配置文件驱动,方便大家画自己的形象。

如果你也有一块 DFR1221,欢迎直接烧来试,也欢迎在评论区交流你画的角色。代码和固件都开源,改坏了重烧 0x20000 就能回来。


许可与致谢:本项目基于开源项目 xiaozhi-esp32,许可证沿用上游 LICENSE。
硬件资料参考 DFRobot 官方 Wiki、原理图与 ST77916_LVGL_DEMO 示例;屏幕初始化命令与官方 scr_st77916.h 一致。
三个角色形象均为程序化绘制的原创 Q 版设计,仅作个人学习与交流使用,未使用任何第三方品牌素材,相关商标归各自权利人所有。

创作许可协议

本项目采用 None(不开放任何权利,保留所有权利) 进行许可。

评论(0)
- 没有更多了 -