Fix OTA download (follow GitHub redirect), rework result screens, drop asset rename

OTA downloads always failed because GitHub's browser_download_url
returns a 301/302 redirect to a signed objects.githubusercontent.com
URL, which HTTPUpdate doesn't follow by default. Now sets
HTTPC_STRICT_FOLLOW_REDIRECTS before downloading.

Update results (success and failure) are now shown as a persistent
info screen requiring an explicit tap to dismiss, instead of a
1.2-1.5s auto-vanishing message followed by an automatic restart -
a firmware update result is important enough that it shouldn't be
missable. Also fixes long dynamic titles (e.g. "Update available:
v3.5.0") and the installing-progress label overflowing the screen
edge in some languages.

Release workflow no longer renames the build artifact to
CYD-flightradar.bin before upload; the release asset is just
firmware.bin now, matching what OtaUpdate::checkForUpdate() looks
for. CLAUDE.md's release workflow updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-13 02:13:24 +02:00
parent 3dd308b39a
commit 0bf8518ac5
11 changed files with 215 additions and 86 deletions

View File

@@ -334,6 +334,16 @@ enum class StringId : uint8_t {
OTA_UPDATE_FAILED,
OTA_UPDATE_SUCCESS,
// Erfolgs-/Fehler-Ergebnis nach Download+Flash wird jetzt als
// dauerhafter Info-Screen mit explizitem Button angezeigt (statt
// automatischem Neustart bzw. kurzer, automatisch verschwindender
// Meldung) - der Nutzer muss bei einer sicherheitsrelevanten Aktion wie
// einem Firmware-Update IMMER aktiv informiert werden und selbst
// bestaetigen, siehe menu_screen.cpp::infoScreen().
OTA_SUCCESS_BODY,
OTA_RESTART_BUTTON,
OTA_FAILED_BODY,
COUNT
};

View File

@@ -246,10 +246,13 @@ static const char* const I18N_DE[] = {
"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.",
"Ein neues Firmware-Update wird heruntergeladen und installiert. Bitte WLAN-Verbindung und Stromversorgung während des Vorgangs nicht unterbrechen. Nach der Installation wirst du zum Neustart aufgefordert.",
"Installiere Update ",
"Update fehlgeschlagen.",
"Installiert! Neustart...",
"Update fehlgeschlagen",
"Update installiert",
"Die neue Firmware wurde erfolgreich installiert. Bitte jetzt neu starten, um sie zu verwenden.",
"Jetzt neu starten",
"Das Update konnte nicht heruntergeladen oder installiert werden. Das Gerät läuft unverändert mit der bisherigen Firmware weiter. Bitte WLAN-Verbindung prüfen und es später erneut versuchen.",
};
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -247,10 +247,13 @@ static const char* const I18N_EN[] = {
"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.",
"A new firmware update will be downloaded and installed. Please don't interrupt the WiFi connection or power supply during this process. You'll be asked to restart once installation is complete.",
"Installing update ",
"Update failed.",
"Installed! Restarting...",
"Update failed",
"Update installed",
"The new firmware was installed successfully. Please restart now to start using it.",
"Restart now",
"The update could not be downloaded or installed. The device is still running the previous firmware, unchanged. Please check your WiFi connection and try again later.",
};
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -246,10 +246,13 @@ static const char* const I18N_ES[] = {
"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.",
"Se descargará e instalará una nueva actualización de firmware. No interrumpas la conexión WiFi ni la alimentación durante este proceso. Se te pedirá reiniciar una vez completada la instalación.",
"Instalando actualización ",
"Error al actualizar.",
"¡Instalado! Reiniciando...",
"Error al actualizar",
"Actualización instalada",
"El nuevo firmware se instaló correctamente. Reinicia ahora para empezar a usarlo.",
"Reiniciar ahora",
"No se pudo descargar o instalar la actualización. El dispositivo sigue funcionando sin cambios con el firmware anterior. Comprueba tu conexión WiFi e inténtalo de nuevo más tarde.",
};
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -246,10 +246,13 @@ static const char* const I18N_FR[] = {
"É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.",
"Une nouvelle mise à jour du micrologiciel va être téléchargée et installée. Merci de ne pas interrompre la connexion WiFi ni l'alimentation pendant ce processus. Un redémarrage vous sera demandé une fois l'installation terminée.",
"Installation de la mise à jour ",
"Échec de la mise à jour.",
"Installé ! Redémarrage...",
"Échec de la mise à jour",
"Mise à jour installée",
"Le nouveau micrologiciel a été installé avec succès. Veuillez redémarrer maintenant pour l'utiliser.",
"Redémarrer maintenant",
"La mise à jour n'a pas pu être téléchargée ou installée. L'appareil continue de fonctionner normalement avec le micrologiciel précédent. Merci de vérifier votre connexion WiFi et de réessayer plus tard.",
};
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -246,10 +246,13 @@ static const char* const I18N_IT[] = {
"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.",
"Un nuovo aggiornamento del firmware verrà scaricato e installato. Non interrompere la connessione WiFi o l'alimentazione durante questo processo. Al termine dell'installazione ti verrà chiesto di riavviare.",
"Installazione aggiornamento ",
"Aggiornamento fallito.",
"Installato! Riavvio...",
"Aggiornamento fallito",
"Aggiornamento installato",
"Il nuovo firmware è stato installato correttamente. Riavvia ora per iniziare a usarlo.",
"Riavvia ora",
"Non è stato possibile scaricare o installare l'aggiornamento. Il dispositivo continua a funzionare senza modifiche con il firmware precedente. Controlla la connessione WiFi e riprova più tardi.",
};
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -246,10 +246,13 @@ static const char* const I18N_TR[] = {
"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.",
"Yeni bir donanım yazılımı güncellemesi indirilip yüklenecek. Lütfen bu işlem sırasında WiFi bağlantısını veya güç kaynağını kesmeyin. Kurulum tamamlandığında yeniden başlatmanız istenecek.",
"Güncelleme yükleniyor ",
"Güncelleme başarısız.",
"Yüklendi! Yeniden başlatılıyor...",
"Güncelleme başarısız",
"Güncelleme yüklendi",
"Yeni donanım yazılımı başarıyla yüklendi. Kullanmaya başlamak için lütfen şimdi yeniden başlatın.",
"Şimdi yeniden başlat",
"Güncelleme indirilemedi veya yüklenemedi. Cihaz değişmeden önceki donanım yazılımıyla çalışmaya devam ediyor. Lütfen WiFi bağlantınızı kontrol edip daha sonra tekrar deneyin.",
};
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -274,7 +274,17 @@ namespace {
tft.setTextDatum(MC_DATUM);
tft.setTextColor(accentColor, TFT_BLACK);
// Statische Titel ("Achtung!!!") sind kurz genug fuer Groesse 2,
// aber der OTA-Aufrufer baut den Titel dynamisch mit
// Versionsnummer zusammen (z.B. "Update verfuegbar: v3.5.0") -
// das passt bei Groesse 2 nicht mehr in die Box und lief vorher
// links/rechts ueber den Bildschirmrand hinaus. Deshalb hier
// die Breite bei Groesse 2 pruefen und bei Bedarf auf Groesse 1
// zurueckfallen, statt eine feste Groesse anzunehmen.
tft.setTextSize(2);
if (tft.textWidth(title) > (BOX_W - 20)) {
tft.setTextSize(1);
}
tft.drawString(title, BOX_X + BOX_W / 2, TITLE_Y);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
@@ -312,6 +322,96 @@ namespace {
}
}
// Einfacher Info-Screen mit nur EINEM Button (kein Abbrechen) - fuer
// Endzustaende, bei denen es nichts mehr zu entscheiden gibt, nur zu
// bestaetigen (z.B. Ergebnis eines OTA-Updates). Anders als
// showBriefMessage() (kurze Meldung unten am Bildschirmrand,
// verschwindet nach 1,2s automatisch von selbst) bleibt dieser Screen
// stehen, bis aktiv bestaetigt wird - wichtig bei sicherheitsrelevanten
// Meldungen wie einem fehlgeschlagenen oder erfolgreichen Firmware-
// Update, die der Nutzer auf keinen Fall verpassen darf. Gleicher
// Kasten-/Scroll-Aufbau wie confirmWarningScreen(), nur mit einem
// einzigen, ueber die volle Breite gehenden Button statt OK/Zurueck.
void infoScreen(TFT_eSPI& tft, const String& title, const String& body, uint16_t accentColor, const String& buttonLabel) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
constexpr int16_t BOX_W = Config::SCREEN_WIDTH - 2 * BOX_X;
constexpr int16_t BOX_H = Config::SCREEN_HEIGHT - 2 * BOX_Y;
constexpr int16_t TEXT_MAX_WIDTH = BOX_W - 20;
constexpr int16_t LINE_H = 16;
constexpr int16_t TITLE_Y = BOX_Y + 16;
constexpr int16_t VIEW_TOP = TITLE_Y + 12 + LINE_H;
constexpr int16_t BTN_H = 40;
constexpr int16_t BOTTOM_MARGIN = 10;
constexpr int16_t BTN_Y = BOX_Y + BOX_H - BOTTOM_MARGIN - BTN_H;
constexpr int16_t SCROLL_ROW_H = 28;
constexpr int16_t SCROLL_ROW_GAP = 8;
constexpr int16_t VIEW_BOTTOM_NO_SCROLL = BTN_Y - 8;
constexpr int16_t VIEW_BOTTOM_SCROLL = VIEW_BOTTOM_NO_SCROLL - SCROLL_ROW_H - SCROLL_ROW_GAP;
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;
int16_t viewBottom = scrollable ? VIEW_BOTTOM_SCROLL : VIEW_BOTTOM_NO_SCROLL;
int16_t maxScroll = totalH - viewBottom;
if (maxScroll < 0) maxScroll = 0;
int16_t scrollY = 0;
constexpr int16_t SCROLL_STEP = 48;
Rect okBtn = {(int16_t)(BOX_X + 10), BTN_Y, (int16_t)(BOX_W - 20), BTN_H};
int16_t scrollRowY = VIEW_BOTTOM_SCROLL + SCROLL_ROW_GAP;
Rect upBtn = {(int16_t)(BOX_X + BOX_W / 2 - 64), scrollRowY, 60, SCROLL_ROW_H};
Rect downBtn = {(int16_t)(BOX_X + BOX_W / 2 + 4), scrollRowY, 60, SCROLL_ROW_H};
MenuStars::reset();
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, accentColor);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(accentColor, TFT_BLACK);
tft.setTextSize(2);
if (tft.textWidth(title) > (BOX_W - 20)) {
tft.setTextSize(1);
}
tft.drawString(title, BOX_X + BOX_W / 2, TITLE_Y);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, scrollY, VIEW_TOP, viewBottom, true);
drawButton(tft, okBtn, buttonLabel);
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (okBtn.contains(tap.x, tap.y)) return;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();
} else if (scrollable && downBtn.contains(tap.x, tap.y) && scrollY < maxScroll) {
scrollY += SCROLL_STEP;
if (scrollY > maxScroll) scrollY = maxScroll;
redraw();
}
}
MenuStars::update(tft);
delay(20);
}
}
// Fortschrittsanzeige waehrend OtaUpdate::performUpdate() laeuft -
// gleiches Namespace-globale-Zeiger-Prinzip wie progressTft oben (siehe
// Settings-Backup-Fortschrittspunkte), da OtaUpdate::performUpdate()
@@ -322,12 +422,25 @@ namespace {
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);
// Zwei Zeilen statt einer langen: der Praefix-Text
// (OTA_INSTALLING_PREFIX) ist in manchen Sprachen zu lang, um
// zusammen mit der Prozentzahl auf einer Zeile bei lesbarer
// Schriftgroesse zu passen (lief vorher links/rechts ueber den
// Bildschirmrand hinaus). Jetzt: Beschriftung klein oben, Prozent
// gross darunter.
constexpr int16_t BAND_H = 60;
int16_t cy = Config::SCREEN_HEIGHT / 2;
t.fillRect(0, (int16_t)(cy - BAND_H / 2), Config::SCREEN_WIDTH, BAND_H, 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.drawString(I18n::t(StringId::OTA_INSTALLING_PREFIX), Config::SCREEN_WIDTH / 2, (int16_t)(cy - 14));
String percentLabel = String(percent) + "%";
t.setTextSize(3);
t.drawString(percentLabel, Config::SCREEN_WIDTH / 2, (int16_t)(cy + 12));
t.setTextSize(1);
t.setTextDatum(TL_DATUM);
}
@@ -338,7 +451,12 @@ namespace {
// 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.
// Download+Flash. WICHTIG: startet NICHT mehr automatisch neu und
// springt bei einem Fehler auch nicht einfach stillschweigend zurueck
// ins Menue - jedes Ergebnis (Erfolg wie Fehler) wird ueber infoScreen()
// als eigener, stehenbleibender Screen angezeigt, den der Nutzer aktiv
// bestaetigen muss. Bei Erfolg startet erst ein expliziter Tap auf
// "Jetzt neu starten" tatsaechlich neu.
void runOtaUpdateScreen(TFT_eSPI& tft) {
MenuStars::reset();
tft.fillScreen(TFT_BLACK);
@@ -350,11 +468,12 @@ namespace {
OtaUpdate::CheckInfo info = OtaUpdate::checkForUpdate();
if (info.result == OtaUpdate::CheckResult::Error) {
showBriefMessage(tft, I18n::t(StringId::OTA_CHECK_FAILED), TFT_RED);
infoScreen(tft, I18n::t(StringId::OTA_CHECK_FAILED), "", TFT_RED, I18n::t(StringId::OK));
return;
}
if (info.result == OtaUpdate::CheckResult::UpToDate) {
showBriefMessage(tft, String(I18n::t(StringId::OTA_UP_TO_DATE_PREFIX)) + info.latestVersion, TFT_GREEN);
String upToDateTitle = String(I18n::t(StringId::OTA_UP_TO_DATE_PREFIX)) + info.latestVersion;
infoScreen(tft, upToDateTitle, "", TFT_GREEN, I18n::t(StringId::OK));
return;
}
@@ -369,15 +488,20 @@ namespace {
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);
// Bewusst KEIN automatischer Neustart mehr - der Nutzer
// bestaetigt aktiv per Button, damit er den Erfolg auch wirklich
// mitbekommt (vorher lief die Meldung nur 1,5s an, dann
// Neustart - leicht zu verpassen).
infoScreen(tft, I18n::t(StringId::OTA_UPDATE_SUCCESS), I18n::t(StringId::OTA_SUCCESS_BODY),
TFT_GREEN, I18n::t(StringId::OTA_RESTART_BUTTON));
ESP.restart();
} else {
showBriefMessage(tft, I18n::t(StringId::OTA_UPDATE_FAILED), TFT_RED);
// Bewusst ein stehenbleibender Info-Screen statt der alten
// showBriefMessage() (1,2s, dann automatisch zurueck ins Menue)
// - ein fehlgeschlagenes Firmware-Update ist keine
// Nebensaechlichkeit, die man verpassen darf.
infoScreen(tft, I18n::t(StringId::OTA_UPDATE_FAILED), I18n::t(StringId::OTA_FAILED_BODY),
TFT_RED, I18n::t(StringId::OK));
}
}

View File

@@ -68,21 +68,17 @@ CheckInfo checkForUpdate() {
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
sizeof(info.latestVersion) - 1);
// Der Release-Workflow (siehe CLAUDE.md im Repo) laedt die gebaute
// Firmware unter dem Namen "CYD-flightradar.bin" als Release-Asset
// hoch (umbenannt aus dem rohen "firmware.bin"-Build-Artefakt) - das
// ist auch der Dateiname, unter dem der Web-Flasher sie erwartet.
JsonArray assets = doc["assets"];
for (JsonObject asset : assets) {
const char* name = asset["name"] | "";
if (strcmp(name, "CYD-flightradar.bin") == 0) {
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 CYD-flightradar.bin-Anhang
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;
@@ -94,6 +90,16 @@ bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
client.setInsecure();
client.setTimeout(15000);
// WICHTIG: GitHubs "browser_download_url" fuer Release-Assets ist KEIN
// direkter Download-Link, sondern liefert erst ein HTTP 301/302-Redirect
// auf eine signierte objects.githubusercontent.com-URL. HTTPUpdate folgt
// Redirects standardmaessig NICHT (HTTPC_DISABLE_FOLLOW_REDIRECTS ist der
// Default) - ohne diese Zeile bricht der Download mit HTTP_UPDATE_FAILED
// ab, weil statt der .bin-Datei nur die Redirect-Antwort ankommt. Siehe
// z.B. espressif/arduino-esp32#3020. HTTPC_STRICT_FOLLOW_REDIRECTS
// reicht, da wir nur GET verwenden.
httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
// Wir zeigen nach erfolgreicher Installation selbst noch eine kurze
// Erfolgsmeldung an, bevor das Geraet neu startet - siehe
// menu_screen.cpp::runOtaUpdateScreen().

View File

@@ -18,20 +18,19 @@ namespace OtaUpdate {
// 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 CYD-flightradar.bin des Releases -
// nur gesetzt, wenn result == UpdateAvailable.
// 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
// "CYD-flightradar.bin". CheckResult::Error bei jedem Fehler unterwegs
// (kein WLAN, Zeitueberschreitung, unerwartetes JSON, kein
// CYD-flightradar.bin im Release).
// 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 CYD-flightradar.bin von 'url' herunter und flasht sie (ueber die
// 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