#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 8 /* 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 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_sum = 0; 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_sum += value; samples++; } } else { if (adc_oneshot_read(adc, channel, &value) == ESP_OK) { mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */ samples++; } } } 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; } int battery_mv = (mv_sum / samples) * 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