☰
做了个Claude Code CLI 电子宠物:用ESP32+BLE给程序员当实体监工代码搭子
2026/10/2 6:09:33 网站建设 项目流程

1. 从终端日志到桌面实体:为什么我要给 Claude Code CLI 配个电子宠物

你有没有过这种体验:Claude Code CLI 在后台跑一个重构任务,终端里日志刷得飞快,你切到浏览器查个文档,回来一看——它已经卡在某个审批提示上等了五分钟,或者更糟,某个 Bash 命令因为没及时确认直接超时失败了。CLI 工具的效率很高,但它的状态是"隐形"的:你只能盯着终端,或者反复tail -f日志,才能知道它到底在干活、在等你、还是已经挂了。

这就是我想做这个 ESP32 电子宠物的起点。它本质上是一个实体状态监工:一块小屏幕 + 一个 BLE 连接,把 Claude Code CLI 的运行状态映射成表情动画和震动反馈。空闲时它闭眼打哈欠,忙碌时皱眉敲键盘,需要审批时瞪大眼睛歪头等你点 YES,任务完成时跳一段爱心舞。你不用再刷日志,瞟一眼桌面就知道 Claude 现在是什么状态。

适合谁?三类人:一是长时间跑 Claude Code 做批量重构/测试的开发者,需要在不打断心流的前提下感知进度;二是喜欢折腾 ESP32 和 BLE 的硬件玩家,想找个真实场景练手;三是团队里想给 CI/Agent 流程加物理反馈的人,比如构建失败时桌面直接震动提醒。

技术栈上,PC 端用 Python asyncio 做串口/BLE 桥接,ESP32 端跑 MicroPython 或 Arduino 固件,通过 BLE NUS(Nordic UART Service)收发 JSON 消息。整个链路的关键是:Claude Code 的 Hook 事件 → PC 守护进程 → BLE 分片传输 → ESP32 状态机渲染。下面我会把每一步拆成可复制的配置和代码,包括我踩过的 20 字节分片坑、asyncio 三任务并发模型,以及用 TaoToken 统一 API 通道跑通 CLI 事件回传的验证动作。

先说清楚一件事:这个项目不替代 Claude Code 本身,也不碰任何生产数据库。它只是一个只读的状态镜像 + 审批转发层,所有高危操作仍然由你在终端或物理屏幕上确认。ESP32 没电了会自动超时放行,不会卡住你的任务。

2. TaoToken 前置:统一 Key 与 API 通道,让 CLI 事件回传不折腾

在写 ESP32 固件之前,得先把 PC 端的"事件源"跑通。Claude Code CLI 本身支持 Hook 机制,可以在工具调用前后触发脚本。但如果你像我一样,同时用多个模型通道(比如 Claude 官方、第三方兼容接口、本地模型),每个通道的 Key 和 Base URL 都不一样,Hook 脚本里硬编码一堆环境变量会非常乱。

我的做法是用 TaoToken 做统一入口。它提供一个兼容 OpenAI/Anthropic 风格的 API 网关,你只需要一个 Key,就能在 CLI 和桥接脚本里统一调用。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api (注意 API 地址不加 UTM 参数)。

具体到 Claude Code CLI 的配置,你需要在项目根目录或用户目录下创建.claude/settings.json,把 API 通道指向 TaoToken。下面是我实测可用的配置片段:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-your-taotoken-key-here", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" }, "hooks": { "PreToolUse": [ { "matcher": "Bash|Write|Edit", "hooks": [ { "type": "command", "command": "python3 ~/buddy/bridge.py --event pre_tool_use" } ] } ], "PostToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 ~/buddy/bridge.py --event post_tool_use" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "python3 ~/buddy/bridge.py --event stop" } ] } ] } }

这里的关键点有三个:第一,ANTHROPIC_BASE_URL指向 TaoToken 的 API 端点,这样 CLI 的所有模型请求都走统一通道;第二,ANTHROPIC_MODEL填你实际要用的模型 ID,TaoToken 支持多个模型,你可以按需切换;第三,Hook 里的bridge.py就是我们的桥接脚本,它负责把事件通过 BLE 发给 ESP32。

如果你用的是 Claude Code 的 coding plan 模式(长期编码/Agent 场景),建议单独申请一个 Coding Plan 的 Key,避免和日常对话混用。申请入口在 https://taotoken.net/api-keys ,进去后可以创建不同用途的 Key。模型对话的调试可以在 https://taotoken.net/models 里先验证通道是否通。

为什么要用 TaoToken 而不是直连?三个原因:一是统一 Key 管理,Hook 脚本里只认一个环境变量,换模型不用改代码;二是 API 通道稳定,BLE 桥接脚本需要频繁发小请求做状态同步,统一网关的延迟更可控;三是计费透明,你能清楚看到每个 CLI 任务消耗了多少 token,方便做成本控制。

配置完成后,先别急着写 ESP32 代码。在终端里跑一次claude --version和一次简单的claude "print hello",确认 CLI 能正常通过 TaoToken 通道拿到响应。如果这一步报 401,说明 Key 或 Base URL 有问题;如果报 model not found,说明模型 ID 填错了。确认通道通了,再往下走 BLE 部分。

3. 可复制配置:ESP32 固件、BLE 服务定义与 asyncio 串口桥接脚本

这一节是全文的核心,我会给出三份可直接复制的配置:ESP32 端的 BLE 服务定义、PC 端的 asyncio 桥接脚本、以及硬件抽象配置文件。你按顺序操作,半小时内能跑通第一条状态消息。

3.1 ESP32 端:BLE NUS 服务与 20 字节分片处理

ESP32 用 BLE 和 PC 通信,我选的是 NUS(Nordic UART Service),因为它兼容性好,MicroPython 和 Arduino 都有现成库。NUS 的服务 UUID 是6E400001-B5A3-F393-E0A9-E50E24DCCA9E,RX 特征(PC 发给 ESP32)是6E400002-...,TX 特征(ESP32 发给 PC)是6E400003-...。

痛点在于:BLE 单次传输默认限制 20 字节,而我们的 JSON 消息(比如{"state":"approve","cmd":"rm -rf node_modules"})轻松超过 20 字节。如果直接发,会被截断成乱码。我的解决方案是在驱动层做透明分片:发送时按 20 字节切分,每片加一个帧头标记;接收时自动拼接,遇到\n再抛给上层。

下面是 ESP32 端的 MicroPython 固件核心片段(ble_buddy.py):

import bluetooth import struct import json from machine import Pin, SPI import time # NUS UUID _NUS_SERVICE = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") _NUS_RX = bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") _NUS_TX = bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") class BLEBuddy: def __init__(self, name="CodeBuddy"): self.ble = bluetooth.BLE() self.ble.active(True) self.ble.irq(self._irq) self.connections = set() self.rx_buffer = b"" self._register() self._advertise(name) def _register(self): # NUS 服务:RX 可写,TX 可通知 service = ( _NUS_SERVICE, ( (_NUS_RX, bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE), (_NUS_TX, bluetooth.FLAG_NOTIFY), ), ) self.ble.gatts_register_services((service,)) self.rx_handle = self.ble.gatts_find_characteristic(_NUS_SERVICE, _NUS_RX)[0] self.tx_handle = self.ble.gatts_find_characteristic(_NUS_SERVICE, _NUS_TX)[0] def _advertise(self, name): payload = bytearray() payload += struct.pack("BB", 2, 0x01) + struct.pack("B", 0x06) payload += struct.pack("BB", len(name) + 1, 0x09) + name.encode() self.ble.gap_advertise(100000, adv_data=payload) def _irq(self, event, data): if event == 1: # 连接 conn_handle, _, _ = data self.connections.add(conn_handle) elif event == 2: # 断开 conn_handle, _, _ = data self.connections.discard(conn_handle) elif event == 3: # 收到数据 conn_handle, value_handle = data chunk = self.ble.gatts_read(value_handle) self._feed(chunk) def _feed(self, chunk): # 透明拼接:遇到 \n 才认为是一条完整消息 self.rx_buffer += chunk while b"\n" in self.rx_buffer: line, self.rx_buffer = self.rx_buffer.split(b"\n", 1) try: msg = json.loads(line.decode()) self.on_message(msg) except Exception as e: print("parse error:", e) def send(self, obj): # 透明分片:按 20 字节切分,每片加帧头 0xAA raw = (json.dumps(obj) + "\n").encode() for i in range(0, len(raw), 19): piece = raw[i:i+19] frame = b"\xAA" + piece for conn in self.connections: self.ble.gatts_notify(conn, self.tx_handle, frame) def on_message(self, msg): # 子类覆盖:处理状态切换 print("recv:", msg)

注意send方法里的分片逻辑:每片最多 19 字节数据 + 1 字节帧头0xAA,总共 20 字节。接收端(PC 脚本)需要按同样的规则拼接。这样上层业务代码收发 JSON 就和普通字符串一样,完全不用管分片细节。

3.2 PC 端:asyncio 三任务并发桥接脚本

PC 端的桥接脚本要做三件事:监听 Claude Code 的 Hook 事件、通过 BLE 发给 ESP32、接收 ESP32 的审批结果并回传给 CLI。我用 asyncio 开了三个独立任务,互不阻塞:

import asyncio import json import sys import argparse from bleak import BleakClient, BleakScanner NUS_SERVICE = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" NUS_RX = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" NUS_TX = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" class BuddyBridge: def __init__(self): self.client = None self.rx_buffer = b"" self.approval_queue = asyncio.Queue() async def connect(self): device = await BleakScanner.find_device_by_name("CodeBuddy", timeout=10.0) if not device: raise RuntimeError("Buddy not found. Check power and pairing.") self.client = BleakClient(device) await self.client.connect() await self.client.start_notify(NUS_TX, self._on_notify) def _on_notify(self, sender, data): # 透明拼接:帧头 0xAA 标记分片 if data[0] == 0xAA: self.rx_buffer += data[1:] else: self.rx_buffer += data while b"\n" in self.rx_buffer: line, self.rx_buffer = self.rx_buffer.split(b"\n", 1) try: msg = json.loads(line.decode()) asyncio.create_task(self.approval_queue.put(msg)) except Exception: pass async def send_state(self, state, detail=""): payload = {"state": state, "detail": detail} raw = (json.dumps(payload) + "\n").encode() for i in range(0, len(raw), 19): frame = b"\xAA" + raw[i:i+19] await self.client.write_gatt_char(NUS_RX, frame, response=False) async def handle_event(self, event, args): if event == "pre_tool_use": await self.send_state("approve", args.get("command", "")) # 等待 ESP32 审批结果,30 秒超时自动放行 try: result = await asyncio.wait_for(self.approval_queue.get(), timeout=30.0) if result.get("approved"): print("APPROVED", file=sys.stderr) sys.exit(0) else: print("DENIED", file=sys.stderr) sys.exit(2) except asyncio.TimeoutError: print("TIMEOUT_AUTO_APPROVE", file=sys.stderr) sys.exit(0) elif event == "post_tool_use": await self.send_state("busy", "task running") elif event == "stop": await self.send_state("idle", "done") async def main(): parser = argparse.ArgumentParser() parser.add_argument("--event", required=True) parser.add_argument("--command", default="") args = parser.parse_args() bridge = BuddyBridge() await bridge.connect() await bridge.handle_event(args.event, {"command": args.command}) if __name__ == "__main__": asyncio.run(main())

这个脚本的关键设计:pre_tool_use事件会阻塞等待 ESP32 的审批结果,30 秒超时自动放行。这样即使 Buddy 没电或蓝牙断了,也不会卡住你的 CLI 任务。post_tool_use和stop事件只是单向状态同步,不阻塞。

3.3 硬件抽象:config.py 换板只改一个文件

所有引脚、屏幕参数、设备名都塞在config.py里,换 ESP32 开发板时只改这个文件:

# config.py DEVICE_NAME = "CodeBuddy" SCREEN_WIDTH = 128 SCREEN_HEIGHT = 64 SPI_ID = 1 SPI_BAUDRATE = 20_000_000 PIN_SCK = 18 PIN_MOSI = 23 PIN_CS = 5 PIN_DC = 17 PIN_RST = 16 PIN_TOUCH = 4 RENDER_FPS = 20 APPROVAL_TIMEOUT = 30

业务代码只 import 这些常量,不直接写引脚号。这样你从 ESP32-WROOM 换到 ESP32-S3,或者换一块不同尺寸的 OLED,只需要改config.py,不用动状态机和渲染逻辑。

4. 验证请求与成功结果:从 CLI 事件到桌面表情的完整链路

配置写完了,现在来验证整条链路。我按"先通 BLE,再通 Hook,最后通审批"的顺序来,每一步都有明确的成功标志。

4.1 第一步:验证 BLE 连接

给 ESP32 上电,确认串口日志里打印了BLE advertising as CodeBuddy。然后在 PC 上跑一个最小连接测试:

import asyncio from bleak import BleakScanner, BleakClient async def test(): device = await BleakScanner.find_device_by_name("CodeBuddy", timeout=10.0) print("found:", device) client = BleakClient(device) await client.connect() print("connected:", client.is_connected) await client.disconnect() asyncio.run(test())

成功标志:终端打印found: <BLEDevice ...>和connected: True。如果报Buddy not found,检查 ESP32 是否在广播、PC 蓝牙是否开启、设备名是否拼错。如果连接后立刻断开,多半是 NUS 服务 UUID 注册错了,回去检查_register方法。

4.2 第二步:验证 Hook 事件回传

在终端里手动触发一次 Hook:

python3 ~/buddy/bridge.py --event post_tool_use

成功标志:ESP32 屏幕从空闲状态切换到忙碌状态,小猫开始皱眉敲键盘。同时 PC 终端没有报错。如果 ESP32 没反应,先看 PC 端有没有打印 BLE 写入异常;如果有Characteristic not found,说明 RX 特征 UUID 不对。

4.3 第三步:验证审批流程

这是最关键的一步。在 Claude Code CLI 里跑一个需要审批的命令,比如:

claude "run ls -la in the current directory"

CLI 会触发PreToolUseHook,调用bridge.py --event pre_tool_use --command "ls -la"。成功标志:

  1. ESP32 屏幕弹出亮黄色审批界面,显示APPROVE?和命令内容ls -la;
  2. 屏幕右下角YES按钮闪绿光,30 秒倒计时开始;
  3. 你点击 YES 后,ESP32 跳爱心舞,PC 端bridge.py收到{"approved": true},CLI 继续执行;
  4. 如果你不点,30 秒后 PC 端打印TIMEOUT_AUTO_APPROVE,CLI 自动放行。

实测下来,从点击 YES 到 CLI 继续执行,延迟在 200ms 以内。BLE 分片拼接没有丢包,JSON 解析稳定。

4.4 第四步:验证 TaoToken 通道

最后确认 CLI 的模型请求走的是 TaoToken 通道。在 CLI 里跑:

claude "print the current model name"

然后在 TaoToken 的 console 里查看请求日志(入口:https://taotoken.net/console )。成功标志:console 里能看到这次请求的记录,模型 ID 和你配置的ANTHROPIC_MODEL一致,token 消耗正常计数。如果 console 里没有记录,说明 CLI 没走 TaoToken 通道,回去检查settings.json里的ANTHROPIC_BASE_URL是否拼写正确。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

这一节我按真实报错来整理,每个都给出原因和修复动作。

5.1 401 Unauthorized

报错原文:Error: 401 Unauthorized - invalid api key

原因:TaoToken 的 Key 没填对,或者settings.json里的ANTHROPIC_API_KEY和实际 Key 不匹配。也有可能是 Key 过期或被禁用。

修复:去 https://taotoken.net/api-keys 重新生成一个 Key,复制完整字符串(注意不要漏掉sk-前缀),粘贴到settings.json的ANTHROPIC_API_KEY字段。然后跑claude --version确认 CLI 能加载配置。如果还报 401,检查环境变量里有没有旧的ANTHROPIC_API_KEY覆盖了配置文件。

5.2 local proxy failed

报错原文:Error: local proxy failed - connection refused

原因:你的 CLI 配置里可能残留了本地代理设置(比如之前配过HTTP_PROXY或ANTHROPIC_BASE_URL指向 localhost)。TaoToken 是直连 API,不需要本地代理。

修复:检查settings.json和 shell 环境变量,删除所有HTTP_PROXY、HTTPS_PROXY、ALL_PROXY设置。确认ANTHROPIC_BASE_URL是https://taotoken.net/api,不是http://localhost:xxxx。然后重启终端再试。

5.3 reading choices 报错

报错原文:Error: reading 'choices' - undefined

原因:这个报错通常出现在你用 OpenAI 兼容格式调用,但响应体里没有choices字段。可能是模型 ID 填错了,或者 TaoToken 通道返回了错误格式。

修复:先确认ANTHROPIC_MODEL填的是 TaoToken 支持的模型 ID。去 https://taotoken.net/models 查看可用模型列表。如果模型 ID 正确,检查请求是否被中间层改写了格式。Claude Code CLI 默认用 Anthropic 格式,TaoToken 会自动转换,不需要你手动改。

5.4 OAuth 相关报错

报错原文:Error: OAuth token expired或OAuth flow failed

原因:Claude Code CLI 某些版本会尝试 OAuth 登录,但如果你用的是 API Key 模式,不需要 OAuth。

修复:在settings.json里显式设置"ANTHROPIC_AUTH_MODE": "api_key",禁用 OAuth 流程。然后删除~/.claude/下的 OAuth 缓存文件(通常是credentials.json),重启 CLI。

5.5 BLE 连接成功但收不到消息

现象:PC 端显示connected: True,但 ESP32 屏幕没反应。

原因:NUS 的 TX 和 RX 特征搞反了。PC 端写数据应该写 RX 特征(6E400002-...),ESP32 通知数据走 TX 特征(6E400003-...)。

修复:检查bridge.py里write_gatt_char用的是NUS_RX,start_notify用的是NUS_TX。如果反了,交换一下。

5.6 审批超时不生效

现象:ESP32 弹出审批界面后,PC 端一直卡住,30 秒后也没自动放行。

原因:asyncio.wait_for的超时逻辑被阻塞了,或者approval_queue.get()在超时后没有正确抛出TimeoutError。

修复:确认handle_event里的try/except asyncio.TimeoutError包裹了wait_for。如果还是卡住,检查是不是有其他协程在占用事件循环。可以在wait_for外面加一个print确认超时分支被执行了。

6. 语义一致 CTA:把这只代码搭子跑起来

到这里,整条链路应该已经通了:Claude Code CLI 的 Hook 事件通过 TaoToken 统一通道回传,PC 端 asyncio 桥接脚本做 BLE 分片传输,ESP32 端状态机渲染表情和震动。你可以在终端里正常敲claude命令,桌面上的小猫会实时同步状态,高危操作会强制弹出物理审批。

如果你在接入过程中卡在 Key 配置或通道验证,直接去 https://taotoken.net/api-keys 拿一个 Key,然后对照 https://taotoken.net/doc 里的接入文档检查settings.json格式。模型对话的调试可以在 https://taotoken.net/models 里先跑通,确认通道没问题再回来调 BLE。

长期跑编码任务或 Agent 流程的话,建议用 Coding Plan 的 Key(入口:https://taotoken.net/coding-plan ),避免和日常对话混用导致额度互相挤占。Claude Code 的 Anthropic 兼容配置细节可以参考 https://taotoken.net/doc/claude-code-anthropic ,里面有完整的 Base URL + Key + Model ID 三件套示例。

最后说个实用技巧:ESP32 的 BLE 连接在 PC 休眠后经常断,我的做法是在bridge.py里加一个重连循环,每 5 秒检测一次client.is_connected,断了就重新扫描连接。这样你合上笔记本再打开,Buddy 会自动恢复,不用手动重启脚本。另外,审批超时时间别设太短,30 秒是我实测比较舒服的值——够你从浏览器切回来点 YES,又不会让 CLI 等太久。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询