Add Gitea OTA firmware update, dual-slot partition layout, and Gitea Actions release workflow
This commit is contained in:
@@ -8,8 +8,8 @@ idf_component_register(SRCS
|
||||
"scryfall_client.c"
|
||||
"web_server.c"
|
||||
"encoder.c"
|
||||
"led_manager.c"
|
||||
"buzzer.c"
|
||||
"app_state.c"
|
||||
"ota_manager.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
@@ -12,4 +12,28 @@ menu "MTG RFID Companion"
|
||||
bool "Prefetch card data for all mapped UIDs at boot"
|
||||
default y
|
||||
|
||||
config MTG_GITEA_URL
|
||||
string "Gitea instance base URL (e.g. https://gitea.com)"
|
||||
default "https://gitea.com"
|
||||
help
|
||||
Base HTTPS URL of the Gitea instance hosting releases.
|
||||
|
||||
config MTG_GITEA_REPO_OWNER
|
||||
string "Gitea repository owner / org"
|
||||
default "user"
|
||||
help
|
||||
Repository owner or organization name.
|
||||
|
||||
config MTG_GITEA_REPO_NAME
|
||||
string "Gitea repository name"
|
||||
default "espMTG"
|
||||
help
|
||||
Repository name.
|
||||
|
||||
config MTG_OTA_AUTO_CHECK
|
||||
bool "Automatically check for updates on startup"
|
||||
default y
|
||||
help
|
||||
When connected to Wi-Fi, query Gitea for new releases shortly after boot.
|
||||
|
||||
endmenu
|
||||
@@ -26,6 +26,7 @@ typedef enum {
|
||||
APP_STATE_STORAGE_MANAGER,
|
||||
APP_STATE_DEVICE_INFO,
|
||||
APP_STATE_REGISTRATION,
|
||||
APP_STATE_OTA_UPDATE,
|
||||
} app_state_t;
|
||||
|
||||
/* Life counter state */
|
||||
|
||||
@@ -46,8 +46,5 @@
|
||||
#define HW_ENC_DT_GPIO (GPIO_NUM_33)
|
||||
#define HW_ENC_SW_GPIO (GPIO_NUM_25)
|
||||
|
||||
/* WS2812B Neopixel */
|
||||
#define HW_NEOPIXEL_GPIO (GPIO_NUM_27)
|
||||
|
||||
/* Piezo buzzer */
|
||||
#define HW_BUZZER_GPIO (GPIO_NUM_14)
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* MTG RFID Companion - WS2812B Neopixel LED manager (RMT)
|
||||
*/
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "driver/rmt_tx.h"
|
||||
#include "driver/rmt_encoder.h"
|
||||
#include "hw_pins.h"
|
||||
#include "led_manager.h"
|
||||
|
||||
static const char *TAG = "led";
|
||||
|
||||
static rmt_channel_handle_t s_tx_chan = NULL;
|
||||
static rmt_encoder_handle_t s_enc = NULL;
|
||||
|
||||
esp_err_t led_manager_init(void)
|
||||
{
|
||||
if (s_tx_chan != NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
rmt_tx_channel_config_t chan_cfg = {
|
||||
.gpio_num = HW_NEOPIXEL_GPIO,
|
||||
.clk_src = RMT_CLK_SRC_DEFAULT,
|
||||
.resolution_hz = 10 * 1000 * 1000, /* 100 ns per tick */
|
||||
.mem_block_symbols = 64,
|
||||
.trans_queue_depth = 4,
|
||||
};
|
||||
esp_err_t ret = rmt_new_tx_channel(&chan_cfg, &s_tx_chan);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rmt_new_tx_channel failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
rmt_bytes_encoder_config_t enc_cfg = {
|
||||
.bit0 = {
|
||||
.duration0 = 4, /* T0H ~ 400 ns */
|
||||
.level0 = 1,
|
||||
.duration1 = 8, /* T0L ~ 800 ns */
|
||||
.level1 = 0,
|
||||
},
|
||||
.bit1 = {
|
||||
.duration0 = 9, /* T1H ~ 900 ns */
|
||||
.level0 = 1,
|
||||
.duration1 = 4, /* T1L ~ 400 ns */
|
||||
.level1 = 0,
|
||||
},
|
||||
};
|
||||
ret = rmt_new_bytes_encoder(&enc_cfg, &s_enc);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rmt_new_bytes_encoder failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
ret = rmt_enable(s_tx_chan);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rmt_enable failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "WS2812B LED ready on GPIO %d", HW_NEOPIXEL_GPIO);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t led_manager_set_color(uint8_t r, uint8_t g, uint8_t b)
|
||||
{
|
||||
if (s_tx_chan == NULL) {
|
||||
esp_err_t ret = led_manager_init();
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
/* WS2812B expects GRB byte order */
|
||||
uint8_t txbuf[3] = { g, r, b };
|
||||
|
||||
rmt_transmit_config_t tx_cfg = {
|
||||
.loop_count = 0, /* one-shot; LED latches and holds the level */
|
||||
};
|
||||
esp_err_t ret = rmt_transmit(s_tx_chan, s_enc, txbuf, sizeof(txbuf), &tx_cfg);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
return rmt_tx_wait_all_done(s_tx_chan, pdMS_TO_TICKS(50));
|
||||
}
|
||||
|
||||
esp_err_t led_manager_off(void)
|
||||
{
|
||||
return led_manager_set_color(0, 0, 0);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* MTG RFID Companion - WS2812B Neopixel LED manager (RMT)
|
||||
*
|
||||
* Phase 7 Step 7.3: Single WS2812B driver on GPIO 27. LED holds the last
|
||||
* transmitted color until it is overwritten.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Initialize RMT TX channel for the Neopixel.
|
||||
*/
|
||||
esp_err_t led_manager_init(void);
|
||||
|
||||
/**
|
||||
* @brief Set the LED color immediately (GRB order internally).
|
||||
*/
|
||||
esp_err_t led_manager_set_color(uint8_t r, uint8_t g, uint8_t b);
|
||||
|
||||
/**
|
||||
* @brief Turn the LED off.
|
||||
*/
|
||||
esp_err_t led_manager_off(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "wifi_manager.h"
|
||||
#include "scryfall_client.h"
|
||||
#include "web_server.h"
|
||||
#include "ota_manager.h"
|
||||
|
||||
static const char *TAG = "MAIN";
|
||||
|
||||
@@ -16,6 +17,9 @@ void app_main(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "MTG RFID Companion booting...");
|
||||
|
||||
/* Validate OTA partition & cancel rollback if just updated */
|
||||
ota_manager_init();
|
||||
|
||||
ESP_ERROR_CHECK(storage_manager_init());
|
||||
|
||||
/* RFID comes BEFORE the display: the reader lives on its own SPI3 bus
|
||||
|
||||
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;
|
||||
}
|
||||
95
main/ota_manager.h
Normal file
95
main/ota_manager.h
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
OTA_STATUS_IDLE,
|
||||
OTA_STATUS_CHECKING,
|
||||
OTA_STATUS_UPDATE_AVAILABLE,
|
||||
OTA_STATUS_UP_TO_DATE,
|
||||
OTA_STATUS_DOWNLOADING,
|
||||
OTA_STATUS_SUCCESS_REBOOTING,
|
||||
OTA_STATUS_FAILED
|
||||
} ota_status_t;
|
||||
|
||||
typedef struct {
|
||||
char tag_name[32]; /* e.g. "v1.2.0" */
|
||||
char release_title[64]; /* e.g. "Release 1.2.0" */
|
||||
char download_url[256]; /* URL to mtg-rfid-companion.bin */
|
||||
size_t binary_size; /* in bytes, or 0 if unknown */
|
||||
bool is_newer;
|
||||
} ota_release_info_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize OTA manager, validates running partition, and cancels rollback.
|
||||
*/
|
||||
esp_err_t ota_manager_init(void);
|
||||
|
||||
/**
|
||||
* @brief Current firmware version string (e.g. "1.0.0").
|
||||
*/
|
||||
const char *ota_manager_get_current_version(void);
|
||||
|
||||
/**
|
||||
* @brief Get current OTA state.
|
||||
*/
|
||||
ota_status_t ota_manager_get_status(void);
|
||||
|
||||
/**
|
||||
* @brief Get human-readable status message or error details.
|
||||
*/
|
||||
const char *ota_manager_get_message(void);
|
||||
|
||||
/**
|
||||
* @brief Get percentage of download progress (0-100).
|
||||
*/
|
||||
int ota_manager_get_progress(void);
|
||||
|
||||
/**
|
||||
* @brief Get bytes downloaded so far and total bytes.
|
||||
*/
|
||||
void ota_manager_get_bytes(size_t *read_bytes, size_t *total_bytes);
|
||||
|
||||
/**
|
||||
* @brief Get the latest discovered release metadata.
|
||||
*/
|
||||
const ota_release_info_t *ota_manager_get_release_info(void);
|
||||
|
||||
/**
|
||||
* @brief Kick off an asynchronous release check against the configured Gitea instance.
|
||||
* @return ESP_OK if started, ESP_ERR_INVALID_STATE if already in progress.
|
||||
*/
|
||||
esp_err_t ota_manager_check_update_async(void);
|
||||
|
||||
/**
|
||||
* @brief Kick off asynchronous firmware download and flash.
|
||||
* @return ESP_OK if started.
|
||||
*/
|
||||
esp_err_t ota_manager_start_update_async(void);
|
||||
|
||||
/**
|
||||
* @brief Abort any running OTA operation and reset status to IDLE.
|
||||
*/
|
||||
void ota_manager_cancel(void);
|
||||
|
||||
/**
|
||||
* @brief Clear state back to IDLE.
|
||||
*/
|
||||
void ota_manager_reset(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
415
main/ui_task.c
415
main/ui_task.c
@@ -18,9 +18,9 @@
|
||||
#include "wifi_manager.h"
|
||||
#include "app_state.h"
|
||||
#include "encoder.h"
|
||||
#include "led_manager.h"
|
||||
#include "buzzer.h"
|
||||
#include "jpeg_decoder.h"
|
||||
#include "ota_manager.h"
|
||||
#include "ui_task.h"
|
||||
|
||||
static const char *TAG = "ui";
|
||||
@@ -28,7 +28,6 @@ static const char *TAG = "ui";
|
||||
#define UI_TASK_STACK (8 * 1024)
|
||||
#define UI_TASK_PRIO (5)
|
||||
#define UI_CORE (1)
|
||||
#define LED_HOLD_MS (2500)
|
||||
#define COLOR_AMBER (0xFFB300)
|
||||
#define COLOR_GOLD (0xFFCC00)
|
||||
|
||||
@@ -36,13 +35,12 @@ static TaskHandle_t s_ui_task = NULL;
|
||||
static char s_uid[RFID_UID_STR_MAX_LEN] = {0};
|
||||
static char s_card_name[128] = {0};
|
||||
static bool s_waiting_fetch = false;
|
||||
static uint32_t s_led_off_tick = 0;
|
||||
|
||||
static lv_img_dsc_t s_art_img_dsc;
|
||||
static uint16_t *s_art_pixels = NULL;
|
||||
|
||||
/* Menu items */
|
||||
#define MENU_ITEM_COUNT 7
|
||||
#define MENU_ITEM_COUNT 8
|
||||
static int s_menu_idx = 0;
|
||||
static const char *s_menu_labels[MENU_ITEM_COUNT] = {
|
||||
"1. CARD SCANNER",
|
||||
@@ -51,7 +49,8 @@ static const char *s_menu_labels[MENU_ITEM_COUNT] = {
|
||||
"4. COMMANDER TAX",
|
||||
"5. TOKEN & COUNTERS",
|
||||
"6. DECK STORAGE (LFS)",
|
||||
"7. DEVICE & WIFI INFO"
|
||||
"7. DEVICE & WIFI INFO",
|
||||
"8. CHECK FIRMWARE OTA"
|
||||
};
|
||||
|
||||
/* Life & Counters */
|
||||
@@ -127,6 +126,7 @@ static void view_storage(void);
|
||||
static void view_device_info(void);
|
||||
static void view_unmapped(const char *uid);
|
||||
static void view_fetching(const char *name);
|
||||
static void view_ota(void);
|
||||
|
||||
static void free_art_buffer(void)
|
||||
{
|
||||
@@ -280,13 +280,13 @@ static void view_menu(void)
|
||||
ui_clean_screen();
|
||||
ui_draw_header("MTG COMPANION", "- MAIN MENU -");
|
||||
|
||||
/* 7 Menu Items */
|
||||
int start_y = 52;
|
||||
/* 8 Menu Items */
|
||||
int start_y = 44;
|
||||
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
|
||||
bool selected = (i == s_menu_idx);
|
||||
lv_obj_t *item_box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(item_box, DISP_W - 24, 31);
|
||||
lv_obj_align(item_box, LV_ALIGN_TOP_MID, 0, start_y + i * 34);
|
||||
lv_obj_set_size(item_box, DISP_W - 24, 28);
|
||||
lv_obj_align(item_box, LV_ALIGN_TOP_MID, 0, start_y + i * 31);
|
||||
lv_obj_set_style_radius(item_box, 6, 0);
|
||||
lv_obj_set_style_pad_all(item_box, 2, 0);
|
||||
lv_obj_set_scrollbar_mode(item_box, LV_SCROLLBAR_MODE_OFF);
|
||||
@@ -446,8 +446,6 @@ static void view_life(void)
|
||||
if (!s_was_lethal) {
|
||||
s_was_lethal = true;
|
||||
buzzer_play_sound(BUZZER_SOUND_LOSS);
|
||||
led_manager_set_color(255, 0, 0);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
|
||||
}
|
||||
lv_obj_t *dead_lbl = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(dead_lbl, "*** LETHAL / DEFEAT ***");
|
||||
@@ -595,31 +593,21 @@ static void roll_current_dice(void)
|
||||
s_dice_result = (int)(esp_random() % 20) + 1;
|
||||
if (s_dice_result == 20) {
|
||||
/* Natural 20 - Victory Fanfare! */
|
||||
led_manager_set_color(0, 255, 0);
|
||||
buzzer_play_sound(BUZZER_SOUND_VICTORY);
|
||||
} else if (s_dice_result == 1) {
|
||||
/* Critical Fail */
|
||||
led_manager_set_color(255, 0, 0);
|
||||
buzzer_chirp(220, 150);
|
||||
} else {
|
||||
led_manager_set_color(0, 180, 255);
|
||||
buzzer_chirp(880, 50);
|
||||
}
|
||||
} else if (s_dice_mode == DICE_MODE_D6) {
|
||||
s_dice_result = (int)(esp_random() % 6) + 1;
|
||||
led_manager_set_color(0, 180, 255);
|
||||
buzzer_chirp(880, 50);
|
||||
} else {
|
||||
/* Coin */
|
||||
s_dice_result = (int)(esp_random() % 2); // 0 = Tails, 1 = Heads
|
||||
if (s_dice_result == 1) {
|
||||
led_manager_set_color(0, 255, 60); // Green for Heads
|
||||
} else {
|
||||
led_manager_set_color(255, 140, 0); // Amber for Tails
|
||||
}
|
||||
buzzer_chirp(988, 60);
|
||||
}
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(2000);
|
||||
s_dice_rolled = true;
|
||||
view_dice();
|
||||
}
|
||||
@@ -1005,8 +993,9 @@ static void view_device_info(void)
|
||||
lv_obj_set_style_text_font(lbl_keys, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(lbl_keys, lv_color_hex(0x00E5FF), 0);
|
||||
lv_label_set_text(lbl_keys,
|
||||
"WI-FI STA:\n"
|
||||
"WEB PAIR:\n"
|
||||
"VERSION:\n"
|
||||
"WI-FI IP:\n"
|
||||
"HOTSPOT:\n"
|
||||
"STORAGE:\n"
|
||||
"CARDS:\n"
|
||||
"FREE RAM:\n"
|
||||
@@ -1021,6 +1010,7 @@ static void view_device_info(void)
|
||||
lv_obj_set_style_text_color(lbl_vals, lv_color_hex(COLOR_AMBER), 0);
|
||||
lv_obj_set_style_text_align(lbl_vals, LV_TEXT_ALIGN_RIGHT, 0);
|
||||
lv_label_set_text_fmt(lbl_vals,
|
||||
"%s\n"
|
||||
"%s\n"
|
||||
"192.168.4.1\n"
|
||||
"%d / %d KB\n"
|
||||
@@ -1029,6 +1019,7 @@ static void view_device_info(void)
|
||||
"%d steps\n"
|
||||
"ESP32+ST7789\n"
|
||||
"MFRC522",
|
||||
ota_manager_get_current_version(),
|
||||
wifi_manager_get_ip(),
|
||||
(int)(used_b / 1024), (int)(total_b / 1024),
|
||||
json_c,
|
||||
@@ -1045,6 +1036,237 @@ static void view_device_info(void)
|
||||
lvgl_port_unlock();
|
||||
}
|
||||
|
||||
/* 8. Firmware OTA Update Screen */
|
||||
static ota_status_t s_last_ota_rendered_st = OTA_STATUS_IDLE;
|
||||
static int s_last_ota_percent = -1;
|
||||
|
||||
static void view_ota(void)
|
||||
{
|
||||
lvgl_port_lock(0);
|
||||
ui_clean_screen();
|
||||
|
||||
ota_status_t st = ota_manager_get_status();
|
||||
s_last_ota_rendered_st = st;
|
||||
|
||||
ui_draw_header("FIRMWARE UPDATE", "GITEA OTA");
|
||||
|
||||
switch (st) {
|
||||
case OTA_STATUS_CHECKING: {
|
||||
lv_obj_t *box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(box, DISP_W - 24, 180);
|
||||
lv_obj_align(box, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(0x1C1C1C), 0);
|
||||
lv_obj_set_style_border_color(box, lv_color_hex(0x00E5FF), 0);
|
||||
lv_obj_set_style_border_width(box, 2, 0);
|
||||
lv_obj_set_style_radius(box, 8, 0);
|
||||
|
||||
lv_obj_t *lbl = lv_label_create(box);
|
||||
lv_label_set_text(lbl, "CHECKING GITEA...\n\nConnecting to\nreleases API");
|
||||
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(lbl, lv_color_hex(0x00E5FF), 0);
|
||||
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
lv_obj_t *hint = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(hint, "HOLD BUTTON: MAIN MENU");
|
||||
lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
|
||||
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
break;
|
||||
}
|
||||
|
||||
case OTA_STATUS_UPDATE_AVAILABLE: {
|
||||
const ota_release_info_t *rel = ota_manager_get_release_info();
|
||||
const char *cur_ver = ota_manager_get_current_version();
|
||||
|
||||
lv_obj_t *box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(box, DISP_W - 24, 200);
|
||||
lv_obj_align(box, LV_ALIGN_CENTER, 0, -15);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(0x102818), 0);
|
||||
lv_obj_set_style_border_color(box, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_set_style_border_width(box, 2, 0);
|
||||
lv_obj_set_style_radius(box, 8, 0);
|
||||
|
||||
lv_obj_t *t = lv_label_create(box);
|
||||
lv_label_set_text(t, "NEW VERSION AVAILABLE!");
|
||||
lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8);
|
||||
|
||||
lv_obj_t *v = lv_label_create(box);
|
||||
lv_label_set_text_fmt(v, "TARGET: %s", rel->tag_name);
|
||||
lv_obj_set_style_text_font(v, &lv_font_montserrat_20, 0);
|
||||
lv_obj_set_style_text_color(v, lv_color_hex(0xFFCC00), 0);
|
||||
lv_obj_align(v, LV_ALIGN_TOP_MID, 0, 38);
|
||||
|
||||
lv_obj_t *c = lv_label_create(box);
|
||||
lv_label_set_text_fmt(c, "Current: %s", cur_ver);
|
||||
lv_obj_set_style_text_font(c, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(c, lv_color_white(), 0);
|
||||
lv_obj_align(c, LV_ALIGN_TOP_MID, 0, 70);
|
||||
|
||||
lv_obj_t *s = lv_label_create(box);
|
||||
if (rel->binary_size > 0) {
|
||||
lv_label_set_text_fmt(s, "Size: %d KB", (int)(rel->binary_size / 1024));
|
||||
} else {
|
||||
lv_label_set_text(s, "Ready to stream");
|
||||
}
|
||||
lv_obj_set_style_text_font(s, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(s, lv_color_hex(0x888888), 0);
|
||||
lv_obj_align(s, LV_ALIGN_TOP_MID, 0, 95);
|
||||
|
||||
lv_obj_t *act = lv_label_create(box);
|
||||
lv_label_set_text(act, "> CLICK: INSTALL NOW <");
|
||||
lv_obj_set_style_text_font(act, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(act, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_align(act, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
|
||||
lv_obj_t *hint = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(hint, "ROTATE / HOLD: SKIP FOR NOW");
|
||||
lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
|
||||
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
break;
|
||||
}
|
||||
|
||||
case OTA_STATUS_UP_TO_DATE: {
|
||||
const char *cur_ver = ota_manager_get_current_version();
|
||||
lv_obj_t *box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(box, DISP_W - 24, 180);
|
||||
lv_obj_align(box, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(0x1C1C1C), 0);
|
||||
lv_obj_set_style_border_color(box, lv_color_hex(0x333333), 0);
|
||||
lv_obj_set_style_border_width(box, 1, 0);
|
||||
lv_obj_set_style_radius(box, 8, 0);
|
||||
|
||||
lv_obj_t *t = lv_label_create(box);
|
||||
lv_label_set_text(t, "UP TO DATE");
|
||||
lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 15);
|
||||
|
||||
lv_obj_t *v = lv_label_create(box);
|
||||
lv_label_set_text_fmt(v, "Version: %s\n\nNo newer releases\nfound on Gitea.", cur_ver);
|
||||
lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(v, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(v, LV_ALIGN_CENTER, 0, 15);
|
||||
|
||||
lv_obj_t *hint = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(hint, "CLICK: MAIN MENU");
|
||||
lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
|
||||
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
break;
|
||||
}
|
||||
|
||||
case OTA_STATUS_DOWNLOADING: {
|
||||
int percent = ota_manager_get_progress();
|
||||
s_last_ota_percent = percent;
|
||||
size_t rb = 0, tb = 0;
|
||||
ota_manager_get_bytes(&rb, &tb);
|
||||
|
||||
lv_obj_t *box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(box, DISP_W - 24, 200);
|
||||
lv_obj_align(box, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(0x181828), 0);
|
||||
lv_obj_set_style_border_color(box, lv_color_hex(0x00E5FF), 0);
|
||||
lv_obj_set_style_border_width(box, 2, 0);
|
||||
lv_obj_set_style_radius(box, 8, 0);
|
||||
|
||||
lv_obj_t *t = lv_label_create(box);
|
||||
lv_label_set_text(t, "FLASHING FIRMWARE...");
|
||||
lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(0x00E5FF), 0);
|
||||
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
lv_obj_t *p_lbl = lv_label_create(box);
|
||||
lv_label_set_text_fmt(p_lbl, "%d%%", percent);
|
||||
lv_obj_set_style_text_font(p_lbl, &lv_font_montserrat_48, 0);
|
||||
lv_obj_set_style_text_color(p_lbl, lv_color_hex(0xFFCC00), 0);
|
||||
lv_obj_align(p_lbl, LV_ALIGN_CENTER, 0, -15);
|
||||
|
||||
lv_obj_t *bar = lv_bar_create(box);
|
||||
lv_obj_set_size(bar, DISP_W - 60, 14);
|
||||
lv_obj_align(bar, LV_ALIGN_CENTER, 0, 35);
|
||||
lv_bar_set_range(bar, 0, 100);
|
||||
lv_bar_set_value(bar, percent, LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(bar, lv_color_hex(0x333333), 0);
|
||||
lv_obj_set_style_bg_color(bar, lv_color_hex(0x00E676), LV_PART_INDICATOR);
|
||||
|
||||
lv_obj_t *b_lbl = lv_label_create(box);
|
||||
lv_label_set_text_fmt(b_lbl, "%d / %d KB", (int)(rb / 1024), (int)(tb / 1024));
|
||||
lv_obj_set_style_text_font(b_lbl, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(b_lbl, lv_color_hex(0xAAAAAA), 0);
|
||||
lv_obj_align(b_lbl, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
|
||||
lv_obj_t *warn = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(warn, "DO NOT TURN OFF POWER");
|
||||
lv_obj_set_style_text_font(warn, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(warn, lv_color_hex(0xFF1744), 0);
|
||||
lv_obj_align(warn, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
break;
|
||||
}
|
||||
|
||||
case OTA_STATUS_SUCCESS_REBOOTING: {
|
||||
lv_obj_t *box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(box, DISP_W - 24, 180);
|
||||
lv_obj_align(box, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(0x102818), 0);
|
||||
lv_obj_set_style_border_color(box, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_set_style_border_width(box, 2, 0);
|
||||
lv_obj_set_style_radius(box, 8, 0);
|
||||
|
||||
lv_obj_t *t = lv_label_create(box);
|
||||
lv_label_set_text(t, "UPDATE SUCCESSFUL!");
|
||||
lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 20);
|
||||
|
||||
lv_obj_t *m = lv_label_create(box);
|
||||
lv_label_set_text(m, "New firmware verified.\n\nRebooting device...");
|
||||
lv_obj_set_style_text_font(m, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(m, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_align(m, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(m, LV_ALIGN_CENTER, 0, 25);
|
||||
break;
|
||||
}
|
||||
|
||||
case OTA_STATUS_FAILED:
|
||||
default: {
|
||||
lv_obj_t *box = lv_obj_create(lv_scr_act());
|
||||
lv_obj_set_size(box, DISP_W - 24, 180);
|
||||
lv_obj_align(box, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(0x281010), 0);
|
||||
lv_obj_set_style_border_color(box, lv_color_hex(0xFF1744), 0);
|
||||
lv_obj_set_style_border_width(box, 2, 0);
|
||||
lv_obj_set_style_radius(box, 8, 0);
|
||||
|
||||
lv_obj_t *t = lv_label_create(box);
|
||||
lv_label_set_text(t, "UPDATE FAILED");
|
||||
lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(0xFF1744), 0);
|
||||
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 15);
|
||||
|
||||
lv_obj_t *msg = lv_label_create(box);
|
||||
lv_label_set_text_fmt(msg, "%s\n\nExisting firmware intact.", ota_manager_get_message());
|
||||
lv_obj_set_style_text_font(msg, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(msg, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_align(msg, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(msg, LV_ALIGN_CENTER, 0, 20);
|
||||
|
||||
lv_obj_t *hint = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(hint, "CLICK: MAIN MENU");
|
||||
lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0);
|
||||
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lvgl_port_unlock();
|
||||
}
|
||||
|
||||
/* 8. Card View Screen */
|
||||
static void view_card(void)
|
||||
{
|
||||
@@ -1225,22 +1447,39 @@ static void view_unmapped(const char *uid)
|
||||
lv_obj_align(u_lbl, LV_ALIGN_TOP_MID, 0, 70);
|
||||
|
||||
lv_obj_t *sub = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(sub, "PAIR IT ON THE WEB:");
|
||||
lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(sub, lv_color_white(), 0);
|
||||
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 130);
|
||||
|
||||
lv_obj_t *ssid = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(ssid, "SSID: MTG-Companion");
|
||||
lv_obj_set_style_text_font(ssid, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(ssid, lv_color_hex(0xFFCC00), 0);
|
||||
lv_obj_align(ssid, LV_ALIGN_TOP_MID, 0, 160);
|
||||
|
||||
lv_obj_t *ip = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(ip, "http://192.168.4.1");
|
||||
lv_obj_set_style_text_font(ip, &lv_font_montserrat_20, 0);
|
||||
lv_obj_set_style_text_color(ip, lv_color_hex(0x00E676), 0);
|
||||
lv_obj_align(ip, LV_ALIGN_TOP_MID, 0, 190);
|
||||
|
||||
if (wifi_manager_connected()) {
|
||||
lv_label_set_text(sub, "PAIR VIA WI-FI:");
|
||||
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 105);
|
||||
|
||||
lv_label_set_text_fmt(ip, "http://%s", wifi_manager_get_ip());
|
||||
lv_obj_align(ip, LV_ALIGN_TOP_MID, 0, 135);
|
||||
|
||||
lv_obj_t *alt = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(alt, "or Hotspot: MTG-Companion\n(http://192.168.4.1)");
|
||||
lv_obj_set_style_text_font(alt, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(alt, lv_color_hex(0x888888), 0);
|
||||
lv_obj_set_style_text_align(alt, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(alt, LV_ALIGN_TOP_MID, 0, 175);
|
||||
} else {
|
||||
lv_label_set_text(sub, "PAIR ON HOTSPOT:");
|
||||
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 105);
|
||||
|
||||
lv_obj_t *ssid = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(ssid, "SSID: MTG-Companion");
|
||||
lv_obj_set_style_text_font(ssid, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(ssid, lv_color_hex(0xFFCC00), 0);
|
||||
lv_obj_align(ssid, LV_ALIGN_TOP_MID, 0, 135);
|
||||
|
||||
lv_label_set_text(ip, "http://192.168.4.1");
|
||||
lv_obj_align(ip, LV_ALIGN_TOP_MID, 0, 165);
|
||||
}
|
||||
|
||||
lv_obj_t *hint = lv_label_create(lv_scr_act());
|
||||
lv_label_set_text(hint, "HOLD BUTTON: MAIN MENU");
|
||||
@@ -1281,40 +1520,6 @@ static void view_fetching(const char *name)
|
||||
lvgl_port_unlock();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* LED helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void led_for_card(const char *mana)
|
||||
{
|
||||
uint8_t r = 255, g = 200, b = 0;
|
||||
if (mana != NULL && mana[0] != '\0') {
|
||||
if (strstr(mana, "{W}")) { r = 255; g = 255; b = 200; }
|
||||
else if (strstr(mana, "{U}")) { r = 40; g = 100; b = 255; }
|
||||
else if (strstr(mana, "{B}")) { r = 90; g = 20; b = 150; }
|
||||
else if (strstr(mana, "{R}")) { r = 255; g = 60; b = 20; }
|
||||
else if (strstr(mana, "{G}")) { r = 20; g = 200; b = 60; }
|
||||
}
|
||||
led_manager_set_color(r, g, b);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
|
||||
}
|
||||
|
||||
static void read_cached_color(const char *uid)
|
||||
{
|
||||
char json_path[72];
|
||||
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
|
||||
char *buf = NULL;
|
||||
size_t len = 0;
|
||||
if (storage_read_text(json_path, &buf, &len) == ESP_OK && buf != NULL) {
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root != NULL) {
|
||||
led_for_card(get_json_str(root, "mana_cost", ""));
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_new_card(const char *uid)
|
||||
{
|
||||
snprintf(s_uid, sizeof(s_uid), "%s", uid);
|
||||
@@ -1325,8 +1530,6 @@ static void handle_new_card(const char *uid)
|
||||
if (ret != ESP_OK || name == NULL) {
|
||||
app_set_view(APP_STATE_REGISTRATION);
|
||||
view_unmapped(uid);
|
||||
led_manager_set_color(60, 60, 200);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
|
||||
buzzer_chirp(392, 80);
|
||||
free(name);
|
||||
return;
|
||||
@@ -1336,7 +1539,6 @@ static void handle_new_card(const char *uid)
|
||||
app_set_view(APP_STATE_CARD_VIEW);
|
||||
|
||||
buzzer_chirp(880, 60);
|
||||
read_cached_color(uid);
|
||||
|
||||
char json_path[72];
|
||||
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
|
||||
@@ -1375,12 +1577,9 @@ static void handle_fetch_ready(void)
|
||||
scryfall_consume_result();
|
||||
s_waiting_fetch = false;
|
||||
if (success) {
|
||||
read_cached_color(s_uid);
|
||||
view_card();
|
||||
} else {
|
||||
buzzer_chirp(220, 150);
|
||||
led_manager_set_color(255, 0, 0);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
|
||||
|
||||
lvgl_port_lock(0);
|
||||
ui_clean_screen();
|
||||
@@ -1465,11 +1664,31 @@ static void handle_encoder_click(void)
|
||||
app_set_view(APP_STATE_DEVICE_INFO);
|
||||
view_device_info();
|
||||
break;
|
||||
case 7:
|
||||
app_set_view(APP_STATE_OTA_UPDATE);
|
||||
buzzer_chirp(988, 40);
|
||||
ota_manager_check_update_async();
|
||||
view_ota();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case APP_STATE_OTA_UPDATE: {
|
||||
ota_status_t st = ota_manager_get_status();
|
||||
if (st == OTA_STATUS_UPDATE_AVAILABLE) {
|
||||
buzzer_chirp(1046, 100);
|
||||
ota_manager_start_update_async();
|
||||
view_ota();
|
||||
} else if (st == OTA_STATUS_UP_TO_DATE || st == OTA_STATUS_FAILED) {
|
||||
ota_manager_reset();
|
||||
app_set_view(APP_STATE_MENU);
|
||||
view_menu();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case APP_STATE_LIFE_COUNTER:
|
||||
s_life_mode = (s_life_mode + 1) % 3;
|
||||
buzzer_chirp(880, 30);
|
||||
@@ -1506,8 +1725,6 @@ static void handle_encoder_click(void)
|
||||
if (s_storage_option_idx == 0) {
|
||||
storage_manager_clear_all_cards(true);
|
||||
buzzer_chirp(1046, 100);
|
||||
led_manager_set_color(0, 255, 60);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(1500);
|
||||
s_storage_confirm_dialog = false;
|
||||
view_storage();
|
||||
} else {
|
||||
@@ -1654,6 +1871,16 @@ static void handle_encoder_rotation(int32_t delta)
|
||||
view_storage();
|
||||
break;
|
||||
|
||||
case APP_STATE_OTA_UPDATE: {
|
||||
ota_status_t st = ota_manager_get_status();
|
||||
if (st == OTA_STATUS_UPDATE_AVAILABLE) {
|
||||
ota_manager_reset();
|
||||
app_set_view(APP_STATE_MENU);
|
||||
view_menu();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1669,13 +1896,17 @@ static void ui_task(void *arg)
|
||||
ESP_LOGI(TAG, "UI task on core %d", xPortGetCoreID());
|
||||
|
||||
encoder_init();
|
||||
led_manager_init();
|
||||
buzzer_init();
|
||||
|
||||
/* Start on Main Menu at boot */
|
||||
app_set_view(APP_STATE_MENU);
|
||||
view_menu();
|
||||
|
||||
#if CONFIG_MTG_OTA_AUTO_CHECK
|
||||
static bool s_boot_ota_checked = false;
|
||||
static uint32_t s_wifi_up_ms = 0;
|
||||
#endif
|
||||
|
||||
while (1) {
|
||||
/* 1. New RFID card - scans immediately anywhere */
|
||||
if (rfid_manager_has_pending_card()) {
|
||||
@@ -1697,8 +1928,6 @@ static void ui_task(void *arg)
|
||||
buzzer_chirp(1046, 50);
|
||||
vTaskDelay(pdMS_TO_TICKS(40));
|
||||
buzzer_chirp(1318, 70);
|
||||
led_manager_set_color(180, 0, 255);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(800);
|
||||
app_set_view(APP_STATE_MENU);
|
||||
view_menu();
|
||||
}
|
||||
@@ -1716,12 +1945,34 @@ static void ui_task(void *arg)
|
||||
handle_encoder_rotation(delta);
|
||||
}
|
||||
|
||||
/* 5. Auto-dim LED */
|
||||
if (s_led_off_tick != 0 && xTaskGetTickCount() >= s_led_off_tick) {
|
||||
s_led_off_tick = 0;
|
||||
led_manager_off();
|
||||
/* 5. OTA screen live refresh / status change */
|
||||
if (app_get_view() == APP_STATE_OTA_UPDATE) {
|
||||
ota_status_t cur_ota_st = ota_manager_get_status();
|
||||
int cur_percent = ota_manager_get_progress();
|
||||
if (cur_ota_st != s_last_ota_rendered_st ||
|
||||
(cur_ota_st == OTA_STATUS_DOWNLOADING && cur_percent != s_last_ota_percent)) {
|
||||
view_ota();
|
||||
}
|
||||
}
|
||||
|
||||
#if CONFIG_MTG_OTA_AUTO_CHECK
|
||||
/* 6. Automatic background OTA check on startup after Wi-Fi connects */
|
||||
if (!s_boot_ota_checked && wifi_manager_connected()) {
|
||||
s_wifi_up_ms += 25;
|
||||
if (s_wifi_up_ms >= 4000) {
|
||||
s_boot_ota_checked = true;
|
||||
ESP_LOGI(TAG, "Triggering startup Gitea OTA check...");
|
||||
ota_manager_check_update_async();
|
||||
}
|
||||
}
|
||||
if (s_boot_ota_checked && ota_manager_get_status() == OTA_STATUS_UPDATE_AVAILABLE &&
|
||||
app_get_view() == APP_STATE_MENU) {
|
||||
app_set_view(APP_STATE_OTA_UPDATE);
|
||||
buzzer_chirp(1046, 120);
|
||||
view_ota();
|
||||
}
|
||||
#endif
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(25));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "rfid_manager.h"
|
||||
#include "storage_manager.h"
|
||||
#include "scryfall_client.h"
|
||||
#include "wifi_manager.h"
|
||||
#include "web_server.h"
|
||||
|
||||
static const char *TAG = "web";
|
||||
@@ -397,6 +398,7 @@ esp_err_t web_server_start(void)
|
||||
ESP_LOGE(TAG, "failed to create AP netif");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
esp_netif_set_hostname(ap, "mtg-companion");
|
||||
}
|
||||
|
||||
esp_err_t ret = esp_wifi_stop(); /* safe even if not started */
|
||||
@@ -471,7 +473,7 @@ esp_err_t web_server_start(void)
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ART));
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND));
|
||||
|
||||
ESP_LOGI(TAG, "SoftAP '%s' at 192.168.4.1, web server ready", AP_SSID);
|
||||
ESP_LOGI(TAG, "Web server ready on all interfaces (Hotspot: 192.168.4.1, Wi-Fi: %s)", wifi_manager_get_ip());
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,10 @@ esp_err_t wifi_manager_init(void)
|
||||
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
esp_netif_create_default_wifi_sta();
|
||||
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
|
||||
if (sta_netif != NULL) {
|
||||
esp_netif_set_hostname(sta_netif, "mtg-companion");
|
||||
}
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
|
||||
Reference in New Issue
Block a user