Files
espresso_frame/firmware/main/battery.c
T
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00

209 lines
6.9 KiB
C

#include <stdlib.h>
#include "driver/gpio.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "battery.h"
static const char *TAG = "battery";
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
#define BATTERY_SAMPLES 16
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
* RF transients, not the true resting voltage) are dropped before
* averaging the rest -- a plain average lets even one or two of those
* skew the result enough to read as a real percent change downstream
* (see the recharge-jump handling in routers/device.py). */
#define BATTERY_TRIM 3
/* The external divider halves the battery voltage (2x200k, per the
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
* pin, inside the 12dB-attenuation ADC range. */
#define BATTERY_DIVIDER_RATIO 2
/* Plausibility bounds after un-dividing, in mV. Below the floor means
* no battery attached or a button held on the shared pin (~0V); above
* the ceiling isn't a 1S LiPo. Either way: no valid reading. */
#define BATTERY_MV_MIN 2900
#define BATTERY_MV_MAX 4350
/* Piecewise-linear 1S LiPo discharge curve, resting voltage -> percent.
* Coarse deliberately -- an e-ink frame needs "roughly how full", not
* fuel-gauge precision. */
static const struct {
int mv;
int percent;
} LIPO_CURVE[] = {
{ 4200, 100 }, { 4060, 90 }, { 3980, 80 }, { 3920, 70 }, { 3870, 60 },
{ 3820, 50 }, { 3780, 40 }, { 3740, 30 }, { 3680, 20 }, { 3550, 10 },
{ 3300, 5 }, { 3000, 0 },
};
static int int_cmp(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b;
}
static int mv_to_percent(int mv)
{
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
if (mv >= LIPO_CURVE[0].mv) {
return 100;
}
if (mv <= LIPO_CURVE[n - 1].mv) {
return 0;
}
for (int i = 1; i < n; i++) {
if (mv >= LIPO_CURVE[i].mv) {
int span_mv = LIPO_CURVE[i - 1].mv - LIPO_CURVE[i].mv;
int span_pct = LIPO_CURVE[i - 1].percent - LIPO_CURVE[i].percent;
return LIPO_CURVE[i].percent + (mv - LIPO_CURVE[i].mv) * span_pct / span_mv;
}
}
return 0;
}
static bool on_mains(void)
{
#if CONFIG_FRAME_VBUS_SENSE_GPIO >= 0
/* The 5V pin only carries voltage when USB is plugged in (dead on
* battery, per Seeed's docs); an external 2x100k divider halves it
* to ~2.5V at this pin -- a clean logic high. On battery the
* divider's bottom resistor holds the pin at GND. No internal pulls:
* the divider drives the node either way. */
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << CONFIG_FRAME_VBUS_SENSE_GPIO,
.mode = GPIO_MODE_INPUT,
};
gpio_config(&io_conf);
return gpio_get_level(CONFIG_FRAME_VBUS_SENSE_GPIO) != 0;
#else
return false; /* no sense pin configured -- can't tell, assume battery */
#endif
}
/* Puts the (shared, see battery.h) pin back on button duty: the same
* input + pull-up + deep-sleep-wake-arm sequence every button _init()
* runs. If the pin is NOT actually shared with a button, the extra
* wake-arm is harmless -- the divider holds the node around 2.9V,
* far above the wake-on-low threshold, so it can never fire. */
static void restore_button_pin(void)
{
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << BATTERY_ADC_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io_conf);
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BATTERY_ADC_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
int battery_read_percent(void)
{
if (on_mains()) {
ESP_LOGI(TAG, "On mains power (VBUS present), no battery reading");
return -1;
}
adc_unit_t unit;
adc_channel_t channel;
esp_err_t err = adc_oneshot_io_to_channel(BATTERY_ADC_GPIO, &unit, &channel);
if (err != ESP_OK) {
ESP_LOGW(TAG, "GPIO%d is not an ADC pin (%s)", BATTERY_ADC_GPIO, esp_err_to_name(err));
return -1;
}
adc_oneshot_unit_init_cfg_t unit_cfg = { .unit_id = unit };
adc_oneshot_unit_handle_t adc = NULL;
err = adc_oneshot_new_unit(&unit_cfg, &adc);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ADC init failed (%s)", esp_err_to_name(err));
restore_button_pin();
return -1;
}
adc_oneshot_chan_cfg_t chan_cfg = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
err = adc_oneshot_config_channel(adc, channel, &chan_cfg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ADC channel config failed (%s)", esp_err_to_name(err));
adc_oneshot_del_unit(adc);
restore_button_pin();
return -1;
}
/* Curve fitting is the ESP32-C6's calibration scheme. Without it,
* fall back to raw readings scaled by the nominal full-scale range
* -- coarser, but the percent curve is coarse anyway. */
adc_cali_handle_t cali = NULL;
adc_cali_curve_fitting_config_t cali_cfg = {
.unit_id = unit,
.chan = channel,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
bool calibrated = adc_cali_create_scheme_curve_fitting(&cali_cfg, &cali) == ESP_OK;
if (!calibrated) {
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
}
int mv_samples[BATTERY_SAMPLES];
int samples = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
int value;
if (calibrated) {
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
mv_samples[samples++] = value;
}
} else {
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
}
}
}
if (calibrated) {
adc_cali_delete_scheme_curve_fitting(cali);
}
adc_oneshot_del_unit(adc);
restore_button_pin();
if (samples == 0) {
ESP_LOGW(TAG, "All ADC reads failed");
return -1;
}
/* Only trim if there's enough left afterward to still be a
* meaningful average -- falls back to a plain average of whatever
* came in on a wake where most reads failed. */
qsort(mv_samples, samples, sizeof(int), int_cmp);
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
int mv_sum = 0;
int kept = 0;
for (int i = trim; i < samples - trim; i++) {
mv_sum += mv_samples[i];
kept++;
}
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
return -1;
}
int percent = mv_to_percent(battery_mv);
ESP_LOGI(TAG, "Battery: %dmV -> %d%%", battery_mv, percent);
return percent;
}
#else
int battery_read_percent(void) { return -1; }
#endif