Add battery level reporting (Kconfig-gated) and display orientation
Build and push server image / build-and-push (push) Successful in 32s
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).
This commit is contained in:
@@ -49,6 +49,8 @@ Under **ESPresso Frame Configuration**:
|
||||
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
|
||||
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
|
||||
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
|
||||
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
|
||||
|
||||
Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin
|
||||
assignments and SPI clock speed -- see
|
||||
@@ -177,6 +179,37 @@ If there's nothing to go back to yet (freshly provisioned, or you've
|
||||
already gone back as far as there is history), it's a no-op -- the
|
||||
current photo stays exactly as it was, no flash on the panel.
|
||||
|
||||
## Battery (XIAO ESP32-C6)
|
||||
|
||||
Disabled by default (`FRAME_BATTERY_ADC_GPIO = -1`) -- correct for a
|
||||
dev board with no battery wired. On the intended production board (Seeed
|
||||
XIAO ESP32-C6) with a 1S LiPo:
|
||||
|
||||
**Wiring** (beyond soldering the battery itself to the BAT+/BAT- pads on
|
||||
the XIAO's underside -- note the polarity markings there, and that
|
||||
JST-PH battery connector polarity is *not* standardized, so verify with
|
||||
a multimeter before first plug-in):
|
||||
|
||||
- **Battery sense**: a 2x200k voltage divider from BAT+ to GND, midpoint
|
||||
into an ADC-capable pin (GPIO 0-6; the settled design shares the back
|
||||
button's GPIO0/A0 -- the high-impedance divider coexists fine with the
|
||||
button, and firmware time-shares the pin with a brief ADC read once
|
||||
per wake). Set `FRAME_BATTERY_ADC_GPIO` to match.
|
||||
- **Mains detection** (optional): a 2x100k divider from the 5V pin
|
||||
(which only carries voltage when USB is plugged in) into any spare
|
||||
GPIO -- plain digital high/low, no ADC needed. The divider is
|
||||
required: raw 5V exceeds the 3.3V pin limit. Set
|
||||
`FRAME_VBUS_SENSE_GPIO`.
|
||||
|
||||
**Behavior**: once per wake the firmware reads the battery voltage,
|
||||
converts it to a percent via a LiPo discharge curve, shows it (battery
|
||||
icon + "NN%") below the "scan to manage" QR box whenever the management
|
||||
menu is up, and reports it to the server (`POST /frame/battery`), which
|
||||
displays it in the web UI with an "as of" timestamp. All of that is
|
||||
skipped when on mains power (the charging voltage would read
|
||||
misleadingly full), when the reading is implausible (no battery
|
||||
attached), or when the feature is disabled.
|
||||
|
||||
## Managing the queue, soft-resetting, and factory-resetting
|
||||
|
||||
One more button, wired between GPIO1 and GND (same wiring style as the
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c
|
||||
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio
|
||||
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c
|
||||
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc
|
||||
EMBED_FILES root.html)
|
||||
|
||||
@@ -155,4 +155,37 @@ menu "ESPresso Frame Configuration"
|
||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
||||
confused for each other.
|
||||
|
||||
config FRAME_BATTERY_ADC_GPIO
|
||||
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
||||
default -1
|
||||
range -1 6
|
||||
help
|
||||
GPIO wired to the midpoint of a 2x200k voltage divider from
|
||||
BAT+ to GND (halving the battery voltage into ADC range --
|
||||
the wiring Seeed documents for the XIAO ESP32-C6's A0).
|
||||
Must be GPIO 0-6, the ESP32-C6's only ADC-capable pins. The
|
||||
settled XIAO design shares this with the back button's pin
|
||||
(0): a high-impedance divider coexists fine with the
|
||||
button's pull-up and deep-sleep wake, and the firmware
|
||||
time-shares the pin (brief ADC read once per wake, restored
|
||||
to button duty right after). -1 (the default) disables
|
||||
battery reporting entirely -- correct for boards with no
|
||||
battery wired, like the DevKitC-1 dev board.
|
||||
|
||||
config FRAME_VBUS_SENSE_GPIO
|
||||
int "USB-power (VBUS) sense GPIO (-1 to disable)"
|
||||
default -1
|
||||
range -1 23
|
||||
depends on FRAME_BATTERY_ADC_GPIO >= 0
|
||||
help
|
||||
GPIO wired to the midpoint of a 2x100k divider from the 5V
|
||||
pin (which only carries voltage when USB is plugged in --
|
||||
it's dead on battery power) -- a plain digital high/low
|
||||
"on mains" signal, no ADC needed. The divider is required:
|
||||
raw 5V exceeds the 3.3V pin limit. When on mains, the
|
||||
battery indicator is hidden and no battery report is sent
|
||||
(the charging voltage would read misleadingly full). -1
|
||||
disables mains detection -- the indicator then shows
|
||||
whenever the battery reading is plausible.
|
||||
|
||||
endmenu
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#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
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* Reads the battery level via the ADC voltage divider
|
||||
* (CONFIG_FRAME_BATTERY_ADC_GPIO -- see Kconfig for the expected XIAO
|
||||
* ESP32-C6 wiring). Returns 0-100, or -1 for any of:
|
||||
* - feature disabled (CONFIG_FRAME_BATTERY_ADC_GPIO < 0),
|
||||
* - running on mains (CONFIG_FRAME_VBUS_SENSE_GPIO reads high),
|
||||
* - reading outside the plausible LiPo range (catches "no battery
|
||||
* attached" and a button held on a shared pin, which reads ~0V),
|
||||
* - any ADC setup/read error.
|
||||
*
|
||||
* The battery pin is expected to be shared with a button (the settled
|
||||
* XIAO design shares the back button's GPIO0/A0): call this AFTER the
|
||||
* button checks at boot, since the ADC read temporarily reconfigures
|
||||
* the pin. On return the pin is always restored to button duty (input +
|
||||
* pull-up + deep-sleep wake armed).
|
||||
*/
|
||||
int battery_read_percent(void);
|
||||
@@ -588,7 +588,8 @@ static bool wait_for_button_press(uint32_t timeout_ms)
|
||||
* had that data); level 2 adds named-face labels on top. action only
|
||||
* applies at level 1 -- escalating to level 2 redisplays the same
|
||||
* photo, so it never re-advances/-backs. */
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
|
||||
int battery_percent)
|
||||
{
|
||||
char management_url[256];
|
||||
build_url(management_url, sizeof(management_url), cfg, "");
|
||||
@@ -621,6 +622,7 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
.share_url = share_url[0] != '\0' ? share_url : NULL,
|
||||
.face_labels = face_labels,
|
||||
.face_label_count = face_label_count,
|
||||
.battery_percent = battery_percent,
|
||||
};
|
||||
|
||||
manage_overlay_set_t overlay;
|
||||
@@ -645,10 +647,10 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
* that (escalating, or the final revert) are logged but don't count as
|
||||
* an overall failure -- something was already shown successfully, which
|
||||
* was the point of the button. */
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
|
||||
{
|
||||
int level = 1;
|
||||
esp_err_t err = show_menu_level(cfg, action, level);
|
||||
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
@@ -661,7 +663,7 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
break; /* timeout at any level, or a press while already maxed out -- exit */
|
||||
}
|
||||
level++;
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
|
||||
if (level_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
||||
break;
|
||||
@@ -678,15 +680,58 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||
* show_management_qr -- the escalating manage menu (see
|
||||
* run_management_menu()). */
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
{
|
||||
if (!show_management_qr) {
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
}
|
||||
return run_management_menu(cfg, action);
|
||||
return run_management_menu(cfg, action, battery_percent);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
/* Reports the battery percent to the server (POST /frame/battery).
|
||||
* Best-effort only: a battery report must never fail a photo cycle, so
|
||||
* every failure here is just a warning. No-op for percent < 0. */
|
||||
static void report_battery(const frame_config_t *cfg, int percent)
|
||||
{
|
||||
if (percent < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/battery");
|
||||
|
||||
char body[48];
|
||||
int body_len = snprintf(body, sizeof(body), "{\"percent\": %d}", percent);
|
||||
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_POST,
|
||||
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, body_len);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Battery report failed to connect: %s", esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return;
|
||||
}
|
||||
esp_http_client_write(client, body, body_len);
|
||||
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
|
||||
if (status != 200) {
|
||||
ESP_LOGW(TAG, "Battery report returned HTTP %d", status);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Reported battery %d%% to server", percent);
|
||||
}
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
{
|
||||
esp_err_t epd_err = epd_init();
|
||||
bool have_display = (epd_err == ESP_OK);
|
||||
@@ -720,7 +765,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* worth it to stop false-failing on the common case. */
|
||||
bool image_ok = true;
|
||||
if (have_display) {
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
|
||||
image_ok = (fetch_err == ESP_OK);
|
||||
if (!image_ok) {
|
||||
/* epd_display_stream() never triggers a physical refresh on a
|
||||
@@ -741,6 +786,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* just be discarded. */
|
||||
uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
if (image_ok) {
|
||||
report_battery(cfg, battery_percent);
|
||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
||||
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
}
|
||||
|
||||
@@ -37,5 +37,10 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
|
||||
* top-right corner linking to the server's config page, held for 30
|
||||
* seconds (the device stays awake), then reverted back to the plain
|
||||
* photo before proceeding to the normal sleep-interval logic.
|
||||
*
|
||||
* battery_percent (0-100, or -1 for "no reading" -- see
|
||||
* battery_read_percent()) is shown on the management menu overlay and
|
||||
* reported to the server after a successful fetch; -1 skips both.
|
||||
*/
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "next_button.h"
|
||||
#include "back_button.h"
|
||||
#include "combo_button.h"
|
||||
#include "battery.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
@@ -51,12 +52,18 @@ void app_main(void)
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
bool show_management_qr = combo_button_check();
|
||||
|
||||
/* Must come after the button checks: the battery pin is (by design,
|
||||
* on the XIAO board) shared with a button, and the ADC read briefly
|
||||
* takes the pin over -- see battery.h. -1 = no reading (disabled,
|
||||
* on mains, or implausible). */
|
||||
int battery_percent = battery_read_percent();
|
||||
|
||||
frame_config_t cfg;
|
||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||
if (cfg_err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
||||
frame_client_run(&cfg, action, show_management_qr);
|
||||
frame_client_run(&cfg, action, show_management_qr, battery_percent);
|
||||
return; /* frame_client_run currently never returns */
|
||||
}
|
||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
||||
|
||||
@@ -223,6 +223,71 @@ static esp_err_t render_face_label_region(const char *name, int anchor_x, int an
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Battery glyph dimensions -- a static outline (body rectangle + small
|
||||
* terminal nub on the right), deliberately NOT a fill-level graphic. */
|
||||
#define BATTERY_ICON_W 44
|
||||
#define BATTERY_ICON_H 24
|
||||
#define BATTERY_ICON_STROKE 2
|
||||
#define BATTERY_NUB_W 6
|
||||
#define BATTERY_NUB_H 12
|
||||
#define BATTERY_ICON_TEXT_GAP 8
|
||||
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
|
||||
|
||||
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
|
||||
{
|
||||
for (int y = 0; y < BATTERY_ICON_H; y++) {
|
||||
for (int x = 0; x < BATTERY_ICON_W; x++) {
|
||||
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
|
||||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
|
||||
if (edge) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
|
||||
for (int y = 0; y < BATTERY_NUB_H; y++) {
|
||||
for (int x = 0; x < BATTERY_NUB_W; x++) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* White-padded box with the battery glyph and "NN%" beside it, placed
|
||||
* directly below an already-positioned anchor region (the top-right
|
||||
* manage QR box), right-aligned to the anchor's right edge. */
|
||||
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
|
||||
manage_overlay_region_t *out)
|
||||
{
|
||||
char text[8];
|
||||
snprintf(text, sizeof(text), "%d%%", percent);
|
||||
|
||||
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
|
||||
int text_w = (int)strlen(text) * Font24.Width;
|
||||
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
|
||||
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
|
||||
|
||||
int w = content_w + PADDING * 2;
|
||||
int h = content_h + PADDING * 2;
|
||||
w += w % 2;
|
||||
|
||||
int stride = w / 2;
|
||||
uint8_t *buf = malloc((size_t)stride * h);
|
||||
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
|
||||
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||
|
||||
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
|
||||
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
|
||||
PADDING + (content_h - Font24.Height) / 2);
|
||||
|
||||
out->buf = buf;
|
||||
out->w = w;
|
||||
out->h = h;
|
||||
out->x0 = anchor->x0 + anchor->w - w;
|
||||
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
|
||||
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
|
||||
{
|
||||
out->count = 0;
|
||||
@@ -234,6 +299,14 @@ esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_
|
||||
}
|
||||
out->count++;
|
||||
|
||||
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
|
||||
/* Anchored below the manage QR box just rendered (regions[0]). */
|
||||
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
|
||||
ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
|
||||
const char *line2 =
|
||||
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/* 4 fixed corner regions (manage QR, location, date, share QR) plus up
|
||||
* to MANAGE_FACE_LABELS_MAX arbitrary-position named-face labels (see
|
||||
* manage_face_label_t below). MANAGE_FACE_LABELS_MAX is capped small
|
||||
* deliberately, not arbitrarily -- each label is its own malloc'd
|
||||
* buffer, and the 4 fixed regions alone already use a meaningful chunk
|
||||
* of the ESP32-C6's limited RAM; this keeps worst-case overlay memory
|
||||
* well clear of what the WiFi/HTTP stack needs alongside it. */
|
||||
/* 5 fixed regions (manage QR, battery indicator, location, date, share
|
||||
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
|
||||
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
|
||||
* capped small deliberately, not arbitrarily -- each label is its own
|
||||
* malloc'd buffer, and the fixed regions alone already use a meaningful
|
||||
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
|
||||
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
|
||||
#define MANAGE_FACE_LABELS_MAX 4
|
||||
#define MANAGE_OVERLAY_MAX_REGIONS (4 + MANAGE_FACE_LABELS_MAX)
|
||||
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
|
||||
|
||||
typedef struct {
|
||||
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
|
||||
@@ -38,6 +38,7 @@ typedef struct {
|
||||
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
|
||||
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
|
||||
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
|
||||
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
|
||||
} manage_overlay_content_t;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user