diff options
| author | Luke Hoersten <[email protected]> | 2026-06-20 13:03:23 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-20 13:03:23 -0500 |
| commit | 175dd50ba2a2a6c8d033a9fd4e91f4823f9e210a (patch) | |
| tree | 2f41235b0a4792822f737953e6fa553383a7bd36 | |
| parent | 3729f04458e358fab7872b9590d9fdb012f5ddf9 (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.
| -rw-r--r-- | README.md | 60 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 7 | ||||
| -rw-r--r-- | main/app_main.c | 6 | ||||
| -rw-r--r-- | main/http_api.c | 166 | ||||
| -rw-r--r-- | main/ota.c | 52 | ||||
| -rw-r--r-- | main/ota.h | 14 | ||||
| -rw-r--r-- | main/viewport_state.h | 2 | ||||
| -rw-r--r-- | sdkconfig.defaults | 6 |
8 files changed, 307 insertions, 6 deletions
@@ -93,6 +93,23 @@ Scrypted-side discovery flow: If mDNS-SD is unavailable in the deployment (some Docker setups, certain VLAN configurations), allow the operator to set an explicit `http://<ip>:<port>` per viewport in Scrypted-side config as a fallback. +### Discover from the CLI + +The custom service type is the discovery handle — no need to know names ahead of time. From macOS: + +```sh +# List every viewport on the LAN +dns-sd -B _scrypted-viewport._tcp local. + +# Resolve one to an IPv4 address +dns-sd -G v4 viewport-kitchen.local. + +# Talk to it (mDNS resolves the hostname directly) +curl http://viewport-kitchen.local/state +``` + +On Linux: `avahi-browse -rt _scrypted-viewport._tcp` for the same effect with addresses inline. The instance name (`viewport-kitchen`) **is** the hostname prefix — they're the same string. + ## API Four endpoints. `GET /state` and `GET /config` are the read surface; `POST /config`, `POST /state`, and `POST /frame` are the write surface. @@ -192,6 +209,37 @@ Paints a frame. Does **not** change wake/sleep state. - Returns `204` once decoded and pushed to the panel. - `400` malformed JPEG, `409` device asleep, `413` over size, `500` decode/display failure. On failure the previous frame stays on screen. +### POST /firmware + +Push a new application image and reboot into it. The device's HTTP OTA +endpoint — replaces USB reflash once the device is on the LAN. + +- `Content-Type: application/octet-stream`, body is the raw built app + image (`build/scrypted-viewport.bin`, ~1.5 MB). `Content-Length` + required. +- Single-shot: a second concurrent POST returns `409 Conflict`. +- Streams straight to the inactive OTA slot (no full-image RAM buffer), + validates header + checksum on `esp_ota_end`, flips `otadata` to point + at the new slot, replies `200` with + `{"status":"ok","previous":"<git>","next":"<git>","slot":"ota_1","reboot_in_ms":500}`, + then reboots ~500 ms later so the response can flush. +- On failure (`400` bad body / validate failed, `413` over partition size, + `500` flash error) the OTA handle is aborted and the running image stays + live. No partial-write half-state. +- **Rollback armed.** The new image boots `pending-verify`; firmware + flips it to `valid` after 30 s of healthy uptime + (`ota_arm_healthy_timer`). If the new image panics or the device is + power-cycled before the timer fires, the bootloader reverts to the + previous slot on next reset. `/state` reports the current state via + `ota_state`. + +```sh +idf.py build +curl -v --data-binary @build/scrypted-viewport.bin \ + -H 'Content-Type: application/octet-stream' \ + http://<device-ip>/firmware +``` + ## Wake / Sleep Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state. @@ -422,7 +470,7 @@ Scrypted should use the same `idle_timeout_ms` value it sent in `/config` as its ## Ops -- Firmware updates: reflash over USB. No OTA in v1 (planned post-v1: HTTP OTA from Scrypted). +- Firmware updates: `POST /firmware` with the raw built `.bin` (see [POST /firmware](#post-firmware)). First flash of any new device still needs USB to install the bootloader + initial image; every update after that is over the LAN. Rollback is armed — a panicking new image reverts to the previous slot on next reset. - Provisioning: flash the same firmware to every device. On first boot the screen shows its IP; register it from Scrypted via `POST /config`. - Viewport names must be unique across the LAN — mDNS hostnames are derived from `viewport` and two devices configured with the same name will collide. - NVS wipe: plug USB and run `idf.py erase-flash` followed by `idf.py flash`. The device boots clean and shows the info screen until `/config` is POSTed. @@ -441,6 +489,16 @@ idf.py build idf.py -p /dev/cu.usbmodem* flash monitor ``` +After the first USB flash, subsequent updates go over the LAN — no cable +needed: + +```sh +idf.py build +curl --data-binary @build/scrypted-viewport.bin \ + -H 'Content-Type: application/octet-stream' \ + http://<device-ip>/firmware +``` + ## Firmware implementation notes ### Source map (`main/`) 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 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 1ba8375..a32a88e 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -51,3 +51,9 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 # Log level CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# OTA: dual-partition layout in partitions.csv. Rollback marks new +# images as PENDING_VERIFY at first boot; ota_arm_healthy_timer flips +# them to VALID after 30 s of healthy uptime, otherwise the bootloader +# reverts to the previous slot on the next reset. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y |
