From b9195a0dda71d26698ee54bb23e24a8a6de0c8b3 Mon Sep 17 00:00:00 2001 From: dvdrw Date: Sun, 17 May 2026 00:24:43 +0200 Subject: [PATCH] feat: implement OTA updates over HTTP(S), initiated over MQTT --- components/config_store/src/config_store.c | 27 ++++-- .../mqtt_publisher/include/mqtt_publisher.h | 13 +++ .../mqtt_publisher/src/mqtt_publisher.c | 33 +++++++- components/ota_manager/CMakeLists.txt | 5 ++ components/ota_manager/include/ota_manager.h | 12 +++ components/ota_manager/src/ota_manager.c | 84 +++++++++++++++++++ main/CMakeLists.txt | 2 + main/main.c | 21 ++++- sdkconfig | 31 +++++++ sdkconfig.defaults | 1 + 10 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 components/ota_manager/CMakeLists.txt create mode 100644 components/ota_manager/include/ota_manager.h create mode 100644 components/ota_manager/src/ota_manager.c diff --git a/components/config_store/src/config_store.c b/components/config_store/src/config_store.c index f3344d9..cbe63bc 100644 --- a/components/config_store/src/config_store.c +++ b/components/config_store/src/config_store.c @@ -5,10 +5,12 @@ #include "esp_log.h" #include -#define NS "anchor_cfg" -#define KEY_PROV "provisioned" -#define KEY_HOST "mqtt_host" -#define KEY_PORT "mqtt_port" +#define NS "anchor_cfg" +#define KEY_PROV "provisioned" +#define KEY_HOST "mqtt_host" +#define KEY_PORT "mqtt_port" +#define KEY_SCHEMA_VER "schema_ver" +#define CURRENT_SCHEMA_VER 1 static const char *TAG = "config_store"; @@ -20,7 +22,22 @@ esp_err_t config_store_init(void) ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } - return err; + if (err != ESP_OK) return err; + + nvs_handle_t h; + err = nvs_open(NS, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + + uint8_t schema_ver = 0; + nvs_get_u8(h, KEY_SCHEMA_VER, &schema_ver); + if (schema_ver < CURRENT_SCHEMA_VER) { + /* Add migration steps here for future schema changes */ + nvs_set_u8(h, KEY_SCHEMA_VER, CURRENT_SCHEMA_VER); + nvs_commit(h); + ESP_LOGI(TAG, "NVS schema migrated %u -> %u", schema_ver, CURRENT_SCHEMA_VER); + } + nvs_close(h); + return ESP_OK; } bool config_store_is_provisioned(void) diff --git a/components/mqtt_publisher/include/mqtt_publisher.h b/components/mqtt_publisher/include/mqtt_publisher.h index 721b4fd..ae1686c 100644 --- a/components/mqtt_publisher/include/mqtt_publisher.h +++ b/components/mqtt_publisher/include/mqtt_publisher.h @@ -13,6 +13,7 @@ #define MQTT_DESELECTED_BIT BIT4 #define MQTT_FACTORY_RESET_BIT BIT5 #define MQTT_RECONFIGURE_SETTINGS_BIT BIT6 +#define MQTT_OTA_BIT BIT7 /* Populated by handle_cmd() before MQTT_RECONFIGURE_SETTINGS_BIT is set. * All fields are optional — empty string means "not provided, leave unchanged". */ @@ -27,6 +28,18 @@ typedef struct { * MQTT_RECONFIGURE_SETTINGS_BIT fires; must be read before the next MQTT event. */ const mqtt_reconfigure_data_t *mqtt_publisher_get_reconfigure_data(void); +typedef struct { + char url[256]; + char version[32]; +} mqtt_ota_data_t; + +/** Returns a pointer to the last parsed OTA payload. Valid only after + * MQTT_OTA_BIT fires; must be read before the next MQTT event. */ +const mqtt_ota_data_t *mqtt_publisher_get_ota_data(void); + +/** Publish OTA status to localiser/sensor/{id}/ota. Non-blocking (QoS 1). */ +void mqtt_publisher_send_ota_status(const char *status, const char *version); + /** * Initialise and connect the MQTT client. * diff --git a/components/mqtt_publisher/src/mqtt_publisher.c b/components/mqtt_publisher/src/mqtt_publisher.c index 799d767..9f068fd 100644 --- a/components/mqtt_publisher/src/mqtt_publisher.c +++ b/components/mqtt_publisher/src/mqtt_publisher.c @@ -7,21 +7,29 @@ #define TAG "mqtt_publisher" #define TOPIC_PREFIX "localiser/sensor" +#define TOPIC_OTA_BROADCAST "localiser/ota" static esp_mqtt_client_handle_t s_client = NULL; static char s_sensor_id[32]; static EventGroupHandle_t s_evt = NULL; static mqtt_reconfigure_data_t s_reconfigure_data; +static mqtt_ota_data_t s_ota_data; const mqtt_reconfigure_data_t *mqtt_publisher_get_reconfigure_data(void) { return &s_reconfigure_data; } +const mqtt_ota_data_t *mqtt_publisher_get_ota_data(void) +{ + return &s_ota_data; +} + /* Pre-built topic strings */ static char s_topic_rssi[96]; static char s_topic_announce[96]; static char s_topic_cmd[96]; +static char s_topic_ota[96]; static void build_topics(void) { @@ -31,6 +39,8 @@ static void build_topics(void) "%s/%s/announce", TOPIC_PREFIX, s_sensor_id); snprintf(s_topic_cmd, sizeof(s_topic_cmd), "%s/%s/cmd", TOPIC_PREFIX, s_sensor_id); + snprintf(s_topic_ota, sizeof(s_topic_ota), + "%s/%s/ota", TOPIC_PREFIX, s_sensor_id); } static void handle_cmd(const char *data, int data_len) @@ -50,6 +60,15 @@ static void handle_cmd(const char *data, int data_len) else if (strcmp(a, "selected") == 0) xEventGroupSetBits(s_evt, MQTT_SELECTED_BIT); else if (strcmp(a, "deselected") == 0) xEventGroupSetBits(s_evt, MQTT_DESELECTED_BIT); else if (strcmp(a, "factory_reset") == 0) xEventGroupSetBits(s_evt, MQTT_FACTORY_RESET_BIT); + else if (strcmp(a, "ota") == 0) { + memset(&s_ota_data, 0, sizeof(s_ota_data)); + cJSON *url_j = cJSON_GetObjectItemCaseSensitive(root, "url"); + cJSON *ver_j = cJSON_GetObjectItemCaseSensitive(root, "version"); + if (cJSON_IsString(url_j)) strncpy(s_ota_data.url, url_j->valuestring, sizeof(s_ota_data.url) - 1); + if (cJSON_IsString(ver_j)) strncpy(s_ota_data.version, ver_j->valuestring, sizeof(s_ota_data.version) - 1); + if (s_ota_data.url[0] != '\0') xEventGroupSetBits(s_evt, MQTT_OTA_BIT); + else ESP_LOGW(TAG, "OTA cmd missing url"); + } else if (strcmp(a, "reconfigure_settings") == 0) { memset(&s_reconfigure_data, 0, sizeof(s_reconfigure_data)); cJSON *ssid_j = cJSON_GetObjectItemCaseSensitive(root, "ssid"); @@ -75,6 +94,7 @@ static void mqtt_event_handler(void *arg, esp_event_base_t base, case MQTT_EVENT_CONNECTED: ESP_LOGI(TAG, "Connected to broker"); esp_mqtt_client_subscribe(s_client, s_topic_cmd, 1); + esp_mqtt_client_subscribe(s_client, TOPIC_OTA_BROADCAST, 1); xEventGroupSetBits(s_evt, MQTT_CONNECTED_BIT); mqtt_publisher_announce(); break; @@ -85,7 +105,8 @@ static void mqtt_event_handler(void *arg, esp_event_base_t base, break; case MQTT_EVENT_DATA: - if (strncmp(event->topic, s_topic_cmd, event->topic_len) == 0) { + if (strncmp(event->topic, s_topic_cmd, event->topic_len) == 0 || + strncmp(event->topic, TOPIC_OTA_BROADCAST, event->topic_len) == 0) { handle_cmd(event->data, event->data_len); } break; @@ -150,3 +171,13 @@ void mqtt_publisher_send_beacon(const char *type, const char *id, int8_t tx_powe type, id, (int)tx_power, (int)rssi); esp_mqtt_client_publish(s_client, s_topic_rssi, payload, len, 1, 0); } + +void mqtt_publisher_send_ota_status(const char *status, const char *version) +{ + if (!s_client) return; + + char payload[96]; + int len = snprintf(payload, sizeof(payload), + "{\"status\":\"%s\",\"version\":\"%s\"}", status, version ? version : ""); + esp_mqtt_client_publish(s_client, s_topic_ota, payload, len, 1, 0); +} diff --git a/components/ota_manager/CMakeLists.txt b/components/ota_manager/CMakeLists.txt new file mode 100644 index 0000000..96c449a --- /dev/null +++ b/components/ota_manager/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "src/ota_manager.c" + INCLUDE_DIRS "include" + REQUIRES esp_https_ota app_update mqtt_publisher esp_system +) diff --git a/components/ota_manager/include/ota_manager.h b/components/ota_manager/include/ota_manager.h new file mode 100644 index 0000000..fcd00c2 --- /dev/null +++ b/components/ota_manager/include/ota_manager.h @@ -0,0 +1,12 @@ +#pragma once + +#include "esp_err.h" + +/** + * Begin an OTA update in a background task. + * Applies a MAC-derived jitter delay before downloading so a fleet-wide + * trigger staggers reboots instead of dropping all coverage at once. + * Reports status via mqtt_publisher_send_ota_status(). + * On success the device reboots; on failure it stays on the current firmware. + */ +esp_err_t ota_manager_start(const char *url, const char *version); diff --git a/components/ota_manager/src/ota_manager.c b/components/ota_manager/src/ota_manager.c new file mode 100644 index 0000000..3d6a8c9 --- /dev/null +++ b/components/ota_manager/src/ota_manager.c @@ -0,0 +1,84 @@ +#include "ota_manager.h" +#include "mqtt_publisher.h" +#include "esp_https_ota.h" +#include "esp_ota_ops.h" +#include "esp_mac.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +#define TAG "ota_manager" + +/* Prevent concurrent OTA attempts */ +static volatile bool s_ota_running = false; + +typedef struct { + char url[256]; + char version[32]; +} ota_task_arg_t; + +static void ota_task(void *arg) +{ + ota_task_arg_t *params = (ota_task_arg_t *)arg; + + /* Spread reboots across up to 60 s using last 3 MAC bytes as seed */ + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + uint32_t jitter_ms = ((uint32_t)(mac[3] ^ mac[4] ^ mac[5])) % 60000; + if (jitter_ms > 0) { + ESP_LOGI(TAG, "OTA jitter: %lu ms", jitter_ms); + mqtt_publisher_send_ota_status("pending", params->version); + vTaskDelay(pdMS_TO_TICKS(jitter_ms)); + } + + ESP_LOGI(TAG, "Starting OTA from %s", params->url); + mqtt_publisher_send_ota_status("downloading", params->version); + + esp_http_client_config_t http_cfg = { + .url = params->url, + .timeout_ms = 30000, + .keep_alive_enable = true, + }; + esp_https_ota_config_t ota_cfg = { + .http_config = &http_cfg, + }; + + esp_err_t err = esp_https_ota(&ota_cfg); + if (err == ESP_OK) { + ESP_LOGI(TAG, "OTA succeeded, rebooting"); + mqtt_publisher_send_ota_status("rebooting", params->version); + vTaskDelay(pdMS_TO_TICKS(500)); /* let MQTT publish flush */ + esp_restart(); + } else { + ESP_LOGE(TAG, "OTA failed: %s", esp_err_to_name(err)); + mqtt_publisher_send_ota_status("failed", params->version); + } + + free(params); + s_ota_running = false; + vTaskDelete(NULL); +} + +esp_err_t ota_manager_start(const char *url, const char *version) +{ + if (s_ota_running) { + ESP_LOGW(TAG, "OTA already in progress, ignoring"); + return ESP_ERR_INVALID_STATE; + } + + ota_task_arg_t *params = malloc(sizeof(ota_task_arg_t)); + if (!params) return ESP_ERR_NO_MEM; + + strncpy(params->url, url, sizeof(params->url) - 1); + strncpy(params->version, version ? version : "", sizeof(params->version) - 1); + + s_ota_running = true; + if (xTaskCreate(ota_task, "ota_task", 8192, params, 5, NULL) != pdPASS) { + free(params); + s_ota_running = false; + return ESP_FAIL; + } + return ESP_OK; +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 192eb15..bd4c0ef 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -12,6 +12,8 @@ idf_component_register( ble_scanner mqtt_publisher led_indicator + ota_manager + app_update driver esp_driver_gpio ) diff --git a/main/main.c b/main/main.c index a8bc5f4..fb04af2 100644 --- a/main/main.c +++ b/main/main.c @@ -3,6 +3,8 @@ #include "ble_scanner.h" #include "mqtt_publisher.h" #include "led_indicator.h" +#include "ota_manager.h" +#include "esp_ota_ops.h" #include "esp_wifi.h" #include "esp_event.h" @@ -19,7 +21,7 @@ #define TAG "main" -#define WIFI_CONNECTED_BIT BIT8 /* must not clash with mqtt_publisher bits (BIT0–BIT6) */ +#define WIFI_CONNECTED_BIT BIT8 /* must not clash with mqtt_publisher bits (BIT0-BIT7) */ #define DEFAULT_MQTT_PORT 1883 #define MDNS_QUERY_TIMEOUT_MS 3000 @@ -222,6 +224,15 @@ void app_main(void) ESP_ERROR_CHECK(mqtt_publisher_init(sensor_id, broker_uri, s_evt)); xEventGroupWaitBits(s_evt, MQTT_CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY); + /* Confirm this firmware is healthy so the bootloader won't roll back */ + esp_ota_img_states_t ota_state; + const esp_partition_t *running = esp_ota_get_running_partition(); + if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK && + ota_state == ESP_OTA_IMG_PENDING_VERIFY) { + esp_ota_mark_app_valid_cancel_rollback(); + ESP_LOGI(TAG, "OTA rollback cancelled - firmware validated"); + } + // init BLE scanner ble_scanner_init(on_ble_scan_result); ble_scanner_start(); @@ -233,7 +244,8 @@ void app_main(void) const EventBits_t cmd_bits = MQTT_CALIBRATE_START | MQTT_CALIBRATE_STOP | MQTT_SELECTED_BIT | MQTT_DESELECTED_BIT | - MQTT_FACTORY_RESET_BIT | MQTT_RECONFIGURE_SETTINGS_BIT; + MQTT_FACTORY_RESET_BIT | MQTT_RECONFIGURE_SETTINGS_BIT | + MQTT_OTA_BIT; bool calibrating = false; bool selected = false; @@ -262,6 +274,11 @@ void app_main(void) selected = false; if (!calibrating) led_indicator_set(LED_SCANNING); } + if (bits & MQTT_OTA_BIT) { + const mqtt_ota_data_t *ota = mqtt_publisher_get_ota_data(); + ESP_LOGI(TAG, "OTA requested: url=%s version=%s", ota->url, ota->version); + ota_manager_start(ota->url, ota->version); + } if (bits & MQTT_FACTORY_RESET_BIT) { // factory reset requested - erase NVS and reboot ESP_LOGI(TAG, "Factory reset requested"); diff --git a/sdkconfig b/sdkconfig index a4a151e..6db8bc5 100644 --- a/sdkconfig +++ b/sdkconfig @@ -1547,6 +1547,23 @@ CONFIG_ESP_GDBSTUB_SUPPORT_TASKS=y CONFIG_ESP_GDBSTUB_MAX_TASKS=32 # end of GDB Stub +# +# ESP HTTP client +# +# default: +CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS=y +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH is not set +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_DIGEST_AUTH is not set +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT is not set +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_GET_CONTENT_RANGE is not set +# default: +CONFIG_ESP_HTTP_CLIENT_EVENT_POST_TIMEOUT=2000 +# end of ESP HTTP client + # # HTTP Server # @@ -1568,6 +1585,19 @@ CONFIG_HTTPD_PURGE_BUF_LEN=32 CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000 # end of HTTP Server +# +# ESP HTTPS OTA +# +# default: +# CONFIG_ESP_HTTPS_OTA_DECRYPT_CB is not set +# default: +# CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP is not set +# default: +CONFIG_ESP_HTTPS_OTA_EVENT_POST_TIMEOUT=2000 +# default: +# CONFIG_ESP_HTTPS_OTA_ENABLE_PARTIAL_DOWNLOAD is not set +# end of ESP HTTPS OTA + # # Hardware Settings # @@ -3672,6 +3702,7 @@ CONFIG_POST_EVENTS_FROM_ISR=y CONFIG_POST_EVENTS_FROM_IRAM_ISR=y CONFIG_GDBSTUB_SUPPORT_TASKS=y CONFIG_GDBSTUB_MAX_TASKS=32 +# CONFIG_OTA_ALLOW_HTTP is not set # CONFIG_TWO_UNIVERSAL_MAC_ADDRESS is not set CONFIG_FOUR_UNIVERSAL_MAC_ADDRESS=y CONFIG_NUMBER_OF_UNIVERSAL_MAC_ADDRESS=4 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 1f67bda..0e57c66 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -20,6 +20,7 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" # Enable OTA CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y # Factory reset button (GPIO 0 = BOOT button on ESP32 DevKit) CONFIG_ANCHOR_RESET_GPIO=0