Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
772 lines
31 KiB
C
772 lines
31 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 "epd_board.h"
|
|
#include "status_screen.h"
|
|
#include "combo_button.h"
|
|
#include "ota_update.h"
|
|
#include "board_antenna.h"
|
|
#include "battery.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->device_token as &token= once one's been delivered.
|
|
* 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=. 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);
|
|
}
|
|
|
|
if (cfg->device_token[0] != '\0' && len < out_size) {
|
|
snprintf(out + len, out_size - len, "&token=%s", cfg->device_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 */
|
|
/* How long NEXT/BACK must be held to trigger a global action instead
|
|
* of a short press (see next_button.h/back_button.h) --
|
|
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
|
|
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
|
|
* the *next* boot's button-hold decision -- this fetch happens too
|
|
* late in the cycle for its own boot's decision, see that function's
|
|
* own doc comment. */
|
|
uint32_t hold_duration_ms;
|
|
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,
|
|
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
|
|
};
|
|
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);
|
|
}
|
|
uint32_t hold_ms;
|
|
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
|
|
result.hold_duration_ms = hold_ms;
|
|
}
|
|
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;
|
|
}
|
|
|
|
typedef struct {
|
|
esp_http_client_handle_t client;
|
|
} http_read_ctx_t;
|
|
|
|
/* 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. Just a plain relay: the manage overlay
|
|
* (scan-to-manage QR, battery, location/date, share-QR, named face
|
|
* labels) is composited server-side now (see server/app/manage_overlay.py),
|
|
* baked into the same image bytes as any other render -- this function,
|
|
* like the rest of this file, has no idea an overlay exists. */
|
|
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);
|
|
return n > 0 ? (size_t)n : 0;
|
|
}
|
|
|
|
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
|
|
* /frame/global-next, or /frame/global-back to force a move/action
|
|
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
|
|
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
|
|
* manage=true (the manage button) appends &manage=1, telling the server
|
|
* to bake its overlay into this same response instead of returning the
|
|
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
|
|
* means the panel was never actually refreshed -- epd_display_stream()
|
|
* (see the active EPD driver component, main/epd_board.h) 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, bool manage)
|
|
{
|
|
const char *path = "frame/image";
|
|
if (action == FETCH_ADVANCE) {
|
|
path = "frame/advance";
|
|
} else if (action == FETCH_BACK) {
|
|
path = "frame/back";
|
|
} else if (action == FETCH_GLOBAL_NEXT) {
|
|
path = "frame/global-next";
|
|
} else if (action == FETCH_GLOBAL_BACK) {
|
|
path = "frame/global-back";
|
|
}
|
|
|
|
char url[256];
|
|
build_url(url, sizeof(url), cfg, path);
|
|
if (manage) {
|
|
size_t len = strlen(url);
|
|
if (len + strlen("&manage=1") < sizeof(url)) {
|
|
strcpy(url + len, "&manage=1");
|
|
}
|
|
}
|
|
|
|
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 };
|
|
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_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;
|
|
}
|
|
|
|
/* Runs the manage-button view: fetches once with manage=1 (the server
|
|
* bakes its whole overlay -- scan-to-manage QR, battery, location/date/
|
|
* share-QR, every named face label, no more RAM-driven cap on how many --
|
|
* into the response), shows it, then waits up to 30s for either another
|
|
* press or the timeout before reverting to a plain fetch. Device stays
|
|
* awake throughout (doesn't sleep the panel or the chip). Returns
|
|
* non-ESP_OK only if the manage fetch itself failed; a revert failure
|
|
* after that is logged but doesn'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)
|
|
{
|
|
esp_err_t err = fetch_and_display(cfg, action, true);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
|
|
return fetch_and_display(cfg, action, false);
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
|
|
wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);
|
|
|
|
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
|
|
if (revert_err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Failed to revert manage view (%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 manage view (see run_management_menu()). */
|
|
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
|
{
|
|
if (!show_management_qr) {
|
|
return fetch_and_display(cfg, action, false);
|
|
}
|
|
return run_management_menu(cfg, action);
|
|
}
|
|
|
|
/* 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)
|
|
{
|
|
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);
|
|
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 the active EPD driver
|
|
* component, main/epd_board.h), 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();
|
|
|
|
/* Read now, not at boot: the photo (and, if shown, the manage
|
|
* overlay -- entirely server-composited now, using the server's
|
|
* own last-known battery value, not a local reading, see
|
|
* server/app/manage_overlay.py) is already on the panel, so
|
|
* there's no display deadline to beat.
|
|
* Reading here instead of right after waking sidesteps taking the
|
|
* ADC sample while the rail's still settling from whatever the
|
|
* boot/reset just did, with no need to guess a settle delay --
|
|
* the fetch/display work already done this cycle is the delay.
|
|
* Still safe re: the battery/button pin sharing (battery.h) --
|
|
* every button check main.c does happens well before this, at
|
|
* the very start of boot. */
|
|
int battery_percent = battery_read_percent();
|
|
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;
|
|
if (server_cfg.reachable) {
|
|
/* For next boot's button-hold decision, not this one -- see
|
|
* frame_config_get_hold_duration_ms()'s own doc comment. */
|
|
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
|
|
}
|
|
|
|
/* 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();
|
|
}
|