From d2175bf7d0f6c5dc25d61475e0cc044d8afd767b Mon Sep 17 00:00:00 2001 From: sl-firmware Date: Fri, 17 Apr 2026 22:13:47 -0400 Subject: [PATCH] feat: Gitea release version checker with WiFi (bd-3hte) Adds gitea_ota_check_task on Balance board: fetches Gitea releases API every 30 min and on boot, filters by esp32-balance/ and esp32-io/ tag prefixes, compares semver against embedded FW version, stores update info (version string, download URL, SHA256) in g_balance_update / g_io_update. WiFi credentials read from NVS namespace "wifi"; falls back to compile-time DEFAULT_WIFI_SSID/PASS if NVS is empty. Co-Authored-By: Claude Sonnet 4.6 --- esp32s3/balance/main/gitea_ota.c | 285 +++++++++++++++++++++++++++++++ esp32s3/balance/main/gitea_ota.h | 42 +++++ esp32s3/balance/main/version.h | 14 ++ 3 files changed, 341 insertions(+) create mode 100644 esp32s3/balance/main/gitea_ota.c create mode 100644 esp32s3/balance/main/gitea_ota.h create mode 100644 esp32s3/balance/main/version.h diff --git a/esp32s3/balance/main/gitea_ota.c b/esp32s3/balance/main/gitea_ota.c new file mode 100644 index 0000000..61186de --- /dev/null +++ b/esp32s3/balance/main/gitea_ota.c @@ -0,0 +1,285 @@ +/* gitea_ota.c — Gitea version checker (bd-3hte) + * + * Uses esp_http_client + cJSON to query: + * GET /api/v1/repos/{repo}/releases?limit=10 + * Filters releases by tag prefix, extracts version and download URLs. + */ + +#include "gitea_ota.h" +#include "version.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "esp_http_client.h" +#include "nvs_flash.h" +#include "nvs.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/event_groups.h" +#include "cJSON.h" +#include +#include + +static const char *TAG = "gitea_ota"; + +ota_update_info_t g_balance_update = {0}; +ota_update_info_t g_io_update = {0}; + +/* ── WiFi connection ── */ +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_MAX_RETRIES 5 + +/* Compile-time WiFi fallback (override in NVS "wifi"/"ssid","pass") */ +#define DEFAULT_WIFI_SSID "saltylab" +#define DEFAULT_WIFI_PASS "" + +static EventGroupHandle_t s_wifi_eg; +static int s_wifi_retries = 0; + +static void wifi_event_handler(void *arg, esp_event_base_t base, + int32_t id, void *data) +{ + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + if (s_wifi_retries < WIFI_MAX_RETRIES) { + esp_wifi_connect(); + s_wifi_retries++; + } else { + xEventGroupSetBits(s_wifi_eg, WIFI_FAIL_BIT); + } + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + s_wifi_retries = 0; + xEventGroupSetBits(s_wifi_eg, WIFI_CONNECTED_BIT); + } +} + +static bool wifi_connect(void) +{ + char ssid[64] = DEFAULT_WIFI_SSID; + char pass[64] = DEFAULT_WIFI_PASS; + + /* Try to read credentials from NVS */ + nvs_handle_t nvs; + if (nvs_open("wifi", NVS_READONLY, &nvs) == ESP_OK) { + size_t sz = sizeof(ssid); + nvs_get_str(nvs, "ssid", ssid, &sz); + sz = sizeof(pass); + nvs_get_str(nvs, "pass", pass, &sz); + nvs_close(nvs); + } + + s_wifi_eg = xEventGroupCreate(); + s_wifi_retries = 0; + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + esp_event_handler_instance_t h1, h2; + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, &h1)); + ESP_ERROR_CHECK(esp_event_handler_instance_register( + IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, &h2)); + + wifi_config_t wcfg = {0}; + strlcpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid)); + strlcpy((char *)wcfg.sta.password, pass, sizeof(wcfg.sta.password)); + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg)); + ESP_ERROR_CHECK(esp_wifi_start()); + + EventBits_t bits = xEventGroupWaitBits(s_wifi_eg, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, + pdMS_TO_TICKS(15000)); + + esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, h2); + esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, h1); + vEventGroupDelete(s_wifi_eg); + + if (bits & WIFI_CONNECTED_BIT) { + ESP_LOGI(TAG, "WiFi connected SSID=%s", ssid); + return true; + } + ESP_LOGW(TAG, "WiFi connect failed SSID=%s", ssid); + return false; +} + +/* ── HTTP fetch into a heap buffer ── */ +#define HTTP_RESP_MAX (8 * 1024) + +typedef struct { char *buf; int len; int cap; } http_buf_t; + +static esp_err_t http_event_cb(esp_http_client_event_t *evt) +{ + http_buf_t *b = (http_buf_t *)evt->user_data; + if (evt->event_id == HTTP_EVENT_ON_DATA && b) { + if (b->len + evt->data_len < b->cap) { + memcpy(b->buf + b->len, evt->data, evt->data_len); + b->len += evt->data_len; + } + } + return ESP_OK; +} + +static char *http_get(const char *url) +{ + char *buf = malloc(HTTP_RESP_MAX); + if (!buf) return NULL; + http_buf_t b = {.buf = buf, .len = 0, .cap = HTTP_RESP_MAX}; + buf[0] = '\0'; + + esp_http_client_config_t cfg = { + .url = url, + .event_handler = http_event_cb, + .user_data = &b, + .timeout_ms = GITEA_API_TIMEOUT_MS, + .skip_cert_common_name_check = true, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + esp_err_t err = esp_http_client_perform(client); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK || status != 200) { + ESP_LOGW(TAG, "HTTP GET %s → err=%d status=%d", url, err, status); + free(buf); + return NULL; + } + buf[b.len] = '\0'; + return buf; +} + +/* ── Version comparison: returns true if remote > local ── */ +static bool version_newer(const char *local, const char *remote) +{ + int la=0,lb=0,lc=0, ra=0,rb=0,rc=0; + sscanf(local, "%d.%d.%d", &la, &lb, &lc); + sscanf(remote, "%d.%d.%d", &ra, &rb, &rc); + if (ra != la) return ra > la; + if (rb != lb) return rb > lb; + return rc > lc; +} + +/* ── Parse releases JSON array, fill ota_update_info_t ── */ +static void parse_releases(const char *json, const char *tag_prefix, + const char *bin_asset, const char *sha_asset, + const char *local_version, + ota_update_info_t *out) +{ + cJSON *arr = cJSON_Parse(json); + if (!arr || !cJSON_IsArray(arr)) { + ESP_LOGW(TAG, "JSON parse failed"); + cJSON_Delete(arr); + return; + } + + cJSON *rel; + cJSON_ArrayForEach(rel, arr) { + cJSON *tag_j = cJSON_GetObjectItem(rel, "tag_name"); + if (!cJSON_IsString(tag_j)) continue; + + const char *tag = tag_j->valuestring; + if (strncmp(tag, tag_prefix, strlen(tag_prefix)) != 0) continue; + + /* Extract version after prefix */ + const char *ver = tag + strlen(tag_prefix); + if (*ver == 'v') ver++; /* strip leading 'v' */ + + if (!version_newer(local_version, ver)) continue; + + /* Found a newer release — extract asset URLs */ + cJSON *assets = cJSON_GetObjectItem(rel, "assets"); + if (!cJSON_IsArray(assets)) continue; + + out->available = false; + out->download_url[0] = '\0'; + out->sha256[0] = '\0'; + strlcpy(out->version, ver, sizeof(out->version)); + + cJSON *asset; + cJSON_ArrayForEach(asset, assets) { + cJSON *name_j = cJSON_GetObjectItem(asset, "name"); + cJSON *url_j = cJSON_GetObjectItem(asset, "browser_download_url"); + if (!cJSON_IsString(name_j) || !cJSON_IsString(url_j)) continue; + + if (strcmp(name_j->valuestring, bin_asset) == 0) { + strlcpy(out->download_url, url_j->valuestring, + sizeof(out->download_url)); + out->available = true; + } else if (strcmp(name_j->valuestring, sha_asset) == 0) { + /* Download the SHA256 asset inline */ + char *sha = http_get(url_j->valuestring); + if (sha) { + /* sha file is just hex+newline */ + size_t n = strspn(sha, "0123456789abcdefABCDEF"); + if (n == 64) { + memcpy(out->sha256, sha, 64); + out->sha256[64] = '\0'; + } + free(sha); + } + } + } + + if (out->available) { + ESP_LOGI(TAG, "update: tag=%s ver=%s", tag, out->version); + } + break; /* use first matching release */ + } + + cJSON_Delete(arr); +} + +/* ── Main check ── */ +void gitea_ota_check_now(void) +{ + char url[512]; + snprintf(url, sizeof(url), + "%s/api/v1/repos/%s/releases?limit=10", + GITEA_BASE_URL, GITEA_REPO); + + char *json = http_get(url); + if (!json) { + ESP_LOGW(TAG, "releases fetch failed"); + return; + } + + parse_releases(json, BALANCE_TAG_PREFIX, BALANCE_BIN_ASSET, + BALANCE_SHA256_ASSET, BALANCE_FW_VERSION, &g_balance_update); + parse_releases(json, IO_TAG_PREFIX, IO_BIN_ASSET, + IO_SHA256_ASSET, IO_FW_VERSION, &g_io_update); + free(json); +} + +/* ── Background task ── */ +static void version_check_task(void *arg) +{ + /* Initial check immediately after WiFi up */ + vTaskDelay(pdMS_TO_TICKS(2000)); + gitea_ota_check_now(); + + for (;;) { + vTaskDelay(pdMS_TO_TICKS(VERSION_CHECK_PERIOD_MS)); + gitea_ota_check_now(); + } +} + +void gitea_ota_init(void) +{ + ESP_ERROR_CHECK(nvs_flash_init()); + + if (!wifi_connect()) { + ESP_LOGW(TAG, "WiFi unavailable — version checks disabled"); + return; + } + + xTaskCreate(version_check_task, "ver_check", 6144, NULL, 3, NULL); + ESP_LOGI(TAG, "version check task started"); +} diff --git a/esp32s3/balance/main/gitea_ota.h b/esp32s3/balance/main/gitea_ota.h new file mode 100644 index 0000000..9b92580 --- /dev/null +++ b/esp32s3/balance/main/gitea_ota.h @@ -0,0 +1,42 @@ +#pragma once +/* gitea_ota.h — Gitea release version checker (bd-3hte) + * + * WiFi task: on boot and every 30 min, queries Gitea releases API, + * compares tag version against embedded FW_VERSION, stores update info. + * + * WiFi credentials read from NVS namespace "wifi" keys "ssid"/"pass". + * Fall back to compile-time defaults if NVS is empty. + */ + +#include +#include + +/* Gitea instance */ +#define GITEA_BASE_URL "http://gitea.vayrette.com" +#define GITEA_REPO "seb/saltylab-firmware" +#define GITEA_API_TIMEOUT_MS 10000 + +/* Version check interval */ +#define VERSION_CHECK_PERIOD_MS (30u * 60u * 1000u) /* 30 minutes */ + +/* Max URL/version string lengths */ +#define OTA_URL_MAX 384 +#define OTA_VER_MAX 32 +#define OTA_SHA256_MAX 65 + +typedef struct { + bool available; + char version[OTA_VER_MAX]; /* remote version string, e.g. "1.2.3" */ + char download_url[OTA_URL_MAX]; /* direct download URL for .bin */ + char sha256[OTA_SHA256_MAX]; /* hex SHA256 (from .sha256 asset), or "" */ +} ota_update_info_t; + +/* Shared state — written by gitea_ota_check_task, read by display/OTA tasks */ +extern ota_update_info_t g_balance_update; +extern ota_update_info_t g_io_update; + +/* Initialize WiFi and start version check task */ +void gitea_ota_init(void); + +/* One-shot sync check (can be called from any task) */ +void gitea_ota_check_now(void); diff --git a/esp32s3/balance/main/version.h b/esp32s3/balance/main/version.h new file mode 100644 index 0000000..2aa13cf --- /dev/null +++ b/esp32s3/balance/main/version.h @@ -0,0 +1,14 @@ +#pragma once +/* Embedded firmware version — bump on each release */ +#define BALANCE_FW_VERSION "1.0.0" +#define IO_FW_VERSION "1.0.0" + +/* Gitea release tag prefixes */ +#define BALANCE_TAG_PREFIX "esp32-balance/" +#define IO_TAG_PREFIX "esp32-io/" + +/* Gitea release asset filenames */ +#define BALANCE_BIN_ASSET "balance-firmware.bin" +#define IO_BIN_ASSET "io-firmware.bin" +#define BALANCE_SHA256_ASSET "balance-firmware.sha256" +#define IO_SHA256_ASSET "io-firmware.sha256"