把 Claude 的愚人节彩蛋跑在 行空板K10上:BLE 应用与 ASCII 宠物动画实战

本文阐述《行空板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 支持两种角色渲染方式:

  1. ASCII 像素动画(buddy.cpp + 18 个物种文件):用等宽字符在屏幕上绘制像素级宠物,每个字符 6×8 像素,6px 字符宽度在 240 宽屏幕上最多排 40 字符------对宠物绘制绰绰有余
  2. 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

相关推荐
春风有信1 小时前
【DM】DDPM与DDIM的数学原理
人工智能·深度学习·机器学习
ShareCreators1 小时前
洞见 | 数字化
人工智能·汽车·blueberry
财迅通Ai1 小时前
百通能源:2026年一季度营收稳步增长,资产结构持续优化
大数据·人工智能·能源·百通能源
风落无尘1 小时前
第二章《概率与生存》完整学习资料
人工智能·矩阵·概率论
迪娜学姐1 小时前
ChatGPT image 2 科研绘图实测分享
人工智能·chatgpt
千匠网络1 小时前
数智全链赋能,千匠网络钢铁能源供应链平台解决方案
大数据·人工智能
小超同学你好1 小时前
论文精读:《Indirect Prompt Injection》—— 当AI助手成为别人的“提线木偶“
人工智能·prompt
liulilittle1 小时前
OpenCode AI 代理配置(基本)
自动化
wuxinyan1231 小时前
大模型学习之路03:提示工程从入门到精通(第三篇)
人工智能·python·学习