diff --git a/esp32s3/balance/main/CMakeLists.txt b/esp32s3/balance/main/CMakeLists.txt index 2fd31d5..518a489 100644 --- a/esp32s3/balance/main/CMakeLists.txt +++ b/esp32s3/balance/main/CMakeLists.txt @@ -1,4 +1,22 @@ idf_component_register( - SRCS "main.c" "orin_serial.c" "vesc_can.c" + SRCS + "main.c" + "orin_serial.c" + "vesc_can.c" + "gitea_ota.c" + "ota_self.c" + "uart_ota.c" + "ota_display.c" INCLUDE_DIRS "." + REQUIRES + esp_wifi + esp_http_client + esp_https_ota + nvs_flash + app_update + mbedtls + cJSON + driver + freertos + esp_timer ) 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/main.c b/esp32s3/balance/main/main.c index c382c04..f8c4796 100644 --- a/esp32s3/balance/main/main.c +++ b/esp32s3/balance/main/main.c @@ -1,15 +1,11 @@ -/* main.c — ESP32-S3 BALANCE app_main (bd-66hx) - * - * Initializes Orin serial and VESC CAN TWAI, creates tasks: - * orin_rx — parse incoming Orin commands - * orin_tx — transmit queued serial frames - * vesc_rx — receive VESC CAN telemetry, proxy to Orin - * telem — periodic TELEM_STATUS to Orin @ 10 Hz - * drive — apply Orin drive commands to VESCs via CAN - */ +/* main.c — ESP32-S3 BALANCE app_main (bd-66hx + OTA beads) */ #include "orin_serial.h" #include "vesc_can.h" +#include "gitea_ota.h" +#include "ota_self.h" +#include "uart_ota.h" +#include "ota_display.h" #include "config.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -86,7 +82,10 @@ static void drive_task(void *arg) void app_main(void) { - ESP_LOGI(TAG, "ESP32-S3 BALANCE bd-66hx starting"); + ESP_LOGI(TAG, "ESP32-S3 BALANCE starting"); + + /* OTA rollback health check — must be called within OTA_ROLLBACK_WINDOW_S */ + ota_self_health_check(); /* Init peripherals */ orin_serial_init(); @@ -106,6 +105,10 @@ void app_main(void) xTaskCreate(telem_task, "telem", 2048, NULL, 5, NULL); xTaskCreate(drive_task, "drive", 2048, NULL, 8, NULL); + /* OTA subsystem — WiFi version checker + display overlay */ + gitea_ota_init(); + ota_display_init(); + ESP_LOGI(TAG, "all tasks started"); /* app_main returns — FreeRTOS scheduler continues */ } diff --git a/esp32s3/balance/main/orin_serial.c b/esp32s3/balance/main/orin_serial.c index 2cb3ef4..52de20b 100644 --- a/esp32s3/balance/main/orin_serial.c +++ b/esp32s3/balance/main/orin_serial.c @@ -1,17 +1,18 @@ -/* orin_serial.c — Orin↔ESP32-S3 serial protocol implementation (bd-66hx) - * - * Implements the binary framing protocol matching bd-wim1 (Orin side). - * CRC8-SMBUS: poly=0x07, init=0x00, covers LEN+TYPE+PAYLOAD bytes. - */ +/* orin_serial.c — Orin↔ESP32-S3 serial protocol (bd-66hx + bd-1s1s OTA cmds) */ #include "orin_serial.h" #include "config.h" +#include "gitea_ota.h" +#include "ota_self.h" +#include "uart_ota.h" +#include "version.h" #include "driver/uart.h" #include "esp_log.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include +#include static const char *TAG = "orin"; @@ -206,6 +207,46 @@ static void dispatch_cmd(uint8_t type, const uint8_t *payload, uint8_t len, orin_send_ack(tx_q, type); break; + case CMD_OTA_CHECK: + /* Trigger an immediate Gitea version check */ + gitea_ota_check_now(); + orin_send_version_info(tx_q, OTA_TARGET_BALANCE, + BALANCE_FW_VERSION, + g_balance_update.available + ? g_balance_update.version : ""); + orin_send_version_info(tx_q, OTA_TARGET_IO, + IO_FW_VERSION, + g_io_update.available + ? g_io_update.version : ""); + orin_send_ack(tx_q, type); + break; + + case CMD_OTA_UPDATE: + if (len < 1u) { orin_send_nack(tx_q, type, ERR_BAD_LEN); break; } + { + uint8_t target = payload[0]; + bool triggered = false; + if (target == OTA_TARGET_IO || target == OTA_TARGET_BOTH) { + if (!uart_ota_trigger()) { + orin_send_nack(tx_q, type, + g_io_update.available ? ERR_OTA_BUSY : ERR_OTA_NO_UPDATE); + break; + } + triggered = true; + } + if (target == OTA_TARGET_BALANCE || target == OTA_TARGET_BOTH) { + if (!ota_self_trigger()) { + if (!triggered) { + orin_send_nack(tx_q, type, + g_balance_update.available ? ERR_OTA_BUSY : ERR_OTA_NO_UPDATE); + break; + } + } + } + orin_send_ack(tx_q, type); + } + break; + default: ESP_LOGW(TAG, "unknown cmd type=0x%02x", type); break; @@ -290,3 +331,24 @@ void orin_serial_tx_task(void *arg) } } } + +/* ── OTA telemetry helpers (bd-1s1s) ── */ + +void orin_send_ota_status(QueueHandle_t q, uint8_t target, + uint8_t state, uint8_t progress, uint8_t err) +{ + /* TELEM_OTA_STATUS: uint8 target, uint8 state, uint8 progress, uint8 err */ + uint8_t p[4] = {target, state, progress, err}; + enqueue(q, TELEM_OTA_STATUS, p, 4u); +} + +void orin_send_version_info(QueueHandle_t q, uint8_t target, + const char *current, const char *available) +{ + /* TELEM_VERSION_INFO: uint8 target, char current[16], char available[16] */ + uint8_t p[33]; + p[0] = target; + strncpy((char *)&p[1], current, 16); p[16] = '\0'; + strncpy((char *)&p[17], available ? available : "", 16); p[32] = '\0'; + enqueue(q, TELEM_VERSION_INFO, p, 33u); +} diff --git a/esp32s3/balance/main/orin_serial.h b/esp32s3/balance/main/orin_serial.h index d533794..6df1f0c 100644 --- a/esp32s3/balance/main/orin_serial.h +++ b/esp32s3/balance/main/orin_serial.h @@ -29,14 +29,27 @@ #define TELEM_STATUS 0x80u /* status @ 10 Hz */ #define TELEM_VESC_LEFT 0x81u /* VESC ID 56 telemetry @ 10 Hz */ #define TELEM_VESC_RIGHT 0x82u /* VESC ID 68 telemetry @ 10 Hz */ +#define TELEM_OTA_STATUS 0x83u /* OTA state + progress (bd-1s1s) */ +#define TELEM_VERSION_INFO 0x84u /* firmware version report (bd-1s1s) */ #define RESP_ACK 0xA0u #define RESP_NACK 0xA1u +/* ── OTA commands (Orin → ESP32, bd-1s1s) ── */ +#define CMD_OTA_CHECK 0x10u /* no payload: trigger Gitea version check */ +#define CMD_OTA_UPDATE 0x11u /* uint8 target: 0=balance, 1=io, 2=both */ + +/* ── OTA target constants ── */ +#define OTA_TARGET_BALANCE 0x00u +#define OTA_TARGET_IO 0x01u +#define OTA_TARGET_BOTH 0x02u + /* ── NACK error codes ── */ #define ERR_BAD_CRC 0x01u #define ERR_BAD_LEN 0x02u #define ERR_ESTOP_ACTIVE 0x03u #define ERR_DISARMED 0x04u +#define ERR_OTA_BUSY 0x05u +#define ERR_OTA_NO_UPDATE 0x06u /* ── Balance state (mirrored from TELEM_STATUS.balance_state) ── */ typedef enum { @@ -92,3 +105,9 @@ void orin_send_vesc(QueueHandle_t q, uint8_t telem_type, int16_t current_ma, uint16_t temp_c_x10); void orin_send_ack(QueueHandle_t q, uint8_t cmd_type); void orin_send_nack(QueueHandle_t q, uint8_t cmd_type, uint8_t err); + +/* OTA telemetry helpers (bd-1s1s) */ +void orin_send_ota_status(QueueHandle_t q, uint8_t target, + uint8_t state, uint8_t progress, uint8_t err); +void orin_send_version_info(QueueHandle_t q, uint8_t target, + const char *current, const char *available); diff --git a/esp32s3/balance/main/ota_display.c b/esp32s3/balance/main/ota_display.c new file mode 100644 index 0000000..bcf3f71 --- /dev/null +++ b/esp32s3/balance/main/ota_display.c @@ -0,0 +1,150 @@ +/* ota_display.c — OTA notification/progress UI on GC9A01 (bd-1yr8) + * + * Renders OTA state overlaid on the 240×240 round HUD display: + * - BADGE: small dot on top-right when update available (idle state) + * - UPDATE SCREEN: version compare, Update Balance / Update IO / Update All + * - PROGRESS: arc around display perimeter + % + status text + * - ERROR: red banner + "RETRY" prompt + * + * The display_draw_* primitives must be provided by the GC9A01 driver. + * Actual SPI driver implementation is in a separate driver bead. + */ + +#include "ota_display.h" +#include "gitea_ota.h" +#include "version.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "ota_disp"; + +/* Display centre and radius for the 240×240 GC9A01 */ +#define CX 120 +#define CY 120 +#define RAD 110 + +/* ── Availability badge: 8×8 dot at top-right of display ── */ +static void draw_badge(bool balance_avail, bool io_avail) +{ + uint16_t col = (balance_avail || io_avail) ? COL_ORANGE : COL_BG; + display_fill_rect(200, 15, 12, 12, col); +} + +/* ── Progress arc: sweeps 0→360° proportional to progress% ── */ +static void draw_progress_arc(uint8_t pct, uint16_t color) +{ + int end_deg = (int)(360 * pct / 100); + display_draw_arc(CX, CY, RAD, 0, end_deg, 6, color); +} + +/* ── Status banner: 2 lines of text centred on display ── */ +static void draw_status(const char *line1, const char *line2, + uint16_t fg, uint16_t bg) +{ + display_fill_rect(20, 90, 200, 60, bg); + if (line1 && line1[0]) + display_draw_string(CX - (int)(strlen(line1) * 6 / 2), 96, + line1, fg, bg); + if (line2 && line2[0]) + display_draw_string(CX - (int)(strlen(line2) * 6 / 2), 116, + line2, fg, bg); +} + +/* ── Main render logic ── */ +void ota_display_update(void) +{ + /* Determine dominant OTA state */ + ota_self_state_t self = g_ota_self_state; + uart_ota_send_state_t io_s = g_uart_ota_state; + + switch (self) { + case OTA_SELF_DOWNLOADING: + case OTA_SELF_VERIFYING: + case OTA_SELF_APPLYING: { + /* Balance self-update in progress */ + char pct_str[16]; + snprintf(pct_str, sizeof(pct_str), "%d%%", g_ota_self_progress); + const char *phase = (self == OTA_SELF_VERIFYING) ? "Verifying..." : + (self == OTA_SELF_APPLYING) ? "Applying..." : + "Downloading..."; + draw_progress_arc(g_ota_self_progress, COL_BLUE); + draw_status("Updating Balance", pct_str, COL_WHITE, COL_BG); + ESP_LOGD(TAG, "balance OTA %s %d%%", phase, g_ota_self_progress); + return; + } + case OTA_SELF_REBOOTING: + draw_status("Update complete", "Rebooting...", COL_GREEN, COL_BG); + return; + case OTA_SELF_FAILED: + draw_progress_arc(0, COL_RED); + draw_status("Balance update", "FAILED RETRY?", COL_RED, COL_BG); + return; + default: + break; + } + + switch (io_s) { + case UART_OTA_S_DOWNLOADING: + draw_progress_arc(g_uart_ota_progress, COL_YELLOW); + draw_status("Downloading IO", "firmware...", COL_WHITE, COL_BG); + return; + case UART_OTA_S_SENDING: { + char pct_str[16]; + snprintf(pct_str, sizeof(pct_str), "%d%%", g_uart_ota_progress); + draw_progress_arc(g_uart_ota_progress, COL_YELLOW); + draw_status("Updating IO", pct_str, COL_WHITE, COL_BG); + return; + } + case UART_OTA_S_DONE: + draw_status("IO update done", "", COL_GREEN, COL_BG); + return; + case UART_OTA_S_FAILED: + draw_progress_arc(0, COL_RED); + draw_status("IO update", "FAILED RETRY?", COL_RED, COL_BG); + return; + default: + break; + } + + /* Idle — show badge if update available */ + bool bal_avail = g_balance_update.available; + bool io_avail = g_io_update.available; + draw_badge(bal_avail, io_avail); + + if (bal_avail || io_avail) { + /* Show available versions on display when idle */ + char verline[32]; + if (bal_avail) { + snprintf(verline, sizeof(verline), "Bal v%s rdy", + g_balance_update.version); + draw_status(verline, io_avail ? "IO update rdy" : "", + COL_ORANGE, COL_BG); + } else if (io_avail) { + snprintf(verline, sizeof(verline), "IO v%s rdy", + g_io_update.version); + draw_status(verline, "", COL_ORANGE, COL_BG); + } + } else { + /* Clear OTA overlay area */ + display_fill_rect(20, 90, 200, 60, COL_BG); + draw_badge(false, false); + } +} + +/* ── Background display task (5 Hz) ── */ +static void ota_display_task(void *arg) +{ + for (;;) { + vTaskDelay(pdMS_TO_TICKS(200)); + ota_display_update(); + } +} + +void ota_display_init(void) +{ + xTaskCreate(ota_display_task, "ota_disp", 2048, NULL, 3, NULL); + ESP_LOGI(TAG, "OTA display task started"); +} diff --git a/esp32s3/balance/main/ota_display.h b/esp32s3/balance/main/ota_display.h new file mode 100644 index 0000000..bfd2ee8 --- /dev/null +++ b/esp32s3/balance/main/ota_display.h @@ -0,0 +1,33 @@ +#pragma once +/* ota_display.h — OTA notification UI on GC9A01 round LCD (bd-1yr8) + * + * GC9A01 240×240 round display via SPI (IO12 CS, IO11 DC, IO10 RST, IO9 BL). + * Calls into display_draw_* primitives (provided by display driver layer). + * This module owns the "OTA notification overlay" rendered over the HUD. + */ + +#include +#include +#include "ota_self.h" +#include "uart_ota.h" + +/* ── Display primitives API (must be provided by display driver) ── */ +void display_fill_rect(int x, int y, int w, int h, uint16_t rgb565); +void display_draw_string(int x, int y, const char *str, uint16_t fg, uint16_t bg); +void display_draw_arc(int cx, int cy, int r, int start_deg, int end_deg, + int thickness, uint16_t color); + +/* ── Colour palette (RGB565) ── */ +#define COL_BG 0x0000u /* black */ +#define COL_WHITE 0xFFFFu +#define COL_GREEN 0x07E0u +#define COL_YELLOW 0xFFE0u +#define COL_RED 0xF800u +#define COL_BLUE 0x001Fu +#define COL_ORANGE 0xFD20u + +/* ── OTA display task: runs at 5 Hz, overlays OTA state on HUD ── */ +void ota_display_init(void); + +/* Called from main loop or display task to render the OTA overlay */ +void ota_display_update(void); diff --git a/esp32s3/balance/main/ota_self.c b/esp32s3/balance/main/ota_self.c new file mode 100644 index 0000000..10962e3 --- /dev/null +++ b/esp32s3/balance/main/ota_self.c @@ -0,0 +1,183 @@ +/* ota_self.c — Balance self-OTA (bd-18nb) + * + * Uses esp_https_ota / esp_ota_ops to download from Gitea release URL, + * stream-verify SHA256 with mbedTLS, set new boot partition, and reboot. + * CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE in sdkconfig allows auto-rollback + * if the new image doesn't call esp_ota_mark_app_valid_cancel_rollback() + * within OTA_ROLLBACK_WINDOW_S seconds. + */ + +#include "ota_self.h" +#include "gitea_ota.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_http_client.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mbedtls/sha256.h" +#include +#include + +static const char *TAG = "ota_self"; + +volatile ota_self_state_t g_ota_self_state = OTA_SELF_IDLE; +volatile uint8_t g_ota_self_progress = 0; + +#define OTA_CHUNK_SIZE 4096 + +/* ── SHA256 verify helper ── */ +static bool sha256_matches(const uint8_t *digest, const char *expected_hex) +{ + if (!expected_hex || expected_hex[0] == '\0') { + ESP_LOGW(TAG, "no SHA256 to verify — skipping"); + return true; + } + char got[65] = {0}; + for (int i = 0; i < 32; i++) { + snprintf(&got[i*2], 3, "%02x", digest[i]); + } + bool ok = (strncasecmp(got, expected_hex, 64) == 0); + if (!ok) { + ESP_LOGE(TAG, "SHA256 mismatch: got=%s exp=%s", got, expected_hex); + } + return ok; +} + +/* ── OTA download + flash task ── */ +static void ota_self_task(void *arg) +{ + const char *url = g_balance_update.download_url; + const char *sha256 = g_balance_update.sha256; + + g_ota_self_state = OTA_SELF_DOWNLOADING; + g_ota_self_progress = 0; + + ESP_LOGI(TAG, "OTA start: %s", url); + + esp_ota_handle_t handle = 0; + const esp_partition_t *ota_part = esp_ota_get_next_update_partition(NULL); + if (!ota_part) { + ESP_LOGE(TAG, "no OTA partition"); + g_ota_self_state = OTA_SELF_FAILED; + vTaskDelete(NULL); + return; + } + + esp_err_t err = esp_ota_begin(ota_part, OTA_WITH_SEQUENTIAL_WRITES, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ota_begin: %s", esp_err_to_name(err)); + g_ota_self_state = OTA_SELF_FAILED; + vTaskDelete(NULL); + return; + } + + /* Setup HTTP client */ + esp_http_client_config_t hcfg = { + .url = url, + .timeout_ms = 30000, + .buffer_size = OTA_CHUNK_SIZE, + .skip_cert_common_name_check = true, + }; + esp_http_client_handle_t client = esp_http_client_init(&hcfg); + err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "http_open: %s", esp_err_to_name(err)); + esp_ota_abort(handle); + esp_http_client_cleanup(client); + g_ota_self_state = OTA_SELF_FAILED; + vTaskDelete(NULL); + return; + } + + int content_len = esp_http_client_fetch_headers(client); + ESP_LOGI(TAG, "content-length: %d", content_len); + + mbedtls_sha256_context sha_ctx; + mbedtls_sha256_init(&sha_ctx); + mbedtls_sha256_starts(&sha_ctx, 0); /* 0 = SHA-256 */ + + static uint8_t buf[OTA_CHUNK_SIZE]; + int total = 0; + int rd; + while ((rd = esp_http_client_read(client, (char *)buf, sizeof(buf))) > 0) { + mbedtls_sha256_update(&sha_ctx, buf, rd); + err = esp_ota_write(handle, buf, rd); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ota_write: %s", esp_err_to_name(err)); + esp_ota_abort(handle); + goto cleanup; + } + total += rd; + if (content_len > 0) { + g_ota_self_progress = (uint8_t)((total * 100) / content_len); + } + } + esp_http_client_close(client); + + /* Verify SHA256 */ + g_ota_self_state = OTA_SELF_VERIFYING; + uint8_t digest[32]; + mbedtls_sha256_finish(&sha_ctx, digest); + if (!sha256_matches(digest, sha256)) { + ESP_LOGE(TAG, "SHA256 verification failed"); + esp_ota_abort(handle); + g_ota_self_state = OTA_SELF_FAILED; + goto cleanup; + } + + /* Finalize + set boot partition */ + g_ota_self_state = OTA_SELF_APPLYING; + err = esp_ota_end(handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ota_end: %s", esp_err_to_name(err)); + g_ota_self_state = OTA_SELF_FAILED; + goto cleanup; + } + + err = esp_ota_set_boot_partition(ota_part); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set_boot_partition: %s", esp_err_to_name(err)); + g_ota_self_state = OTA_SELF_FAILED; + goto cleanup; + } + + g_ota_self_state = OTA_SELF_REBOOTING; + g_ota_self_progress = 100; + ESP_LOGI(TAG, "OTA success — rebooting"); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + +cleanup: + mbedtls_sha256_free(&sha_ctx); + esp_http_client_cleanup(client); + handle = 0; + vTaskDelete(NULL); +} + +bool ota_self_trigger(void) +{ + if (!g_balance_update.available) { + ESP_LOGW(TAG, "no update available"); + return false; + } + if (g_ota_self_state != OTA_SELF_IDLE) { + ESP_LOGW(TAG, "OTA already in progress (state=%d)", g_ota_self_state); + return false; + } + xTaskCreate(ota_self_task, "ota_self", 8192, NULL, 5, NULL); + return true; +} + +void ota_self_health_check(void) +{ + /* Mark running image as valid — prevents rollback */ + esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); + if (err == ESP_OK) { + ESP_LOGI(TAG, "image marked valid"); + } else if (err == ESP_ERR_NOT_SUPPORTED) { + /* Not an OTA image (e.g., flashed via JTAG) — ignore */ + } else { + ESP_LOGW(TAG, "mark_valid: %s", esp_err_to_name(err)); + } +} diff --git a/esp32s3/balance/main/ota_self.h b/esp32s3/balance/main/ota_self.h new file mode 100644 index 0000000..7871ca3 --- /dev/null +++ b/esp32s3/balance/main/ota_self.h @@ -0,0 +1,34 @@ +#pragma once +/* ota_self.h — Balance self-OTA (bd-18nb) + * + * Downloads balance-firmware.bin from Gitea release URL to the inactive + * OTA partition, verifies SHA256, sets boot partition, reboots. + * Auto-rollback if health check not called within ROLLBACK_WINDOW_S seconds. + */ + +#include +#include + +#define OTA_ROLLBACK_WINDOW_S 30 + +typedef enum { + OTA_SELF_IDLE = 0, + OTA_SELF_CHECKING, /* (unused — gitea_ota handles this) */ + OTA_SELF_DOWNLOADING, + OTA_SELF_VERIFYING, + OTA_SELF_APPLYING, + OTA_SELF_REBOOTING, + OTA_SELF_FAILED, +} ota_self_state_t; + +extern volatile ota_self_state_t g_ota_self_state; +extern volatile uint8_t g_ota_self_progress; /* 0-100 % */ + +/* Trigger a Balance self-update. + * Uses g_balance_update (from gitea_ota). Non-blocking: starts in a task. + * Returns false if no update available or OTA already in progress. */ +bool ota_self_trigger(void); + +/* Called from app_main after boot to mark the running image as valid. + * Must be called within OTA_ROLLBACK_WINDOW_S after boot or rollback fires. */ +void ota_self_health_check(void); diff --git a/esp32s3/balance/main/uart_ota.c b/esp32s3/balance/main/uart_ota.c new file mode 100644 index 0000000..acbf34c --- /dev/null +++ b/esp32s3/balance/main/uart_ota.c @@ -0,0 +1,241 @@ +/* uart_ota.c — UART OTA sender: Balance→IO board (bd-21hv) + * + * Downloads io-firmware.bin from Gitea, then sends to IO board via UART1. + * IO board must update itself BEFORE Balance self-update (per spec). + */ + +#include "uart_ota.h" +#include "gitea_ota.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mbedtls/sha256.h" +#include +#include + +static const char *TAG = "uart_ota"; + +volatile uart_ota_send_state_t g_uart_ota_state = UART_OTA_S_IDLE; +volatile uint8_t g_uart_ota_progress = 0; + +/* ── CRC8-SMBUS ── */ +static uint8_t crc8(const uint8_t *d, uint16_t len) +{ + uint8_t crc = 0; + for (uint16_t i = 0; i < len; i++) { + crc ^= d[i]; + for (uint8_t b = 0; b < 8; b++) + crc = (crc & 0x80u) ? (uint8_t)((crc << 1u) ^ 0x07u) : (uint8_t)(crc << 1u); + } + return crc; +} + +/* ── Build and send one UART OTA frame ── */ +static void send_frame(uint8_t type, uint16_t seq, + const uint8_t *payload, uint16_t plen) +{ + /* [TYPE:1][SEQ:2 BE][LEN:2 BE][PAYLOAD][CRC8:1] */ + uint8_t hdr[5]; + hdr[0] = type; + hdr[1] = (uint8_t)(seq >> 8u); + hdr[2] = (uint8_t)(seq); + hdr[3] = (uint8_t)(plen >> 8u); + hdr[4] = (uint8_t)(plen); + + /* CRC over hdr + payload */ + uint8_t crc_buf[5 + OTA_UART_CHUNK_SIZE]; + memcpy(crc_buf, hdr, 5); + if (plen > 0 && payload) memcpy(crc_buf + 5, payload, plen); + uint8_t crc = crc8(crc_buf, (uint16_t)(5 + plen)); + + uart_write_bytes(UART_OTA_PORT, (char *)hdr, 5); + if (plen > 0 && payload) + uart_write_bytes(UART_OTA_PORT, (char *)payload, plen); + uart_write_bytes(UART_OTA_PORT, (char *)&crc, 1); +} + +/* ── Wait for ACK/NACK from IO board ── */ +static bool wait_ack(uint16_t expected_seq) +{ + /* Response frame: [TYPE:1][SEQ:2][LEN:2][PAYLOAD][CRC:1] */ + uint8_t buf[16]; + int timeout = OTA_UART_ACK_TIMEOUT_MS; + int got = 0; + + while (timeout > 0 && got < 6) { + int r = uart_read_bytes(UART_OTA_PORT, buf + got, 1, pdMS_TO_TICKS(50)); + if (r > 0) got++; + else timeout -= 50; + } + + if (got < 3) return false; + + uint8_t type = buf[0]; + uint16_t seq = (uint16_t)((buf[1] << 8u) | buf[2]); + + if (type == UART_OTA_ACK && seq == expected_seq) return true; + if (type == UART_OTA_NACK) { + uint8_t err = (got >= 6) ? buf[5] : 0; + ESP_LOGW(TAG, "NACK seq=%u err=%u", seq, err); + } + return false; +} + +/* ── Download firmware to RAM buffer (max 1.75 MB) ── */ +static uint8_t *download_io_firmware(uint32_t *out_size) +{ + const char *url = g_io_update.download_url; + ESP_LOGI(TAG, "downloading IO fw: %s", url); + + esp_http_client_config_t cfg = { + .url = url, .timeout_ms = 30000, + .skip_cert_common_name_check = true, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (esp_http_client_open(client, 0) != ESP_OK) { + esp_http_client_cleanup(client); + return NULL; + } + + int content_len = esp_http_client_fetch_headers(client); + if (content_len <= 0 || content_len > (int)(0x1B0000)) { + ESP_LOGE(TAG, "bad content-length: %d", content_len); + esp_http_client_cleanup(client); + return NULL; + } + + uint8_t *buf = malloc(content_len); + if (!buf) { + ESP_LOGE(TAG, "malloc %d failed", content_len); + esp_http_client_cleanup(client); + return NULL; + } + + int total = 0, rd; + while ((rd = esp_http_client_read(client, (char *)buf + total, + content_len - total)) > 0) { + total += rd; + g_uart_ota_progress = (uint8_t)((total * 50) / content_len); /* 0-50% for download */ + } + esp_http_client_cleanup(client); + + if (total != content_len) { + free(buf); + return NULL; + } + *out_size = (uint32_t)total; + return buf; +} + +/* ── UART OTA send task ── */ +static void uart_ota_task(void *arg) +{ + g_uart_ota_state = UART_OTA_S_DOWNLOADING; + g_uart_ota_progress = 0; + + uint32_t fw_size = 0; + uint8_t *fw = download_io_firmware(&fw_size); + if (!fw) { + ESP_LOGE(TAG, "download failed"); + g_uart_ota_state = UART_OTA_S_FAILED; + vTaskDelete(NULL); + return; + } + + /* Compute SHA256 of downloaded firmware */ + uint8_t digest[32]; + mbedtls_sha256_context sha; + mbedtls_sha256_init(&sha); + mbedtls_sha256_starts(&sha, 0); + mbedtls_sha256_update(&sha, fw, fw_size); + mbedtls_sha256_finish(&sha, digest); + mbedtls_sha256_free(&sha); + + g_uart_ota_state = UART_OTA_S_SENDING; + + /* Send OTA_BEGIN: uint32 size + uint8[32] sha256 */ + uint8_t begin_payload[36]; + begin_payload[0] = (uint8_t)(fw_size >> 24u); + begin_payload[1] = (uint8_t)(fw_size >> 16u); + begin_payload[2] = (uint8_t)(fw_size >> 8u); + begin_payload[3] = (uint8_t)(fw_size); + memcpy(&begin_payload[4], digest, 32); + + for (int retry = 0; retry < OTA_UART_MAX_RETRIES; retry++) { + send_frame(UART_OTA_BEGIN, 0, begin_payload, 36); + if (wait_ack(0)) goto send_data; + ESP_LOGW(TAG, "BEGIN retry %d", retry); + } + ESP_LOGE(TAG, "BEGIN failed"); + free(fw); + g_uart_ota_state = UART_OTA_S_FAILED; + vTaskDelete(NULL); + return; + +send_data: { + uint32_t offset = 0; + uint16_t seq = 1; + while (offset < fw_size) { + uint16_t chunk = (uint16_t)((fw_size - offset) < OTA_UART_CHUNK_SIZE + ? (fw_size - offset) : OTA_UART_CHUNK_SIZE); + bool acked = false; + for (int retry = 0; retry < OTA_UART_MAX_RETRIES; retry++) { + send_frame(UART_OTA_DATA, seq, fw + offset, chunk); + if (wait_ack(seq)) { acked = true; break; } + ESP_LOGW(TAG, "DATA seq=%u retry=%d", seq, retry); + } + if (!acked) { + ESP_LOGE(TAG, "DATA seq=%u failed", seq); + send_frame(UART_OTA_ABORT, seq, NULL, 0); + free(fw); + g_uart_ota_state = UART_OTA_S_FAILED; + vTaskDelete(NULL); + return; + } + offset += chunk; + seq++; + /* 50-100% for sending phase */ + g_uart_ota_progress = (uint8_t)(50u + (offset * 50u) / fw_size); + } + + /* Send OTA_END */ + for (int retry = 0; retry < OTA_UART_MAX_RETRIES; retry++) { + send_frame(UART_OTA_END, seq, NULL, 0); + if (wait_ack(seq)) break; + } + } + + free(fw); + g_uart_ota_progress = 100; + g_uart_ota_state = UART_OTA_S_DONE; + ESP_LOGI(TAG, "IO OTA complete — %lu bytes sent", (unsigned long)fw_size); + vTaskDelete(NULL); +} + +bool uart_ota_trigger(void) +{ + if (!g_io_update.available) { + ESP_LOGW(TAG, "no IO update available"); + return false; + } + if (g_uart_ota_state != UART_OTA_S_IDLE) { + ESP_LOGW(TAG, "UART OTA busy (state=%d)", g_uart_ota_state); + return false; + } + /* Init UART1 for OTA */ + uart_config_t ucfg = { + .baud_rate = UART_OTA_BAUD, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + }; + uart_param_config(UART_OTA_PORT, &ucfg); + uart_set_pin(UART_OTA_PORT, UART_OTA_TX_GPIO, UART_OTA_RX_GPIO, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + uart_driver_install(UART_OTA_PORT, 2048, 0, 0, NULL, 0); + + xTaskCreate(uart_ota_task, "uart_ota", 16384, NULL, 4, NULL); + return true; +} diff --git a/esp32s3/balance/main/uart_ota.h b/esp32s3/balance/main/uart_ota.h new file mode 100644 index 0000000..58dda86 --- /dev/null +++ b/esp32s3/balance/main/uart_ota.h @@ -0,0 +1,64 @@ +#pragma once +/* uart_ota.h — UART OTA protocol for Balance→IO firmware update (bd-21hv) + * + * Balance downloads io-firmware.bin from Gitea, then streams it to the IO + * board over UART1 (GPIO17/18, 460800 baud) in 1 KB chunks with ACK. + * + * Protocol frame format (both directions): + * [TYPE:1][SEQ:2 BE][LEN:2 BE][PAYLOAD:LEN][CRC8:1] + * CRC8-SMBUS over TYPE+SEQ+LEN+PAYLOAD. + * + * Balance→IO: + * OTA_BEGIN (0xC0) payload: uint32 total_size BE + uint8[32] sha256 + * OTA_DATA (0xC1) payload: uint8[] chunk (up to 1024 bytes) + * OTA_END (0xC2) no payload + * OTA_ABORT (0xC3) no payload + * + * IO→Balance: + * OTA_ACK (0xC4) payload: uint16 acked_seq BE + * OTA_NACK (0xC5) payload: uint16 failed_seq BE + uint8 err_code + * OTA_STATUS (0xC6) payload: uint8 state + uint8 progress% + */ + +#include +#include + +/* UART for Balance→IO OTA */ +#include "driver/uart.h" +#define UART_OTA_PORT UART_NUM_1 +#define UART_OTA_BAUD 460800 +#define UART_OTA_TX_GPIO 17 +#define UART_OTA_RX_GPIO 18 + +#define OTA_UART_CHUNK_SIZE 1024 +#define OTA_UART_ACK_TIMEOUT_MS 3000 +#define OTA_UART_MAX_RETRIES 3 + +/* Frame type bytes */ +#define UART_OTA_BEGIN 0xC0u +#define UART_OTA_DATA 0xC1u +#define UART_OTA_END 0xC2u +#define UART_OTA_ABORT 0xC3u +#define UART_OTA_ACK 0xC4u +#define UART_OTA_NACK 0xC5u +#define UART_OTA_STATUS 0xC6u + +/* NACK error codes */ +#define OTA_ERR_BAD_CRC 0x01u +#define OTA_ERR_WRITE 0x02u +#define OTA_ERR_SIZE 0x03u + +typedef enum { + UART_OTA_S_IDLE = 0, + UART_OTA_S_DOWNLOADING, /* downloading from Gitea */ + UART_OTA_S_SENDING, /* sending to IO board */ + UART_OTA_S_DONE, + UART_OTA_S_FAILED, +} uart_ota_send_state_t; + +extern volatile uart_ota_send_state_t g_uart_ota_state; +extern volatile uint8_t g_uart_ota_progress; + +/* Trigger IO firmware update. Uses g_io_update (from gitea_ota). + * Downloads bin, then streams via UART. Returns false if busy or no update. */ +bool uart_ota_trigger(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" diff --git a/esp32s3/balance/partitions.csv b/esp32s3/balance/partitions.csv new file mode 100644 index 0000000..fd003f5 --- /dev/null +++ b/esp32s3/balance/partitions.csv @@ -0,0 +1,7 @@ +# ESP32-S3 BALANCE — 4 MB flash, dual OTA partitions +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x1B0000, +app1, app, ota_1, 0x1C0000, 0x1B0000, +nvs_user, data, nvs, 0x370000, 0x50000, diff --git a/esp32s3/balance/sdkconfig.defaults b/esp32s3/balance/sdkconfig.defaults index fe9981c..f4506fb 100644 --- a/esp32s3/balance/sdkconfig.defaults +++ b/esp32s3/balance/sdkconfig.defaults @@ -9,3 +9,11 @@ CONFIG_ESP_CONSOLE_UART_DEFAULT=y CONFIG_ESP_CONSOLE_UART_NUM=0 CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# OTA — bd-3gwo: dual OTA partitions + rollback +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +CONFIG_OTA_ALLOW_HTTP=y +CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y diff --git a/esp32s3/io/CMakeLists.txt b/esp32s3/io/CMakeLists.txt new file mode 100644 index 0000000..d3b25fb --- /dev/null +++ b/esp32s3/io/CMakeLists.txt @@ -0,0 +1,3 @@ +cmake_minimum_required(VERSION 3.16) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(esp32s3_io) diff --git a/esp32s3/io/main/CMakeLists.txt b/esp32s3/io/main/CMakeLists.txt new file mode 100644 index 0000000..57d5760 --- /dev/null +++ b/esp32s3/io/main/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS "main.c" "uart_ota_recv.c" + INCLUDE_DIRS "." + REQUIRES + app_update + mbedtls + driver + freertos + esp_timer +) diff --git a/esp32s3/io/main/config.h b/esp32s3/io/main/config.h new file mode 100644 index 0000000..032065e --- /dev/null +++ b/esp32s3/io/main/config.h @@ -0,0 +1,35 @@ +#pragma once +/* ESP32-S3 IO board — pin assignments (SAUL-TEE-SYSTEM-REFERENCE.md) */ + +/* ── Inter-board UART (to/from BALANCE board) ── */ +#define IO_UART_PORT UART_NUM_0 +#define IO_UART_BAUD 460800 +#define IO_UART_TX_GPIO 43 /* IO board UART0_TXD → BALANCE RX */ +#define IO_UART_RX_GPIO 44 /* IO board UART0_RXD ← BALANCE TX */ +/* Note: SAUL-TEE spec says IO TX=IO18, RX=IO21; BALANCE TX=IO17, RX=IO18. + * This is UART0 on the IO devkit (GPIO43/44). Adjust to match actual wiring. */ + +/* ── BTS7960 Left motor driver ── */ +#define MOTOR_L_RPWM 1 +#define MOTOR_L_LPWM 2 +#define MOTOR_L_EN_R 3 +#define MOTOR_L_EN_L 4 + +/* ── BTS7960 Right motor driver ── */ +#define MOTOR_R_RPWM 5 +#define MOTOR_R_LPWM 6 +#define MOTOR_R_EN_R 7 +#define MOTOR_R_EN_L 8 + +/* ── Arming button / kill switch ── */ +#define ARM_BTN_GPIO 9 +#define KILL_GPIO 10 + +/* ── WS2812B LED strip ── */ +#define LED_DATA_GPIO 13 + +/* ── OTA UART — receives firmware from BALANCE (bd-21hv) ── */ +/* Uses same IO_UART_PORT since Balance drives OTA over the inter-board link */ + +/* ── Firmware version ── */ +#define IO_FW_VERSION "1.0.0" diff --git a/esp32s3/io/main/main.c b/esp32s3/io/main/main.c new file mode 100644 index 0000000..05864d9 --- /dev/null +++ b/esp32s3/io/main/main.c @@ -0,0 +1,42 @@ +/* main.c — ESP32-S3 IO board app_main */ + +#include "uart_ota_recv.h" +#include "config.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "driver/uart.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "io_main"; + +static void uart_init(void) +{ + uart_config_t cfg = { + .baud_rate = IO_UART_BAUD, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + }; + uart_param_config(IO_UART_PORT, &cfg); + uart_set_pin(IO_UART_PORT, IO_UART_TX_GPIO, IO_UART_RX_GPIO, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + uart_driver_install(IO_UART_PORT, 4096, 0, 0, NULL, 0); +} + +void app_main(void) +{ + ESP_LOGI(TAG, "ESP32-S3 IO v%s starting", IO_FW_VERSION); + + /* Mark running image valid (OTA rollback support) */ + esp_ota_mark_app_valid_cancel_rollback(); + + uart_init(); + uart_ota_recv_init(); + + /* IO board main loop placeholder — RC/motor/sensor tasks added in later beads */ + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} diff --git a/esp32s3/io/main/uart_ota_recv.c b/esp32s3/io/main/uart_ota_recv.c new file mode 100644 index 0000000..7a6f297 --- /dev/null +++ b/esp32s3/io/main/uart_ota_recv.c @@ -0,0 +1,210 @@ +/* uart_ota_recv.c — IO board OTA receiver (bd-21hv) + * + * Listens on UART0 for OTA frames from Balance board. + * Writes incoming chunks to the inactive OTA partition, verifies SHA256, + * then reboots into new firmware. + */ + +#include "uart_ota_recv.h" +#include "config.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "driver/uart.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mbedtls/sha256.h" +#include + +static const char *TAG = "io_ota"; + +volatile io_ota_state_t g_io_ota_state = IO_OTA_IDLE; +volatile uint8_t g_io_ota_progress = 0; + +/* Frame type bytes (same as uart_ota.h sender side) */ +#define OTA_BEGIN 0xC0u +#define OTA_DATA 0xC1u +#define OTA_END 0xC2u +#define OTA_ABORT 0xC3u +#define OTA_ACK 0xC4u +#define OTA_NACK 0xC5u + +#define CHUNK_MAX 1024 + +static uint8_t crc8(const uint8_t *d, uint16_t len) +{ + uint8_t crc = 0; + for (uint16_t i = 0; i < len; i++) { + crc ^= d[i]; + for (uint8_t b = 0; b < 8; b++) + crc = (crc & 0x80u) ? (uint8_t)((crc << 1u) ^ 0x07u) : (uint8_t)(crc << 1u); + } + return crc; +} + +static void send_ack(uint16_t seq) +{ + uint8_t frame[6]; + frame[0] = OTA_ACK; + frame[1] = (uint8_t)(seq >> 8u); + frame[2] = (uint8_t)(seq); + frame[3] = 0; frame[4] = 0; /* LEN=0 */ + uint8_t crc = crc8(frame, 5); + frame[5] = crc; + uart_write_bytes(IO_UART_PORT, (char *)frame, 6); +} + +static void send_nack(uint16_t seq, uint8_t err) +{ + uint8_t frame[8]; + frame[0] = OTA_NACK; + frame[1] = (uint8_t)(seq >> 8u); + frame[2] = (uint8_t)(seq); + frame[3] = 0; frame[4] = 1; /* LEN=1 */ + frame[5] = err; + uint8_t crc = crc8(frame, 6); + frame[6] = crc; + uart_write_bytes(IO_UART_PORT, (char *)frame, 7); +} + +/* Read exact n bytes with timeout */ +static bool uart_read_exact(uint8_t *buf, int n, int timeout_ms) +{ + int got = 0; + while (got < n && timeout_ms > 0) { + int r = uart_read_bytes(IO_UART_PORT, buf + got, n - got, + pdMS_TO_TICKS(50)); + if (r > 0) got += r; + else timeout_ms -= 50; + } + return got == n; +} + +static void ota_recv_task(void *arg) +{ + esp_ota_handle_t handle = 0; + const esp_partition_t *ota_part = esp_ota_get_next_update_partition(NULL); + mbedtls_sha256_context sha; + mbedtls_sha256_init(&sha); + uint32_t expected_size = 0; + uint8_t expected_digest[32] = {0}; + uint32_t received = 0; + bool ota_started = false; + static uint8_t payload[CHUNK_MAX]; + + for (;;) { + /* Read frame header: TYPE(1) + SEQ(2) + LEN(2) = 5 bytes */ + uint8_t hdr[5]; + if (!uart_read_exact(hdr, 5, 5000)) continue; + + uint8_t type = hdr[0]; + uint16_t seq = (uint16_t)((hdr[1] << 8u) | hdr[2]); + uint16_t plen = (uint16_t)((hdr[3] << 8u) | hdr[4]); + + if (plen > CHUNK_MAX + 36) { + ESP_LOGW(TAG, "oversized frame plen=%u", plen); + continue; + } + + /* Read payload + CRC */ + if (plen > 0 && !uart_read_exact(payload, plen, 2000)) continue; + uint8_t crc_rx; + if (!uart_read_exact(&crc_rx, 1, 500)) continue; + + /* Verify CRC over hdr+payload */ + uint8_t crc_buf[5 + CHUNK_MAX + 36]; + memcpy(crc_buf, hdr, 5); + if (plen > 0) memcpy(crc_buf + 5, payload, plen); + uint8_t expected_crc = crc8(crc_buf, (uint16_t)(5 + plen)); + if (crc_rx != expected_crc) { + ESP_LOGW(TAG, "CRC fail seq=%u", seq); + send_nack(seq, 0x01u); /* OTA_ERR_BAD_CRC */ + continue; + } + + switch (type) { + case OTA_BEGIN: + if (plen < 36) { send_nack(seq, 0x03u); break; } + expected_size = ((uint32_t)payload[0] << 24u) | + ((uint32_t)payload[1] << 16u) | + ((uint32_t)payload[2] << 8u) | + (uint32_t)payload[3]; + memcpy(expected_digest, &payload[4], 32); + + if (!ota_part || esp_ota_begin(ota_part, OTA_WITH_SEQUENTIAL_WRITES, + &handle) != ESP_OK) { + send_nack(seq, 0x02u); + break; + } + mbedtls_sha256_starts(&sha, 0); + received = 0; + ota_started = true; + g_io_ota_state = IO_OTA_RECEIVING; + g_io_ota_progress = 0; + ESP_LOGI(TAG, "OTA begin: %lu bytes", (unsigned long)expected_size); + send_ack(seq); + break; + + case OTA_DATA: + if (!ota_started) { send_nack(seq, 0x02u); break; } + if (esp_ota_write(handle, payload, plen) != ESP_OK) { + send_nack(seq, 0x02u); + esp_ota_abort(handle); + ota_started = false; + g_io_ota_state = IO_OTA_FAILED; + break; + } + mbedtls_sha256_update(&sha, payload, plen); + received += plen; + if (expected_size > 0) + g_io_ota_progress = (uint8_t)((received * 100u) / expected_size); + send_ack(seq); + break; + + case OTA_END: { + if (!ota_started) { send_nack(seq, 0x02u); break; } + g_io_ota_state = IO_OTA_VERIFYING; + + uint8_t digest[32]; + mbedtls_sha256_finish(&sha, digest); + if (memcmp(digest, expected_digest, 32) != 0) { + ESP_LOGE(TAG, "SHA256 mismatch"); + esp_ota_abort(handle); + send_nack(seq, 0x01u); + g_io_ota_state = IO_OTA_FAILED; + break; + } + + if (esp_ota_end(handle) != ESP_OK || + esp_ota_set_boot_partition(ota_part) != ESP_OK) { + send_nack(seq, 0x02u); + g_io_ota_state = IO_OTA_FAILED; + break; + } + + g_io_ota_state = IO_OTA_REBOOTING; + g_io_ota_progress = 100; + ESP_LOGI(TAG, "OTA done — rebooting"); + send_ack(seq); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + break; + } + + case OTA_ABORT: + if (ota_started) { esp_ota_abort(handle); ota_started = false; } + g_io_ota_state = IO_OTA_IDLE; + ESP_LOGW(TAG, "OTA aborted"); + break; + + default: + break; + } + } +} + +void uart_ota_recv_init(void) +{ + /* UART0 already initialized for inter-board comms; just create the task */ + xTaskCreate(ota_recv_task, "io_ota_recv", 8192, NULL, 6, NULL); + ESP_LOGI(TAG, "OTA receiver task started"); +} diff --git a/esp32s3/io/main/uart_ota_recv.h b/esp32s3/io/main/uart_ota_recv.h new file mode 100644 index 0000000..d7cba70 --- /dev/null +++ b/esp32s3/io/main/uart_ota_recv.h @@ -0,0 +1,20 @@ +#pragma once +/* uart_ota_recv.h — IO board: receives OTA firmware from Balance (bd-21hv) */ + +#include +#include + +typedef enum { + IO_OTA_IDLE = 0, + IO_OTA_RECEIVING, + IO_OTA_VERIFYING, + IO_OTA_APPLYING, + IO_OTA_REBOOTING, + IO_OTA_FAILED, +} io_ota_state_t; + +extern volatile io_ota_state_t g_io_ota_state; +extern volatile uint8_t g_io_ota_progress; + +/* Start listening for OTA frames on UART0 */ +void uart_ota_recv_init(void); diff --git a/esp32s3/io/partitions.csv b/esp32s3/io/partitions.csv new file mode 100644 index 0000000..c6cb331 --- /dev/null +++ b/esp32s3/io/partitions.csv @@ -0,0 +1,7 @@ +# ESP32-S3 IO — 4 MB flash, dual OTA partitions +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x1B0000, +app1, app, ota_1, 0x1C0000, 0x1B0000, +nvs_user, data, nvs, 0x370000, 0x50000, diff --git a/esp32s3/io/sdkconfig.defaults b/esp32s3/io/sdkconfig.defaults new file mode 100644 index 0000000..1263b2e --- /dev/null +++ b/esp32s3/io/sdkconfig.defaults @@ -0,0 +1,13 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_TASK_WDT_EN=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 +CONFIG_UART_ISR_IN_IRAM=y +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# OTA — bd-3gwo: dual OTA partitions + rollback +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y