深度睡眠解决了「省电」(见上一篇), 但电池设备还差两件事:测得准——还剩多少电,要不要提醒用户;充得明 ——现在是不是在充电。PocketOracle 用一颗 TP4054 充电芯片加一条电阻分压 支路,把这笔电源账算清了。这篇文章讲硬件、测量、估算和它们驱动的产品策略。
硬件:一条分压支路 + 一个开漏指示脚
- 电池电压采样:Li-ion 电池经 1MΩ / 2.2MΩ 电阻分压接到 GPIO4 (ADC1_CH4)。分压比 0.6875,反算电池电压要乘 32/22;
- 充电检测:TP4054 的 CHRG 脚是开漏输出,充电时拉低,接在 GPIO21 (内部上拉),读电平即知状态;
- 充电芯片自带过充保护,满电 4.2V。
GPIO21 有个坑要记在本子上:它是 UART0 的 TX 脚,带电池烧录固件时, 如果电池恰好正在充电,CHRG 的电平和串口 TX 驱动会打架。烧录前拔电池, 或者充完电再烧。
测量:中位数 + 曲线拟合校准
ESP32-C3 的 ADC 以不准出名,直接单次采样的读数能抖出一百多毫伏,反映到 电量百分比上就是好几个点的跳动。驱动用了三层降噪:
int battery_measure_mv(void)
{
// 1. 建 ADC 单元,12bit + 12dB 衰减
// 2. eFuse 曲线拟合校准(raw → 节点 mV,芯片出厂曲线烧在 eFuse 里)
// 3. 连采 16 次 → 插入排序 → 取中位数(第 7/8 个的平均)
int node_mv = mid_raw * 3300 / 4095; // 校准不可用时的退化公式
if (cali) adc_cali_raw_to_voltage(cali, mid_raw, &node_mv);
int bat_mv = node_mv * 32 / 22; // 还原分压前的电池侧电压
if (bat_mv > 4200) bat_mv = 4200; // 钳到 Li-ion 满电
return bat_mv;
}- 16 点中位数:比平均值稳——毛刺样本被排序挤到两头,影响不到中位数;
- 曲线拟合校准:C3 的 eFuse 里烧了出厂 ADC 曲线,
adc_cali_create_scheme_curve_fitting一行接入;校准方案创建失败时 退化回线性换算,日志里会告警; - 分压支路的另一个细节:12dB 衰减下节点侧量程 0–3.6V,折算电池侧约 5.2V——4.2V 的满电锂电池离削顶还很远。(板卡注释里曾有一句「3.6V 以上 会削顶」,是理解错了量程主体,已纠正。)
还有一个深度睡眠设备特有的风格:battery_measure_mv() 每次调用都新建
ADC 单元、用完即删。因为每次唤醒都是一次重启,不存在「复用上次的句柄」,
无状态反而是对的。
电量估算:OCV 表 + 线性插值
从电压算百分比,正经办法是库仑计,但那需要额外芯片和采样电阻。对这台 「99% 时间在深度睡眠、醒来量一次」的设备,开路电压(OCV)查表就够了—— 测量时刻设备几乎不带负载,端电压极接近开路电压:
static const struct { int mv; int pct; } SOC_TABLE[] = {
{3000, 0}, {3300, 5}, {3500, 10}, {3600, 20},
{3730, 40}, {3850, 60}, {3970, 80}, {4100, 100},
};
int battery_soc(int mv) // 表内线性插值,两端钳位 0..100锚点取自 Li-ion 放电曲线的拐点:3.5–3.7V 之间电压掉得慢,3.5V 以下 断崖。插值让百分比不至于一格一格跳。
代码组织上有个刻意的设计:battery_soc() / battery_level() 所在的
battery_core.c 零 ESP-IDF 依赖,在 PC 上直接被
tools/test_battery_core.py 跑断言,不用为改一行插值逻辑就烧一次板子。
充电检测:一个脚,两个坑
battery_charge_t battery_charge(void)
{
// CHRG 开漏:内部上拉,充电时被 TP4054 拉低
gpio_config_t io = {
.pin_bit_mask = 1ULL << PIN_CHRG,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io);
return gpio_get_level(PIN_CHRG) == 0 ? BATTERY_CHARGE_YES : BATTERY_CHARGE_NO;
}配置失败返回 UNKNOWN 而不是硬猜——调用方对 UNKNOWN 的处理是「当作没在 充电但不做低电动作」,比错误地宣称「正在充电」安全。
策略:测不准也不惩罚用户
测量值最终驱动的是几个用户看得见的行为,每条都为「传感器不可靠」留了退路:
| 情况 | 设备行为 |
|---|---|
| 正常 | 屏幕右上角画电池图标:外壳 + 按 SOC/20 填充的 5 段电量条 |
| 充电中 | 图标换成闪电符号;视为 NORMAL,跳过一切低电动作 |
| 测量失败(返回 -1) | 只画外壳;电量按 4200mV 处理——不因传感器故障吓唬用户 |
| LOW(≤3450mV) | 图标下加 "LOW" 角标;巡检间隔从 12 小时缩到 1 小时,尽快发现临界电量 |
| CRITICAL(≤3300mV) | 整屏画 4 倍放大的空心电池 + 「请充电」,然后睡 12 小时 |
低电时缩短巡检周期看着反直觉(不是更费电吗),但这是电量曲线的断崖区: 从 LOW 掉到 CRITICAL 可能就一两天,每小时醒来一次能保证设备在彻底断电前 把「请充电」亮出来,而不是默默死掉。这段时间总共也醒不了几十次,代价可以 忽略。
电量图标本身是构建期生成的 1bpp 位图(tools/gen_batt_icons.py 生成
icon_data.c),和字库同一套管线;「请充电」三个字则直接复用文章字库的
16px 子集。
小结
这套电源管理的总代码不到 300 行,账本逻辑却层层设防:采样靠中位数, 换算靠出厂校准,估算靠 OCV 表,策略靠「失败即宽松」。电池设备的电源 读数永远带噪声,设计时先想好每一个读数失败时的行为,比把精度再抠高 一个百分点重要得多。