ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESP32-S3与电子墨水屏打造Claude API用量监控仪表

ESP32-S3与电子墨水屏打造Claude API用量监控仪表

很多人第一次看到“Claude Meter with XIAO ePaper DIY Kit (ESP32-S3) EE05”这个项目名,第一反应是:拿一块电子墨水屏给Claude API做个体检仪表?这名字听着就很有意思。说白了这个项目就是用Seeed家的XIAO ESP32-S3小板子,加上配套的墨水屏扩展板,做一个能显示Claude API调用量、Token消耗、费用情况的桌面小仪表。接上WiFi之后自动拉数据,时间、用量、配额、状态一目了然。这块屏幕最吸引人的地方是静态显示不耗电,放在桌面上像一张随时更新的印刷小报。适合三类人来玩:天天调Claude API想盯着配额花销的开发者,想练手ESP32+墨水屏的嵌入式新手,还有单纯想给桌面添一个极客小玩具的DIY爱好者。

我最初是被墨水屏那种纸质般的显示效果吸引的,但真正做完后发现,这个项目的价值远不止是“有个屏显示数字”。它背后涉及物联网设备如何对接云服务API、如何在低功耗设备上做网络请求、怎么用极少的资源完成JSON解析和排版渲染,以及墨水屏这种特殊显示器件驱动的坑。这篇文章我会把从零开始到跑通全流程的经验全部写出来,尤其是那些文档里不会写在明面上的细节。

1. 项目轮廓与方案选型

1.1 这是一个什么样的设备

简单说,这是一个桌面级API监控仪表。取名“Meter”很贴切,因为它干的就是仪表的事:采集数据、量化展示。Claude API的使用计量通常包括请求次数、输入Token数、输出Token数、计费金额、当前配额或者剩余额度。这些数据从官方提供的用量查询接口拿,经过ESP32处理后渲染到墨水屏上。

这种设备解决的核心痛点是:API费用是隐性消耗,月底账单出来才发现超支。传统做法是打开网页看仪表盘,或者写脚本定时查一下。但网页不会一直开着,脚本结果也只活在终端里。把它们固化到一块常驻桌面的低功耗屏幕上是这个方案最直接的价值。早上扫一眼屏幕,剩余配额清不清楚心里有数,这种获取信息的体验是完全不同层级的。

做这类设备的思路其实跟做一个智能家居中控面板高度一致:设备联网、拉取数据、本地解析、界面渲染、定时刷新。把这套逻辑理清楚了,以后做任何信息展示类设备都是同一套方法论,换个数据源就能复用。

1.2 为什么是XIAO ESP32-S3而不是其他板子

选型是这个项目最关键的前置决策。我对比过几套方案:Arduino Uno+串口屏模块、树莓派Zero+HDMI小屏、ESP32全家桶。Unio性能太弱也没WiFi,外接模块连线乱到怀疑人生;树莓派Zero一台的价格够买三套这套方案,而且开机速度和功耗都不是一个量级。最终锁定XIAO ESP32-S3是有硬理由的。

XIAO ESP32-S3这块板子是Seeed XIAO系列里的主力型号。尺寸只有拇指大小,却集成了双核240MHz的Xtensa LX7处理器、WiFi 4和BLE 5.0、2.4GHz射频,还有8MB Flash和2MB PSRAM。关键是它直接集成了锂电池充放电管理电路,可以边充边用,也可以电池供电脱离USB线。配合它的墨水屏扩展板,规格是2.13英寸,250x122分辨率,三色显示(黑、白、红),通过SPI接口驱动。这套组合完完全全就是为桌面信息仪表这种场景设计的,所有模块通过排针直插连接,不需要面包板和杜邦线。

还有个容易被忽略的点:这款扩展板在PCB上留了RTC槽位,可以插一个RTC芯片给设备提供断电后的时间保持能力。考虑到仪表设备要显示时间戳,这个设计很实用。

1.3 整体工作流程

设备的工作流程可以用一个闭环来概括:上电初始化 -> 连接WiFi -> 请求Claude API用量数据 -> 解析JSON -> 排版渲染到墨水屏 -> 进入低功耗状态等待下一个刷新周期。

这里面有一个容易轻视但实际很关键的环节:墨水屏的刷新机制。它和LCD、OLED有本质不同,墨水屏刷新时需要把整块屏幕置黑、置白、再点墨,这个过程在视觉上是明显的闪烁,而且一次完整的刷新要耗时一两秒。刷新次数也有寿命限制,通常在十万次量级。这对软件设计提出了要求:数据没变化时不要频繁刷新,能局部更新就不要全刷。我自己在项目里加了脏数据检查机制,Token数值和上次一致直接跳过刷新,把屏幕寿命和功耗都控制住了。

2. 硬件物料与接线要点

2.1 物料清单

这套DIY Kit的物料非常收敛,来回收纳就七个部件:

物料型号/规格作用
主控板XIAO ESP32-S3主控芯片、WiFi通信、数据处理
墨水屏模块2.13英寸三色墨水屏信息展示
扩展板XIAO ePaper ESP32-S3扩展板承载屏幕、提供电路连接、预留RTC槽位
锂电池3.7V聚合物电池无线供电
USB线Type-C数据线烧录固件与调试
外壳透明亚克力夹板固定与保护
RTC芯片(选配)常见I2C接口RTC模块断电时间保持

如果购买的是完整Kit,核心部件到手即插即用,不需要额外焊接,对新手极度友好。不过要注意一点:从排针焊接到扩展板这个动作还是绕不开的,包装里带的排针需要自己动手焊上去。没有焊接经验的朋友建议先找块废板练手,XIAO的焊盘间距小,排针固定不牢容易虚焊。我第一次焊这种小而密的焊盘就踩了坑,教训是焊台温度不要超过350℃,单点焊接时间控制在2到3秒,焊完用放大镜检查每个焊点是否有润湿饱满。

2.2 接线与电平匹配

XIAO ESP32-S3和墨水屏扩展板是通过排母直插的方式连接的。只要方向对准插到底就行,不存在交叉接错的可能,这一步比面包板方案省心太多。整套系统运行在3.3V逻辑电平上,ESP32-S3的GPIO输出就是3.3V,和墨水屏控制器的逻辑电平完全兼容,不需要额外做电平转换。

如果你的RTC模块是自己外接的独立模块,需要注意SDA、SCL接到扩展板上对应引脚。XIAO ESP32-S3默认的I2C引脚是GPIO41(SDA)和GPIO42(SCL),这一点跟很多ESP32老型号不一样,一开始我按惯用习惯接GPIO21、22搞了半天检测不到设备,看原理图才发现问题。遇到设备扫描不到的情况,第一步永远是用I2C扫描工具确认引脚定义。

2.3 上电检查顺序

插好屏幕、接好电池、连上USB之后,不要急着写代码。先做一轮硬件自检,顺序如下:先看板载RGB指示灯是否闪烁,说明主控正常运行;再用I2C扫描确认RTC(如果有)是否在总线上响应;最后检查墨水屏在Arduino示例代码下能否完成一次完整的初始化刷新。这个过程花费五分钟,能省下后面好几个小时的排障时间。硬件链路如果没通,软件写再多也是白搭,这是嵌入式开发里永恒的第一性原理。

3. 开发环境与固件实现

3.1 Arduino环境搭建

这个项目的核心代码用Arduino框架写是最快的。虽然ESP-IDF功能更底层、可控性更强,但对于这种规模的应用,Arduino的库生态能省掉大量造轮子的时间。我用的是Arduino IDE 2.x版本,安装过程分三步。

先在“开发板管理器”里添加ESP32的板级支持包。打开偏好设置,在“附加开发板管理器网址”中填入官方ESP32索引地址,然后在开发板管理器里搜索esp32并安装。这一步网络状况不稳定时容易中断,安装完成后最好核对一下版本号,我建议选择3.x版本,后续的库兼容性更省心。安装完成之后在开发板列表里选XIAO ESP32-S3,注意对应关系,不要误选成其他ESP32-S3变体导致引脚定义错位。

然后是安装项目依赖的库。在库管理器里分别搜索安装以下三个库:GxEPD2,这是目前最好用的墨水屏驱动库,支持大量主流型号;ArduinoJson,用来解析API返回的JSON数据;U8g2_for_Adafruit_GFX,这是给墨水屏提供高质量字体渲染的适配库。这三个库装好,开发环境的底座就齐了。

3.2 核心代码逻辑拆解

代码层面核心就干四件事:联网、拉取数据、解析、显示。我把关键代码的骨架写出来,你可以基于这个框架继续扩展。

#include <WiFi.h> #include <HTTPClient.h> #include <ArduinoJson.h> #include <GxEPD2_BW.h> #include <U8g2_for_Adafruit_GFX.h> // WiFi凭据 const char* ssid = "your_ssid"; const char* password = "your_password"; // API相关参数 const char* apiKey = "your_claude_api_key"; const char* apiUrl = "your_usage_endpoint"; // 墨水屏对象实例化,2.13英寸三色屏 GxEPD2_213_ZS_GDEY021Z98_B72 display(GxEPD2_213_ZS_GDEY021Z98_B72::EPD_SIZE_213_ZS); U8g2_for_Adafruit_GFX u8g2; // 数据缓存 float inputTokens = 0; float outputTokens = 0; int requestCount = 0; float totalCost = 0; void setup() { Serial.begin(115200); initDisplay(); connectWiFi(); fetchUsageData(); renderDisplay(); enterSleep(); } void loop() { // 定时唤醒逻辑,通过深度睡眠定时器触发 }

这里有个关键点:loop()里几乎不做事情,而是进深度睡眠。设备的刷新周期按小时算,几十分钟甚至几小时唤醒一次,每次唤醒做完工作继续睡。这是墨水屏仪表和普通智能屏最大的区别,功耗量级完全不同。

3.3 联网与数据获取模块

WiFi连接这块有一个值得优化的细节。直接用WiFi.begin()后加delay()等待连接是最常见的新手写法,但可靠性差。我用的是带超时的轮询机制:

void connectWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 30) { delay(500); attempts++; } if (WiFi.status() == WL_CONNECTED) { Serial.println("WiFi connected, IP: " + WiFi.localIP().toString()); } else { // 失败后直接显示错误信息,而不是硬等 displayError("WiFi Failed"); } }

超时设计为15秒,30次每次半秒。这个值是经验值:太短在弱信号下容易误报,太长又等于把设备拖在阻塞状态里耗电。实测在正常家庭WiFi环境下,3到5秒内基本完成连接。

然后是HTTP请求。Claude的用量数据通过HTTPS接口返回,ESP32-S3需要先加载CA证书,否则TLS握手会失败。我把证书以字符串形式直接编进固件里,避免依赖文件系统加载。请求头里加上认证信息,用GET方式拿到JSON响应。这里给一个完整的最小请求片段:

void fetchUsageData() { HTTPClient http; http.begin(apiUrl); http.setTimeout(10000); http.addHeader("Authorization", String("Bearer ") + apiKey); http.addHeader("Content-Type", "application/json"); int httpCode = http.GET(); if (httpCode > 0) { if (httpCode == HTTP_CODE_OK) { String payload = http.getString(); parseResponse(payload); } else { Serial.printf("HTTP error code: %d\n", httpCode); } } else { Serial.printf("HTTP request failed: %s\n", http.errorToString(httpCode).c_str()); } http.end(); }

这里的细节是http.setTimeout(10000)。默认超时值偏短,在弱网环境下数据包稍大一点就会读取超时,导致整轮刷新失败。设为10秒比较均衡。另外http.end()必须执行,否则会占用连接资源,下次请求可能报错。

3.4 JSON解析与关键字段提取

Claude API用量接口返回的JSON结构相对复杂,嵌套层级深,字段多。用ArduinoJson库解析时,一个常见误区是先Serial.print()看原始响应再设计解析代码。省时间的方法是直接用一个在线的JSON格式化工具把样例数据规整化,然后对照写解析逻辑。

void parseResponse(String payload) { JsonDocument doc; DeserializationError error = deserializeJson(doc, payload); if (error) { Serial.print(F("JSON parse failed: ")); Serial.println(error.c_str()); displayError("JSON Error"); return; } inputTokens = doc["data"]["usage"]["inputTokens"] | 0.0f; outputTokens = doc["data"]["usage"]["outputTokens"] | 0.0f; requestCount = doc["data"]["meta"]["requestCount"] | 0; totalCost = doc["data"]["billing"]["estimatedCost"] | 0.0f; }

使用|运算符设置默认值是防御性编程的好习惯,API返回结构有调整或者字段缺失时,程序不会因为读取空值而崩溃。另外ArduinoJson 7.x版本默认的JsonDocument会动态分配内存,实际使用时优先用JsonDocument而不用旧的StaticJsonDocument,省去了手动估算缓冲区大小的麻烦。不过要注意,解析超大响应时内存占用会升高,ESP32-S3的PSRAM在这里发挥了作用。

3.5 显示排版与刷新控制

墨水屏的显示逻辑是整个项目中视觉体验的核心。2.13英寸三色屏的分辨率是250x122,空间有限,排版必须克制。我的版面设计是上下三区:顶部小字体显示当前时间和日期;中间大字号显示累计费用数字,作为整个仪表最有感知力的指标;底部两行小字分别显示输入Token数、输出Token数以及请求次数。

字体渲染用U8g2库,有非常丰富的字体资源。这里有个细节:同一尺寸下不同字体的实际显示宽度差异很大,排版前先在电脑上用U8g2的字体预览工具选中合适的字号,而不是在代码里一个个试。我最后选用了12像素高的无衬线字体用于详情,20像素高的粗体用于主费用数字,视觉层级非常清晰。

刷新控制方面,我实现了脏检测刷新:

void renderDisplay() { float lastCost = ...; // 从RTC或Flash读取上次的值 if (abs(totalCost - lastCost) < 0.001 && requestCount == lastCount) { Serial.println("No data change, skip refresh"); return; } display.setFullWindow(); display.firstPage(); do { drawHeader(); drawCost(); drawDetails(); } while (display.nextPage()); display.display(); saveLastValues(); }

数据未变化时直接跳过物理刷新,这能显著延长墨水屏寿命,也避免不必要的闪烁干扰视线。墨水屏不是LCD,每次刷新都有机械颗粒移动的物理过程,这个损耗是不可逆的。

4. 数据解析与展示优化

4.1 数值格式化的处理

Token计数和费用金额是浮点数,直接渲染到屏幕上会出现一长串小数,既不美观也超出屏幕宽度。换算和格式化是必须做的。我的处理方式是这样的:费用保留四位小数并带上货币符号;Token数分档处理,过千显示为k,过百万显示为M。举个例子,输入Token数是1534200,直接显示会撑爆版面,格式化为1.53M后又清晰又省空间。

String formatLargeNumber(float value) { if (value >= 1000000) { return String(value / 1000000, 2) + "M"; } else if (value >= 1000) { return String(value / 1000, 1) + "k"; } return String(value, 0); }

有个容易犯的错:ESP32的浮点数转字符串时,默认精度行为容易产生尾数误差,比如4.35被格式化为4.349999。处理方法是格式化时指定小数位数,不要依赖默认转换。

4.2 时间与时区问题

设备显示时间必须考虑时区问题。ESP32默认获取的是UTC时间,如果不加时区偏移,屏幕上的时间会和北京时间差8小时。最简单的方式是连接WiFi后通过NTP获取时间,然后设置正确的Time Zone偏移值。

configTime(8 * 3600, 0, "ntp.aliyun.com", "pool.ntp.org");

第一个参数是秒级时区偏移,中国时区是8小时即28800秒。如果你只是设置了configTime却没配置setenv("TZ", "CST-8", 1)和tzset(),部分时间函数仍然会返回UTC时间,这个坑很隐蔽。项目里务必三者配合使用。

4.3 低功耗与电池续航策略

墨水屏仪表的优势在低功耗,但低功耗不是白来的,需要软件配合。我的策略分三档:不休眠、轻度睡眠、深度睡眠。工作状态下设备消耗在80mA左右,深度睡眠则能掉到100uA量级。假如每天只唤醒几次,每次工作20秒,一颗300mAh电池撑上几周完全没有问题。

深度睡眠的唤醒方式我选择了定时器:

esp_sleep_enable_timer_wakeup(refreshIntervalSeconds * 1000000ULL); esp_deep_sleep_start();

注意esp_sleep_enable_timer_wakeup的单位是微秒,直接填秒会溢出导致唤醒时间完全不对。我第一次写的时候把60秒直接当微秒传进去,设备“沉睡”了17个小时才醒来,还以为是硬件出了问题。另外唤醒后建议先只初始化必要的外设和WiFi,等拿到数据初始化显示部分,不需要的外设保持关闭状态。省电的核心就是延迟初始化,用多少开多少。

5. 常见问题与排错手册

5.1 墨水屏显示异常

玩墨水屏最常见的问题就是首屏显示花屏、闪烁后内容消失。大多数情况是初始化时序不对导致的,墨水屏的控制器对复位脉冲宽度和等待时间有严格要求。我在代码里加入适当的延迟后问题就消失了。另一个高发场景是显示器只显示部分内容,这种情况几乎都是因为调用了setPartialWindow(),在部分刷新模式下绘制的区域坐标超出实际屏幕尺寸。新手阶段建议直接全屏刷新,稳定了再考虑局部刷新优化。

5.2 联网不稳定与请求超时

设备连不上WiFi、请求超时是第二大问题源。我的排查顺序是:先确认2.4G频段,ESP32不支持5G频段,如果你的路由器开了双频合一,建议给设备单独开一个2.4G访客网络;再检查信号强度,放在金属外壳或者封闭桌面柜里信号衰减严重;最后检查证书和API地址。

还有一个容易被忽略的点:如果路由器开启了AP隔离,设备虽然能连上WiFi,但无法与外部网络通信,表现就是一直连接成功但请求始终无响应。把AP隔离关掉或者给设备设置固定IP,问题迎刃而解。

5.3 API返回数据解析失败

解析失败的典型表现是屏幕显示“JSON Error”或者某个字段显示为0。原因有两类:一是API实际返回的结构与代码中假设的路径不一致,比如某个层级字段被包在data下面而你直接取顶层,我建议先在PC上把响应存成文件,格式化后核对层级再写代码;二是API接口有鉴权信息过期的情况,认证失败时返回的是错误JSON,和正常响应结构完全不同,代码中应该先判断HTTP状态码,对非200响应单独做错误处理。

5.4 一张问题速查表

现象直接原因处理建议
屏幕完全不亮排针方向反或者松动断电重新插拔确认方向
首屏花屏初始化时序过短增加复位后延迟到100ms以上
能画图但不变色三色屏驱动型号选错核对屏幕型号与GxEPD2构造函数
WiFi连不上路由器只开了5G开启2.4G网络
请求超时TLS握手失败检查证书或尝试关闭TLS验证
电池一天就没电刷新过于频繁或未进休眠检查refreshInterval及深度睡眠参数
时间始终不更新NTP无法访问更换国内NTP服务器或配置代理
重启后不显示上次数据Flash未存储增加Preferences存储层

6. 从仪表到工作台的小拓展

6.1 多页面与按键交互

基础版本做完之后,可以加一个物理按键来切换显示页面。第一个页面显示概览费用,第二个页面显示这次结算周期的请求趋势,第三个页面显示配置和网络状态。实现思路是在loop()里监听按键电平变化,用外部中断唤醒设备或者在服务函数中翻转页面标志。墨水屏的切换记得先display.setFullWindow()全刷一次,否则两页内容会叠加残留,产生残影。

6.2 接入更多数据源

这套硬件架构本质上是通用的API监控屏。Claude用量只是第一个数据源,你可以把天气、汇率、待办事项、服务器状态、股票价格统统接进来。每次唤醒依次请求多个API再排版渲染。要注意的是请求串行执行,总耗时累加。我试过接三个API后单次刷新时间达到45秒,体验很煎熬,最终还是改为错峰请求,每小时只刷新一个数据源,降低单次唤醒的工作量。

6.3 我给这个项目的实操体会

做完这套系统,最大感受是:墨水屏设备的开发和手机App开发完全是两种思维。手机App追求实时和流畅,墨水屏追求的是“恰到好处的更新”。强迫习惯做实时界面的开发者去接受“一小时更新一次还闪烁好几秒”的约束,本身就是一种有趣的思维转换。

如果让我给后来者一条最核心的建议:先把纯显示部分做好再接入网络。单独跑一个显示Demo把布局调到自己满意,再一步步加WiFi、加API、加低功耗。不要试图一次集成所有功能,“通电能显示”和“工程可用”之间的距离比你想象的远。模块化解耦每个环节都能单独验证,出了问题也能准确定位,这是整个项目过程中我最深的体会。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进