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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 7.7 MiB

View File

@@ -5,13 +5,17 @@ framework = arduino
monitor_speed = 115200 monitor_speed = 115200
upload_speed = 921600 upload_speed = 921600
; No OTA (over-the-air firmware update) in use - always flashed via ; Zwei App-Partitionen noetig fuer OTA-Updates (aktuell laufende + neu
; cable/web-flasher. The default partition scheme reserves two app ; heruntergeladene, siehe ota_update.cpp). "min_spiffs.csv" (von Espressif
; partitions (~1.25 MB each) plus an unused SPIFFS data area (~1.375 MB) - ; mitgeliefert) gibt jeder der beiden App-Partitionen 0x1E0000 (1,875 MB)
; over 2.5 MB of 4 MB flash wasted. "no_ota.csv" (shipped with the ; und schrumpft dafuer die SPIFFS-Partition auf nur noch 0x20000 (128 KB) -
; Espressif framework) uses a single, 2 MB app partition instead - doubles ; unproblematisch, da dieses Projekt den internen Flash-Speicher fuer Daten
; the space available for the actual firmware, no code changes needed. ; gar nicht nutzt (alles liegt auf der SD-Karte, siehe Config::SD_*). Bei
board_build.partitions = no_ota.csv ; aktuell ca. 1,25 MB Firmware-Groesse bleiben so noch reichlich ueber
; 600 KB Luft pro Partition. Vorher (bis v3.4.0) kam "no_ota.csv" zum
; Einsatz - eine einzige 2 MB grosse App-Partition ohne OTA-Faehigkeit, da
; OTA damals noch nicht existierte.
board_build.partitions = min_spiffs.csv
lib_deps = lib_deps =
bodmer/TFT_eSPI @ ^2.5.43 bodmer/TFT_eSPI @ ^2.5.43

View File

@@ -316,6 +316,19 @@ enum class StringId : uint8_t {
// noetig. // noetig.
WEBUI_QR_HINT, 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 COUNT
}; };

View File

@@ -240,6 +240,15 @@ static const char* const I18N_DE[] = {
"Tippen zum Überspringen", "Tippen zum Überspringen",
"Route: ", "Route: ",
"Mit dem Handy scannen:", "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, 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", "Tap to skip",
"Route: ", "Route: ",
"Scan with your phone:", "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, 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", "Toca para saltar",
"Ruta: ", "Ruta: ",
"Escanea con el móvil:", "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, 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", "Toucher pour passer",
"Trajet : ", "Trajet : ",
"Scanner avec le téléphone :", "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, 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", "Tocca per saltare",
"Rotta: ", "Rotta: ",
"Scansiona con il telefono:", "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, 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", "Atlamak için dokun",
"Rota: ", "Rota: ",
"Telefonla tarayın:", "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, 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 "stats_history_screen.h"
#include "logbook_files_screen.h" #include "logbook_files_screen.h"
#include "webui_screen.h" #include "webui_screen.h"
#include "ota_update.h"
#include "location_presets_screen.h" #include "location_presets_screen.h"
#include "airline_filter_screen.h" #include "airline_filter_screen.h"
#include "aircraft_watchlist_screen.h" #include "aircraft_watchlist_screen.h"
@@ -84,8 +85,9 @@ namespace {
// "Sicherung & Reset"-Unterseite ausgelagert wurden (siehe // "Sicherung & Reset"-Unterseite ausgelagert wurden (siehe
// BACKUP_RESET_ROW_COUNT unten) und dort durch einen einzelnen // BACKUP_RESET_ROW_COUNT unten) und dort durch einen einzelnen
// Ordner-Button ersetzt wurden: -2 (Backup/Restore raus) +1 (neuer // Ordner-Button ersetzt wurden: -2 (Backup/Restore raus) +1 (neuer
// Ordner-Button) = 9. // Ordner-Button) = 9. Danach +1 fuer den neuen "Nach Update
constexpr uint8_t SYSTEM_ROW_COUNT = 9; // 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_ROW_GAP = 4;
constexpr int16_t SYSTEM_START_Y = 18; constexpr int16_t SYSTEM_START_Y = 18;
constexpr int16_t SYSTEM_END_Y = Config::SCREEN_HEIGHT - 10; 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 // allen anderen Menue-Screens (nur der Radar-Screen selbst spart sich
// das wegen der CPU-Last durch Abfragen/Zeichnen). Gibt true zurueck, // das wegen der CPU-Last durch Abfragen/Zeichnen). Gibt true zurueck,
// wenn "OK" angetippt wurde, false bei "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_X = 4;
constexpr int16_t BOX_Y = 4; constexpr int16_t BOX_Y = 4;
constexpr int16_t BOX_W = Config::SCREEN_WIDTH - 2 * BOX_X; 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_NO_SCROLL = OK_Y - 8;
constexpr int16_t VIEW_BOTTOM_SCROLL = VIEW_BOTTOM_NO_SCROLL - SCROLL_ROW_H - SCROLL_ROW_GAP; 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); 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; bool scrollable = (totalH - VIEW_BOTTOM_NO_SCROLL) > 0;
@@ -261,12 +270,12 @@ namespace {
auto redraw = [&]() { auto redraw = [&]() {
tft.fillScreen(TFT_BLACK); 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.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextColor(accentColor, TFT_BLACK);
tft.setTextSize(2); 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.setTextSize(1);
tft.setTextDatum(TL_DATUM); 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 }; enum class Page { Main, Region, System, Flight, BackupReset };
} }
@@ -390,13 +468,15 @@ void run(TFT_eSPI& tft) {
Rect nightDimBtn = systemRowRect(4); Rect nightDimBtn = systemRowRect(4);
Rect webuiBtn = systemRowRect(5); Rect webuiBtn = systemRowRect(5);
// "Sicherung & Reset" (Backup/Restore/Werksreset, siehe // "Sicherung & Reset" (Backup/Restore/Werksreset, siehe
// Page::BackupReset unten) steht bewusst direkt ueber "Info", // Page::BackupReset unten) und "Nach Update suchen" stehen
// das seinerseits als letzter Punkt direkt ueber dem Zurueck- // bewusst direkt ueber "Info", das seinerseits als letzter
// Button steht - so bleiben beide Positionen stabil, egal wie // Punkt direkt ueber dem Zurueck-Button steht - so bleiben
// viele weitere Punkte davor noch dazukommen. // beide Positionen stabil, egal wie viele weitere Punkte davor
// noch dazukommen.
Rect backupResetBtn = systemRowRect(6); Rect backupResetBtn = systemRowRect(6);
Rect aboutBtn = systemRowRect(7); Rect checkUpdateBtn = systemRowRect(7);
Rect backBtn = systemRowRect(8); Rect aboutBtn = systemRowRect(8);
Rect backBtn = systemRowRect(9);
drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE)); 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, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled()));
drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI)); drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI));
drawButton(tft, backupResetBtn, I18n::t(StringId::MENU_BACKUP_RESET)); 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, aboutBtn, I18n::t(StringId::MENU_ABOUT));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW)); drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
@@ -436,6 +517,8 @@ void run(TFT_eSPI& tft) {
SettingsStore::setNightDimmingEnabled(!SettingsStore::nightDimmingEnabled()); SettingsStore::setNightDimmingEnabled(!SettingsStore::nightDimmingEnabled());
} else if (backupResetBtn.contains(tap.x, tap.y)) { } else if (backupResetBtn.contains(tap.x, tap.y)) {
page = Page::BackupReset; page = Page::BackupReset;
} else if (checkUpdateBtn.contains(tap.x, tap.y)) {
runOtaUpdateScreen(tft);
} else if (aboutBtn.contains(tap.x, tap.y)) { } else if (aboutBtn.contains(tap.x, tap.y)) {
AboutScreen::run(tft); AboutScreen::run(tft);
} else if (webuiBtn.contains(tap.x, tap.y)) { } else if (webuiBtn.contains(tap.x, tap.y)) {
@@ -490,8 +573,8 @@ void run(TFT_eSPI& tft) {
ok ? TFT_GREEN : TFT_RED); ok ? TFT_GREEN : TFT_RED);
} }
} else if (resetBtn.contains(tap.x, tap.y)) { } else if (resetBtn.contains(tap.x, tap.y)) {
if (confirmWarningScreen(tft, StringId::MENU_LOGBOOK_WARNING_TITLE, if (confirmWarningScreen(tft, I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE),
StringId::MENU_FACTORY_RESET_WARNING_BODY)) { I18n::t(StringId::MENU_FACTORY_RESET_WARNING_BODY))) {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM); tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextColor(TFT_RED, TFT_BLACK);
@@ -591,8 +674,8 @@ void run(TFT_eSPI& tft) {
SettingsStore::setFlightLogbookEnabled(false); SettingsStore::setFlightLogbookEnabled(false);
SettingsStore::setFlightLogbookEnabledAtEpoch(0); SettingsStore::setFlightLogbookEnabledAtEpoch(0);
SettingsStore::setFlightLogbookSessionFile(""); SettingsStore::setFlightLogbookSessionFile("");
} else if (confirmWarningScreen(tft, StringId::MENU_LOGBOOK_WARNING_TITLE, } else if (confirmWarningScreen(tft, I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE),
StringId::MENU_LOGBOOK_WARNING_BODY)) { I18n::t(StringId::MENU_LOGBOOK_WARNING_BODY))) {
SettingsStore::setFlightLogbookEnabled(true); SettingsStore::setFlightLogbookEnabled(true);
SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)time(nullptr)); SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)time(nullptr));
// Leerer Eintrag erzwingt eine frische Sitzungsdatei beim // 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));
}