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 @@ +