Add battery level reporting (Kconfig-gated) and display orientation
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:
2026-07-19 22:11:14 -04:00
parent 86d5852f8e
commit 015993af00
17 changed files with 593 additions and 42 deletions
+18
View File
@@ -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 are often unreliable much past a few MHz. Raise it once your physical
wiring is confirmed solid. 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 ## Power
The device spends nearly all its time in deep sleep, waking briefly once The device spends nearly all its time in deep sleep, waking briefly once
+33
View File
@@ -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_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_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_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 Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin
assignments and SPI clock speed -- see 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 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. 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 ## Managing the queue, soft-resetting, and factory-resetting
One more button, wired between GPIO1 and GND (same wiring style as the One more button, wired between GPIO1 and GND (same wiring style as the
+2 -2
View File
@@ -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 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 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) EMBED_FILES root.html)
+33
View File
@@ -155,4 +155,37 @@ menu "ESPresso Frame Configuration"
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other. 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 endmenu
+185
View File
@@ -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
+19
View File
@@ -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);
+54 -8
View File
@@ -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 * had that data); level 2 adds named-face labels on top. action only
* applies at level 1 -- escalating to level 2 redisplays the same * applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances/-backs. */ * 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]; char management_url[256];
build_url(management_url, sizeof(management_url), cfg, ""); 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, .share_url = share_url[0] != '\0' ? share_url : NULL,
.face_labels = face_labels, .face_labels = face_labels,
.face_label_count = face_label_count, .face_label_count = face_label_count,
.battery_percent = battery_percent,
}; };
manage_overlay_set_t overlay; 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 * that (escalating, or the final revert) are logged but don't count as
* an overall failure -- something was already shown successfully, which * an overall failure -- something was already shown successfully, which
* was the point of the button. */ * 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; 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) { if (err != ESP_OK) {
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err)); ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, NULL); 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 */ break; /* timeout at any level, or a press while already maxed out -- exit */
} }
level++; 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) { if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err)); ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break; 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 /* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see * show_management_qr -- the escalating manage menu (see
* run_management_menu()). */ * 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) { if (!show_management_qr) {
return fetch_and_display(cfg, action, NULL); 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(); esp_err_t epd_err = epd_init();
bool have_display = (epd_err == ESP_OK); 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. */ * worth it to stop false-failing on the common case. */
bool image_ok = true; bool image_ok = true;
if (have_display) { 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); image_ok = (fetch_err == ESP_OK);
if (!image_ok) { if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a /* 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. */ * just be discarded. */
uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
if (image_ok) { if (image_ok) {
report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg); 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; sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
} }
+6 -1
View File
@@ -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 * top-right corner linking to the server's config page, held for 30
* seconds (the device stays awake), then reverted back to the plain * seconds (the device stays awake), then reverted back to the plain
* photo before proceeding to the normal sleep-interval logic. * 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);
+8 -1
View File
@@ -10,6 +10,7 @@
#include "next_button.h" #include "next_button.h"
#include "back_button.h" #include "back_button.h"
#include "combo_button.h" #include "combo_button.h"
#include "battery.h"
static const char *TAG = "main"; static const char *TAG = "main";
@@ -51,12 +52,18 @@ void app_main(void)
* for "not pressed" (false) or "quick press" (true, show the menu). */ * for "not pressed" (false) or "quick press" (true, show the menu). */
bool show_management_qr = combo_button_check(); 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; frame_config_t cfg;
esp_err_t cfg_err = frame_config_load(&cfg); esp_err_t cfg_err = frame_config_load(&cfg);
if (cfg_err == ESP_OK) { if (cfg_err == ESP_OK) {
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid); ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
if (frame_wifi_connect_sta(&cfg) == ESP_OK) { 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 */ return; /* frame_client_run currently never returns */
} }
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning", ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
+73
View File
@@ -223,6 +223,71 @@ static esp_err_t render_face_label_region(const char *name, int anchor_x, int an
return ESP_OK; 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) esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
{ {
out->count = 0; out->count = 0;
@@ -234,6 +299,14 @@ esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_
} }
out->count++; 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') { if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
const char *line2 = const char *line2 =
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL; (content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
+9 -8
View File
@@ -4,15 +4,15 @@
#include "esp_err.h" #include "esp_err.h"
/* 4 fixed corner regions (manage QR, location, date, share QR) plus up /* 5 fixed regions (manage QR, battery indicator, location, date, share
* to MANAGE_FACE_LABELS_MAX arbitrary-position named-face labels (see * QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
* manage_face_label_t below). MANAGE_FACE_LABELS_MAX is capped small * labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
* deliberately, not arbitrarily -- each label is its own malloc'd * capped small deliberately, not arbitrarily -- each label is its own
* buffer, and the 4 fixed regions alone already use a meaningful chunk * malloc'd buffer, and the fixed regions alone already use a meaningful
* of the ESP32-C6's limited RAM; this keeps worst-case overlay memory * chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
* well clear of what the WiFi/HTTP stack needs alongside it. */ * memory well clear of what the WiFi/HTTP stack needs alongside it. */
#define MANAGE_FACE_LABELS_MAX 4 #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 { typedef struct {
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */ 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 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 */ 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 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; } manage_overlay_content_t;
/** /**
+18 -3
View File
@@ -51,7 +51,16 @@ algorithm itself -- it just streams the response straight to the panel.
toggle, upcoming-photos count, now-displaying + drag-to-reorder toggle, upcoming-photos count, now-displaying + drag-to-reorder
upcoming grid -- not Immich URL/API key, see Setup above) upcoming grid -- not Immich URL/API key, see Setup above)
- `GET /api/albums` -- lists Immich albums (used by the config UI) - `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 - `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** (`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 detection/recognition happens in this project, see
`app/face_labels.py`); `count: 0` if none are named. Used by the `app/face_labels.py`); `count: 0` if none are named. Used by the
device manage button's escalated second menu level device manage button's escalated second menu level
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each - `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
entry an asset id + thumbnail URL; used by the config UI 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 - `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having `{"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) changed server-side since the client's last fetch (e.g. a top-up/trim)
+10
View File
@@ -27,6 +27,10 @@ class FrameConfig(BaseModel):
order: str = "sequential" # or "shuffle" order: str = "sequential" # or "shuffle"
refresh_interval_s: int = 3600 refresh_interval_s: int = 3600
smart_crop_faces: bool = True 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 # 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 # 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 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) 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: def load() -> FrameConfig:
with _lock: with _lock:
+26 -13
View File
@@ -15,7 +15,12 @@ import io
from PIL import Image, ImageOps 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 # 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 # 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 NAME_MAX_LEN = 10
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool) -> list[dict]: def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in final orientation: str = "landscape") -> list[dict]:
800x480 frame pixel space at each named face's bottom-center point. """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. Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and smart_crop_faces must match the the currently-displayed frame, and smart_crop_faces/orientation must
setting that was active then -- otherwise the crop box computed here match the settings that were active then -- otherwise the crop box and
won't match what's actually on screen. 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")] named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named: if not named:
return [] return []
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB")) fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
if smart_crop_faces and faces: 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 crop_w, crop_h = right - left, bottom - top
else: 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 = [] labels = []
for face in named[:MAX_LABELED_FACES]: 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 center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
bottom_y = face["boundingBoxY2"] * scale_y bottom_y = face["boundingBoxY2"] * scale_y
frame_x = (center_x - left) * (EPD_WIDTH / crop_w) frame_x = (center_x - left) * (logical_w / crop_w)
frame_y = (bottom_y - top) * (EPD_HEIGHT / crop_h) 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 continue # this face got cropped out of the final frame entirely
name = face["person"]["name"] name = face["person"]["name"]
if len(name) > NAME_MAX_LEN: if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..." 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 return labels
+51 -4
View File
@@ -7,6 +7,44 @@ from PIL import Image, ImageOps
EPD_WIDTH = 800 EPD_WIDTH = 800
EPD_HEIGHT = 480 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 # Approximate sRGB for each of the panel's 6 ink colors. These are
# reasonable placeholders, not measured values -- Waveshare doesn't publish # reasonable placeholders, not measured values -- Waveshare doesn't publish
# exact color primaries for this panel. Tune them once you can compare a # 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) 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 """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 palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes. epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
toward keeping them on screen instead of a plain center-crop. 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")) fitted = ImageOps.exif_transpose(source.convert("RGB"))
if faces: if faces:
box = _face_aware_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT, faces) box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
fitted = fitted.crop(box).resize((EPD_WIDTH, EPD_HEIGHT), Image.LANCZOS) fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
else: 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) 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() pixels = quantized.load()
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2) out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
+32 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import io import io
import logging import logging
import time
from datetime import datetime from datetime import datetime
import httpx import httpx
@@ -29,6 +30,8 @@ MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5 MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000 MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
MANAGEMENT_TOKEN_COOKIE = "mgmt_token" MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
@@ -117,6 +120,7 @@ def api_config_save(
refresh_interval_s: int = Form(3600), refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True), smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20), queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
): ):
# Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see # Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
# docker-compose.yml.example) -- config.load() already applies them, # 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.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces 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.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) config.save(cfg)
return {"status": "saved"} 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) logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
source = Image.open(io.BytesIO(jpeg_bytes)) 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)]) @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") 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 LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = { 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) logger.warning("Could not download asset %s for face-label mapping: %s", cfg.current_asset_id, e)
return {"count": 0} 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)} result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels): for i, label in enumerate(labels):
@@ -444,6 +469,11 @@ def api_queue():
return { return {
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None, "current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in cfg.queue], "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
),
} }
+16
View File
@@ -83,6 +83,14 @@
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option> <option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select> </select>
</label> </label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes) <label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440" <input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required> value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
@@ -125,6 +133,7 @@
const body = new URLSearchParams({ const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '', album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value, order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60), refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked), smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value, queue_target_len: document.getElementById('queue_target_len').value,
@@ -424,6 +433,13 @@
} else { } else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>'; currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
} }
if (data.battery) {
const batteryLine = document.createElement('p');
batteryLine.className = 'sub';
const asOf = new Date(data.battery.as_of * 1000).toLocaleString();
batteryLine.textContent = `Battery: ${data.battery.percent}% (as of ${asOf})`;
currentEl.appendChild(batteryLine);
}
renderUpcoming(data.upcoming); renderUpcoming(data.upcoming);
} catch (e) { } catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>'; currentEl.innerHTML = '<p class="sub">Could not load.</p>';