Add OTA firmware update via GitHub Releases API

New "Check for update" entry in Menu > System checks the latest
GitHub release's tag against the running version and, if newer,
downloads and flashes its firmware.bin asset directly over WiFi -
no cable or web flasher needed. Requires switching the partition
scheme to min_spiffs (two OTA-capable app partitions instead of a
single non-OTA one), which needs a full reflash.

confirmWarningScreen() now takes title/body as strings instead of
StringIds so it can show a dynamically composed message (the target
version number) for the update confirmation, with an accent color
parameter so the OTA confirmation can use green instead of the
red used for the existing destructive-action confirmations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-12 22:57:12 +02:00
parent e4a29a090f
commit d7454d9784
12 changed files with 327 additions and 24 deletions

View File

@@ -316,6 +316,19 @@ enum class StringId : uint8_t {
// noetig.
WEBUI_QR_HINT,
// OTA-Firmware-Update ueber WLAN (Menue > System > "Nach Update
// suchen", siehe menu_screen.cpp::runOtaUpdateScreen() und
// ota_update.h/.cpp).
MENU_CHECK_UPDATE,
OTA_CHECKING,
OTA_CHECK_FAILED,
OTA_UP_TO_DATE_PREFIX,
OTA_UPDATE_AVAILABLE_PREFIX,
OTA_CONFIRM_BODY,
OTA_INSTALLING_PREFIX,
OTA_UPDATE_FAILED,
OTA_UPDATE_SUCCESS,
COUNT
};

View File

@@ -240,6 +240,15 @@ static const char* const I18N_DE[] = {
"Tippen zum Überspringen",
"Route: ",
"Mit dem Handy scannen:",
"Nach Update suchen",
"Suche nach Updates...",
"Prüfung fehlgeschlagen. WLAN prüfen.",
"Bereits aktuell: v",
"Update verfügbar: v",
"Ein neues Firmware-Update wird heruntergeladen und installiert. Das Gerät startet danach automatisch neu. Bitte WLAN-Verbindung und Stromversorgung während des Vorgangs nicht unterbrechen.",
"Installiere Update ",
"Update fehlgeschlagen.",
"Installiert! Neustart...",
};
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -241,6 +241,15 @@ static const char* const I18N_EN[] = {
"Tap to skip",
"Route: ",
"Scan with your phone:",
"Check for update",
"Checking for updates...",
"Check failed. Check your WiFi connection.",
"Already up to date: v",
"Update available: v",
"A new firmware update will be downloaded and installed. The device will restart automatically afterwards. Please don't interrupt the WiFi connection or power supply during this process.",
"Installing update ",
"Update failed.",
"Installed! Restarting...",
};
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -240,6 +240,15 @@ static const char* const I18N_ES[] = {
"Toca para saltar",
"Ruta: ",
"Escanea con el móvil:",
"Buscar actualización",
"Buscando actualizaciones...",
"Error al comprobar. Revisa tu conexión WiFi.",
"Ya actualizado: v",
"Actualización disponible: v",
"Se descargará e instalará una nueva actualización de firmware. El dispositivo se reiniciará automáticamente después. No interrumpas la conexión WiFi ni la alimentación durante este proceso.",
"Instalando actualización ",
"Error al actualizar.",
"¡Instalado! Reiniciando...",
};
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -240,6 +240,15 @@ static const char* const I18N_FR[] = {
"Toucher pour passer",
"Trajet : ",
"Scanner avec le téléphone :",
"Rechercher une mise à jour",
"Recherche de mises à jour...",
"Échec de la vérification. Vérifiez votre connexion WiFi.",
"Déjà à jour : v",
"Mise à jour disponible : v",
"Une nouvelle mise à jour du micrologiciel va être téléchargée et installée. L'appareil redémarrera automatiquement ensuite. Merci de ne pas interrompre la connexion WiFi ni l'alimentation pendant ce processus.",
"Installation de la mise à jour ",
"Échec de la mise à jour.",
"Installé ! Redémarrage...",
};
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -240,6 +240,15 @@ static const char* const I18N_IT[] = {
"Tocca per saltare",
"Rotta: ",
"Scansiona con il telefono:",
"Cerca aggiornamenti",
"Ricerca aggiornamenti...",
"Verifica fallita. Controlla la connessione WiFi.",
"Già aggiornato: v",
"Aggiornamento disponibile: v",
"Un nuovo aggiornamento del firmware verrà scaricato e installato. Il dispositivo si riavvierà automaticamente al termine. Non interrompere la connessione WiFi o l'alimentazione durante questo processo.",
"Installazione aggiornamento ",
"Aggiornamento fallito.",
"Installato! Riavvio...",
};
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -240,6 +240,15 @@ static const char* const I18N_TR[] = {
"Atlamak için dokun",
"Rota: ",
"Telefonla tarayın:",
"Güncelleme ara",
"Güncellemeler aranıyor...",
"Kontrol başarısız. WiFi bağlantınızı kontrol edin.",
"Zaten güncel: v",
"Güncelleme mevcut: v",
"Yeni bir donanım yazılımı güncellemesi indirilip yüklenecek. Cihaz bundan sonra otomatik olarak yeniden başlayacak. Lütfen bu işlem sırasında WiFi bağlantısını veya güç kaynağını kesmeyin.",
"Güncelleme yükleniyor ",
"Güncelleme başarısız.",
"Yüklendi! Yeniden başlatılıyor...",
};
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -6,6 +6,7 @@
#include "stats_history_screen.h"
#include "logbook_files_screen.h"
#include "webui_screen.h"
#include "ota_update.h"
#include "location_presets_screen.h"
#include "airline_filter_screen.h"
#include "aircraft_watchlist_screen.h"
@@ -84,8 +85,9 @@ namespace {
// "Sicherung & Reset"-Unterseite ausgelagert wurden (siehe
// BACKUP_RESET_ROW_COUNT unten) und dort durch einen einzelnen
// Ordner-Button ersetzt wurden: -2 (Backup/Restore raus) +1 (neuer
// Ordner-Button) = 9.
constexpr uint8_t SYSTEM_ROW_COUNT = 9;
// Ordner-Button) = 9. Danach +1 fuer den neuen "Nach Update
// suchen"-Punkt (OTA-Update, siehe ota_update.h) = 10.
constexpr uint8_t SYSTEM_ROW_COUNT = 10;
constexpr int16_t SYSTEM_ROW_GAP = 4;
constexpr int16_t SYSTEM_START_Y = 18;
constexpr int16_t SYSTEM_END_Y = Config::SCREEN_HEIGHT - 10;
@@ -216,7 +218,15 @@ namespace {
// allen anderen Menue-Screens (nur der Radar-Screen selbst spart sich
// das wegen der CPU-Last durch Abfragen/Zeichnen). Gibt true zurueck,
// wenn "OK" angetippt wurde, false bei "Zurueck".
bool confirmWarningScreen(TFT_eSPI& tft, StringId titleId, StringId bodyId) {
//
// title/body stehen VOR dem Aufruf per I18n::t() fest (statt StringIds
// entgegenzunehmen), damit auch dynamisch zusammengesetzte Texte (z.B.
// mit eingefuegter Versionsnummer bei OTA) moeglich sind. accentColor
// (Default TFT_RED fuer die beiden bestehenden, wirklich destruktiven
// Aufrufer) faerbt Rahmen und Titel - der OTA-Aufrufer uebergibt
// TFT_GREEN, da ein Update zwar bestaetigt werden sollte, aber keine
// "gefaehrliche" Loesch-Aktion wie Werksreset/Flugbuch ist.
bool confirmWarningScreen(TFT_eSPI& tft, const String& title, const String& body, uint16_t accentColor = TFT_RED) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
constexpr int16_t BOX_W = Config::SCREEN_WIDTH - 2 * BOX_X;
@@ -241,7 +251,6 @@ namespace {
constexpr int16_t VIEW_BOTTOM_NO_SCROLL = OK_Y - 8;
constexpr int16_t VIEW_BOTTOM_SCROLL = VIEW_BOTTOM_NO_SCROLL - SCROLL_ROW_H - SCROLL_ROW_GAP;
String body = I18n::t(bodyId);
int16_t totalH = layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, 0, 0, 0, false);
bool scrollable = (totalH - VIEW_BOTTOM_NO_SCROLL) > 0;
@@ -261,12 +270,12 @@ namespace {
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, TFT_RED);
tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, accentColor);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.setTextColor(accentColor, TFT_BLACK);
tft.setTextSize(2);
tft.drawString(I18n::t(titleId), BOX_X + BOX_W / 2, TITLE_Y);
tft.drawString(title, BOX_X + BOX_W / 2, TITLE_Y);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
@@ -303,6 +312,75 @@ namespace {
}
}
// Fortschrittsanzeige waehrend OtaUpdate::performUpdate() laeuft -
// gleiches Namespace-globale-Zeiger-Prinzip wie progressTft oben (siehe
// Settings-Backup-Fortschrittspunkte), da OtaUpdate::performUpdate()
// ebenfalls einen einfachen C-Funktionszeiger erwartet, keine
// Lambda-Capture erlaubt.
TFT_eSPI* otaProgressTft = nullptr;
void drawOtaProgress(uint8_t percent) {
if (!otaProgressTft) return;
TFT_eSPI& t = *otaProgressTft;
String label = String(I18n::t(StringId::OTA_INSTALLING_PREFIX)) + String(percent) + "%";
t.fillRect(0, (int16_t)(Config::SCREEN_HEIGHT / 2 - 14), Config::SCREEN_WIDTH, 28, TFT_BLACK);
t.setTextDatum(MC_DATUM);
t.setTextColor(TFT_GREEN, TFT_BLACK);
t.setTextSize(2);
t.drawString(label, Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2);
t.setTextSize(1);
t.setTextDatum(TL_DATUM);
}
// Kompletter Ablauf fuer "Nach Update suchen" (System-Menue) - Pruefung
// gegen GitHub-Releases, bei verfuegbarem Update explizite Bestaetigung
// (confirmWarningScreen() mit gruenem statt rotem Akzent - ein Update
// ist keine destruktive Aktion wie Werksreset, verdient aber trotzdem
// eine bewusste Bestaetigung, da WLAN/Strom waehrend des Vorgangs nicht
// unterbrochen werden sollten), danach Fortschrittsanzeige waehrend
// Download+Flash. Startet das Geraet bei Erfolg selbst neu.
void runOtaUpdateScreen(TFT_eSPI& tft) {
MenuStars::reset();
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(I18n::t(StringId::OTA_CHECKING), Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2);
tft.setTextDatum(TL_DATUM);
OtaUpdate::CheckInfo info = OtaUpdate::checkForUpdate();
if (info.result == OtaUpdate::CheckResult::Error) {
showBriefMessage(tft, I18n::t(StringId::OTA_CHECK_FAILED), TFT_RED);
return;
}
if (info.result == OtaUpdate::CheckResult::UpToDate) {
showBriefMessage(tft, String(I18n::t(StringId::OTA_UP_TO_DATE_PREFIX)) + info.latestVersion, TFT_GREEN);
return;
}
String title = String(I18n::t(StringId::OTA_UPDATE_AVAILABLE_PREFIX)) + info.latestVersion;
bool confirmed = confirmWarningScreen(tft, title, I18n::t(StringId::OTA_CONFIRM_BODY), TFT_GREEN);
if (!confirmed) return;
otaProgressTft = &tft;
tft.fillScreen(TFT_BLACK);
drawOtaProgress(0);
bool ok = OtaUpdate::performUpdate(info.downloadUrl, drawOtaProgress);
otaProgressTft = nullptr;
if (ok) {
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(I18n::t(StringId::OTA_UPDATE_SUCCESS), Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2);
tft.setTextDatum(TL_DATUM);
delay(1500);
ESP.restart();
} else {
showBriefMessage(tft, I18n::t(StringId::OTA_UPDATE_FAILED), TFT_RED);
}
}
enum class Page { Main, Region, System, Flight, BackupReset };
}
@@ -390,13 +468,15 @@ void run(TFT_eSPI& tft) {
Rect nightDimBtn = systemRowRect(4);
Rect webuiBtn = systemRowRect(5);
// "Sicherung & Reset" (Backup/Restore/Werksreset, siehe
// Page::BackupReset unten) steht bewusst direkt ueber "Info",
// das seinerseits als letzter Punkt direkt ueber dem Zurueck-
// Button steht - so bleiben beide Positionen stabil, egal wie
// viele weitere Punkte davor noch dazukommen.
// Page::BackupReset unten) und "Nach Update suchen" stehen
// bewusst direkt ueber "Info", das seinerseits als letzter
// Punkt direkt ueber dem Zurueck-Button steht - so bleiben
// beide Positionen stabil, egal wie viele weitere Punkte davor
// noch dazukommen.
Rect backupResetBtn = systemRowRect(6);
Rect aboutBtn = systemRowRect(7);
Rect backBtn = systemRowRect(8);
Rect checkUpdateBtn = systemRowRect(7);
Rect aboutBtn = systemRowRect(8);
Rect backBtn = systemRowRect(9);
drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE));
@@ -410,6 +490,7 @@ void run(TFT_eSPI& tft) {
drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled()));
drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI));
drawButton(tft, backupResetBtn, I18n::t(StringId::MENU_BACKUP_RESET));
drawButton(tft, checkUpdateBtn, I18n::t(StringId::MENU_CHECK_UPDATE));
drawButton(tft, aboutBtn, I18n::t(StringId::MENU_ABOUT));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
@@ -436,6 +517,8 @@ void run(TFT_eSPI& tft) {
SettingsStore::setNightDimmingEnabled(!SettingsStore::nightDimmingEnabled());
} else if (backupResetBtn.contains(tap.x, tap.y)) {
page = Page::BackupReset;
} else if (checkUpdateBtn.contains(tap.x, tap.y)) {
runOtaUpdateScreen(tft);
} else if (aboutBtn.contains(tap.x, tap.y)) {
AboutScreen::run(tft);
} else if (webuiBtn.contains(tap.x, tap.y)) {
@@ -490,8 +573,8 @@ void run(TFT_eSPI& tft) {
ok ? TFT_GREEN : TFT_RED);
}
} else if (resetBtn.contains(tap.x, tap.y)) {
if (confirmWarningScreen(tft, StringId::MENU_LOGBOOK_WARNING_TITLE,
StringId::MENU_FACTORY_RESET_WARNING_BODY)) {
if (confirmWarningScreen(tft, I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE),
I18n::t(StringId::MENU_FACTORY_RESET_WARNING_BODY))) {
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_RED, TFT_BLACK);
@@ -591,8 +674,8 @@ void run(TFT_eSPI& tft) {
SettingsStore::setFlightLogbookEnabled(false);
SettingsStore::setFlightLogbookEnabledAtEpoch(0);
SettingsStore::setFlightLogbookSessionFile("");
} else if (confirmWarningScreen(tft, StringId::MENU_LOGBOOK_WARNING_TITLE,
StringId::MENU_LOGBOOK_WARNING_BODY)) {
} else if (confirmWarningScreen(tft, I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE),
I18n::t(StringId::MENU_LOGBOOK_WARNING_BODY))) {
SettingsStore::setFlightLogbookEnabled(true);
SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)time(nullptr));
// Leerer Eintrag erzwingt eine frische Sitzungsdatei beim

107
src/ota_update.cpp Normal file
View File

@@ -0,0 +1,107 @@
#include "ota_update.h"
#include "config.h"
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <HTTPUpdate.h>
#include <ArduinoJson.h>
#include <cstdlib>
#include <cstdio>
#include <cstring>
namespace OtaUpdate {
namespace {
constexpr const char* RELEASES_API_URL =
"https://api.github.com/repos/Eiswolf-BG/eiswolfs-flightradar-CYD/releases/latest";
// GitHub verlangt bei API-Anfragen einen aussagekraeftigen User-Agent
// (sonst HTTP 403) - gleiches Prinzip wie Config::NOMINATIM_USER_AGENT
// fuer die Adresssuche.
constexpr const char* USER_AGENT =
"EiswolfsFlightradarCYD-OTA/1.0 (+https://github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)";
bool parseVersion(const char* s, int& major, int& minor, int& patch) {
major = minor = patch = 0;
if (!s || !s[0]) return false;
if (s[0] == 'v' || s[0] == 'V') s++;
return sscanf(s, "%d.%d.%d", &major, &minor, &patch) == 3;
}
// > 0 wenn a neuer als b, 0 wenn gleich, < 0 wenn a aelter als b.
// Bewusst eine echte numerische Versionsvergleich statt eines simplen
// String-Vergleichs (der wuerde z.B. "3.10.0" faelschlich als "kleiner"
// als "3.9.0" einordnen).
int compareVersions(const char* a, const char* b) {
int aMaj, aMin, aPat, bMaj, bMin, bPat;
if (!parseVersion(a, aMaj, aMin, aPat) || !parseVersion(b, bMaj, bMin, bPat)) return 0;
if (aMaj != bMaj) return aMaj - bMaj;
if (aMin != bMin) return aMin - bMin;
return aPat - bPat;
}
}
CheckInfo checkForUpdate() {
CheckInfo info;
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(8000);
HTTPClient http;
http.setTimeout(8000);
if (!http.begin(client, RELEASES_API_URL)) return info;
http.addHeader("User-Agent", USER_AGENT);
int code = http.GET();
if (code != HTTP_CODE_OK) {
http.end();
return info;
}
String body = http.getString();
http.end();
JsonDocument doc;
if (deserializeJson(doc, body)) return info;
const char* tag = doc["tag_name"] | "";
if (!tag[0]) return info;
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
sizeof(info.latestVersion) - 1);
JsonArray assets = doc["assets"];
for (JsonObject asset : assets) {
const char* name = asset["name"] | "";
if (strcmp(name, "firmware.bin") == 0) {
const char* url = asset["browser_download_url"] | "";
strncpy(info.downloadUrl, url, sizeof(info.downloadUrl) - 1);
break;
}
}
if (!info.downloadUrl[0]) return info; // Release ohne firmware.bin-Anhang
int cmp = compareVersions(info.latestVersion, Config::APP_VERSION);
info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate;
return info;
}
bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(15000);
// Wir zeigen nach erfolgreicher Installation selbst noch eine kurze
// Erfolgsmeldung an, bevor das Geraet neu startet - siehe
// menu_screen.cpp::runOtaUpdateScreen().
httpUpdate.rebootOnUpdate(false);
httpUpdate.onProgress([onProgress](int cur, int total) {
if (onProgress && total > 0) {
onProgress((uint8_t)((cur * 100) / total));
}
});
t_httpUpdate_return result = httpUpdate.update(client, url);
return result == HTTP_UPDATE_OK;
}
}

42
src/ota_update.h Normal file
View File

@@ -0,0 +1,42 @@
#pragma once
#include <Arduino.h>
// Firmware-Update ueber WLAN (Menue > System > "Nach Update suchen") -
// prueft gegen die neueste Version im GitHub-Repository (Releases-API) und
// installiert sie bei Bestaetigung direkt auf dem Geraet, ohne Kabel/
// Web-Flasher. Blockierende, synchrone HTTPS-Aufrufe - werden nur bei
// explizitem Tastendruck ausgefuehrt (kein Hintergrund-Polling), analog zu
// AircraftDetails' Modell-/Routen-Abfragen, nur eben auf Core 1 statt
// Core 0, da hier direkt auf Nutzer-Interaktion reagiert wird.
namespace OtaUpdate {
enum class CheckResult { Error, UpToDate, UpdateAvailable };
struct CheckInfo {
CheckResult result = CheckResult::Error;
// Neueste verfuegbare Version OHNE "v"-Praefix (z.B. "3.5.0") -
// sowohl bei UpToDate als auch bei UpdateAvailable gesetzt, damit
// der aufrufende Screen sie in beiden Faellen anzeigen kann.
char latestVersion[16] = {0};
// Direkter Download-Link zur firmware.bin des Releases - nur
// gesetzt, wenn result == UpdateAvailable.
char downloadUrl[192] = {0};
};
// Fragt die GitHub-Releases-API nach dem neuesten Release ab, vergleicht
// dessen Versionsnummer (Tag-Name, "v"-Praefix wird ignoriert) gegen
// Config::APP_VERSION und sucht im Release den Anhang "firmware.bin".
// CheckResult::Error bei jedem Fehler unterwegs (kein WLAN, Zeitueber-
// schreitung, unerwartetes JSON, kein firmware.bin im Release).
CheckInfo checkForUpdate();
// Laedt die firmware.bin von 'url' herunter und flasht sie (ueber die
// Standard-Arduino-HTTPUpdate-Bibliothek). Ruft bei Erfolg BEWUSST
// NICHT selbst ESP.restart() auf - der Aufrufer zeigt zuerst eine
// kurze Erfolgsmeldung an und startet danach selbst neu. onProgress
// wird waehrend des Downloads wiederholt mit 0-100 (Prozent) aufgerufen
// (darf nullptr sein). Gibt false zurueck, wenn Download oder Flash-
// Vorgang fehlschlagen - das Geraet laeuft in dem Fall unveraendert mit
// der bisherigen Firmware weiter.
bool performUpdate(const char* url, void (*onProgress)(uint8_t percent));
}