Build and push server image / build-and-push (push) Successful in 32s
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).
186 lines
6.0 KiB
C
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
|