Files
espresso_frame/firmware/main/battery.c
T
tfaour 015993af00
Build and push server image / build-and-push (push) Successful in 32s
Add battery level reporting (Kconfig-gated) and display orientation
Battery (firmware + server, disabled by default): new battery.c reads
a 2x200k voltage divider via ADC oneshot with curve-fitting calibration
(the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve,
and restores the pin to button duty after each read -- the settled
XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per
wake. Skipped entirely when on mains (a 2x100k VBUS divider into a
spare digital pin -- the 5V pin is dead on battery power, so presence =
mains, where the charging voltage would read misleadingly full) or when
the reading is implausible. The manage overlay gains a battery region
(static outline glyph + "NN%", below the manage QR, all menu levels),
and the device POSTs to the new /frame/battery endpoint after a
successful fetch; the server stores percent + as-of timestamp, exposed
via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO /
FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board);
compile-verified both disabled and enabled, hardware bring-up deferred
until the ordered XIAO + batteries arrive.

Orientation (server-side only): new config setting + web UI dropdown
(landscape / portrait / landscape_flipped / portrait_flipped). Photos
are composed/cropped at the logical hanging shape (portrait crops at
480x800, so face-aware crops match how the frame actually hangs), then
rotated losslessly into the panel's native 800x480 byte layout after
dithering -- the device never knows. Face-label anchors are transformed
through the same rotation (logical_to_native()) so they stay attached
to faces on rotated frames. Known documented limitation: the on-device
manage overlay still renders in native orientation, so it appears
sideways on a portrait-hung frame (QRs scan at any rotation; text reads
sideways).
2026-07-19 22:11:14 -04:00

186 lines
6.0 KiB
C

#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