From 5e2dff375ed550443d2162911c1ae6b1cec78259 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sat, 12 Sep 2026 10:58:09 +0200 Subject: [PATCH] fix: abgewiesener fw_update verdirbt last_ota_error des laufenden Updates nicht mehr Ein zweiter fw_update waehrend eines laufenden Downloads setzte s_error auf "busy". Da ota_task() nur bei leerem Fehlerfeld auf "download" zurueckfaellt, wurde ein spaeter gescheiterter Transfer danach als "busy" gemeldet statt als "download". Die Abweisung hat kein eigenes Ergebnis - sie wird der API mit 409 gemeldet, last_ota_error bleibt beim Ergebnis des laufenden bzw. letzten Updates. Dazu die Doku auf das am EVK gemessene Verhalten korrigiert: stirbt die Gegenstelle mitten im Download, haengt der Lesevorgang rund sieben Minuten in einem TCP-Timeout und bricht ohne Range-Resume ab. Co-Authored-By: Claude Opus 5 --- README.md | 14 ++++++++++---- docs/hardware-checklist.md | 6 +++--- src/net/nl_api.c | 6 +++--- src/sys/nl_ota.c | 7 ++++++- test/test_api.c | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 01c5070..239509a 100644 --- a/README.md +++ b/README.md @@ -94,19 +94,25 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409 (gleicher Wert wie `last_ota_error`). Läuft der Trailer- bzw. Hash-Check durch, resettet die Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein -Verbindungsabbruch mitten im Download löst dagegen einen Wiederaufnahmeversuch per HTTP-Range aus (begrenzte -Anzahl Runden); erst wenn auch das ausgeht, landet der Versuch bei `last_ota_error = download`. +Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen +sauber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt), aber +darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang +am EVK gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout — das `RECV_TIMEOUT` von 5 s des +HTTP-Clients greift dort nicht — und bricht danach ohne Wiederaufnahme ab. Die Lampe bleibt in dieser Zeit +über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab. Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist): | Wert | Bedeutung | |---|---| -| `download` | Verbindung/Transfer gescheitert (auch nach ausgeschöpftem Wiederaufnahmeversuch) | +| `download` | Verbindung/Transfer gescheitert | | `crc` | Prüfsumme des SDK-eigenen Update-Mechanismus stimmt nicht | | `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version | | `device_mismatch` | Trailer nennt einen anderen Gerätetyp | | `hash` | SHA256 im Trailer stimmt nicht mit dem übertragenen Image überein | -| `busy` | es läuft bereits ein Update | +| `busy` | die Firmware konnte den Download-Task nicht anlegen (Speichermangel) | + +Ein `fw_update`, der abgewiesen wird, weil bereits ein Update läuft, beantwortet die API mit `409` und lässt `last_ota_error` unangetastet — dort steht weiterhin das Ergebnis des laufenden bzw. letzten Updates. Ein erneuter `fw_update`-Aufruf mit einem gültigen Image funktioniert nach einer Ablehnung sofort, ohne manuellen Reboot. diff --git a/docs/hardware-checklist.md b/docs/hardware-checklist.md index 9cb8d56..b30f278 100644 --- a/docs/hardware-checklist.md +++ b/docs/hardware-checklist.md @@ -42,15 +42,15 @@ Konsole: `tools/monitor.sh /dev/cu.` (115200 8N1). Flashen: `tools/flash.s - [x] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version — 2026-09-12 (0.3.0 → 0.3.1 → 0.3.2) - [x] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter — 2026-09-12 (Ablehnung nach ~4,5 s, Uptime lief durch) - [x] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset — 2026-09-12 (`crc`, kein Reset) -- [ ] OTA: HTTP-Server während des Downloads abschalten → Wiederaufnahmeversuch (Range-Resume) sichtbar in der Konsole, nach Ausschöpfen der Runden `last_ota_error` = `download`, Lampe bootet normal +- [x] OTA: HTTP-Server während des Downloads abschalten → Lampe bleibt über REST erreichbar und schaltbar, weist weitere `fw_update` mit `409` ab, und beendet den Versuch mit `last_ota_error = download` — 2026-09-12 (dauerte ~7 min, kein Range-Resume; siehe README) - [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler - [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen -- [ ] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) +- [x] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) — 2026-09-12 (`scan done after 1500 ms, 7 AP(s)`, danach normaler WLAN-Start) - [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben - [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand) - [x] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version — 2026-09-12 (erst nach dem SHA256-Kontext-Fix) - [x] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200 — 2026-09-12 (system/info und device/state je 200) -- [ ] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war +- [x] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war — 2026-09-12 (pob=off + Licht an → blieb an; pob=on + Licht aus → blieb aus) - [x] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen) — 2026-09-12 - [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware - [ ] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht) diff --git a/src/net/nl_api.c b/src/net/nl_api.c index c53d325..e65869f 100644 --- a/src/net/nl_api.c +++ b/src/net/nl_api.c @@ -339,9 +339,9 @@ static void handle_command(const nl_api_ctx_t *ctx, const nl_http_request_t *req else if (ctx->fw_update(url) != 0) { /* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the * reason, not a blanket "not implemented". */ - nl_sys_info_t in; - ctx->get_info(&in); - reply_error(resp, 409, in.last_ota_error ? in.last_ota_error : "fw_update busy or invalid url"); + /* Do not reuse last_ota_error for this message: it belongs to the running or last + * update, not to the request we are rejecting right now. */ + reply_error(resp, 409, "an update is already running"); } else reply(resp, 202, "{\"status\":\"updating\"}"); } else if (strcmp(type, "reboot") == 0) { diff --git a/src/sys/nl_ota.c b/src/sys/nl_ota.c index 99360b0..3025587 100644 --- a/src/sys/nl_ota.c +++ b/src/sys/nl_ota.c @@ -115,7 +115,12 @@ static int ota_start(const char *url, bool reject_running_version) if (n <= 7 || n >= sizeof s_url) /* n == 7 is bare "http://" with no host */ return -1; if (s_busy) { - s_error = "busy"; + /* Do NOT touch s_error here. last_ota_error describes the outcome of the *running or last* + * update; a request we reject because one is already in flight has no outcome of its own. + * Writing "busy" into it used to poison the running attempt's slot: ota_task() only falls + * back to "download" when s_error is still NULL, so a failed transfer was afterwards + * reported as `busy` instead of `download`. Found on the EVK, 2026-09-12. The REST layer + * reports the rejection to its own caller with 409. */ return -1; } s_busy = true; diff --git a/test/test_api.c b/test/test_api.c index 3ff2981..090037d 100644 --- a/test/test_api.c +++ b/test/test_api.c @@ -324,7 +324,7 @@ static void test_commands(void) call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}"); TEST_ASSERT_EQUAL_INT(409, resp.status); - TEST_ASSERT_EQUAL_STRING("fw_update busy or invalid url", error_text()); + TEST_ASSERT_EQUAL_STRING("an update is already running", error_text()); TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url); fake_fw_result = 0; call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");