diff --git a/docs/hardware.md b/docs/hardware.md
index 1a8e2f2..d036e1f 100644
--- a/docs/hardware.md
+++ b/docs/hardware.md
@@ -72,6 +72,24 @@ A couple of things worth knowing if you pick different pins:
are often unreliable much past a few MHz. Raise it once your physical
wiring is confirmed solid.
+## Battery (optional, XIAO ESP32-C6)
+
+For a battery-powered build on the Seeed XIAO ESP32-C6 (which has
+BAT+/BAT- charge pads on its underside and charges over USB-C):
+
+- A 1S 3.7V LiPo **with an integrated protection circuit** (the board
+ does no low-voltage cutoff of its own), soldered or JST-PH-pigtailed
+ to the BAT pads. JST-PH polarity is not standardized -- verify with a
+ multimeter before connecting.
+- Battery level sense: 2x200k divider from BAT+ to GND, midpoint to
+ GPIO0/A0 (shared with the back button -- deliberate, see
+ [`firmware/README.md`](../firmware/README.md#battery-xiao-esp32-c6)).
+- Optional mains detection: 2x100k divider from the 5V pin to GND,
+ midpoint to a spare GPIO (e.g. 22).
+
+Both features are off by default in firmware
+(`FRAME_BATTERY_ADC_GPIO`/`FRAME_VBUS_SENSE_GPIO` = -1).
+
## Power
The device spends nearly all its time in deep sleep, waking briefly once
diff --git a/firmware/README.md b/firmware/README.md
index b6a3a35..b84fdac 100644
--- a/firmware/README.md
+++ b/firmware/README.md
@@ -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
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index e4b2973..669e427 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -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)
diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild
index 849c8d0..a03ee3a 100644
--- a/firmware/main/Kconfig.projbuild
+++ b/firmware/main/Kconfig.projbuild
@@ -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
diff --git a/firmware/main/battery.c b/firmware/main/battery.c
new file mode 100644
index 0000000..55dd0ca
--- /dev/null
+++ b/firmware/main/battery.c
@@ -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
diff --git a/firmware/main/battery.h b/firmware/main/battery.h
new file mode 100644
index 0000000..57d7a41
--- /dev/null
+++ b/firmware/main/battery.h
@@ -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);
diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c
index d69b090..aefa3f1 100644
--- a/firmware/main/frame_client.c
+++ b/firmware/main/frame_client.c
@@ -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;
}
diff --git a/firmware/main/frame_client.h b/firmware/main/frame_client.h
index 3577db4..3f84c1e 100644
--- a/firmware/main/frame_client.h
+++ b/firmware/main/frame_client.h
@@ -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);
diff --git a/firmware/main/main.c b/firmware/main/main.c
index faf44e2..42996bd 100644
--- a/firmware/main/main.c
+++ b/firmware/main/main.c
@@ -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",
diff --git a/firmware/main/manage_qr_overlay.c b/firmware/main/manage_qr_overlay.c
index 141f2be..b63dc16 100644
--- a/firmware/main/manage_qr_overlay.c
+++ b/firmware/main/manage_qr_overlay.c
@@ -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;
diff --git a/firmware/main/manage_qr_overlay.h b/firmware/main/manage_qr_overlay.h
index 14132eb..033135e 100644
--- a/firmware/main/manage_qr_overlay.h
+++ b/firmware/main/manage_qr_overlay.h
@@ -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;
/**
diff --git a/server/README.md b/server/README.md
index 79fd70b..ba65268 100644
--- a/server/README.md
+++ b/server/README.md
@@ -51,7 +51,16 @@ algorithm itself -- it just streams the response straight to the panel.
toggle, upcoming-photos count, now-displaying + drag-to-reorder
upcoming grid -- not Immich URL/API key, see Setup above)
- `GET /api/albums` -- lists Immich albums (used by the config UI)
-- `POST /api/config` -- saves album/order/refresh_interval_s/smart_crop_faces/queue_target_len
+- `POST /api/config` -- saves album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len.
+ `orientation` (`landscape`, `portrait`, `landscape_flipped`,
+ `portrait_flipped`) matches how the frame is physically hung: photos
+ are composed/cropped for that shape (portrait crops at 480x800), then
+ rotated into the panel's native 800x480 byte layout server-side --
+ the device never knows. Note the device-side manage-menu overlay
+ (QRs, text, battery indicator, face labels) still renders in native
+ panel orientation, so on a portrait-hung frame it appears rotated
+ 90° to the viewer -- QR codes scan fine at any rotation, but the text
+ reads sideways. A known limitation, not planned to change soon
- `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
@@ -99,8 +108,14 @@ algorithm itself -- it just streams the response straight to the panel.
detection/recognition happens in this project, see
`app/face_labels.py`); `count: 0` if none are named. Used by the
device manage button's escalated second menu level
-- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each
- entry an asset id + thumbnail URL; used by the config UI
+- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
+ battery reading, stored with a timestamp. Only sent when the device
+ is actually running on battery (see `firmware/README.md`'s Battery
+ section) -- a frame on mains power never reports
+- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
+ "battery": {"percent": N, "as_of": ts} | null}`, each queue entry an
+ asset id + thumbnail URL; used by the config UI (which shows the
+ battery line under "Now displaying" when present)
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
changed server-side since the client's last fetch (e.g. a top-up/trim)
diff --git a/server/app/config.py b/server/app/config.py
index cf6be4c..8f1d4da 100644
--- a/server/app/config.py
+++ b/server/app/config.py
@@ -27,6 +27,10 @@ class FrameConfig(BaseModel):
order: str = "sequential" # or "shuffle"
refresh_interval_s: int = 3600
smart_crop_faces: bool = True
+ # How the physical frame is hung: landscape (native), portrait,
+ # landscape_flipped, portrait_flipped. Purely a server-side render
+ # decision -- the device always receives native 800x480 bytes.
+ orientation: str = "landscape"
# Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at
# is what lets the server decide "has it been long enough to advance" on its
@@ -39,6 +43,12 @@ class FrameConfig(BaseModel):
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich)
+ # Last battery report from the device (POST /frame/battery); -1 = never
+ # reported / not battery-powered. battery_as_of mirrors the
+ # current_asset_set_at timestamp pattern.
+ battery_percent: int = -1
+ battery_as_of: float = 0.0
+
def load() -> FrameConfig:
with _lock:
diff --git a/server/app/face_labels.py b/server/app/face_labels.py
index e0f4172..05696b7 100644
--- a/server/app/face_labels.py
+++ b/server/app/face_labels.py
@@ -15,7 +15,12 @@ import io
from PIL import Image, ImageOps
-from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _face_aware_crop_box, _plain_center_crop_box
+from .image_pipeline import (
+ _face_aware_crop_box,
+ _plain_center_crop_box,
+ logical_render_size,
+ logical_to_native,
+)
# Small caps, not arbitrary: each label is its own malloc'd overlay
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
@@ -26,26 +31,33 @@ MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
-def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool) -> list[dict]:
- """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in final
- 800x480 frame pixel space at each named face's bottom-center point.
+def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
+ orientation: str = "landscape") -> list[dict]:
+ """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
+ 800x480 panel pixel space at each named face's bottom-center point.
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
- the currently-displayed frame, and smart_crop_faces must match the
- setting that was active then -- otherwise the crop box computed here
- won't match what's actually on screen.
+ the currently-displayed frame, and smart_crop_faces/orientation must
+ match the settings that were active then -- otherwise the crop box and
+ rotation computed here won't match what's actually on screen.
+
+ The crop math runs in logical (pre-rotation) space, matching
+ render_frame()'s composition step; each anchor is then rotated into
+ native panel coordinates via logical_to_native(), since the firmware
+ draws labels in native space.
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
return []
+ logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
if smart_crop_faces and faces:
- left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT, faces)
+ left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
crop_w, crop_h = right - left, bottom - top
else:
- left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT)
+ left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
labels = []
for face in named[:MAX_LABELED_FACES]:
@@ -57,16 +69,17 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
bottom_y = face["boundingBoxY2"] * scale_y
- frame_x = (center_x - left) * (EPD_WIDTH / crop_w)
- frame_y = (bottom_y - top) * (EPD_HEIGHT / crop_h)
+ frame_x = (center_x - left) * (logical_w / crop_w)
+ frame_y = (bottom_y - top) * (logical_h / crop_h)
- if not (0 <= frame_x <= EPD_WIDTH and 0 <= frame_y <= EPD_HEIGHT):
+ if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
- labels.append({"name": name, "x": int(frame_x), "y": int(frame_y)})
+ native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
+ labels.append({"name": name, "x": native_x, "y": native_y})
return labels
diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py
index c98a373..8a80716 100644
--- a/server/app/image_pipeline.py
+++ b/server/app/image_pipeline.py
@@ -7,6 +7,44 @@ from PIL import Image, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
+# How each orientation maps the logically-composed image onto the native
+# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
+# crop ratio matches how the frame actually hangs) and rotate into native
+# space afterwards -- rotation happens after dithering, which is lossless
+# (a pure pixel permutation). Which of 90/270 is "portrait" vs
+# "portrait_flipped" is a convention pick; whichever way the frame is
+# hung, one of the two is right.
+ORIENTATION_TRANSPOSE = {
+ "landscape": None,
+ "landscape_flipped": Image.Transpose.ROTATE_180,
+ "portrait": Image.Transpose.ROTATE_90,
+ "portrait_flipped": Image.Transpose.ROTATE_270,
+}
+
+
+def logical_render_size(orientation: str) -> tuple[int, int]:
+ """(width, height) the photo is composed/cropped at for this
+ orientation, before rotating into native panel space."""
+ if orientation in ("portrait", "portrait_flipped"):
+ return EPD_HEIGHT, EPD_WIDTH
+ return EPD_WIDTH, EPD_HEIGHT
+
+
+def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
+ """Maps a point in logical (pre-rotation) frame space to native
+ 800x480 panel space, applying the same rotation ORIENTATION_TRANSPOSE
+ applies to the pixels -- anything positioned in logical coordinates
+ (e.g. face labels) needs this to stay attached to the rotated
+ content. PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
+ logical_w, logical_h = logical_render_size(orientation)
+ if orientation == "landscape_flipped":
+ return int(logical_w - 1 - x), int(logical_h - 1 - y)
+ if orientation == "portrait": # ROTATE_90 (CCW)
+ return int(y), int(logical_w - 1 - x)
+ if orientation == "portrait_flipped": # ROTATE_270 (CW)
+ return int(logical_h - 1 - y), int(x)
+ return int(x), int(y)
+
# Approximate sRGB for each of the panel's 6 ink colors. These are
# reasonable placeholders, not measured values -- Waveshare doesn't publish
# exact color primaries for this panel. Tune them once you can compare a
@@ -108,23 +146,32 @@ def _face_aware_crop_box(
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
-def render_frame(source: Image.Image, faces: list[dict] | None = None) -> bytes:
+def render_frame(source: Image.Image, faces: list[dict] | None = None,
+ orientation: str = "landscape") -> bytes:
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
toward keeping them on screen instead of a plain center-crop.
+
+ `orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
+ the frame physically hangs, then rotates into native panel space --
+ the output byte layout is identical either way.
"""
+ logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if faces:
- box = _face_aware_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT, faces)
- fitted = fitted.crop(box).resize((EPD_WIDTH, EPD_HEIGHT), Image.LANCZOS)
+ box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
+ fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
else:
- fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS)
+ fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
+ transpose = ORIENTATION_TRANSPOSE.get(orientation)
+ if transpose is not None:
+ quantized = quantized.transpose(transpose)
pixels = quantized.load()
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
diff --git a/server/app/main.py b/server/app/main.py
index 66af35b..ca00287 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import io
import logging
+import time
from datetime import datetime
import httpx
@@ -29,6 +30,8 @@ MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
+ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
+
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
@@ -117,6 +120,7 @@ def api_config_save(
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
+ orientation: str = Form("landscape"),
):
# Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
# docker-compose.yml.example) -- config.load() already applies them,
@@ -138,6 +142,7 @@ def api_config_save(
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
+ cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
config.save(cfg)
return {"status": "saved"}
@@ -175,7 +180,7 @@ def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str)
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
source = Image.open(io.BytesIO(jpeg_bytes))
- return render_frame(source, faces=faces)
+ return render_frame(source, faces=faces, orientation=cfg.orientation)
@app.get("/frame/image", dependencies=[Depends(require_access_token)])
@@ -240,6 +245,26 @@ def frame_back():
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
+class BatteryReport(BaseModel):
+ percent: int
+
+
+@app.post("/frame/battery", dependencies=[Depends(require_access_token)])
+def frame_battery(body: BatteryReport):
+ """Battery level reported by the device (only when running on battery
+ -- it stays silent on mains, where the charging voltage would read
+ misleadingly full). Stored with a timestamp so the web UI can show
+ both the level and how stale it is."""
+ if not 0 <= body.percent <= 100:
+ raise HTTPException(400, "percent must be 0-100")
+ with config.locked():
+ cfg = config.load()
+ cfg.battery_percent = body.percent
+ cfg.battery_as_of = time.time()
+ config.save(cfg)
+ return {"status": "saved"}
+
+
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
@@ -412,7 +437,7 @@ def frame_face_labels():
logger.warning("Could not download asset %s for face-label mapping: %s", cfg.current_asset_id, e)
return {"count": 0}
- labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces)
+ labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces, cfg.orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
@@ -444,6 +469,11 @@ def api_queue():
return {
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
+ "battery": (
+ {"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
+ if cfg.battery_percent >= 0
+ else None
+ ),
}
diff --git a/server/app/templates/index.html b/server/app/templates/index.html
index 77d2572..817a2fc 100644
--- a/server/app/templates/index.html
+++ b/server/app/templates/index.html
@@ -83,6 +83,14 @@
+