Build and push server image / build-and-push (push) Successful in 31s
ESP32 side can now reach the tools server over HTTPS: the Tools Server field accepts an https:// address for a TLS-terminating reverse proxy in front of the server (which still only ever speaks plain HTTP itself), trusting Cloudflare's Origin CA root (embedded at build time) since that's the common way to get a real cert on a private origin. Every URL the device builds -- image fetch, config check, manage-menu data, the QR codes' own links -- goes through one build_url() helper that picks the scheme from what's configured. Also adds an optional MANAGEMENT_TOKEN (docker-compose.yml) that gates the web UI (/, /api/*) behind a shared secret -- unset by default, so existing trusted-LAN deployments are unaffected. The same token is entered once during the ESP32's captive-portal setup and gets baked into the manage-menu's QR code (?token=...), so scanning it just works; visiting the page without a valid token shows a plain entry prompt instead of the config UI, and a valid query-param hit sets a cookie so the page's own fetch()/<img> calls stay authorized for the rest of the visit. Device-facing /frame/* endpoints are unaffected -- a separate, already-documented trust boundary.
742 lines
28 KiB
C
742 lines
28 KiB
C
#include <string.h>
|
|
|
|
#include "esp_event.h"
|
|
#include "esp_log.h"
|
|
#include "esp_wifi.h"
|
|
#include "esp_wifi_default.h"
|
|
#include "esp_netif.h"
|
|
#include "esp_http_client.h"
|
|
#include "esp_sleep.h"
|
|
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/event_groups.h"
|
|
|
|
#include "epd7in3e.h"
|
|
#include "status_screen.h"
|
|
#include "manage_qr_overlay.h"
|
|
#include "manage_button.h"
|
|
|
|
#include "frame_client.h"
|
|
|
|
static const char *TAG = "frame_client";
|
|
|
|
#define STA_CONNECTED_BIT BIT0
|
|
#define STA_FAILED_BIT BIT1
|
|
|
|
static EventGroupHandle_t s_sta_event_group;
|
|
|
|
/* Cloudflare's Origin CA root certs (RSA + ECC, both concatenated --
|
|
* mbedtls_x509_crt_parse() chains every cert in a PEM buffer into the
|
|
* trust store, so either one nginx presents validates), for trusting an
|
|
* https tools server whose reverse proxy terminates TLS with a
|
|
* Cloudflare-issued origin certificate. See firmware/main/certs/. */
|
|
extern const char cloudflare_origin_ca_pem_start[] asm("_binary_cloudflare_origin_ca_pem_start");
|
|
|
|
/* Builds a full URL from cfg->toolsserver + a path (no leading slash).
|
|
* toolsserver is normally a bare "host:port", defaulting to plain http;
|
|
* it may instead carry an explicit "http://" or "https://" prefix to
|
|
* pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
|
|
* is terminating TLS in front of the tools server. */
|
|
static void build_url(char *out, size_t out_size, const char *toolsserver, const char *path)
|
|
{
|
|
if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) {
|
|
snprintf(out, out_size, "%s/%s", toolsserver, path);
|
|
} else {
|
|
snprintf(out, out_size, "http://%s/%s", toolsserver, path);
|
|
}
|
|
}
|
|
|
|
/* wifi_sta_config_t's ssid/password fields are fixed-size byte arrays, not
|
|
* necessarily null-terminated (a full 32-char SSID fills the field exactly).
|
|
* snprintf() flags that as a possible truncation at -Werror, so copy by
|
|
* hand instead. */
|
|
static void copy_wifi_field(uint8_t *dst, size_t dst_size, const char *src)
|
|
{
|
|
size_t len = strnlen(src, dst_size);
|
|
memcpy(dst, src, len);
|
|
if (len < dst_size) {
|
|
dst[len] = '\0';
|
|
}
|
|
}
|
|
|
|
static void sta_event_handler(void *arg, esp_event_base_t event_base,
|
|
int32_t event_id, void *event_data)
|
|
{
|
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
|
|
esp_wifi_connect();
|
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
|
ESP_LOGW(TAG, "Disconnected from home WiFi");
|
|
xEventGroupSetBits(s_sta_event_group, STA_FAILED_BIT);
|
|
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
|
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
|
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
|
xEventGroupSetBits(s_sta_event_group, STA_CONNECTED_BIT);
|
|
}
|
|
}
|
|
|
|
esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
|
|
{
|
|
s_sta_event_group = xEventGroupCreate();
|
|
|
|
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
|
|
|
|
wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
|
ESP_ERROR_CHECK(esp_wifi_init(&init_cfg));
|
|
|
|
esp_event_handler_instance_t wifi_handler;
|
|
esp_event_handler_instance_t ip_handler;
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &sta_event_handler, NULL, &wifi_handler));
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, NULL, &ip_handler));
|
|
|
|
wifi_config_t wifi_config = {0};
|
|
copy_wifi_field(wifi_config.sta.ssid, sizeof(wifi_config.sta.ssid), cfg->sta_ssid);
|
|
copy_wifi_field(wifi_config.sta.password, sizeof(wifi_config.sta.password), cfg->sta_password);
|
|
|
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
|
ESP_ERROR_CHECK(esp_wifi_start());
|
|
|
|
esp_err_t result = ESP_FAIL;
|
|
for (int attempt = 1; attempt <= CONFIG_FRAME_STA_CONNECT_MAX_RETRIES; attempt++) {
|
|
ESP_LOGI(TAG, "Connecting to '%s' (attempt %d/%d)", cfg->sta_ssid, attempt,
|
|
CONFIG_FRAME_STA_CONNECT_MAX_RETRIES);
|
|
|
|
xEventGroupClearBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT);
|
|
esp_wifi_connect();
|
|
|
|
EventBits_t bits = xEventGroupWaitBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT,
|
|
pdTRUE, pdFALSE,
|
|
pdMS_TO_TICKS(CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS));
|
|
|
|
if (bits & STA_CONNECTED_BIT) {
|
|
result = ESP_OK;
|
|
break;
|
|
}
|
|
ESP_LOGW(TAG, "Attempt %d/%d failed", attempt, CONFIG_FRAME_STA_CONNECT_MAX_RETRIES);
|
|
}
|
|
|
|
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_handler);
|
|
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, ip_handler);
|
|
vEventGroupDelete(s_sta_event_group);
|
|
s_sta_event_group = NULL;
|
|
|
|
if (result != ESP_OK) {
|
|
/* Fully tear the WiFi driver back down on failure -- the caller
|
|
* falls back to provisioning, which calls esp_wifi_init() again
|
|
* for AP mode. Leaving the driver merely stopped (rather than
|
|
* deinitialized) made that second esp_wifi_init() call fail with
|
|
* ESP_ERR_INVALID_STATE and abort, confirmed on hardware. */
|
|
esp_wifi_stop();
|
|
esp_wifi_deinit();
|
|
esp_netif_destroy_default_wifi(sta_netif);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
typedef struct {
|
|
bool reachable;
|
|
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
|
|
} frame_server_config_t;
|
|
|
|
/* Finds the first integer value associated with "key" in a small JSON
|
|
* blob, e.g. 3600 in {"refresh_interval_s": 3600}. Not a general JSON
|
|
* parser -- just enough for this project's small, flat config response,
|
|
* to avoid pulling in a JSON library for one scalar field. */
|
|
static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
|
|
{
|
|
char needle[48];
|
|
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
|
const char *pos = strstr(json, needle);
|
|
if (pos == NULL) {
|
|
return false;
|
|
}
|
|
pos = strchr(pos, ':');
|
|
if (pos == NULL) {
|
|
return false;
|
|
}
|
|
pos++;
|
|
while (*pos == ' ') {
|
|
pos++;
|
|
}
|
|
char *end;
|
|
unsigned long value = strtoul(pos, &end, 10);
|
|
if (end == pos) {
|
|
return false;
|
|
}
|
|
*out = (uint32_t)value;
|
|
return true;
|
|
}
|
|
|
|
/* Finds the string value associated with "key" in a small, flat JSON
|
|
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
|
|
* Same rationale as json_extract_uint() -- not a general parser. Returns
|
|
* false if the key is missing or its value is JSON null. Only unescapes
|
|
* \" -- values from this server need nothing fancier. */
|
|
static bool json_extract_string(const char *json, const char *key, char *out, size_t out_size)
|
|
{
|
|
char needle[48];
|
|
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
|
const char *pos = strstr(json, needle);
|
|
if (pos == NULL) {
|
|
return false;
|
|
}
|
|
pos = strchr(pos, ':');
|
|
if (pos == NULL) {
|
|
return false;
|
|
}
|
|
pos++;
|
|
while (*pos == ' ') {
|
|
pos++;
|
|
}
|
|
if (strncmp(pos, "null", 4) == 0) {
|
|
return false;
|
|
}
|
|
if (*pos != '"') {
|
|
return false;
|
|
}
|
|
pos++;
|
|
|
|
size_t i = 0;
|
|
while (*pos != '\0' && *pos != '"' && i + 1 < out_size) {
|
|
if (pos[0] == '\\' && pos[1] == '"') {
|
|
out[i++] = '"';
|
|
pos += 2;
|
|
} else {
|
|
out[i++] = *pos++;
|
|
}
|
|
}
|
|
out[i] = '\0';
|
|
return true;
|
|
}
|
|
|
|
/* GETs the server's /frame/config -- doubles as both the reachability
|
|
* check (any completed HTTP response means the socket-level connection
|
|
* succeeded) and the source of the server-configurable refresh interval. */
|
|
static frame_server_config_t fetch_frame_config(const char *toolsserver)
|
|
{
|
|
frame_server_config_t result = {
|
|
.reachable = false,
|
|
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
|
};
|
|
|
|
char url[160];
|
|
build_url(url, sizeof(url), toolsserver, "frame/config");
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = HTTP_METHOD_GET,
|
|
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
|
|
.cert_pem = cloudflare_origin_ca_pem_start,
|
|
};
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
|
|
esp_err_t err = esp_http_client_open(client, 0);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Server '%s' not reachable: %s", toolsserver, esp_err_to_name(err));
|
|
esp_http_client_cleanup(client);
|
|
return result;
|
|
}
|
|
|
|
esp_http_client_fetch_headers(client);
|
|
result.reachable = true;
|
|
|
|
char body[256];
|
|
int total = 0;
|
|
int n;
|
|
while (total < (int)sizeof(body) - 1 &&
|
|
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
|
|
total += n;
|
|
}
|
|
body[total] = '\0';
|
|
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
uint32_t interval;
|
|
if (json_extract_uint(body, "refresh_interval_s", &interval)) {
|
|
result.refresh_interval_s = interval;
|
|
} else {
|
|
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
|
|
(int)result.refresh_interval_s);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/* GETs the server's /frame/photo-info for the manage-button overlay:
|
|
* location/taken_at text (left empty if the server didn't have them --
|
|
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
|
|
* from the returned asset_id, same construction pattern as
|
|
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
|
|
* current photo, etc.) just leaves all outputs empty -- the caller
|
|
* treats that as "skip these optional overlay regions", not a hard
|
|
* error, since the base "scan to manage" QR should still show. */
|
|
static void fetch_photo_info(const char *toolsserver, char *location_line1, size_t location_line1_size,
|
|
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
|
|
char *share_url, size_t share_url_size)
|
|
{
|
|
location_line1[0] = '\0';
|
|
location_line2[0] = '\0';
|
|
taken_at[0] = '\0';
|
|
share_url[0] = '\0';
|
|
|
|
char url[160];
|
|
build_url(url, sizeof(url), toolsserver, "frame/photo-info");
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = HTTP_METHOD_GET,
|
|
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
|
|
.cert_pem = cloudflare_origin_ca_pem_start,
|
|
};
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
|
|
esp_err_t err = esp_http_client_open(client, 0);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
|
|
esp_http_client_cleanup(client);
|
|
return;
|
|
}
|
|
|
|
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
|
|
if (status != 200) {
|
|
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
return;
|
|
}
|
|
|
|
char body[384];
|
|
int total = 0;
|
|
int n;
|
|
while (total < (int)sizeof(body) - 1 &&
|
|
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
|
|
total += n;
|
|
}
|
|
body[total] = '\0';
|
|
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
json_extract_string(body, "location_line1", location_line1, location_line1_size);
|
|
json_extract_string(body, "location_line2", location_line2, location_line2_size);
|
|
json_extract_string(body, "taken_at", taken_at, taken_at_size);
|
|
|
|
char asset_id[48];
|
|
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
|
|
char path[80];
|
|
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
|
|
build_url(share_url, share_url_size, toolsserver, path);
|
|
}
|
|
}
|
|
|
|
/* GETs the server's /frame/face-labels for the manage-button's escalated
|
|
* "level 2" menu -- named-face positions, if Immich has any for the
|
|
* current photo. Response is a flattened, fixed-slot shape ("count",
|
|
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
|
|
* array, read with the same flat-scalar helpers as everywhere else in
|
|
* this file instead of needing an actual array parser. Any failure
|
|
* (unreachable, malformed response, etc.) just returns 0 -- named faces
|
|
* are a "nice to have" addition to the menu, not worth failing it over. */
|
|
static int fetch_face_labels(const char *toolsserver, manage_face_label_t *out, int max_labels)
|
|
{
|
|
char url[160];
|
|
build_url(url, sizeof(url), toolsserver, "frame/face-labels");
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = HTTP_METHOD_GET,
|
|
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
|
|
.cert_pem = cloudflare_origin_ca_pem_start,
|
|
};
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
|
|
esp_err_t err = esp_http_client_open(client, 0);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
|
|
esp_http_client_cleanup(client);
|
|
return 0;
|
|
}
|
|
|
|
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
|
|
if (status != 200) {
|
|
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
return 0;
|
|
}
|
|
|
|
char body[768];
|
|
int total = 0;
|
|
int n;
|
|
while (total < (int)sizeof(body) - 1 &&
|
|
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
|
|
total += n;
|
|
}
|
|
body[total] = '\0';
|
|
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
uint32_t count = 0;
|
|
json_extract_uint(body, "count", &count);
|
|
if ((int)count > max_labels) {
|
|
count = (uint32_t)max_labels;
|
|
}
|
|
|
|
int found = 0;
|
|
for (uint32_t i = 0; i < count; i++) {
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
|
|
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
|
|
continue;
|
|
}
|
|
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
|
|
uint32_t x;
|
|
if (!json_extract_uint(body, key, &x)) {
|
|
continue;
|
|
}
|
|
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
|
|
uint32_t y;
|
|
if (!json_extract_uint(body, key, &y)) {
|
|
continue;
|
|
}
|
|
out[found].x = (int)x;
|
|
out[found].y = (int)y;
|
|
found++;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
typedef struct {
|
|
esp_http_client_handle_t client;
|
|
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
|
|
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
|
|
} http_read_ctx_t;
|
|
|
|
/* Splices one overlay region's pixels over the real photo bytes in chunk
|
|
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
|
|
* within the full frame intersects that region's rectangle. Rows/chunks
|
|
* outside the region's footprint are left completely untouched.
|
|
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
|
|
* is exact. */
|
|
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
|
|
const manage_overlay_region_t *region)
|
|
{
|
|
int byte_x0 = region->x0 / 2;
|
|
int byte_w = region->w / 2;
|
|
size_t chunk_end = chunk_start + chunk_len;
|
|
|
|
for (int row = region->y0; row < region->y0 + region->h; row++) {
|
|
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
|
|
size_t row_end = row_start + (size_t)byte_w;
|
|
|
|
size_t lo = row_start > chunk_start ? row_start : chunk_start;
|
|
size_t hi = row_end < chunk_end ? row_end : chunk_end;
|
|
if (lo >= hi) {
|
|
continue;
|
|
}
|
|
|
|
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
|
|
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
|
|
}
|
|
}
|
|
|
|
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
|
|
{
|
|
for (int i = 0; i < overlay->count; i++) {
|
|
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
|
|
}
|
|
}
|
|
|
|
/* Pulls the next chunk straight out of the in-progress HTTP response --
|
|
* epd_write_frame() calls this to feed the panel without ever holding
|
|
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
|
|
* set) as chunks pass through, so the panel driver never needs to know
|
|
* an overlay exists at all. */
|
|
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
|
{
|
|
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
|
|
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
|
|
if (n <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
if (ctx->overlay != NULL) {
|
|
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
|
|
}
|
|
ctx->stream_pos += (size_t)n;
|
|
|
|
return (size_t)n;
|
|
}
|
|
|
|
/* GETs /frame/image (or, if force_advance, POSTs /frame/advance to skip
|
|
* ahead immediately) and streams the response directly into the panel,
|
|
* splicing in overlay's pixels (if non-NULL) as it streams. Returning
|
|
* non-ESP_OK means the panel was never actually refreshed --
|
|
* epd_display_stream() (see epd7in3e.c) refuses to trigger a physical
|
|
* refresh on a short/wrong-size stream, so a failure here always leaves
|
|
* the visible screen exactly as it was. */
|
|
static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance,
|
|
const manage_overlay_set_t *overlay)
|
|
{
|
|
char url[160];
|
|
build_url(url, sizeof(url), cfg->toolsserver, force_advance ? "frame/advance" : "frame/image");
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET,
|
|
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
|
.cert_pem = cloudflare_origin_ca_pem_start,
|
|
};
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
|
|
esp_err_t err = esp_http_client_open(client, 0);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to open '%s': %s", url, esp_err_to_name(err));
|
|
esp_http_client_cleanup(client);
|
|
return err;
|
|
}
|
|
|
|
int content_length = esp_http_client_fetch_headers(client);
|
|
int status = esp_http_client_get_status_code(client);
|
|
if (status != 200) {
|
|
ESP_LOGE(TAG, "'%s' returned HTTP %d", url, status);
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
return ESP_FAIL;
|
|
}
|
|
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
|
|
|
|
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
|
|
uint32_t crc = 0;
|
|
err = epd_write_frame(http_read_fn, &ctx, &crc);
|
|
|
|
esp_http_client_close(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
|
|
uint32_t previous_crc;
|
|
if (frame_config_get_last_display_crc32(&previous_crc) == ESP_OK && previous_crc == crc) {
|
|
/* Same photo already on screen (e.g. redisplayed after a reboot,
|
|
* before the refresh interval elapsed server-side) -- skip the
|
|
* physical refresh, avoiding its visible flash and 15-30s
|
|
* duration for no visual change. */
|
|
ESP_LOGI(TAG, "Frame unchanged since last display, skipping refresh");
|
|
return ESP_OK;
|
|
}
|
|
|
|
err = epd_turn_on_display();
|
|
if (err == ESP_OK) {
|
|
frame_config_set_last_display_crc32(crc);
|
|
}
|
|
return err;
|
|
}
|
|
|
|
#define MANAGE_MENU_MAX_LEVEL 2
|
|
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
|
|
#define MANAGE_MENU_POLL_MS 150
|
|
#define MANAGE_MENU_DEBOUNCE_MS 30
|
|
|
|
/* Polls the manage button for up to timeout_ms for a new press. On
|
|
* detecting one, waits for release before returning true, so a single
|
|
* physical press-and-release is always exactly one event to the caller
|
|
* -- without that, a press held across multiple poll intervals would
|
|
* register as multiple escalations. Returns false if timeout_ms elapses
|
|
* with no press. */
|
|
static bool wait_for_button_press(uint32_t timeout_ms)
|
|
{
|
|
uint32_t elapsed_ms = 0;
|
|
while (elapsed_ms < timeout_ms) {
|
|
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
|
|
elapsed_ms += MANAGE_MENU_POLL_MS;
|
|
|
|
if (!manage_button_is_pressed()) {
|
|
continue;
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_DEBOUNCE_MS));
|
|
if (!manage_button_is_pressed()) {
|
|
continue; /* noise, not a real press */
|
|
}
|
|
while (manage_button_is_pressed()) {
|
|
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Builds and shows one level of the manage menu: level 1 is the base
|
|
* overlay (management QR + location/date/share-QR wherever the server
|
|
* had that data); level 2 adds named-face labels on top. force_advance
|
|
* only applies at level 1 -- escalating to level 2 redisplays the same
|
|
* photo, so it never re-advances. */
|
|
static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance, int level)
|
|
{
|
|
char management_url[256];
|
|
build_url(management_url, sizeof(management_url), cfg->toolsserver, "");
|
|
if (cfg->access_token[0] != '\0') {
|
|
/* Embeds the token so scanning the QR just works -- matches the
|
|
* server's MANAGEMENT_TOKEN gate on GET / (see server/README.md). */
|
|
size_t len = strlen(management_url);
|
|
snprintf(management_url + len, sizeof(management_url) - len, "?token=%s", cfg->access_token);
|
|
}
|
|
|
|
char location_line1[32];
|
|
char location_line2[32];
|
|
char taken_at[32];
|
|
char share_url[160];
|
|
fetch_photo_info(cfg->toolsserver, location_line1, sizeof(location_line1), location_line2,
|
|
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
|
|
|
|
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
|
|
int face_label_count = 0;
|
|
if (level >= 2) {
|
|
face_label_count = fetch_face_labels(cfg->toolsserver, face_labels, MANAGE_FACE_LABELS_MAX);
|
|
}
|
|
|
|
manage_overlay_content_t content = {
|
|
.management_url = management_url,
|
|
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
|
|
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
|
|
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
|
|
.share_url = share_url[0] != '\0' ? share_url : NULL,
|
|
.face_labels = face_labels,
|
|
.face_label_count = face_label_count,
|
|
};
|
|
|
|
manage_overlay_set_t overlay;
|
|
esp_err_t err = manage_overlay_render(&content, &overlay);
|
|
if (err != ESP_OK) {
|
|
manage_overlay_free(&overlay);
|
|
return err;
|
|
}
|
|
|
|
err = fetch_and_display(cfg, force_advance, &overlay);
|
|
manage_overlay_free(&overlay);
|
|
return err;
|
|
}
|
|
|
|
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
|
|
* from there, each further press within 30s escalates one level (up to
|
|
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
|
|
* already at the max level exits immediately instead of escalating
|
|
* further. A 30s timeout at any level also exits. Device stays awake
|
|
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
|
|
* only if the very first (level 1) render/fetch failed; failures after
|
|
* 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, bool force_advance)
|
|
{
|
|
int level = 1;
|
|
esp_err_t err = show_menu_level(cfg, force_advance, level);
|
|
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, force_advance, NULL);
|
|
}
|
|
|
|
for (;;) {
|
|
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
|
|
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
|
|
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
|
|
break; /* timeout at any level, or a press while already maxed out -- exit */
|
|
}
|
|
level++;
|
|
esp_err_t level_err = show_menu_level(cfg, false, level);
|
|
if (level_err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
|
break;
|
|
}
|
|
}
|
|
|
|
esp_err_t revert_err = fetch_and_display(cfg, false, NULL);
|
|
if (revert_err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* 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, bool force_advance, bool show_management_qr)
|
|
{
|
|
if (!show_management_qr) {
|
|
return fetch_and_display(cfg, force_advance, NULL);
|
|
}
|
|
return run_management_menu(cfg, force_advance);
|
|
}
|
|
|
|
void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_management_qr)
|
|
{
|
|
esp_err_t epd_err = epd_init();
|
|
bool have_display = (epd_err == ESP_OK);
|
|
if (!have_display) {
|
|
ESP_LOGW(TAG, "EPD init failed (%s), continuing without display", esp_err_to_name(epd_err));
|
|
}
|
|
|
|
/* Always show the status screen on the first successful connection
|
|
* after (re)provisioning, regardless of outcome -- confirms the
|
|
* connection worked. Skipped on later wakes to save a refresh, except
|
|
* when something's actually wrong (handled below). */
|
|
bool first_connection = !frame_config_has_connected_once();
|
|
if (first_connection) {
|
|
frame_config_mark_connected_once();
|
|
if (have_display) {
|
|
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_PENDING);
|
|
}
|
|
}
|
|
|
|
/* The image fetch goes before the config check, not after. It has a
|
|
* far more generous timeout (CONFIG_FRAME_FETCH_TIMEOUT_MS, 15s by
|
|
* default, vs. the config check's 3s), so it comfortably absorbs the
|
|
* extra connection-setup latency that's common on the very first
|
|
* request after waking from a long deep sleep (stale ARP entries and
|
|
* the like) -- confirmed on hardware: the config check's tight
|
|
* timeout was intermittently tripping on exactly that latency while
|
|
* it went first, even though the image fetch right after it (on an
|
|
* already-warm connection) never had trouble. Trade-off: on a fully
|
|
* down server, the device now waits up to the image fetch's longer
|
|
* timeout to notice, instead of the config check's shorter one --
|
|
* 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, force_advance, show_management_qr);
|
|
image_ok = (fetch_err == ESP_OK);
|
|
if (!image_ok) {
|
|
/* epd_display_stream() never triggers a physical refresh on a
|
|
* failed/short/wrong-size stream (see epd7in3e.c), so the
|
|
* visible screen is guaranteed untouched here -- always safe
|
|
* to show what went wrong instead of leaving stale content
|
|
* with no indication anything failed. */
|
|
ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err));
|
|
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED);
|
|
} else if (first_connection) {
|
|
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK);
|
|
}
|
|
}
|
|
|
|
/* Only worth asking for the refresh interval if the image fetch
|
|
* actually worked -- a failed fetch already means CONFIG_FRAME_RETRY_INTERVAL_S,
|
|
* so there's nothing to gain from a config request whose result would
|
|
* just be discarded. */
|
|
uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
|
|
if (image_ok) {
|
|
frame_server_config_t server_cfg = fetch_frame_config(cfg->toolsserver);
|
|
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
|
}
|
|
|
|
if (have_display) {
|
|
epd_sleep();
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Deep sleeping for %u seconds", (unsigned)sleep_seconds);
|
|
esp_sleep_enable_timer_wakeup((uint64_t)sleep_seconds * 1000000ULL);
|
|
esp_deep_sleep_start();
|
|
}
|