Add battery level reporting (Kconfig-gated) and display orientation
Build and push server image / build-and-push (push) Successful in 32s
Build and push server image / build-and-push (push) Successful in 32s
Battery (firmware + server, disabled by default): new battery.c reads a 2x200k voltage divider via ADC oneshot with curve-fitting calibration (the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve, and restores the pin to button duty after each read -- the settled XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per wake. Skipped entirely when on mains (a 2x100k VBUS divider into a spare digital pin -- the 5V pin is dead on battery power, so presence = mains, where the charging voltage would read misleadingly full) or when the reading is implausible. The manage overlay gains a battery region (static outline glyph + "NN%", below the manage QR, all menu levels), and the device POSTs to the new /frame/battery endpoint after a successful fetch; the server stores percent + as-of timestamp, exposed via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO / FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board); compile-verified both disabled and enabled, hardware bring-up deferred until the ordered XIAO + batteries arrive. Orientation (server-side only): new config setting + web UI dropdown (landscape / portrait / landscape_flipped / portrait_flipped). Photos are composed/cropped at the logical hanging shape (portrait crops at 480x800, so face-aware crops match how the frame actually hangs), then rotated losslessly into the panel's native 800x480 byte layout after dithering -- the device never knows. Face-label anchors are transformed through the same rotation (logical_to_native()) so they stay attached to faces on rotated frames. Known documented limitation: the on-device manage overlay still renders in native orientation, so it appears sideways on a portrait-hung frame (QRs scan at any rotation; text reads sideways).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -49,6 +49,8 @@ Under **ESPresso Frame Configuration**:
|
||||
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
|
||||
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
|
||||
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
|
||||
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
|
||||
|
||||
Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin
|
||||
assignments and SPI clock speed -- see
|
||||
@@ -177,6 +179,37 @@ If there's nothing to go back to yet (freshly provisioned, or you've
|
||||
already gone back as far as there is history), it's a no-op -- the
|
||||
current photo stays exactly as it was, no flash on the panel.
|
||||
|
||||
## Battery (XIAO ESP32-C6)
|
||||
|
||||
Disabled by default (`FRAME_BATTERY_ADC_GPIO = -1`) -- correct for a
|
||||
dev board with no battery wired. On the intended production board (Seeed
|
||||
XIAO ESP32-C6) with a 1S LiPo:
|
||||
|
||||
**Wiring** (beyond soldering the battery itself to the BAT+/BAT- pads on
|
||||
the XIAO's underside -- note the polarity markings there, and that
|
||||
JST-PH battery connector polarity is *not* standardized, so verify with
|
||||
a multimeter before first plug-in):
|
||||
|
||||
- **Battery sense**: a 2x200k voltage divider from BAT+ to GND, midpoint
|
||||
into an ADC-capable pin (GPIO 0-6; the settled design shares the back
|
||||
button's GPIO0/A0 -- the high-impedance divider coexists fine with the
|
||||
button, and firmware time-shares the pin with a brief ADC read once
|
||||
per wake). Set `FRAME_BATTERY_ADC_GPIO` to match.
|
||||
- **Mains detection** (optional): a 2x100k divider from the 5V pin
|
||||
(which only carries voltage when USB is plugged in) into any spare
|
||||
GPIO -- plain digital high/low, no ADC needed. The divider is
|
||||
required: raw 5V exceeds the 3.3V pin limit. Set
|
||||
`FRAME_VBUS_SENSE_GPIO`.
|
||||
|
||||
**Behavior**: once per wake the firmware reads the battery voltage,
|
||||
converts it to a percent via a LiPo discharge curve, shows it (battery
|
||||
icon + "NN%") below the "scan to manage" QR box whenever the management
|
||||
menu is up, and reports it to the server (`POST /frame/battery`), which
|
||||
displays it in the web UI with an "as of" timestamp. All of that is
|
||||
skipped when on mains power (the charging voltage would read
|
||||
misleadingly full), when the reading is implausible (no battery
|
||||
attached), or when the feature is disabled.
|
||||
|
||||
## Managing the queue, soft-resetting, and factory-resetting
|
||||
|
||||
One more button, wired between GPIO1 and GND (same wiring style as the
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c
|
||||
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio
|
||||
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c
|
||||
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc
|
||||
EMBED_FILES root.html)
|
||||
|
||||
@@ -155,4 +155,37 @@ menu "ESPresso Frame Configuration"
|
||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
||||
confused for each other.
|
||||
|
||||
config FRAME_BATTERY_ADC_GPIO
|
||||
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
||||
default -1
|
||||
range -1 6
|
||||
help
|
||||
GPIO wired to the midpoint of a 2x200k voltage divider from
|
||||
BAT+ to GND (halving the battery voltage into ADC range --
|
||||
the wiring Seeed documents for the XIAO ESP32-C6's A0).
|
||||
Must be GPIO 0-6, the ESP32-C6's only ADC-capable pins. The
|
||||
settled XIAO design shares this with the back button's pin
|
||||
(0): a high-impedance divider coexists fine with the
|
||||
button's pull-up and deep-sleep wake, and the firmware
|
||||
time-shares the pin (brief ADC read once per wake, restored
|
||||
to button duty right after). -1 (the default) disables
|
||||
battery reporting entirely -- correct for boards with no
|
||||
battery wired, like the DevKitC-1 dev board.
|
||||
|
||||
config FRAME_VBUS_SENSE_GPIO
|
||||
int "USB-power (VBUS) sense GPIO (-1 to disable)"
|
||||
default -1
|
||||
range -1 23
|
||||
depends on FRAME_BATTERY_ADC_GPIO >= 0
|
||||
help
|
||||
GPIO wired to the midpoint of a 2x100k divider from the 5V
|
||||
pin (which only carries voltage when USB is plugged in --
|
||||
it's dead on battery power) -- a plain digital high/low
|
||||
"on mains" signal, no ADC needed. The divider is required:
|
||||
raw 5V exceeds the 3.3V pin limit. When on mains, the
|
||||
battery indicator is hidden and no battery report is sent
|
||||
(the charging voltage would read misleadingly full). -1
|
||||
disables mains detection -- the indicator then shows
|
||||
whenever the battery reading is plausible.
|
||||
|
||||
endmenu
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_adc/adc_cali_scheme.h"
|
||||
#include "esp_adc/adc_oneshot.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
|
||||
#include "battery.h"
|
||||
|
||||
static const char *TAG = "battery";
|
||||
|
||||
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
|
||||
|
||||
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
|
||||
#define BATTERY_SAMPLES 8
|
||||
/* The external divider halves the battery voltage (2x200k, per the
|
||||
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
|
||||
* pin, inside the 12dB-attenuation ADC range. */
|
||||
#define BATTERY_DIVIDER_RATIO 2
|
||||
/* Plausibility bounds after un-dividing, in mV. Below the floor means
|
||||
* no battery attached or a button held on the shared pin (~0V); above
|
||||
* the ceiling isn't a 1S LiPo. Either way: no valid reading. */
|
||||
#define BATTERY_MV_MIN 2900
|
||||
#define BATTERY_MV_MAX 4350
|
||||
|
||||
/* Piecewise-linear 1S LiPo discharge curve, resting voltage -> percent.
|
||||
* Coarse deliberately -- an e-ink frame needs "roughly how full", not
|
||||
* fuel-gauge precision. */
|
||||
static const struct {
|
||||
int mv;
|
||||
int percent;
|
||||
} LIPO_CURVE[] = {
|
||||
{ 4200, 100 }, { 4060, 90 }, { 3980, 80 }, { 3920, 70 }, { 3870, 60 },
|
||||
{ 3820, 50 }, { 3780, 40 }, { 3740, 30 }, { 3680, 20 }, { 3550, 10 },
|
||||
{ 3300, 5 }, { 3000, 0 },
|
||||
};
|
||||
|
||||
static int mv_to_percent(int mv)
|
||||
{
|
||||
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
|
||||
if (mv >= LIPO_CURVE[0].mv) {
|
||||
return 100;
|
||||
}
|
||||
if (mv <= LIPO_CURVE[n - 1].mv) {
|
||||
return 0;
|
||||
}
|
||||
for (int i = 1; i < n; i++) {
|
||||
if (mv >= LIPO_CURVE[i].mv) {
|
||||
int span_mv = LIPO_CURVE[i - 1].mv - LIPO_CURVE[i].mv;
|
||||
int span_pct = LIPO_CURVE[i - 1].percent - LIPO_CURVE[i].percent;
|
||||
return LIPO_CURVE[i].percent + (mv - LIPO_CURVE[i].mv) * span_pct / span_mv;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool on_mains(void)
|
||||
{
|
||||
#if CONFIG_FRAME_VBUS_SENSE_GPIO >= 0
|
||||
/* The 5V pin only carries voltage when USB is plugged in (dead on
|
||||
* battery, per Seeed's docs); an external 2x100k divider halves it
|
||||
* to ~2.5V at this pin -- a clean logic high. On battery the
|
||||
* divider's bottom resistor holds the pin at GND. No internal pulls:
|
||||
* the divider drives the node either way. */
|
||||
gpio_config_t io_conf = {
|
||||
.pin_bit_mask = 1ULL << CONFIG_FRAME_VBUS_SENSE_GPIO,
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
};
|
||||
gpio_config(&io_conf);
|
||||
return gpio_get_level(CONFIG_FRAME_VBUS_SENSE_GPIO) != 0;
|
||||
#else
|
||||
return false; /* no sense pin configured -- can't tell, assume battery */
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Puts the (shared, see battery.h) pin back on button duty: the same
|
||||
* input + pull-up + deep-sleep-wake-arm sequence every button _init()
|
||||
* runs. If the pin is NOT actually shared with a button, the extra
|
||||
* wake-arm is harmless -- the divider holds the node around 2.9V,
|
||||
* far above the wake-on-low threshold, so it can never fire. */
|
||||
static void restore_button_pin(void)
|
||||
{
|
||||
gpio_config_t io_conf = {
|
||||
.pin_bit_mask = 1ULL << BATTERY_ADC_GPIO,
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_ENABLE,
|
||||
};
|
||||
gpio_config(&io_conf);
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BATTERY_ADC_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
}
|
||||
|
||||
int battery_read_percent(void)
|
||||
{
|
||||
if (on_mains()) {
|
||||
ESP_LOGI(TAG, "On mains power (VBUS present), no battery reading");
|
||||
return -1;
|
||||
}
|
||||
|
||||
adc_unit_t unit;
|
||||
adc_channel_t channel;
|
||||
esp_err_t err = adc_oneshot_io_to_channel(BATTERY_ADC_GPIO, &unit, &channel);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "GPIO%d is not an ADC pin (%s)", BATTERY_ADC_GPIO, esp_err_to_name(err));
|
||||
return -1;
|
||||
}
|
||||
|
||||
adc_oneshot_unit_init_cfg_t unit_cfg = { .unit_id = unit };
|
||||
adc_oneshot_unit_handle_t adc = NULL;
|
||||
err = adc_oneshot_new_unit(&unit_cfg, &adc);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "ADC init failed (%s)", esp_err_to_name(err));
|
||||
restore_button_pin();
|
||||
return -1;
|
||||
}
|
||||
|
||||
adc_oneshot_chan_cfg_t chan_cfg = {
|
||||
.atten = ADC_ATTEN_DB_12,
|
||||
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||
};
|
||||
err = adc_oneshot_config_channel(adc, channel, &chan_cfg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "ADC channel config failed (%s)", esp_err_to_name(err));
|
||||
adc_oneshot_del_unit(adc);
|
||||
restore_button_pin();
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Curve fitting is the ESP32-C6's calibration scheme. Without it,
|
||||
* fall back to raw readings scaled by the nominal full-scale range
|
||||
* -- coarser, but the percent curve is coarse anyway. */
|
||||
adc_cali_handle_t cali = NULL;
|
||||
adc_cali_curve_fitting_config_t cali_cfg = {
|
||||
.unit_id = unit,
|
||||
.chan = channel,
|
||||
.atten = ADC_ATTEN_DB_12,
|
||||
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||
};
|
||||
bool calibrated = adc_cali_create_scheme_curve_fitting(&cali_cfg, &cali) == ESP_OK;
|
||||
if (!calibrated) {
|
||||
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
|
||||
}
|
||||
|
||||
int mv_sum = 0;
|
||||
int samples = 0;
|
||||
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
||||
int value;
|
||||
if (calibrated) {
|
||||
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
|
||||
mv_sum += value;
|
||||
samples++;
|
||||
}
|
||||
} else {
|
||||
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
|
||||
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (calibrated) {
|
||||
adc_cali_delete_scheme_curve_fitting(cali);
|
||||
}
|
||||
adc_oneshot_del_unit(adc);
|
||||
restore_button_pin();
|
||||
|
||||
if (samples == 0) {
|
||||
ESP_LOGW(TAG, "All ADC reads failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
|
||||
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
|
||||
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int percent = mv_to_percent(battery_mv);
|
||||
ESP_LOGI(TAG, "Battery: %dmV -> %d%%", battery_mv, percent);
|
||||
return percent;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
int battery_read_percent(void) { return -1; }
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* Reads the battery level via the ADC voltage divider
|
||||
* (CONFIG_FRAME_BATTERY_ADC_GPIO -- see Kconfig for the expected XIAO
|
||||
* ESP32-C6 wiring). Returns 0-100, or -1 for any of:
|
||||
* - feature disabled (CONFIG_FRAME_BATTERY_ADC_GPIO < 0),
|
||||
* - running on mains (CONFIG_FRAME_VBUS_SENSE_GPIO reads high),
|
||||
* - reading outside the plausible LiPo range (catches "no battery
|
||||
* attached" and a button held on a shared pin, which reads ~0V),
|
||||
* - any ADC setup/read error.
|
||||
*
|
||||
* The battery pin is expected to be shared with a button (the settled
|
||||
* XIAO design shares the back button's GPIO0/A0): call this AFTER the
|
||||
* button checks at boot, since the ADC read temporarily reconfigures
|
||||
* the pin. On return the pin is always restored to button duty (input +
|
||||
* pull-up + deep-sleep wake armed).
|
||||
*/
|
||||
int battery_read_percent(void);
|
||||
@@ -588,7 +588,8 @@ static bool wait_for_button_press(uint32_t timeout_ms)
|
||||
* had that data); level 2 adds named-face labels on top. action only
|
||||
* applies at level 1 -- escalating to level 2 redisplays the same
|
||||
* photo, so it never re-advances/-backs. */
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
|
||||
int battery_percent)
|
||||
{
|
||||
char management_url[256];
|
||||
build_url(management_url, sizeof(management_url), cfg, "");
|
||||
@@ -621,6 +622,7 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
.share_url = share_url[0] != '\0' ? share_url : NULL,
|
||||
.face_labels = face_labels,
|
||||
.face_label_count = face_label_count,
|
||||
.battery_percent = battery_percent,
|
||||
};
|
||||
|
||||
manage_overlay_set_t overlay;
|
||||
@@ -645,10 +647,10 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
* that (escalating, or the final revert) are logged but don't count as
|
||||
* an overall failure -- something was already shown successfully, which
|
||||
* was the point of the button. */
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
|
||||
{
|
||||
int level = 1;
|
||||
esp_err_t err = show_menu_level(cfg, action, level);
|
||||
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
@@ -661,7 +663,7 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
break; /* timeout at any level, or a press while already maxed out -- exit */
|
||||
}
|
||||
level++;
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
|
||||
if (level_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
||||
break;
|
||||
@@ -678,15 +680,58 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||
* show_management_qr -- the escalating manage menu (see
|
||||
* run_management_menu()). */
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
{
|
||||
if (!show_management_qr) {
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
}
|
||||
return run_management_menu(cfg, action);
|
||||
return run_management_menu(cfg, action, battery_percent);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
/* Reports the battery percent to the server (POST /frame/battery).
|
||||
* Best-effort only: a battery report must never fail a photo cycle, so
|
||||
* every failure here is just a warning. No-op for percent < 0. */
|
||||
static void report_battery(const frame_config_t *cfg, int percent)
|
||||
{
|
||||
if (percent < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/battery");
|
||||
|
||||
char body[48];
|
||||
int body_len = snprintf(body, sizeof(body), "{\"percent\": %d}", percent);
|
||||
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_POST,
|
||||
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, body_len);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Battery report failed to connect: %s", esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return;
|
||||
}
|
||||
esp_http_client_write(client, body, body_len);
|
||||
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
|
||||
if (status != 200) {
|
||||
ESP_LOGW(TAG, "Battery report returned HTTP %d", status);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Reported battery %d%% to server", percent);
|
||||
}
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
{
|
||||
esp_err_t epd_err = epd_init();
|
||||
bool have_display = (epd_err == ESP_OK);
|
||||
@@ -720,7 +765,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* worth it to stop false-failing on the common case. */
|
||||
bool image_ok = true;
|
||||
if (have_display) {
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
|
||||
image_ok = (fetch_err == ESP_OK);
|
||||
if (!image_ok) {
|
||||
/* epd_display_stream() never triggers a physical refresh on a
|
||||
@@ -741,6 +786,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* just be discarded. */
|
||||
uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
if (image_ok) {
|
||||
report_battery(cfg, battery_percent);
|
||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
||||
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
}
|
||||
|
||||
@@ -37,5 +37,10 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
|
||||
* top-right corner linking to the server's config page, held for 30
|
||||
* seconds (the device stays awake), then reverted back to the plain
|
||||
* photo before proceeding to the normal sleep-interval logic.
|
||||
*
|
||||
* battery_percent (0-100, or -1 for "no reading" -- see
|
||||
* battery_read_percent()) is shown on the management menu overlay and
|
||||
* reported to the server after a successful fetch; -1 skips both.
|
||||
*/
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "next_button.h"
|
||||
#include "back_button.h"
|
||||
#include "combo_button.h"
|
||||
#include "battery.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
@@ -51,12 +52,18 @@ void app_main(void)
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
bool show_management_qr = combo_button_check();
|
||||
|
||||
/* Must come after the button checks: the battery pin is (by design,
|
||||
* on the XIAO board) shared with a button, and the ADC read briefly
|
||||
* takes the pin over -- see battery.h. -1 = no reading (disabled,
|
||||
* on mains, or implausible). */
|
||||
int battery_percent = battery_read_percent();
|
||||
|
||||
frame_config_t cfg;
|
||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||
if (cfg_err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
||||
frame_client_run(&cfg, action, show_management_qr);
|
||||
frame_client_run(&cfg, action, show_management_qr, battery_percent);
|
||||
return; /* frame_client_run currently never returns */
|
||||
}
|
||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
||||
|
||||
@@ -223,6 +223,71 @@ static esp_err_t render_face_label_region(const char *name, int anchor_x, int an
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Battery glyph dimensions -- a static outline (body rectangle + small
|
||||
* terminal nub on the right), deliberately NOT a fill-level graphic. */
|
||||
#define BATTERY_ICON_W 44
|
||||
#define BATTERY_ICON_H 24
|
||||
#define BATTERY_ICON_STROKE 2
|
||||
#define BATTERY_NUB_W 6
|
||||
#define BATTERY_NUB_H 12
|
||||
#define BATTERY_ICON_TEXT_GAP 8
|
||||
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
|
||||
|
||||
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
|
||||
{
|
||||
for (int y = 0; y < BATTERY_ICON_H; y++) {
|
||||
for (int x = 0; x < BATTERY_ICON_W; x++) {
|
||||
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
|
||||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
|
||||
if (edge) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
|
||||
for (int y = 0; y < BATTERY_NUB_H; y++) {
|
||||
for (int x = 0; x < BATTERY_NUB_W; x++) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* White-padded box with the battery glyph and "NN%" beside it, placed
|
||||
* directly below an already-positioned anchor region (the top-right
|
||||
* manage QR box), right-aligned to the anchor's right edge. */
|
||||
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
|
||||
manage_overlay_region_t *out)
|
||||
{
|
||||
char text[8];
|
||||
snprintf(text, sizeof(text), "%d%%", percent);
|
||||
|
||||
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
|
||||
int text_w = (int)strlen(text) * Font24.Width;
|
||||
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
|
||||
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
|
||||
|
||||
int w = content_w + PADDING * 2;
|
||||
int h = content_h + PADDING * 2;
|
||||
w += w % 2;
|
||||
|
||||
int stride = w / 2;
|
||||
uint8_t *buf = malloc((size_t)stride * h);
|
||||
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
|
||||
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||
|
||||
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
|
||||
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
|
||||
PADDING + (content_h - Font24.Height) / 2);
|
||||
|
||||
out->buf = buf;
|
||||
out->w = w;
|
||||
out->h = h;
|
||||
out->x0 = anchor->x0 + anchor->w - w;
|
||||
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
|
||||
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
|
||||
{
|
||||
out->count = 0;
|
||||
@@ -234,6 +299,14 @@ esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_
|
||||
}
|
||||
out->count++;
|
||||
|
||||
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
|
||||
/* Anchored below the manage QR box just rendered (regions[0]). */
|
||||
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
|
||||
ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
|
||||
const char *line2 =
|
||||
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/* 4 fixed corner regions (manage QR, location, date, share QR) plus up
|
||||
* to MANAGE_FACE_LABELS_MAX arbitrary-position named-face labels (see
|
||||
* manage_face_label_t below). MANAGE_FACE_LABELS_MAX is capped small
|
||||
* deliberately, not arbitrarily -- each label is its own malloc'd
|
||||
* buffer, and the 4 fixed regions alone already use a meaningful chunk
|
||||
* of the ESP32-C6's limited RAM; this keeps worst-case overlay memory
|
||||
* well clear of what the WiFi/HTTP stack needs alongside it. */
|
||||
/* 5 fixed regions (manage QR, battery indicator, location, date, share
|
||||
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
|
||||
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
|
||||
* capped small deliberately, not arbitrarily -- each label is its own
|
||||
* malloc'd buffer, and the fixed regions alone already use a meaningful
|
||||
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
|
||||
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
|
||||
#define MANAGE_FACE_LABELS_MAX 4
|
||||
#define MANAGE_OVERLAY_MAX_REGIONS (4 + MANAGE_FACE_LABELS_MAX)
|
||||
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
|
||||
|
||||
typedef struct {
|
||||
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
|
||||
@@ -38,6 +38,7 @@ typedef struct {
|
||||
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
|
||||
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
|
||||
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
|
||||
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
|
||||
} manage_overlay_content_t;
|
||||
|
||||
/**
|
||||
|
||||
+18
-3
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+26
-13
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+32
-2
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@
|
||||
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</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)
|
||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
|
||||
@@ -125,6 +133,7 @@
|
||||
const body = new URLSearchParams({
|
||||
album_id: document.getElementById('album_id').value || '',
|
||||
order: document.getElementById('order').value,
|
||||
orientation: document.getElementById('orientation').value,
|
||||
refresh_interval_s: String(minutes * 60),
|
||||
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
||||
queue_target_len: document.getElementById('queue_target_len').value,
|
||||
@@ -424,6 +433,13 @@
|
||||
} else {
|
||||
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);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
|
||||
Reference in New Issue
Block a user