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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
* 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
Reference in New Issue
Block a user