The frame-claiming pipeline, end to end. Firmware: every request now carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url), and the captive portal's success page became a redirect that hands the user's browser to <server>/claim?device_id=... after ~7s -- enough time for the phone to drop the provisioning AP while the device reboots. The server pushes a per-frame device token through /frame/config during a one-time handshake; the firmware persists it to NVS (a dedicated single-key write that deliberately doesn't reset the connected-once flag or WiFi cache) and prefers it over the provisioned shared token from the next request on. Config response buffer grows 256->512. Both board variants compile clean; new firmware also works against an old server (which ignores ?id=) and old firmware against this server (the phase A legacy mapping), so either deploy order survives. Server: /claim lands the captive-portal redirect -- claim-gated signup (a valid unclaimed/unregistered device id IS the enrollment invitation), pending claims for the user-beats-the-frame race (auto-attached at self-registration, 24h expiry), and a waiting page that refreshes until the frame checks in. Unclaimed/unconfigured frames get a rendered instruction placeholder with a QR from /frame/image (200, never an error loop) -- new qrcode dep, placeholder shares the exact quantize/pack path photos use. The on-frame manage QR now resolves to a limited no-login page: scans of / carrying device credentials (new ?id&token or the legacy shared token) 303 to /m/<manage_token>, which allows exactly view queue, show-next, advance, back, and scoped thumbnails -- no settings, no removal, no other frames. Full control means logging in. One real protocol hole found by simulating full wake cycles: after self-registration the device could never authenticate again (the wake cycle fetches the image BEFORE /frame/config delivers its token). require_device now treats the id itself as the credential until the first authenticated request flips device_token_ack -- the same trust level as open registration, closing permanently once the handshake completes.
1004 lines
40 KiB
C
1004 lines
40 KiB
C
#include <string.h>
|
|
|
|
#include "esp_app_desc.h"
|
|
#include "esp_event.h"
|
|
#include "esp_ota_ops.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_crt_bundle.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 "combo_button.h"
|
|
#include "ota_update.h"
|
|
#include "board_antenna.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;
|
|
|
|
/* Passed as the event-handler arg during frame_wifi_connect_sta so it can
|
|
* apply the cached static IP (fast_cache != NULL) exactly once, on the
|
|
* fast-connect attempt's own WIFI_EVENT_STA_CONNECTED -- fallback attempts
|
|
* leave fast_cache NULL and get normal DHCP. */
|
|
typedef struct {
|
|
esp_netif_t *netif;
|
|
const frame_wifi_cache_t *fast_cache;
|
|
} sta_connect_ctx_t;
|
|
|
|
/* Applies a cached static IP right after L2 link-up, skipping DHCP.
|
|
* esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (which is what
|
|
* unblocks the connect-attempt wait below) once the netif is already
|
|
* "up" -- true by this point, since the internal netif-glue's own
|
|
* WIFI_EVENT_STA_CONNECTED subscriber (registered earlier, in
|
|
* esp_netif_create_default_wifi_sta()) runs before this one and brings
|
|
* the netif up first. Confirmed against ESP-IDF's own
|
|
* examples/protocols/static_ip. */
|
|
static void apply_fast_ip(esp_netif_t *netif, const frame_wifi_cache_t *cache)
|
|
{
|
|
if (esp_netif_dhcpc_stop(netif) != ESP_OK) {
|
|
return;
|
|
}
|
|
esp_netif_ip_info_t ip_info = {
|
|
.ip.addr = cache->ip,
|
|
.netmask.addr = cache->netmask,
|
|
.gw.addr = cache->gateway,
|
|
};
|
|
if (esp_netif_set_ip_info(netif, &ip_info) != ESP_OK) {
|
|
return;
|
|
}
|
|
if (cache->dns != 0) {
|
|
esp_netif_dns_info_t dns_info = { .ip.type = ESP_IPADDR_TYPE_V4 };
|
|
dns_info.ip.u_addr.ip4.addr = cache->dns;
|
|
esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info);
|
|
}
|
|
}
|
|
|
|
/* Records BSSID/channel/IP/netmask/gateway/DNS from a connection that just
|
|
* succeeded (fast path or normal), for the next wake's fast-connect
|
|
* attempt. Best-effort: any lookup failing here just means next wake
|
|
* falls back to a normal scan+DHCP, not a hard error. */
|
|
static void save_wifi_cache(esp_netif_t *netif)
|
|
{
|
|
wifi_ap_record_t ap_info;
|
|
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK) {
|
|
return;
|
|
}
|
|
esp_netif_ip_info_t ip_info;
|
|
if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) {
|
|
return;
|
|
}
|
|
esp_netif_dns_info_t dns_info = {0};
|
|
esp_netif_get_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info);
|
|
|
|
frame_wifi_cache_t cache = {0};
|
|
memcpy(cache.bssid, ap_info.bssid, sizeof(cache.bssid));
|
|
cache.channel = ap_info.primary;
|
|
cache.ip = ip_info.ip.addr;
|
|
cache.netmask = ip_info.netmask.addr;
|
|
cache.gateway = ip_info.gw.addr;
|
|
cache.dns = dns_info.ip.u_addr.ip4.addr;
|
|
frame_wifi_cache_save(&cache);
|
|
}
|
|
|
|
/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
|
|
* appending cfg->access_token as ?token= if one's set. 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. Every URL carries ?id= (the device's
|
|
* MAC-derived identity -- how a multi-frame server tells frames apart
|
|
* and how an unknown frame self-registers) plus &token=: the server-
|
|
* issued per-frame device token once one has been delivered via
|
|
* /frame/config, else the provisioned access token (the legacy shared
|
|
* secret, also what a pre-multi-frame server still expects). This is
|
|
* the one chokepoint all requests go through, so every caller gets both
|
|
* for free instead of needing to remember to add them. */
|
|
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
|
|
{
|
|
const char *toolsserver = cfg->toolsserver;
|
|
size_t len;
|
|
if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) {
|
|
len = (size_t)snprintf(out, out_size, "%s/%s", toolsserver, path);
|
|
} else {
|
|
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
|
|
}
|
|
|
|
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
|
frame_device_id_get(device_id, sizeof(device_id));
|
|
if (len < out_size) {
|
|
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
|
|
}
|
|
|
|
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
|
|
if (token[0] != '\0' && len < out_size) {
|
|
snprintf(out + len, out_size - len, "&token=%s", token);
|
|
}
|
|
}
|
|
|
|
/* 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)
|
|
{
|
|
sta_connect_ctx_t *ctx = (sta_connect_ctx_t *)arg;
|
|
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_CONNECTED) {
|
|
if (ctx->fast_cache != NULL) {
|
|
apply_fast_ip(ctx->netif, ctx->fast_cache);
|
|
}
|
|
} 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)
|
|
{
|
|
board_antenna_select_onboard();
|
|
|
|
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));
|
|
|
|
sta_connect_ctx_t ctx = { .netif = sta_netif, .fast_cache = NULL };
|
|
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, &ctx, &wifi_handler));
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, &ctx, &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);
|
|
|
|
frame_wifi_cache_t cache;
|
|
bool have_cache = frame_wifi_cache_load(&cache);
|
|
|
|
wifi_config_t start_config = wifi_config;
|
|
if (have_cache) {
|
|
/* Known BSSID/channel -- skips the all-channel scan. The IP side
|
|
* of the fast path (skipping DHCP) happens in apply_fast_ip once
|
|
* WIFI_EVENT_STA_CONNECTED confirms this specific AP answered. */
|
|
start_config.sta.bssid_set = true;
|
|
memcpy(start_config.sta.bssid, cache.bssid, sizeof(cache.bssid));
|
|
start_config.sta.channel = cache.channel;
|
|
start_config.sta.scan_method = WIFI_FAST_SCAN;
|
|
}
|
|
|
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &start_config));
|
|
ESP_ERROR_CHECK(esp_wifi_start());
|
|
|
|
esp_err_t result = ESP_FAIL;
|
|
|
|
if (have_cache) {
|
|
ESP_LOGI(TAG, "Connecting to '%s' (fast path: cached BSSID/channel + static IP)", cfg->sta_ssid);
|
|
ctx.fast_cache = &cache;
|
|
|
|
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));
|
|
ctx.fast_cache = NULL;
|
|
|
|
if (bits & STA_CONNECTED_BIT) {
|
|
result = ESP_OK;
|
|
} else {
|
|
ESP_LOGW(TAG, "Fast-connect attempt failed, falling back to a full scan");
|
|
frame_wifi_cache_clear();
|
|
/* apply_fast_ip's esp_netif_dhcpc_stop() leaves the netif's
|
|
* internal DHCP state STOPPED rather than the default INIT --
|
|
* left alone, the next WIFI_EVENT_STA_CONNECTED would make
|
|
* esp-netif's own glue silently re-post the stale cached IP
|
|
* instead of actually running DHCP (see
|
|
* esp_netif_action_connected in esp_netif_handlers.c). Calling
|
|
* this now (netif is down, mid-retry) just resets that state
|
|
* back to INIT -- confirmed against esp_netif_dhcpc_start's
|
|
* source, doesn't yet touch the network. */
|
|
esp_netif_dhcpc_start(sta_netif);
|
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
|
}
|
|
}
|
|
|
|
for (int attempt = 1; result != ESP_OK && 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) {
|
|
save_wifi_cache(sta_netif);
|
|
} else {
|
|
/* 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 */
|
|
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
|
|
/* Per-frame token the server pushes until this device has
|
|
* authenticated with it once; empty when absent. Persisted via
|
|
* frame_config_set_device_token() and used by build_url() from the
|
|
* next request on. */
|
|
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
|
|
} 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), the source of the server-configurable refresh interval,
|
|
* and (via the X-Frame-Version/X-Frame-Board request headers and
|
|
* firmware_version response field) the device's OTA update check --
|
|
* piggybacked on a request already made every wake, no extra round
|
|
* trip. X-Frame-Board lets the server learn which board this device is
|
|
* (CONFIG_FRAME_BOARD_NAME) so it can pick the right Gitea release
|
|
* asset itself, instead of a user manually selecting a board in the
|
|
* web UI. */
|
|
static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
|
{
|
|
frame_server_config_t result = {
|
|
.reachable = false,
|
|
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
|
};
|
|
result.firmware_version[0] = '\0';
|
|
result.device_token[0] = '\0';
|
|
|
|
char url[256];
|
|
build_url(url, sizeof(url), cfg, "frame/config");
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = HTTP_METHOD_GET,
|
|
.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, "X-Frame-Version", esp_app_get_description()->version);
|
|
esp_http_client_set_header(client, "X-Frame-Board", CONFIG_FRAME_BOARD_NAME);
|
|
|
|
esp_err_t err = esp_http_client_open(client, 0);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Server '%s' not reachable: %s", cfg->toolsserver, esp_err_to_name(err));
|
|
esp_http_client_cleanup(client);
|
|
return result;
|
|
}
|
|
|
|
esp_http_client_fetch_headers(client);
|
|
result.reachable = true;
|
|
|
|
/* 512 (was 256): the response also carries "device_token" during the
|
|
* one-time identity handshake -- worst case is still well under half
|
|
* of this, the rest is headroom for future fields. */
|
|
char body[512];
|
|
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);
|
|
}
|
|
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
|
|
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
|
|
|
|
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 frame_config_t *cfg, 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[256];
|
|
build_url(url, sizeof(url), cfg, "frame/photo-info");
|
|
|
|
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
|
|
* unlike fetch_frame_config() (always called after the image fetch
|
|
* has already warmed the connection, see frame_client_run()), this
|
|
* is the *first* network call of the wake cycle whenever the manage
|
|
* menu is opened -- same cold-connection latency spike that made
|
|
* the short timeout unreliable for /frame/config before, now worse
|
|
* with a real TLS handshake on top. Confirmed on hardware: this
|
|
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
|
|
* rest of the cycle (a fresh connection, but not the *first* one)
|
|
* succeeded fine. */
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = HTTP_METHOD_GET,
|
|
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
|
.crt_bundle_attach = esp_crt_bundle_attach,
|
|
};
|
|
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, cfg, 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 frame_config_t *cfg, manage_face_label_t *out, int max_labels)
|
|
{
|
|
char url[256];
|
|
build_url(url, sizeof(url), cfg, "frame/face-labels");
|
|
|
|
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
|
|
* request too, not a warmed-connection reachability check. */
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = HTTP_METHOD_GET,
|
|
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
|
.crt_bundle_attach = esp_crt_bundle_attach,
|
|
};
|
|
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 (FETCH_NORMAL), or POSTs /frame/advance or
|
|
* /frame/back to force a move in either direction (FETCH_ADVANCE /
|
|
* FETCH_BACK -- the next-photo / back-photo buttons), 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, fetch_action_t action,
|
|
const manage_overlay_set_t *overlay)
|
|
{
|
|
const char *path = "frame/image";
|
|
if (action == FETCH_ADVANCE) {
|
|
path = "frame/advance";
|
|
} else if (action == FETCH_BACK) {
|
|
path = "frame/back";
|
|
}
|
|
|
|
char url[256];
|
|
build_url(url, sizeof(url), cfg, path);
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.method = action == FETCH_NORMAL ? HTTP_METHOD_GET : HTTP_METHOD_POST,
|
|
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
|
.crt_bundle_attach = esp_crt_bundle_attach,
|
|
};
|
|
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 (!combo_button_is_pressed()) {
|
|
continue;
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_DEBOUNCE_MS));
|
|
if (!combo_button_is_pressed()) {
|
|
continue; /* noise, not a real press */
|
|
}
|
|
while (combo_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. 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,
|
|
int battery_percent)
|
|
{
|
|
char management_url[256];
|
|
build_url(management_url, sizeof(management_url), cfg, "");
|
|
|
|
char location_line1[32];
|
|
char location_line2[32];
|
|
char taken_at[32];
|
|
/* Wider than the other URL buffers in this file: unlike a fixed path,
|
|
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
|
|
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
|
|
* worst case ~266 bytes, which a 256-byte buffer could silently
|
|
* truncate the token off of (build_url()'s bounds check avoids an
|
|
* overflow, but a truncated/dropped token still means the resulting
|
|
* request just 401s with no obvious cause). */
|
|
char share_url[320];
|
|
fetch_photo_info(cfg, 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, 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,
|
|
.battery_percent = battery_percent,
|
|
};
|
|
|
|
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, action, &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, fetch_action_t action, int battery_percent)
|
|
{
|
|
int level = 1;
|
|
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);
|
|
}
|
|
|
|
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, 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;
|
|
}
|
|
}
|
|
|
|
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, 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, 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, battery_percent);
|
|
}
|
|
|
|
/* 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);
|
|
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, 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
|
|
* 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));
|
|
/* Covers the fast-connect cache's blind spot: WiFi can report
|
|
* a successful connection (cached static IP "worked" at the
|
|
* link layer) while the cached IP is actually stale/dead at
|
|
* the network layer -- this is the first real evidence of
|
|
* that, since it's the first thing that actually talks to the
|
|
* server. Clearing here means next wake gets a clean scan +
|
|
* DHCP instead of repeating the same silent failure. */
|
|
frame_wifi_cache_clear();
|
|
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) {
|
|
/* A full fetch/display cycle just succeeded -- exactly the proof
|
|
* of life needed to confirm a freshly-OTA'd image is good.
|
|
* No-op if this image was already marked valid (i.e. every
|
|
* normal boot, not just the one right after an update). */
|
|
esp_ota_mark_app_valid_cancel_rollback();
|
|
|
|
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;
|
|
|
|
/* One-time identity handshake: the server pushes this frame's
|
|
* own token until we've authenticated with it once. Persist it
|
|
* and use it immediately (the OTA below is part of this same
|
|
* cycle) via a local working copy -- cfg itself is const. */
|
|
frame_config_t updated_cfg;
|
|
if (server_cfg.device_token[0] != '\0' &&
|
|
strcmp(server_cfg.device_token, cfg->device_token) != 0) {
|
|
frame_config_set_device_token(server_cfg.device_token);
|
|
updated_cfg = *cfg;
|
|
snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
|
|
server_cfg.device_token);
|
|
cfg = &updated_cfg;
|
|
}
|
|
|
|
/* Last, deliberately -- the photo's already on screen and the
|
|
* battery report already sent, so a reboot here (whether OTA
|
|
* succeeds or the device is mid-update) never loses either. */
|
|
ota_update_if_available(cfg, server_cfg.firmware_version);
|
|
}
|
|
|
|
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();
|
|
}
|