Add Gitea OTA firmware update, dual-slot partition layout, and Gitea Actions release workflow
This commit is contained in:
444
main/ota_manager.c
Normal file
444
main/ota_manager.c
Normal file
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* MTG RFID Companion - OTA Firmware Update Manager
|
||||
*
|
||||
* Checks Gitea release API, downloads firmware updates, and flashes them
|
||||
* to the passive OTA slot with rollback protection.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_https_ota.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "cJSON.h"
|
||||
#include "ota_manager.h"
|
||||
|
||||
static const char *TAG = "ota";
|
||||
|
||||
#ifndef CONFIG_MTG_GITEA_URL
|
||||
#define CONFIG_MTG_GITEA_URL "https://gitea.com"
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_MTG_GITEA_REPO_OWNER
|
||||
#define CONFIG_MTG_GITEA_REPO_OWNER "user"
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_MTG_GITEA_REPO_NAME
|
||||
#define CONFIG_MTG_GITEA_REPO_NAME "espMTG"
|
||||
#endif
|
||||
|
||||
#define GITEA_API_BUF_SIZE (8192)
|
||||
|
||||
static SemaphoreHandle_t s_ota_mux = NULL;
|
||||
static ota_status_t s_status = OTA_STATUS_IDLE;
|
||||
static char s_message[128] = "Idle";
|
||||
static ota_release_info_t s_release = {0};
|
||||
static size_t s_bytes_read = 0;
|
||||
static size_t s_total_bytes = 0;
|
||||
static volatile bool s_cancel_requested = false;
|
||||
static TaskHandle_t s_ota_task_handle = NULL;
|
||||
|
||||
static void set_state(ota_status_t st, const char *msg)
|
||||
{
|
||||
if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY);
|
||||
s_status = st;
|
||||
if (msg) {
|
||||
strncpy(s_message, msg, sizeof(s_message) - 1);
|
||||
s_message[sizeof(s_message) - 1] = '\0';
|
||||
}
|
||||
if (s_ota_mux) xSemaphoreGive(s_ota_mux);
|
||||
}
|
||||
|
||||
esp_err_t ota_manager_init(void)
|
||||
{
|
||||
if (s_ota_mux == NULL) {
|
||||
s_ota_mux = xSemaphoreCreateMutex();
|
||||
}
|
||||
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_img_states_t ota_state;
|
||||
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
|
||||
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
ESP_LOGI(TAG, "First boot of new firmware on '%s'! Validating...", running->label);
|
||||
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Firmware validated and rollback canceled successfully");
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to validate firmware: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const esp_app_desc_t *app_desc = esp_app_get_description();
|
||||
ESP_LOGI(TAG, "Running firmware '%s' version '%s' compiled on %s %s on partition '%s'",
|
||||
app_desc->project_name, app_desc->version, app_desc->date, app_desc->time,
|
||||
running ? running->label : "unknown");
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
const char *ota_manager_get_current_version(void)
|
||||
{
|
||||
return esp_app_get_description()->version;
|
||||
}
|
||||
|
||||
ota_status_t ota_manager_get_status(void)
|
||||
{
|
||||
ota_status_t st;
|
||||
if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY);
|
||||
st = s_status;
|
||||
if (s_ota_mux) xSemaphoreGive(s_ota_mux);
|
||||
return st;
|
||||
}
|
||||
|
||||
const char *ota_manager_get_message(void)
|
||||
{
|
||||
return s_message;
|
||||
}
|
||||
|
||||
int ota_manager_get_progress(void)
|
||||
{
|
||||
if (s_total_bytes == 0) return 0;
|
||||
int p = (int)((s_bytes_read * 100) / s_total_bytes);
|
||||
if (p > 100) p = 100;
|
||||
return p;
|
||||
}
|
||||
|
||||
void ota_manager_get_bytes(size_t *read_bytes, size_t *total_bytes)
|
||||
{
|
||||
if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY);
|
||||
if (read_bytes) *read_bytes = s_bytes_read;
|
||||
if (total_bytes) *total_bytes = s_total_bytes;
|
||||
if (s_ota_mux) xSemaphoreGive(s_ota_mux);
|
||||
}
|
||||
|
||||
const ota_release_info_t *ota_manager_get_release_info(void)
|
||||
{
|
||||
return &s_release;
|
||||
}
|
||||
|
||||
void ota_manager_cancel(void)
|
||||
{
|
||||
s_cancel_requested = true;
|
||||
}
|
||||
|
||||
void ota_manager_reset(void)
|
||||
{
|
||||
if (s_status != OTA_STATUS_CHECKING && s_status != OTA_STATUS_DOWNLOADING) {
|
||||
set_state(OTA_STATUS_IDLE, "Idle");
|
||||
}
|
||||
}
|
||||
|
||||
/* Semver parsing: compares "v1.2.3" or "1.2.3" */
|
||||
static bool parse_semver(const char *ver, int *maj, int *min, int *patch)
|
||||
{
|
||||
if (!ver) return false;
|
||||
while (*ver && !isdigit((unsigned char)*ver)) {
|
||||
ver++;
|
||||
}
|
||||
*maj = 0; *min = 0; *patch = 0;
|
||||
int n = sscanf(ver, "%d.%d.%d", maj, min, patch);
|
||||
return (n >= 1);
|
||||
}
|
||||
|
||||
static bool check_is_newer(const char *remote, const char *local)
|
||||
{
|
||||
int r_maj = 0, r_min = 0, r_patch = 0;
|
||||
int l_maj = 0, l_min = 0, l_patch = 0;
|
||||
|
||||
bool r_ok = parse_semver(remote, &r_maj, &r_min, &r_patch);
|
||||
bool l_ok = parse_semver(local, &l_maj, &l_min, &l_patch);
|
||||
|
||||
if (r_ok && l_ok) {
|
||||
if (r_maj > l_maj) return true;
|
||||
if (r_maj < l_maj) return false;
|
||||
if (r_min > l_min) return true;
|
||||
if (r_min < l_min) return false;
|
||||
return (r_patch > l_patch);
|
||||
}
|
||||
/* Fallback to string comparison */
|
||||
return (strcmp(remote, local) != 0);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char *buf;
|
||||
int len;
|
||||
int max;
|
||||
} http_rx_buf_t;
|
||||
|
||||
static esp_err_t http_rx_event_handler(esp_http_client_event_t *evt)
|
||||
{
|
||||
http_rx_buf_t *rx = (http_rx_buf_t *)evt->user_data;
|
||||
if (evt->event_id == HTTP_EVENT_ON_DATA && rx && rx->buf) {
|
||||
if (rx->len + evt->data_len < rx->max - 1) {
|
||||
memcpy(rx->buf + rx->len, evt->data, evt->data_len);
|
||||
rx->len += evt->data_len;
|
||||
rx->buf[rx->len] = '\0';
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void ota_check_task(void *arg)
|
||||
{
|
||||
set_state(OTA_STATUS_CHECKING, "Connecting to Gitea...");
|
||||
|
||||
char api_url[256];
|
||||
snprintf(api_url, sizeof(api_url), "%s/api/v1/repos/%s/%s/releases/latest",
|
||||
CONFIG_MTG_GITEA_URL, CONFIG_MTG_GITEA_REPO_OWNER, CONFIG_MTG_GITEA_REPO_NAME);
|
||||
|
||||
ESP_LOGI(TAG, "Querying Gitea release API: %s", api_url);
|
||||
|
||||
http_rx_buf_t rx = {
|
||||
.buf = malloc(GITEA_API_BUF_SIZE),
|
||||
.len = 0,
|
||||
.max = GITEA_API_BUF_SIZE,
|
||||
};
|
||||
|
||||
if (!rx.buf) {
|
||||
set_state(OTA_STATUS_FAILED, "Out of memory");
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
rx.buf[0] = '\0';
|
||||
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = api_url,
|
||||
.transport_type = HTTP_TRANSPORT_OVER_SSL,
|
||||
.timeout_ms = 10000,
|
||||
.event_handler = http_rx_event_handler,
|
||||
.user_data = &rx,
|
||||
.buffer_size = 2048,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
if (!client) {
|
||||
free(rx.buf);
|
||||
set_state(OTA_STATUS_FAILED, "HTTP init failed");
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
esp_http_client_set_method(client, HTTP_METHOD_GET);
|
||||
esp_http_client_set_header(client, "User-Agent", "MTGCompanion/1.0");
|
||||
esp_http_client_set_header(client, "Accept", "application/json");
|
||||
|
||||
esp_err_t ret = esp_http_client_perform(client);
|
||||
int status_code = esp_http_client_get_status_code(client);
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
if (ret != ESP_OK || status_code != 200) {
|
||||
char err_msg[64];
|
||||
if (status_code == 404) {
|
||||
snprintf(err_msg, sizeof(err_msg), "No releases found (404)");
|
||||
} else if (ret != ESP_OK) {
|
||||
snprintf(err_msg, sizeof(err_msg), "Network error: %s", esp_err_to_name(ret));
|
||||
} else {
|
||||
snprintf(err_msg, sizeof(err_msg), "HTTP error %d", status_code);
|
||||
}
|
||||
ESP_LOGW(TAG, "Gitea check failed: %s (code %d)", esp_err_to_name(ret), status_code);
|
||||
free(rx.buf);
|
||||
set_state(OTA_STATUS_FAILED, err_msg);
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Parse JSON */
|
||||
cJSON *root = cJSON_Parse(rx.buf);
|
||||
free(rx.buf);
|
||||
|
||||
if (!root) {
|
||||
set_state(OTA_STATUS_FAILED, "Invalid JSON from server");
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *tag_item = cJSON_GetObjectItem(root, "tag_name");
|
||||
cJSON *title_item = cJSON_GetObjectItem(root, "name");
|
||||
const char *tag_str = (tag_item && tag_item->valuestring) ? tag_item->valuestring : "unknown";
|
||||
const char *title_str = (title_item && title_item->valuestring) ? title_item->valuestring : tag_str;
|
||||
|
||||
if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY);
|
||||
strncpy(s_release.tag_name, tag_str, sizeof(s_release.tag_name) - 1);
|
||||
strncpy(s_release.release_title, title_str, sizeof(s_release.release_title) - 1);
|
||||
s_release.download_url[0] = '\0';
|
||||
s_release.binary_size = 0;
|
||||
|
||||
/* Search assets for mtg-rfid-companion.bin or *.bin */
|
||||
cJSON *assets = cJSON_GetObjectItem(root, "assets");
|
||||
if (assets && cJSON_IsArray(assets)) {
|
||||
int count = cJSON_GetArraySize(assets);
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *asset = cJSON_GetArrayItem(assets, i);
|
||||
cJSON *aname = cJSON_GetObjectItem(asset, "name");
|
||||
cJSON *aurl = cJSON_GetObjectItem(asset, "browser_download_url");
|
||||
cJSON *asize = cJSON_GetObjectItem(asset, "size");
|
||||
|
||||
if (aname && aname->valuestring && aurl && aurl->valuestring) {
|
||||
if (strstr(aname->valuestring, ".bin") != NULL) {
|
||||
const char *url_str = aurl->valuestring;
|
||||
if (url_str[0] == '/') {
|
||||
snprintf(s_release.download_url, sizeof(s_release.download_url),
|
||||
"%s%s", CONFIG_MTG_GITEA_URL, url_str);
|
||||
} else {
|
||||
strncpy(s_release.download_url, url_str, sizeof(s_release.download_url) - 1);
|
||||
}
|
||||
if (asize && asize->valuedouble > 0) {
|
||||
s_release.binary_size = (size_t)asize->valuedouble;
|
||||
}
|
||||
if (strstr(aname->valuestring, "mtg-rfid-companion") != NULL) {
|
||||
break; /* Exact match, stop looking */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback URL if not in assets list */
|
||||
if (s_release.download_url[0] == '\0') {
|
||||
snprintf(s_release.download_url, sizeof(s_release.download_url),
|
||||
"%s/%s/%s/releases/download/%s/mtg-rfid-companion.bin",
|
||||
CONFIG_MTG_GITEA_URL, CONFIG_MTG_GITEA_REPO_OWNER, CONFIG_MTG_GITEA_REPO_NAME,
|
||||
s_release.tag_name);
|
||||
}
|
||||
|
||||
const char *current_ver = ota_manager_get_current_version();
|
||||
s_release.is_newer = check_is_newer(s_release.tag_name, current_ver);
|
||||
if (s_ota_mux) xSemaphoreGive(s_ota_mux);
|
||||
|
||||
ESP_LOGI(TAG, "Discovered release '%s' (%s), download: %s, is_newer: %d (current: %s)",
|
||||
s_release.tag_name, s_release.release_title, s_release.download_url,
|
||||
s_release.is_newer, current_ver);
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (s_release.is_newer) {
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof(msg), "New firmware %s available!", s_release.tag_name);
|
||||
set_state(OTA_STATUS_UPDATE_AVAILABLE, msg);
|
||||
} else {
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof(msg), "Firmware %s is up to date", current_ver);
|
||||
set_state(OTA_STATUS_UP_TO_DATE, msg);
|
||||
}
|
||||
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void ota_download_task(void *arg)
|
||||
{
|
||||
s_cancel_requested = false;
|
||||
s_bytes_read = 0;
|
||||
s_total_bytes = s_release.binary_size;
|
||||
|
||||
set_state(OTA_STATUS_DOWNLOADING, "Starting download...");
|
||||
ESP_LOGI(TAG, "Starting HTTPS OTA from: %s", s_release.download_url);
|
||||
|
||||
esp_http_client_config_t http_cfg = {
|
||||
.url = s_release.download_url,
|
||||
.transport_type = HTTP_TRANSPORT_OVER_SSL,
|
||||
.timeout_ms = 30000,
|
||||
.keep_alive_enable = true,
|
||||
.buffer_size = 2048,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
|
||||
esp_https_ota_config_t ota_cfg = {
|
||||
.http_config = &http_cfg,
|
||||
};
|
||||
|
||||
esp_https_ota_handle_t ota_handle = NULL;
|
||||
esp_err_t err = esp_https_ota_begin(&ota_cfg, &ota_handle);
|
||||
if (err != ESP_OK || ota_handle == NULL) {
|
||||
ESP_LOGE(TAG, "esp_https_ota_begin failed: %s", esp_err_to_name(err));
|
||||
set_state(OTA_STATUS_FAILED, "Failed to connect to image");
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
while (1) {
|
||||
if (s_cancel_requested) {
|
||||
ESP_LOGW(TAG, "OTA canceled by user");
|
||||
esp_https_ota_abort(ota_handle);
|
||||
set_state(OTA_STATUS_IDLE, "Update canceled");
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
err = esp_https_ota_perform(ota_handle);
|
||||
if (err != ESP_ERR_HTTPS_OTA_IN_PROGRESS) {
|
||||
break;
|
||||
}
|
||||
|
||||
int read = esp_https_ota_get_image_len_read(ota_handle);
|
||||
int total = esp_https_ota_get_image_size(ota_handle);
|
||||
if (read > 0) s_bytes_read = (size_t)read;
|
||||
if (total > 0) s_total_bytes = (size_t)total;
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
|
||||
if (err == ESP_OK) {
|
||||
if (esp_https_ota_is_complete_data_received(ota_handle)) {
|
||||
esp_err_t finish_err = esp_https_ota_finish(ota_handle);
|
||||
if (finish_err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "OTA upgrade successful! Rebooting in 2 seconds...");
|
||||
set_state(OTA_STATUS_SUCCESS_REBOOTING, "Update complete! Rebooting...");
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
esp_restart();
|
||||
} else {
|
||||
ESP_LOGE(TAG, "esp_https_ota_finish failed: %s", esp_err_to_name(finish_err));
|
||||
set_state(OTA_STATUS_FAILED, "Validation failed");
|
||||
}
|
||||
} else {
|
||||
esp_https_ota_abort(ota_handle);
|
||||
set_state(OTA_STATUS_FAILED, "Incomplete download");
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "esp_https_ota_perform failed: %s", esp_err_to_name(err));
|
||||
esp_https_ota_abort(ota_handle);
|
||||
set_state(OTA_STATUS_FAILED, "Flash write failed");
|
||||
}
|
||||
|
||||
s_ota_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
esp_err_t ota_manager_check_update_async(void)
|
||||
{
|
||||
if (s_status == OTA_STATUS_CHECKING || s_status == OTA_STATUS_DOWNLOADING) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
ota_check_task, "ota_check", 8192, NULL, 5, &s_ota_task_handle, 0);
|
||||
return (ret == pdPASS) ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_err_t ota_manager_start_update_async(void)
|
||||
{
|
||||
if (s_status == OTA_STATUS_DOWNLOADING) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (s_release.download_url[0] == '\0') {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
ota_download_task, "ota_update", 8192, NULL, 5, &s_ota_task_handle, 0);
|
||||
return (ret == pdPASS) ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
Reference in New Issue
Block a user