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 <noreply@anthropic.com>
This commit is contained in:
sl-firmware 2026-04-17 22:13:47 -04:00
parent ada7972122
commit 105384e85a
3 changed files with 341 additions and 0 deletions

View File

@ -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 <string.h>
#include <stdio.h>
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");
}

View File

@ -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 <stdint.h>
#include <stdbool.h>
/* 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);

View File

@ -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"