本文阐述《行空板K10调用Claude Buddy桌面宠物》的原理。以这个适配项目为载体,聊聊其中涉及的 BLE 应用开发和 ASCII 宠物动画系统设计。
BLE 通信:从扫描到双向传输
Nordic UART Service 协议
BLE 设备通过 GATT(Generic Attribute Profile)协议交互数据,其层级结构为 Service → Characteristic → Value + Descriptor。Claude Desktop 的彩蛋通过 Nordic UART Service (NUS) 向外暴露数据,这是一个广泛用于 BLE 串口透传的标准服务,固定 UUID 如下:
Service: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
RX (Write): 6e400002-... --- 客户端写入,K10 接收
TX (Notify): 6e400003-... --- K10 推送,客户端订阅后接收
选择 NUS 的好处是生态兼容性------nRF Connect、LightBlue 等调试工具直接识别,开发阶段调试方便。RX 配置了 PROPERTY_WRITE 和 PROPERTY_WRITE_NR(无响应写),TX 配置了 PROPERTY_NOTIFY 并附加 BLE2902 Descriptor(CCCD)。客户端必须先向 CCCD 写入 0x0001 才能收到 TX 推送------这是排查"已连接但收不到数据"问题的第一站。
连接生命周期
ble_bridge.cpp 实现的连接状态机如下:
startAdvertising("Claude-XXXX") → Central 连接 → onConnect
→ 客户端订阅 TX Notify → 双向通信
→ 断开 → onDisconnect → 自动重新 startAdvertising
设备名取 MAC 地址后两字节(Claude-XXYY),方便多设备区分。心跳保活由 bleKeepAlive() 每 5 秒推送一个 \n,防止部分 BLE 栈因长期无数据主动断开。
安全配置
配对安全使用 ESP-IDF Bluedroid 栈的静态配置:
BLESecurity::setAuthenticationMode(ESP_LE_AUTH_REQ_SC_BOND);
BLESecurity::setCapability(ESP_IO_CAP_NONE);
BLESecurity::setKeySize(16);
ESP_LE_AUTH_REQ_SC_BOND 开启 LE Secure Connections + 密钥绑定,设备重启后自动恢复加密无需重配对。ESP_IO_CAP_NONE 声明设备无输入输出能力,BLE 栈退化为 Just Works 配对,无需 PIN 码------这是无键盘外设的实际可行选项。
配对码在三个回调中清零(onConnect、onDisconnect、onAuthenticationComplete),防止异常路径导致过期配对码残留在屏幕上。
MTU 与分包
K10 将 MTU 协商到 517 字节(BLEDevice::setMTU(517)),但应用层在 bleWrite() 中主动限流到 180 字节,每次 notify() 后 delay(4),给接收端 BLE 栈时间将数据从 ATT 层推到应用层。
文件传输协议
除了状态同步,K10 还支持通过 BLE 上传自定义 GIF 角色到头像库。BLE MTU 限制下,xfer.h 实现了一个三阶段分块协议:
char_begin → 擦除旧角色,检查 LittleFS 可用空间
file → chunk(Base64) × N → file_end (可重复多个文件)
char_end → 加载新角色
空间检查在 char_begin 完成,将剩余空间与回收的旧角色空间合并计算(free + reclaimable),不足时传输开始前返回错误,不浪费用户等待时间。二进制数据经 Base64 编码后通过 mbedtls_base64_decode 解码写入 LittleFS。
ASCII 宠物动画:18 种物种 × 7 个状态
物种总览
固件内置了 18 种 ASCII 像素宠物,每种宠物通过统一的 Species 结构体定义:
struct Species {
const char* name; // 物种英文名
uint16_t bodyColor; // 主体颜色(RGB565)
StateFn states[7]; // 7 个状态动画函数指针
};
18 种宠物的完整列表如下:
|-------|----------|---------|-------------------|----------|
| # | 英文名 | 中文名 | 主体颜色 (RGB565) | 视觉色相 |
| 1 | capybara | 水豚 | 0xC2A6 | 浅棕 |
| 2 | duck | 鸭子 | 0xFFE0 | 明黄 |
| 3 | goose | 鹅 | 0xFFFF | 纯白 |
| 4 | blob | 史莱姆 | 0x07F0 | 亮绿 |
| 5 | cat | 猫 | 0xC2A6 | 浅棕 |
| 6 | dragon | 龙 | 0xF800 | 正红 |
| 7 | octopus | 章鱼 | 0xA01F | 紫色 |
| 8 | owl | 猫头鹰 | 0x8430 | 橄榄 |
| 9 | penguin | 企鹅 | 0x041F | 深蓝 |
| 10 | turtle | 乌龟 | 0x07E0 | 翠绿 |
| 11 | snail | 蜗牛 | 0xD8FE | 乳白 |
| 12 | ghost | 幽灵 | 0xFFFF | 纯白 |
| 13 | axolotl | 美西螈 | 0xFB1E | 粉红 |
| 14 | cactus | 仙人掌 | 0x07E0 | 翠绿 |
| 15 | robot | 机器人 | 0xC618 | 银灰 |
| 16 | rabbit | 兔子 | 0xFFFF | 纯白 |
| 17 | mushroom | 蘑菇 | 0xF810 | 朱红 |
| 18 | chonk | 胖墩 | 0xFD20 | 橙色 |
属性与状态详解:以「猫」(cat)为例
以猫为例,可以直观地理解 Species 结构体每个字段的含义和在动画系统中的作用:
extern const Species CAT_SPECIES = {
"cat", // name --- BLE 协议中通过 "species":"cat" 远程切换
0xC2A6, // bodyColor --- RGB565 浅棕色,勾边/主色调
{ cat::doSleep, cat::doIdle, cat::doBusy, cat::doAttention,
cat::doCelebrate, cat::doDizzy, cat::doHeart } // 7 状态函数指针
};
三个属性:
- name(物种名):既是 BLE JSON 中 "species":"cat" 的匹配键,也是屏幕上物种名称的显示文本。桌面端可随时通过 BLE 下发物种切换指令。
- bodyColor(主体颜色):16 位 RGB565 格式。猫用 0xC2A6,即 R=0x18、G=0x15、B=0x06 → 浅棕色。这个颜色用于像素精灵的轮廓/主体渲染。
- states[7](状态动画表):7 个函数指针,每个对应一种角色状态。状态枚举定义在 persona.h 中:
enum PersonaState {
P_SLEEP, // 睡眠 --- 宠物闭眼、打鼾、冒 "Z" 粒子
P_IDLE, // 待机 --- 缓慢眨眼、偶尔伸懒腰,占比最高的默认状态
P_BUSY, // 忙碌 --- 快速敲击、打字、面部专注表情
P_ATTENTION, // 注意 --- 警觉抬头、眼珠追随、等待用户操作
P_CELEBRATE, // 庆祝 --- 跳起、挥舞、撒花/星粒子特效
P_DIZZY, // 眩晕 --- 摇晃、眼冒金星、踉跄步态
P_HEART // 爱心 --- 冒心形泡泡、脸红、幸福表情
};
7 **种状态由 BLE 实时驱动。**桌面端每次收到 Claude 的事件变更(开始生成、工具调用、审批完成等),通过 BLE 下发 JSON 更新 personaState,K10 固件调用 sp->states[personaState](tickCount) 切换到对应动画函数。整个过程仅需一行查表------无需 if-else 或 switch,18 种物种共享同一调度路径。
猫的动画帧序列示例:
猫的 IDLE 状态通过字节数组驱动 10 个帧姿态,(t/5) 将 200ms tick 降频为 1 秒节拍:
REST (70%) 伸懒腰 (15%) 眨眼 (10%) 舔爪 (5%)
/\/\ /\/\ /\/\ /\/\
( o o ) → ( - - ) → ( - o ) → ( ~ ~ )
(> <) (> <) (> <) (> <)
帧索引在 SEQ 数组中的重复次数决定了各姿态的持续时长权重------REST 出现 14 次,特殊动作仅 1~2 次。修改动画只需调整数组中帧索引的位置和重复次数,无需触及渲染逻辑。
两套渲染模式
K10 支持两种角色渲染方式:
- ASCII 像素动画(buddy.cpp + 18 个物种文件):用等宽字符在屏幕上绘制像素级宠物,每个字符 6×8 像素,6px 字符宽度在 240 宽屏幕上最多排 40 字符------对宠物绘制绰绰有余
- GIF 角色渲染(character.cpp):从 LittleFS 加载 GIF 文件,通过 AnimatedGIF 库逐帧解码播放
系统启动时检查 /characters/ 目录,有 GIF 角色就用 GIF,没有就走 ASCII 模式。菜单里 "ascii pet" 选项可循环切换 18 种 ASCII 物种。
函数指针表驱动的多态
18 种物种通过 Species 结构体和函数指针数组实现多态调度:
typedef void (*StateFn)(uint32_t t);
struct Species {
const char* name;
uint16_t bodyColor;
StateFn states[7]; // SLEEP/IDLE/BUSY/ATTENTION/CELEBRATE/DIZZY/HEART
};
static const Species* SPECIES_TABLE[] = {
&CAPYBARA_SPECIES, &DUCK_SPECIES, ..., &CHONK_SPECIES
};
// 调用时只需一行查表
const Species* sp = SPECIES_TABLE[currentSpeciesIdx];
sp->states[personaState](tickCount);
18×7 = 126 个动画函数共享全局 tickCount(每 200ms 自增),各函数内部通过帧序列数组驱动:
// 猫 idle 状态的帧序列片段
static const uint8_t SEQ[] = {
0,0,0,3,0,1,0,2,0, 7,8,7,8,7, 0,5,0,6,0, 4,4,0, ...
};
uint8_t beat = (t / 5) % sizeof(SEQ);
buddyPrintSprite(P[SEQ[beat]], 5, 0, 0xC2A6);
(t/5) 将 200ms tick 映射为 1 秒节拍。帧索引通过重复次数赋权重------REST(索引 0)在数组中占比最高形成常态,特殊动作如伸懒腰、眨眼只出现 1-2 次。整个动画系统没有关键帧插值,纯粹由字节数组数据驱动,修改动画只需改数组。
脏标记优化
buddy.cpp 的 buddyTick() 在状态、物种、tick 三者均未变化时跳过整个渲染流程------避免 150KB 帧缓冲的擦除与 SPI DMA 重推。宠物处于 idle 状态时,连续数十秒零渲染开销。
GIF 模式
GIF 角色存放在 /characters/{name}/ 下,通过 manifest.json 定义各状态对应的 GIF 文件列表。单状态可配置多个 GIF,characterTick() 单文件循环播放,多文件则轮换变体(切换间隔 ANIM_PAUSE_MS = 800ms)避免视觉疲劳。peekMode 通过隔行隔列子采样将 GIF 缩小到 1/4 尺寸,用于信息页面画中画渲染,无需额外 Sprite 缓冲。
成长系统:BLE 数据的持久化与统计
桌面端通过 BLE 推送的 JSON 包含累积 token 数、审批事件等数据,K10 端在 stats.h 中使用混合策略持久化。
低频事件(审批/拒绝)立即写 NVS Flash。高频事件(token 累积,每 5 秒心跳)只在跨越等级边界时写入------token 在 RAM 中累加,每 50K 升一级才触发 NVS 写入。最坏情况断电丢失 ≤50K token 进度。
桌面端发送的是脚本自启动以来的累积值,设备重启后基准归零但桌面值未变,直接计算会重复计。stats.h 用 _tokensSynced 标志实现首次看到锁存------第一次收到数据只记录基准不计算,第二次开始才做增量。若检测到 bridgeTotal < _lastBridgeTokens(桌面端重启),自动重置基准。
宠物心情(MOOD)基于审批响应时间的中位数而非平均值计算。statsMedianVelocity() 维护一个 8 元素环缓冲区,用插入排序(n≤8 时比快排高效)求中位数。中位数对离群值天然鲁棒------用户中途离开桌面 300 秒不会拖偏整体情绪。statsMoodTier() 在中位数基础上叠加拒绝率惩罚:拒绝 > 50% 扣 2 级,> 33% 扣 1 级。
I2S 音频与功放时序
beep() 函数使用 ESP-IDF 原生 I2S 驱动,16kHz 采样率生成方波:
int periodSamples = 16000 / freq;
buf[i] = ((written + i) % periodSamples < periodSamples / 2) ? 6000 : -6000;
幅值 6000 约为 I2S 满幅度(±32768)的 18%。功放使能采用先关后开的时序------PIN_AMP_GAIN 先 LOW 10ms 放电,再 HIGH 30ms 等待功放稳定,最后 i2s_channel_enable 输出音频。不同事件用不同频率:审批提醒 1200Hz/80ms,确认 2400Hz/30ms,拒绝 600Hz/60ms,均在代码各分支中硬编码。
时钟模式下的自主行为
当设备处于时钟模式(无工作会话、USB 连接、RTC 有效、BLE 未连接),宠物不再被动等桌面端 JSON,而由本地逻辑驱动:
if (clocking && !bleConnected()) {
int h = _clkTm.tm_hour;
if (h >= 1 && h < 7) activeState = P_SLEEP;
else if (weekend) activeState = P_HEART/P_SLEEP轮换;
else if (friday && h >= 15) activeState = P_CELEBRATE/P_IDLE轮换;
...
}
三个维度决定行为:小时(作息)、星期几(工作日/周末)、特定时段(周五下午、午饭)。每个时段内用 millis()/N % M 引入时间分片轮换,让宠物在没有桌面信号时呈现自主行为。
结语
从一个终端里的 ASCII 彩蛋,到桌面上有物理屏幕、BLE 通信、音频反馈的实体伴侣,这个项目覆盖了 BLE 应用开发的完整链路------NUS 服务配置、安全配对、MTU 协商、分块文件传输、心跳保活。动画侧则展示了函数指针表驱动的多物种系统、字节数组驱动的帧序列、脏标记渲染优化等嵌入式图形常用模式。
笔者已经将项目分享到:https://gitee.com/pdtopdog/k10_claude_desktop_buddy