4 Commits
Author SHA1 Message Date
Michael SauerandClaude Opus 5 5e2dff375e 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 <noreply@anthropic.com>
2026-09-12 10:58:09 +02:00
Michael SauerandClaude Opus 5 6adf22fb52 docs: EVK-Ergebnisse der OTA-Runde eintragen
Sieben Punkte bestanden. Der zweite fw_update nach einer Ablehnung ging
erst nach dem SHA256-Kontext-Fix. Die einmalige lwIP-pbuf-Assertion beim
verfälschten Image ist als offene Beobachtung vermerkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:42:09 +02:00
Michael SauerandClaude Opus 5 d56f1d25d2 fix: SHA256-Kontext pro Image initialisieren statt einmal beim Task-Start
Der fwup-Task überlebt jede Update-Sitzung. Weil nl_sha256_init() am
Taskanfang stand, benutzte ab dem zweiten Update-Versuch pro Boot ein
bereits finalisierter Kontext weiter - der Trailer-Hash konnte dann gar
nicht mehr passen und jedes weitere Update scheiterte mit
last_ota_error=hash. Am EVK gefunden: nach einer Ablehnung war ein
Reboot nötig, bevor überhaupt wieder ein Update gelingen konnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:38:01 +02:00
Michael SauerandClaude Opus 5 6588085679 fix: flash.sh akzeptiert den Port auch mit /dev/-Praefix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:23:54 +02:00
8 changed files with 1084 additions and 1051 deletions
+10 -4
View File
@@ -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.
+19 -10
View File
@@ -39,19 +39,19 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
- [x] C2: Onboarding ok, 60 s warten, dann Reboot und Router-Neustart → AP `ninlight-<mac4>` darf nicht wiederkommen (Scan am Mac) — 2026-09-12 (Reboot geprüft, Router-Neustart offen)
## OTA und Recovery (Plan 3)
- [ ] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version
- [ ] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter
- [ ] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `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: 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)
- [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
- [ ] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten)
- [ ] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version
- [ ] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 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
- [ ] `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)
- [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)
- [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)
- [ ] OTA im AP-/Fallback-Modus (`mode=apsta`, kein Router erreichbar) und mit `auth_all = true` → Update läuft durch wie im normalen `sta`-Modus
@@ -64,3 +64,12 @@ Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
- [ ] Farbtemperaturbereiche aller Varianten
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
Beim Test mit dem verfälschten Image meldete lwIP einmalig
`Assertion "pbuf_free: p->ref > 0" failed at line 747 in pbuf.c` (10:29:23). Die Lampe lief
unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läufen mit fremdem
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
+7
View File
@@ -70,6 +70,13 @@ Lib/GNU, Bin/secboot.img, Tools/GNU/link_w600_2m.ld (→ ld/) und wm_tool.c (→
exportierte) `session_id`, wäre also ein größerer Eingriff für denselben Effekt. Nebenbei:
`nRetCode = ret` (ein lwIP-`err_t` in einer sonst `HTTP_CLIENT_*`-Codes tragenden Variable) durch
`HTTP_CLIENT_ERROR_BAD_STATE` ersetzt.
**Fix vom 2026-09-12 (am EVK gefunden):** `nl_sha256_init()` stand am Anfang von `fwup_scheduler()`,
also einmal beim Task-Start. Der Task überlebt aber jede Update-Sitzung, sodass ab dem zweiten Versuch
pro Boot ein bereits finalisierter Kontext weiterbenutzt wurde und der Trailer-Hash zwangsläufig nicht
mehr passte (`last_ota_error=hash`). Der Init sitzt jetzt dort, wo der `T_BOOTER`-Header eintrifft, also
genau einmal pro Image. Ohne diesen Fix war nach jeder Ablehnung ein Reboot nötig, bevor ein Update
überhaupt wieder gelingen konnte.
2. `Src/OS/RTOS/include/FreeRTOSConfig.h`: `configGENERATE_RUN_TIME_STATS 0`, QConnex-Debug-Hooks entfernt.
3. `undefined reference` beim Linken auf `EarlyInit` und `pre_gpio_config` (von
`sdk/Platform/Sys/wm_main.c` als `extern`-Hooks erwartet, aber nur von der App bereitgestellt):
+5 -1
View File
@@ -190,8 +190,11 @@ static void fwup_scheduler(void *data)
u32 tmplen = 0;
bool isacrossflash = FALSE;
/* ninlight: the context is (re)initialised per image, where the T_BOOTER header arrives -
* NOT here. This task outlives every update session, so initialising once at task start
* left a finalised context behind after the first attempt and every later update in the
* same boot failed with last_ota_error=hash. Found on the EVK, 2026-09-12. */
nl_sha256_ctx sha;
nl_sha256_init(&sha);
u8 image_sha256[32] = {0};
u8 *RSA_SHA256 = NULL;
@@ -264,6 +267,7 @@ static void fwup_scheduler(void *data)
fwup->current_state |= TLS_FWUP_STATE_ERROR_IO;
goto request_finish;
}
nl_sha256_init(&sha); /* fresh context for this image */
nl_sha256_update(&sha, (u8*)&booter, sizeof(T_BOOTER));
if ((IMG_TYPE_OLD_PLAIN == booter.img_type ) ||(IMG_TYPE_NEW_PLAIN == booter.img_type))
+3 -3
View File
@@ -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) {
+6 -1
View File
@@ -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;
+1 -1
View File
@@ -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\"}}");
+2
View File
@@ -6,6 +6,8 @@ set -euo pipefail
DEV=${1:?usage: tools/flash.sh <device> <port> [full]}
PORT=${2:?usage: tools/flash.sh <device> <port> [full]}
MODE=${3:-app}
# wm_tool prepends /dev/ itself - accept either spelling.
PORT=${PORT#/dev/}
ROOT=$(cd "$(dirname "$0")/.." && pwd)
TOOL="$ROOT/build/host-tools/wm_tool"
if [ ! -x "$TOOL" ] || [ "$ROOT/sdk/tools/wm_tool.c" -nt "$TOOL" ]; then