From a3ab6c5f131b1bcf02086c79bb8cfb5c89576869 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 20 Jul 2026 22:24:15 -0400 Subject: [PATCH] Firmware: OTA client, dual-board build (devkit/XIAO), version reporting, XIAO fixes - version.txt + esp_app_desc_t version reporting (X-Frame-Version header); new ota_update.c checks the server's advertised version against the running one and streams+applies an update via esp_https_ota, gated by bootloader rollback (marks the image valid only after a full successful cycle, so a bad update can't brick a wall-mounted frame). - Dual-OTA partition tables: partitions.csv (8MB dev board, 2MB slots) and new partitions_xiao.csv (4MB XIAO, 1.875MB slots -- the dev board's table doesn't fit the XIAO's flash). New build_for_board.sh gives each board its own build dir + generated sdkconfig via SDKCONFIG_DEFAULTS layering, so switching boards never clobbers the other's config. - fetch_photo_info()/fetch_face_labels() were using the short reachability-check timeout even though the manage-menu path can be the first (cold, TLS-handshake-paying) request of a wake cycle -- switched to the longer fetch timeout to stop spurious ESP_ERR_HTTP_CONNECT failures. - XIAO: the RF switch that selects onboard vs. external antenna (GPIO3/14) isn't initialized by plain ESP-IDF the way Seeed's Arduino package does it, leaving WiFi unable to reliably reach the antenna at all -- new board_antenna.c powers the switch and selects the onboard antenna, gated behind FRAME_XIAO_ANTENNA_INIT (on by default in sdkconfig.xiao). Also remaps the EPD DC/RST/BUSY pins, since the dev board's defaults (GPIO9/10/11) aren't physically exposed on the XIAO. --- .gitignore | 6 +++ docs/hardware.md | 42 +++++++++++++++++ firmware/README.md | 31 ++++++++++--- firmware/build_for_board.sh | 75 +++++++++++++++++++++++++++++++ firmware/main/CMakeLists.txt | 4 +- firmware/main/Kconfig.projbuild | 18 ++++++++ firmware/main/board_antenna.c | 34 ++++++++++++++ firmware/main/board_antenna.h | 8 ++++ firmware/main/frame_client.c | 42 +++++++++++++++-- firmware/main/ota_update.c | 74 ++++++++++++++++++++++++++++++ firmware/main/ota_update.h | 19 ++++++++ firmware/main/wifi_provisioning.c | 3 ++ firmware/partitions.csv | 16 +++++-- firmware/partitions_xiao.csv | 12 +++++ firmware/sdkconfig.defaults | 8 ++++ firmware/sdkconfig.xiao | 24 ++++++++++ firmware/version.txt | 1 + 17 files changed, 402 insertions(+), 15 deletions(-) create mode 100755 firmware/build_for_board.sh create mode 100644 firmware/main/board_antenna.c create mode 100644 firmware/main/board_antenna.h create mode 100644 firmware/main/ota_update.c create mode 100644 firmware/main/ota_update.h create mode 100644 firmware/partitions_xiao.csv create mode 100644 firmware/sdkconfig.xiao create mode 100644 firmware/version.txt diff --git a/.gitignore b/.gitignore index 37918ed..6669c39 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ firmware/sdkconfig firmware/sdkconfig.old firmware/managed_components/ firmware/dependencies.lock +# build_for_board.sh's per-board build dir + generated sdkconfig +# (sdkconfig.xiao itself, the committed override fragment, is NOT +# ignored -- only the *_local generated file and its build output). +firmware/build_xiao/ +firmware/sdkconfig.xiao_local +firmware/sdkconfig.xiao_local.old # Python server server/__pycache__/ diff --git a/docs/hardware.md b/docs/hardware.md index d036e1f..64e66ce 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -58,6 +58,48 @@ actions where instant, unambiguous response matters most (next, back), and everything else folded onto the third pin via hold duration instead of needing its own pin. +### Wiring on the Seeed XIAO ESP32-C6 + +The XIAO only breaks out 11 GPIOs (0, 1, 2, 16, 17, 18, 19, 20, 21, 22, +23), so the dev-board defaults above don't fit -- DC/RST/BUSY (GPIO 9, +10, 11) aren't exposed on this board at all. Built with +[`build_for_board.sh xiao`](../firmware/README.md#building-for-the-seeed-xiao-esp32-c6-production-board), +which layers [`firmware/sdkconfig.xiao`](../firmware/sdkconfig.xiao) on +top of the dev-board defaults, remapping DC/RST/BUSY onto three of the +remaining free pins: + +| Panel pin | XIAO GPIO | XIAO silkscreen label | Kconfig option | +| --------- | --------- | ---------------------- | --------------- | +| CLK | 20 | D9 | `EPD_PIN_CLK` (unchanged) | +| DIN | 19 | D8 | `EPD_PIN_MOSI` (unchanged) | +| CS | 18 | D10 | `EPD_PIN_CS` (unchanged) | +| DC | 16 | D6 | `EPD_PIN_DC` | +| RST | 17 | D7 | `EPD_PIN_RST` | +| BUSY | 21 | D3 | `EPD_PIN_BUSY` | +| VCC | 3.3V | 3V3 | -- | +| GND | GND | GND | -- | + +The XIAO's silkscreen labels its pins D0-D10, not raw GPIO numbers -- +the table above gives both. + +Buttons (unchanged from the table above -- the XIAO's only three +ADC/deep-sleep-capable pins, GPIO 0/1/2, are exactly the ones already +used): next=GPIO2/**D2**, back=GPIO0/**D0**, menu/reset=GPIO1/**D1**. +That leaves GPIO22/**D4** and GPIO23/**D5** free, e.g. for the optional +VBUS mains-sense divider described under +[Battery](#battery-optional-xiao-esp32-c6) below. + +`sdkconfig.xiao` also sets `FRAME_XIAO_ANTENNA_INIT=y`, which powers +the XIAO's onboard RF switch and selects its ceramic antenna at boot +(GPIO3/14, internal to the board -- not part of the wiring above). +Without it, WiFi doesn't reliably work on this board at all: the radio +comes up and logs look normal (e.g. the softAP starts and prints its +SSID/password) but the switch's control pins are left floating, so +nothing actually reaches the antenna -- the AP never becomes visible to +a scan, and a station connection would fail to associate the same way. +Seeed's own Arduino board package does this automatically; plain +ESP-IDF (what this firmware uses) doesn't, hence the explicit init. + A couple of things worth knowing if you pick different pins: - **Avoid the ESP32-C6's strapping pins** (GPIO 4, 5, 8, 9, 15) and the diff --git a/firmware/README.md b/firmware/README.md index b84fdac..aa525c3 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -22,12 +22,31 @@ idf.py -p PORT flash monitor (`Ctrl-]` exits the monitor.) -The committed [`sdkconfig.defaults`](sdkconfig.defaults) pins an 8MB flash -size and a custom [`partitions.csv`](partitions.csv) (2MB app partition -- -the default "single app" ~1MB partition runs out of room once the HTTP -client, TLS, and vendored fonts/QR library are linked in). If your board -has less flash, you'll need to shrink the app partition and drop features -to fit. +The commands above target the dev board this project is built against +(ESP32-C6-DevKitC-1, 8MB flash) -- the committed +[`sdkconfig.defaults`](sdkconfig.defaults) pins that flash size and a +custom [`partitions.csv`](partitions.csv) with dual OTA app partitions (2MB +each; see [OTA updates](#ota-updates) below), since the default "single +app" ~1MB partition runs out of room once the HTTP client, TLS, and +vendored fonts/QR library are linked in. + +### Building for the Seeed XIAO ESP32-C6 (production board) + +The XIAO has only 4MB of flash, which doesn't fit the dev board's two 2MB +OTA slots -- it needs its own partition table +([`partitions_xiao.csv`](partitions_xiao.csv), 1.875MB slots) and flash-size +setting. Rather than hand-editing `sdkconfig` back and forth between boards, +use [`build_for_board.sh`](build_for_board.sh), which builds each board into +its own directory with its own generated config, so switching back and forth +never clobbers the other: + +``` +./build_for_board.sh xiao build +./build_for_board.sh xiao flash monitor -p PORT +``` + +(`./build_for_board.sh devkit ...` does the same for the dev board -- +equivalent to a plain `idf.py`, just consistent with the XIAO invocation.) ## Configuration (`idf.py menuconfig`) diff --git a/firmware/build_for_board.sh b/firmware/build_for_board.sh new file mode 100755 index 0000000..900372f --- /dev/null +++ b/firmware/build_for_board.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Builds/flashes for a specific board variant. This project targets two: +# +# devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is +# also the plain `idf.py` default (sdkconfig/build/), so this +# script's devkit mode is mostly for symmetry -- normal +# `idf.py build`/`flash` work fine too. +# xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board. +# +# The two need different partition tables (the XIAO's 4MB doesn't fit +# the dev board's two 2MB OTA app slots -- see partitions_xiao.csv, +# 1.875MB slots instead) and a different flash-size Kconfig. Rather +# than hand-editing the shared sdkconfig back and forth (fragile, easy +# to leave it in the wrong state for whichever board you flash next), +# each board gets its own build directory and its own generated +# sdkconfig, seeded from sdkconfig.defaults (shared) with the board's +# override file layered on top via ESP-IDF's own SDKCONFIG_DEFAULTS +# mechanism. Switching boards is just switching which one you invoke -- +# neither ever touches the other's config or build output. +# +# Usage: +# ./build_for_board.sh xiao build +# ./build_for_board.sh xiao flash -p /dev/ttyUSB0 +# ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0 +# ./build_for_board.sh devkit build +# +# Defaults to "build" if no idf.py subcommand is given. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$script_dir" + +if [ $# -lt 1 ]; then + echo "Usage: $0 [idf.py args...]" >&2 + exit 1 +fi +board="$1" +shift + +case "$board" in + xiao) + build_dir="$script_dir/build_xiao" + sdkconfig_path="$script_dir/sdkconfig.xiao_local" + defaults="$script_dir/sdkconfig.defaults;$script_dir/sdkconfig.xiao" + ;; + devkit) + build_dir="$script_dir/build" + sdkconfig_path="$script_dir/sdkconfig" + defaults="$script_dir/sdkconfig.defaults" + ;; + *) + echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2 + exit 1 + ;; +esac + +args=("$@") +if [ ${#args[@]} -eq 0 ]; then + args=(build) +fi + +# idf.py is normally a shell *function* (defined by ESP-IDF's +# activate/export script), not a real executable on PATH -- that +# function isn't inherited by this script's own subshell even if you +# sourced the activation script first. IDF_PATH (an exported env var, +# which *is* inherited) lets this work the same way regardless: call +# the underlying Python module directly. +if [ -z "${IDF_PATH:-}" ]; then + echo "IDF_PATH is not set -- source your ESP-IDF activation/export.sh first" >&2 + exit 1 +fi + +echo "==> Board: $board (build dir: $(basename "$build_dir"), sdkconfig: $(basename "$sdkconfig_path"))" +exec python "$IDF_PATH/tools/idf.py" -B "$build_dir" -D "SDKCONFIG=$sdkconfig_path" -D "SDKCONFIG_DEFAULTS=$defaults" "${args[@]}" diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 669e427..1a44032 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,3 +1,3 @@ -idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c - PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc +idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c ota_update.c board_antenna.c + PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format EMBED_FILES root.html) diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index a03ee3a..5d9f038 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -1,5 +1,23 @@ menu "ESPresso Frame Configuration" + config FRAME_XIAO_ANTENNA_INIT + bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)" + default n + help + The XIAO ESP32-C6 routes its antenna through an FM8625H RF + switch (GPIO3 = switch power enable, GPIO14 = antenna + select) so it can optionally use an external u.FL antenna + instead of the onboard ceramic one. Seeed's own Arduino + board package powers the switch and selects the onboard + antenna automatically at boot; plain ESP-IDF (what this + firmware uses) does not -- without this, the switch's + GPIOs are left floating and the radio may not reliably + reach the onboard antenna at all. Symptom: a softAP (or + STA connection) that starts successfully in the log but + is never actually visible/reachable over the air. Enabled + by default in sdkconfig.xiao; leave off for the DevKitC-1 + dev board, which has no such switch. + config ESP_AP_SSID string "Provisioning softAP SSID prefix" default "ESPRESSO" diff --git a/firmware/main/board_antenna.c b/firmware/main/board_antenna.c new file mode 100644 index 0000000..2e06f46 --- /dev/null +++ b/firmware/main/board_antenna.c @@ -0,0 +1,34 @@ +#include "board_antenna.h" + +#if CONFIG_FRAME_XIAO_ANTENNA_INIT + +#include "driver/gpio.h" + +// Fixed by the XIAO ESP32-C6's own PCB (the RF switch wiring, not +// user-configurable), not exposed as Kconfig pins the way the EPD/button +// GPIOs are. GPIO3 is the switch's power enable (active low), GPIO14 +// selects onboard (low) vs external u.FL (high) antenna. Seeed's own +// Arduino board package does this at boot automatically; plain ESP-IDF +// does not, so without it the switch is left unpowered/floating and the +// radio doesn't reliably reach the onboard antenna. +#define ANTENNA_SWITCH_POWER_GPIO 3 +#define ANTENNA_SELECT_GPIO 14 + +void board_antenna_select_onboard(void) +{ + gpio_config_t cfg = { + .pin_bit_mask = (1ULL << ANTENNA_SWITCH_POWER_GPIO) | (1ULL << ANTENNA_SELECT_GPIO), + .mode = GPIO_MODE_OUTPUT, + }; + gpio_config(&cfg); + gpio_set_level(ANTENNA_SWITCH_POWER_GPIO, 0); + gpio_set_level(ANTENNA_SELECT_GPIO, 0); +} + +#else + +void board_antenna_select_onboard(void) +{ +} + +#endif diff --git a/firmware/main/board_antenna.h b/firmware/main/board_antenna.h new file mode 100644 index 0000000..4a9ee20 --- /dev/null +++ b/firmware/main/board_antenna.h @@ -0,0 +1,8 @@ +#pragma once + +// Powers the Seeed XIAO ESP32-C6's FM8625H RF switch and selects the +// onboard ceramic antenna (as opposed to the external u.FL antenna the +// switch can otherwise route to). No-op unless CONFIG_FRAME_XIAO_ANTENNA_INIT +// is set -- the DevKitC-1 dev board has no such switch. Call once before +// esp_wifi_start(), in both the softAP and STA bring-up paths. +void board_antenna_select_onboard(void); diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index aefa3f1..90cc08f 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -1,6 +1,8 @@ #include +#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" @@ -16,6 +18,8 @@ #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" @@ -80,6 +84,8 @@ static void sta_event_handler(void *arg, esp_event_base_t event_base, 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(); @@ -141,6 +147,7 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg) 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 */ } frame_server_config_t; /* Finds the first integer value associated with "key" in a small JSON @@ -216,13 +223,17 @@ static bool json_extract_string(const char *json, const char *key, char *out, si /* 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. */ + * succeeded), the source of the server-configurable refresh interval, + * and (via the X-Frame-Version request header / firmware_version + * response field) the device's OTA update check -- piggybacked on a + * request already made every wake, no extra round trip. */ 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'; char url[256]; build_url(url, sizeof(url), cfg, "frame/config"); @@ -234,6 +245,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg) .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_err_t err = esp_http_client_open(client, 0); if (err != ESP_OK) { @@ -264,6 +276,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg) 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)); return result; } @@ -288,10 +301,20 @@ static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, si 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_SERVER_CHECK_TIMEOUT_MS, + .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); @@ -348,10 +371,12 @@ static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out 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_SERVER_CHECK_TIMEOUT_MS, + .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); @@ -786,9 +811,20 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho * just be discarded. */ uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; if (image_ok) { + /* 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; + + /* 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) { diff --git a/firmware/main/ota_update.c b/firmware/main/ota_update.c new file mode 100644 index 0000000..b15c631 --- /dev/null +++ b/firmware/main/ota_update.c @@ -0,0 +1,74 @@ +#include + +#include "esp_app_desc.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_https_ota.h" +#include "esp_log.h" +#include "esp_system.h" + +#include "ota_update.h" + +static const char *TAG = "ota_update"; + +/* Generous: the image is ~1.2MB and may stream over a WAN reverse + * proxy; esp_https_ota resets this per-read, so it's an inactivity + * timeout, not a total-transfer cap. */ +#define OTA_HTTP_TIMEOUT_MS 30000 + +/* Built the same way as every other tools-server URL -- scheme/cert/ + * token handling all come from build_url()'s conventions. Duplicated + * tiny helper rather than exporting frame_client.c's static build_url(); + * kept byte-identical in behavior (see frame_client.c). */ +static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg) +{ + 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/frame/firmware", toolsserver); + } else { + len = (size_t)snprintf(out, out_size, "http://%s/frame/firmware", toolsserver); + } + if (cfg->access_token[0] != '\0' && len < out_size) { + snprintf(out + len, out_size - len, "?token=%s", cfg->access_token); + } +} + +void ota_update_if_available(const frame_config_t *cfg, const char *server_version) +{ + if (server_version == NULL || server_version[0] == '\0') { + return; /* server has no uploaded firmware */ + } + + const char *running = esp_app_get_description()->version; + if (strcmp(server_version, running) == 0) { + return; /* already running what the server has */ + } + + ESP_LOGI(TAG, "Firmware update available: running '%s', server has '%s' -- starting OTA", running, + server_version); + + char url[256]; + build_ota_url(url, sizeof(url), cfg); + + esp_http_client_config_t http_config = { + .url = url, + .timeout_ms = OTA_HTTP_TIMEOUT_MS, + .crt_bundle_attach = esp_crt_bundle_attach, + .keep_alive_enable = true, + }; + esp_https_ota_config_t ota_config = { + .http_config = &http_config, + }; + + esp_err_t err = esp_https_ota(&ota_config); + if (err != ESP_OK) { + /* Not fatal -- the photo already displayed this cycle; we just + * try again on a future wake. */ + ESP_LOGW(TAG, "OTA failed (%s), will retry on a later wake", esp_err_to_name(err)); + return; + } + + ESP_LOGI(TAG, "OTA complete, restarting into '%s'", server_version); + esp_restart(); +} diff --git a/firmware/main/ota_update.h b/firmware/main/ota_update.h new file mode 100644 index 0000000..ab162bc --- /dev/null +++ b/firmware/main/ota_update.h @@ -0,0 +1,19 @@ +#pragma once + +#include "wifi_provisioning.h" + +/** + * If server_version is non-empty and differs from the running firmware's + * embedded version (esp_app_desc), streams the server's uploaded image + * (GET /frame/firmware) into the idle OTA slot via esp_https_ota and + * restarts into it -- so this only returns when there's nothing to do or + * the update failed (logged, retried on a later wake). Plain string + * inequality, deliberately: uploading an older image downgrades, which + * is a feature. + * + * Rollback safety is handled elsewhere: the freshly-booted image is + * marked valid in frame_client_run() only after a full successful + * fetch/display cycle (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) -- until + * then, any reboot reverts to the previous slot. + */ +void ota_update_if_available(const frame_config_t *cfg, const char *server_version); diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 49155a3..3e2486c 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -22,6 +22,7 @@ #include "epd7in3e.h" #include "qr_onboarding.h" #include "wifi_provisioning.h" +#include "board_antenna.h" #define NVS_NAMESPACE "frame_cfg" @@ -408,6 +409,8 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, static void wifi_init_softap(const char *ap_ssid, const char *ap_password) { + board_antenna_select_onboard(); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); diff --git a/firmware/partitions.csv b/firmware/partitions.csv index 2c33a2a..924665f 100644 --- a/firmware/partitions.csv +++ b/firmware/partitions.csv @@ -1,4 +1,12 @@ -# Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x6000, -phy_init, data, phy, 0xf000, 0x1000, -factory, app, factory, 0x10000, 0x200000, +# Name, Type, SubType, Offset, Size, Flags +# nvs/phy_init/ota_0 keep the exact offsets of the pre-OTA layout (nvs +# 0x9000, phy 0xf000, app 0x10000 -- ota_0 replaces the old "factory" +# slot in place), so migrating an existing device to this table via a +# one-time USB `idf.py flash` preserves its provisioning/NVS state. +# otadata/ota_1 land in previously-unused flash. App slots must be 64KB +# (0x10000) aligned, hence ota_1 at 0x220000 rather than 0x212000. +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x10000, 0x200000, +otadata, data, ota, 0x210000, 0x2000, +ota_1, app, ota_1, 0x220000, 0x200000, diff --git a/firmware/partitions_xiao.csv b/firmware/partitions_xiao.csv new file mode 100644 index 0000000..e89860f --- /dev/null +++ b/firmware/partitions_xiao.csv @@ -0,0 +1,12 @@ +# Name, Type, SubType, Offset, Size, Flags +# Same OTA layout as partitions.csv, resized for the Seeed XIAO +# ESP32-C6's 4MB flash (the dev board's 8MB table's 2x 2MB app slots +# don't fit). App slots are 0x1e0000 (1.875MB) each -- current firmware +# runs at ~1.2MB, so this leaves ~38% headroom per slot, same margin as +# the 8MB table's 2MB slots give today. nvs/phy offsets match the 8MB +# table so provisioning data is byte-compatible between the two. +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +otadata, data, ota, 0x10000, 0x2000, +ota_0, app, ota_0, 0x20000, 0x1e0000, +ota_1, app, ota_1, 0x200000, 0x1e0000, diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index c6f1838..2a2baee 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -23,6 +23,14 @@ CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +# OTA rollback safety: a freshly-OTA'd image boots "pending verify" and +# must call esp_ota_mark_app_valid_cancel_rollback() (done in +# frame_client.c after one full successful fetch/display cycle -- the +# right proof of life for this device) or the bootloader reverts to the +# previous slot on the next reboot. A bad OTA can't brick a wall-mounted +# frame. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + # The whole boot flow (provisioning, QR rendering, and eventually the HTTP # fetch cycle) runs in the single default "main" task. The 3584-byte # default already crashed with a stack protection fault once (a 4KB SPI diff --git a/firmware/sdkconfig.xiao b/firmware/sdkconfig.xiao new file mode 100644 index 0000000..49c1af4 --- /dev/null +++ b/firmware/sdkconfig.xiao @@ -0,0 +1,24 @@ +# Board-specific overrides for the Seeed XIAO ESP32-C6, layered on top +# of sdkconfig.defaults (which targets the 8MB ESP32-C6-DevKitC-1 dev +# board) via SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the +# supported way to build with this file. Don't set this via a plain +# `idf.py menuconfig` on the default build; that writes straight into +# the shared sdkconfig, not this file. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv" + +# Powers the XIAO's RF switch and selects its onboard antenna -- without +# this the softAP/STA radio doesn't reliably reach the antenna at all. +# See the Kconfig help text (FRAME_XIAO_ANTENNA_INIT) for why. +CONFIG_FRAME_XIAO_ANTENNA_INIT=y + +# EPD wiring: the dev-board defaults (DC=9, RST=10, BUSY=11) aren't +# physically exposed on the XIAO -- only GPIO 0,1,2,16,17,18,19,20,21, +# 22,23 are broken out, and 18/19/20 are already the panel's SPI pins +# (CS/MOSI/CLK) while 0/1/2 are the buttons. Remapped to three of the +# remaining free pins; see docs/hardware.md for the full XIAO wiring +# table. +CONFIG_EPD_PIN_DC=16 +CONFIG_EPD_PIN_RST=17 +CONFIG_EPD_PIN_BUSY=21 diff --git a/firmware/version.txt b/firmware/version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/firmware/version.txt @@ -0,0 +1 @@ +1.0.0