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.
209 lines
6.9 KiB
C
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
|