src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 13:03:23 -0500
committerLuke Hoersten <[email protected]>2026-06-20 13:03:23 -0500
commit175dd50ba2a2a6c8d033a9fd4e91f4823f9e210a (patch)
tree2f41235b0a4792822f737953e6fa553383a7bd36 /main
parent3729f04458e358fab7872b9590d9fdb012f5ddf9 (diff)
firmware: OTA firmware updates via POST /firmware + rollback
Streams the raw .bin to the inactive ota_0/ota_1 slot via esp_ota_*, flips otadata, replies 200, reboots after 500 ms. Single-shot guarded by atomic_flag (409 on concurrent). CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE armed: new images boot pending-verify and ota_arm_healthy_timer marks them valid after 30 s of healthy uptime; otherwise the bootloader reverts on next reset. /state gains ota_state.
Diffstat (limited to 'main')
-rw-r--r--main/CMakeLists.txt7
-rw-r--r--main/app_main.c6
-rw-r--r--main/http_api.c166
-rw-r--r--main/ota.c52
-rw-r--r--main/ota.h14
-rw-r--r--main/viewport_state.h2
6 files changed, 242 insertions, 5 deletions
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index ee679a2..dbb4d4b 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -1,7 +1,8 @@
idf_component_register(
SRC_DIRS "."
INCLUDE_DIRS "."
- REQUIRES driver esp_app_format esp_driver_i2c esp_driver_jpeg esp_eth
- esp_event esp_http_client esp_http_server esp_hw_support
- esp_lcd esp_netif esp_pm esp_timer hal json mdns nvs_flash
+ REQUIRES app_update driver esp_app_format esp_driver_i2c
+ esp_driver_jpeg esp_eth esp_event esp_http_client
+ esp_http_server esp_hw_support esp_lcd esp_netif esp_pm
+ esp_timer hal json mdns nvs_flash
)
diff --git a/main/app_main.c b/main/app_main.c
index a6174c8..fa63e26 100644
--- a/main/app_main.c
+++ b/main/app_main.c
@@ -5,6 +5,7 @@
#include "mdns_service.h"
#include "net_eth.h"
#include "nvs_config.h"
+#include "ota.h"
#include "state_client.h"
#include "state_machine.h"
#include "stream_server.h"
@@ -77,6 +78,11 @@ void app_main(void)
// the snapshot one-shot and for curl debugging.
mark(stream_server_start(81), 'S', &flags[3]);
+ // If this image is still PENDING_VERIFY from a fresh OTA, mark it
+ // valid after 30 s of healthy uptime so the bootloader stops
+ // considering it revertible. No-op once already marked valid.
+ ota_arm_healthy_timer();
+
// ------------------------------------------------------------------
// Display + I²C-bound peripherals run on their own task. ESP-IDF's
// esp_lcd_new_dsi_bus() spins forever in a PHY-PLL busy-wait if the
diff --git a/main/http_api.c b/main/http_api.c
index 85992b1..238514b 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -4,11 +4,17 @@
#include <stdio.h>
#include <string.h>
+#include <stdatomic.h>
+
#include "cJSON.h"
+#include "esp_app_desc.h"
#include "esp_check.h"
#include "esp_heap_caps.h"
#include "esp_http_server.h"
#include "esp_log.h"
+#include "esp_ota_ops.h"
+#include "esp_partition.h"
+#include "esp_system.h"
#include "esp_timer.h"
#include "display.h"
@@ -16,6 +22,7 @@
#include "mdns_service.h"
#include "net_eth.h"
#include "nvs_config.h"
+#include "ota.h"
#include "stream_server.h"
#include "state_machine.h"
#include "viewport_state.h"
@@ -43,6 +50,7 @@ static esp_err_t state_get_handler(httpd_req_t *req)
cJSON_AddStringToObject(root, "name", st->viewport_name);
cJSON_AddStringToObject(root, "mac", st->mac_str);
cJSON_AddStringToObject(root, "version", VIEWPORT_VERSION);
+ cJSON_AddStringToObject(root, "ota_state", ota_running_state_str());
cJSON_AddBoolToObject (root, "configured", st->configured);
cJSON_AddStringToObject(root, "state",
(st->state == VIEWPORT_STATE_AWAKE) ? "awake" : "asleep");
@@ -466,6 +474,157 @@ static esp_err_t frame_post_handler(httpd_req_t *req)
}
// ============================================================================
+// POST /firmware — push raw app .bin, flip OTA slot, reboot
+// ============================================================================
+static atomic_flag s_ota_in_progress = ATOMIC_FLAG_INIT;
+
+static void reboot_cb(void *arg)
+{
+ ESP_LOGW(TAG, "rebooting into new image");
+ esp_restart();
+}
+
+static esp_err_t firmware_post_handler(httpd_req_t *req)
+{
+ if (atomic_flag_test_and_set(&s_ota_in_progress)) {
+ return respond_status(req, "409 Conflict", "OTA already in progress");
+ }
+
+ esp_err_t result = ESP_OK;
+ const char *err_msg = NULL;
+ const char *err_status = "500 Internal Server Error";
+ esp_ota_handle_t handle = 0;
+ bool handle_open = false;
+
+ if (req->content_len <= 0) {
+ err_status = "400 Bad Request";
+ err_msg = "missing Content-Length";
+ result = ESP_FAIL;
+ goto done;
+ }
+
+ const esp_partition_t *running = esp_ota_get_running_partition();
+ const esp_partition_t *target = esp_ota_get_next_update_partition(NULL);
+ if (!target) {
+ err_msg = "no OTA target partition";
+ result = ESP_FAIL;
+ goto done;
+ }
+ if ((size_t)req->content_len > target->size) {
+ err_status = "413 Payload Too Large";
+ err_msg = "image exceeds partition size";
+ result = ESP_FAIL;
+ goto done;
+ }
+
+ ESP_LOGI(TAG, "ota: begin target=%s size=%d running=%s",
+ target->label, req->content_len,
+ running ? running->label : "?");
+
+ esp_err_t err = esp_ota_begin(target, req->content_len, &handle);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_begin: %s", esp_err_to_name(err));
+ err_msg = esp_err_to_name(err);
+ result = ESP_FAIL;
+ goto done;
+ }
+ handle_open = true;
+
+ uint8_t buf[4096];
+ int remaining = req->content_len;
+ int last_logged_pct = -1;
+ while (remaining > 0) {
+ int want = remaining < (int)sizeof(buf) ? remaining : (int)sizeof(buf);
+ int n = httpd_req_recv(req, (char *)buf, want);
+ if (n == HTTPD_SOCK_ERR_TIMEOUT) continue;
+ if (n <= 0) {
+ err_status = "400 Bad Request";
+ err_msg = "body read failed";
+ result = ESP_FAIL;
+ goto done;
+ }
+ err = esp_ota_write(handle, buf, n);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_write: %s", esp_err_to_name(err));
+ err_msg = esp_err_to_name(err);
+ result = ESP_FAIL;
+ goto done;
+ }
+ remaining -= n;
+ int pct = (int)(100LL * (req->content_len - remaining) / req->content_len);
+ if (pct / 10 != last_logged_pct / 10) {
+ ESP_LOGI(TAG, "ota: %d%% (%d/%d bytes)",
+ pct, req->content_len - remaining, req->content_len);
+ last_logged_pct = pct;
+ }
+ }
+
+ err = esp_ota_end(handle);
+ handle_open = false;
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_end: %s", esp_err_to_name(err));
+ err_status = (err == ESP_ERR_OTA_VALIDATE_FAILED)
+ ? "400 Bad Request" : "500 Internal Server Error";
+ err_msg = esp_err_to_name(err);
+ result = ESP_FAIL;
+ goto done;
+ }
+
+ err = esp_ota_set_boot_partition(target);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_set_boot_partition: %s", esp_err_to_name(err));
+ err_msg = esp_err_to_name(err);
+ result = ESP_FAIL;
+ goto done;
+ }
+
+ ESP_LOGI(TAG, "ota: success — booting %s on reboot", target->label);
+
+ cJSON *root = cJSON_CreateObject();
+ cJSON_AddStringToObject(root, "status", "ok");
+ const esp_app_desc_t *running_desc = esp_app_get_description();
+ cJSON_AddStringToObject(root, "previous",
+ running_desc ? running_desc->version : "?");
+ esp_app_desc_t next_desc;
+ if (esp_ota_get_partition_description(target, &next_desc) == ESP_OK) {
+ cJSON_AddStringToObject(root, "next", next_desc.version);
+ } else {
+ cJSON_AddStringToObject(root, "next", "?");
+ }
+ cJSON_AddStringToObject(root, "slot", target->label);
+ cJSON_AddNumberToObject(root, "reboot_in_ms", 500);
+ char *body = cJSON_PrintUnformatted(root);
+ httpd_resp_set_type(req, "application/json");
+ httpd_resp_set_status(req, "200 OK");
+ httpd_resp_send(req, body, HTTPD_RESP_USE_STRLEN);
+ cJSON_free(body);
+ cJSON_Delete(root);
+
+ // Reboot after the response has time to flush. One-shot esp_timer so
+ // this handler can return cleanly and httpd can finish the socket.
+ const esp_timer_create_args_t args = {
+ .callback = &reboot_cb,
+ .name = "ota_reboot",
+ };
+ esp_timer_handle_t t = NULL;
+ if (esp_timer_create(&args, &t) == ESP_OK) {
+ esp_timer_start_once(t, 500 * 1000);
+ } else {
+ esp_restart();
+ }
+ // Leave s_ota_in_progress set — we're rebooting.
+ return ESP_OK;
+
+done:
+ if (handle_open) esp_ota_abort(handle);
+ atomic_flag_clear(&s_ota_in_progress);
+ if (result != ESP_OK) {
+ return respond_status(req, err_status, err_msg ? err_msg : "OTA failed");
+ }
+ return ESP_OK;
+}
+
+// ============================================================================
// Route table + start
// ============================================================================
static const httpd_uri_t s_state_get = {
@@ -483,6 +642,9 @@ static const httpd_uri_t s_state_post = {
static const httpd_uri_t s_frame_post = {
.uri = "/frame", .method = HTTP_POST, .handler = frame_post_handler,
};
+static const httpd_uri_t s_firmware_post = {
+ .uri = "/firmware", .method = HTTP_POST, .handler = firmware_post_handler,
+};
esp_err_t http_api_start(void)
{
@@ -508,8 +670,10 @@ esp_err_t http_api_start(void)
TAG, "register POST /state");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_frame_post),
TAG, "register POST /frame");
+ ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_firmware_post),
+ TAG, "register POST /firmware");
ESP_LOGI(TAG, "http server listening on :80 "
- "(GET/POST /state, GET/POST /config, POST /frame)");
+ "(GET/POST /state, GET/POST /config, POST /frame, POST /firmware)");
return ESP_OK;
}
diff --git a/main/ota.c b/main/ota.c
new file mode 100644
index 0000000..3924dca
--- /dev/null
+++ b/main/ota.c
@@ -0,0 +1,52 @@
+#include "ota.h"
+
+#include "esp_log.h"
+#include "esp_ota_ops.h"
+#include "esp_partition.h"
+#include "esp_timer.h"
+
+static const char *TAG = "ota";
+
+#define OTA_HEALTHY_DELAY_US (30ULL * 1000 * 1000)
+
+static void healthy_cb(void *arg)
+{
+ const esp_partition_t *running = esp_ota_get_running_partition();
+ esp_ota_img_states_t st;
+ if (running && esp_ota_get_state_partition(running, &st) == ESP_OK
+ && st == ESP_OTA_IMG_PENDING_VERIFY) {
+ if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) {
+ ESP_LOGI(TAG, "image marked valid (rollback cancelled)");
+ } else {
+ ESP_LOGW(TAG, "esp_ota_mark_app_valid_cancel_rollback failed");
+ }
+ }
+}
+
+void ota_arm_healthy_timer(void)
+{
+ const esp_timer_create_args_t args = {
+ .callback = &healthy_cb,
+ .name = "ota_healthy",
+ };
+ esp_timer_handle_t t = NULL;
+ if (esp_timer_create(&args, &t) != ESP_OK) return;
+ esp_timer_start_once(t, OTA_HEALTHY_DELAY_US);
+}
+
+const char *ota_running_state_str(void)
+{
+ const esp_partition_t *running = esp_ota_get_running_partition();
+ if (!running) return "unknown";
+ esp_ota_img_states_t st;
+ if (esp_ota_get_state_partition(running, &st) != ESP_OK) return "unknown";
+ switch (st) {
+ case ESP_OTA_IMG_NEW: return "new";
+ case ESP_OTA_IMG_PENDING_VERIFY: return "pending-verify";
+ case ESP_OTA_IMG_VALID: return "valid";
+ case ESP_OTA_IMG_INVALID: return "invalid";
+ case ESP_OTA_IMG_ABORTED: return "aborted";
+ case ESP_OTA_IMG_UNDEFINED: return "undefined";
+ default: return "unknown";
+ }
+}
diff --git a/main/ota.h b/main/ota.h
new file mode 100644
index 0000000..a6f7c9b
--- /dev/null
+++ b/main/ota.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#include "esp_err.h"
+
+// Arms a 30 s one-shot timer; on fire, if the running image is in
+// PENDING_VERIFY, calls esp_ota_mark_app_valid_cancel_rollback() so the
+// bootloader stops considering this image revertible. Safe to call when
+// rollback is disabled or when the image is already marked valid — both
+// are no-ops. Call once from app_main after http_api_start.
+void ota_arm_healthy_timer(void);
+
+// Returns a short tag for the running image's OTA state, suitable for
+// /state JSON. Never NULL.
+const char *ota_running_state_str(void);
diff --git a/main/viewport_state.h b/main/viewport_state.h
index 165d1a7..730767c 100644
--- a/main/viewport_state.h
+++ b/main/viewport_state.h
@@ -3,7 +3,7 @@
#include <stdbool.h>
#include <stdint.h>
-#define VIEWPORT_VERSION "1.1.0"
+#define VIEWPORT_VERSION "1.2.0"
#define VIEWPORT_PANEL_WIDTH 800
#define VIEWPORT_PANEL_HEIGHT 480