Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a5fe2ca9 | ||
|
|
c5d76efb25 | ||
|
|
5e2dff375e | ||
|
|
6adf22fb52 |
@@ -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
|
(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
|
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
|
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
|
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
|
||||||
Anzahl Runden); erst wenn auch das ausgeht, landet der Versuch bei `last_ota_error = download`.
|
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):
|
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||||
|
|
||||||
| Wert | Bedeutung |
|
| 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 |
|
| `crc` | Prüfsumme des SDK-eigenen Update-Mechanismus stimmt nicht |
|
||||||
| `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version |
|
| `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version |
|
||||||
| `device_mismatch` | Trailer nennt einen anderen Gerätetyp |
|
| `device_mismatch` | Trailer nennt einen anderen Gerätetyp |
|
||||||
| `hash` | SHA256 im Trailer stimmt nicht mit dem übertragenen Image überein |
|
| `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
|
Ein erneuter `fw_update`-Aufruf mit einem gültigen Image funktioniert nach einer Ablehnung sofort, ohne
|
||||||
manuellen Reboot.
|
manuellen Reboot.
|
||||||
|
|||||||
+21
-12
@@ -39,22 +39,22 @@ 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)
|
- [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 und Recovery (Plan 3)
|
||||||
- [ ] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version
|
- [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)
|
||||||
- [ ] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter
|
- [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)
|
||||||
- [ ] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset
|
- [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
|
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler — *offen, WLAN am Teststandort nicht abschaltbar*
|
||||||
- [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen
|
- [ ] 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
|
- [ ] 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)
|
- [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand)
|
||||||
- [ ] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version
|
- [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)
|
||||||
- [ ] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200
|
- [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)
|
||||||
- [ ] `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] `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
|
- [ ] 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)
|
- [ ] 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
|
- [ ] OTA im **AP-/Fallback-Modus** (`mode=apsta`, kein Router erreichbar) → Update läuft durch wie im normalen `sta`-Modus — *offen, braucht einen abschaltbaren Access Point*. Mit `auth_all = true` am 2026-09-12 geprüft: Update läuft durch, `device/*` antwortet ohne Token korrekt mit 401 (`system/info` bleibt laut Spec §7.2 immer offen)
|
||||||
|
|
||||||
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
||||||
|
|
||||||
@@ -64,3 +64,12 @@ Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in
|
|||||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
||||||
- [ ] Farbtemperaturbereiche aller Varianten
|
- [ ] 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.
|
||||||
|
|||||||
+3
-3
@@ -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) {
|
else if (ctx->fw_update(url) != 0) {
|
||||||
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
|
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
|
||||||
* reason, not a blanket "not implemented". */
|
* reason, not a blanket "not implemented". */
|
||||||
nl_sys_info_t in;
|
/* Do not reuse last_ota_error for this message: it belongs to the running or last
|
||||||
ctx->get_info(&in);
|
* update, not to the request we are rejecting right now. */
|
||||||
reply_error(resp, 409, in.last_ota_error ? in.last_ota_error : "fw_update busy or invalid url");
|
reply_error(resp, 409, "an update is already running");
|
||||||
} else
|
} else
|
||||||
reply(resp, 202, "{\"status\":\"updating\"}");
|
reply(resp, 202, "{\"status\":\"updating\"}");
|
||||||
} else if (strcmp(type, "reboot") == 0) {
|
} else if (strcmp(type, "reboot") == 0) {
|
||||||
|
|||||||
@@ -162,8 +162,14 @@ void nl_recovery_try(void)
|
|||||||
tls_os_time_delay(HZ / 2);
|
tls_os_time_delay(HZ / 2);
|
||||||
waited += 500;
|
waited += 500;
|
||||||
}
|
}
|
||||||
if (feeding)
|
if (feeding) {
|
||||||
|
/* A skipped image (same version as the running firmware) is not a failure - saying so
|
||||||
|
would leave a healthy lamp looking like it had rejected a broken image. */
|
||||||
|
if (nl_ota_skipped())
|
||||||
|
printf("recovery: image already up to date, continuing normal boot\r\n");
|
||||||
|
else
|
||||||
printf("recovery: update failed (%s), continuing normal boot\r\n",
|
printf("recovery: update failed (%s), continuing normal boot\r\n",
|
||||||
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
|
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-3
@@ -23,6 +23,7 @@ static char s_url[NL_OTA_URL_MAX];
|
|||||||
static volatile bool s_busy;
|
static volatile bool s_busy;
|
||||||
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
|
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
|
||||||
static const char * volatile s_error;
|
static const char * volatile s_error;
|
||||||
|
static volatile bool s_skipped;
|
||||||
static u32 s_stack[NL_OTA_STACK];
|
static u32 s_stack[NL_OTA_STACK];
|
||||||
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
|
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ void nl_ota_init(void)
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool nl_ota_busy(void) { return s_busy; }
|
bool nl_ota_busy(void) { return s_busy; }
|
||||||
|
bool nl_ota_skipped(void) { return s_skipped; }
|
||||||
const char *nl_ota_last_error(void) { return s_error; }
|
const char *nl_ota_last_error(void) { return s_error; }
|
||||||
void nl_ota_set_error(const char *e) { s_error = e; }
|
void nl_ota_set_error(const char *e) { s_error = e; }
|
||||||
|
|
||||||
@@ -61,7 +63,10 @@ bool nl_fwup_verify_tail(const uint8_t sha256[32], const uint8_t *tail, uint32_t
|
|||||||
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
|
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
|
||||||
printf("ota: recovery image already carries the running version %s, not flashing\r\n",
|
printf("ota: recovery image already carries the running version %s, not flashing\r\n",
|
||||||
s_tail_version);
|
s_tail_version);
|
||||||
s_error = "trailer";
|
/* Deliberately skipped, not failed: leave last_ota_error alone. Reporting "trailer"
|
||||||
|
* here used to make a perfectly healthy lamp look like it had rejected a malformed
|
||||||
|
* image, which is exactly the wrong thing to read off a lamp in the field. */
|
||||||
|
s_skipped = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
|
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
|
||||||
@@ -90,7 +95,7 @@ static void ota_task(void *arg)
|
|||||||
log_url(s_url);
|
log_url(s_url);
|
||||||
rc = t_http_fwup(s_url);
|
rc = t_http_fwup(s_url);
|
||||||
/* Only reached on failure - the SDK resets the chip itself after a good image. */
|
/* Only reached on failure - the SDK resets the chip itself after a good image. */
|
||||||
if (s_error == NULL)
|
if (s_error == NULL && !s_skipped)
|
||||||
s_error = "download";
|
s_error = "download";
|
||||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||||
{
|
{
|
||||||
@@ -115,11 +120,17 @@ 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 */
|
if (n <= 7 || n >= sizeof s_url) /* n == 7 is bare "http://" with no host */
|
||||||
return -1;
|
return -1;
|
||||||
if (s_busy) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
s_busy = true;
|
s_busy = true;
|
||||||
s_error = NULL;
|
s_error = NULL;
|
||||||
|
s_skipped = false;
|
||||||
s_reject_running_version = reject_running_version;
|
s_reject_running_version = reject_running_version;
|
||||||
memcpy(s_url, url, n + 1);
|
memcpy(s_url, url, n + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ int nl_ota_start(const char *url);
|
|||||||
* forever. A deliberate re-flash of the same version stays possible over the REST API. */
|
* forever. A deliberate re-flash of the same version stays possible over the REST API. */
|
||||||
int nl_ota_start_recovery(const char *url);
|
int nl_ota_start_recovery(const char *url);
|
||||||
bool nl_ota_busy(void);
|
bool nl_ota_busy(void);
|
||||||
|
/* True when the last recovery attempt deliberately skipped an image whose version already
|
||||||
|
matches the running firmware - that is not a failure and sets no last_ota_error. */
|
||||||
|
bool nl_ota_skipped(void);
|
||||||
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
|
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
|
||||||
const char *nl_ota_last_error(void);
|
const char *nl_ota_last_error(void);
|
||||||
void nl_ota_set_error(const char *e);
|
void nl_ota_set_error(const char *e);
|
||||||
|
|||||||
+1
-1
@@ -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\"}}");
|
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_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);
|
TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url);
|
||||||
fake_fw_result = 0;
|
fake_fw_result = 0;
|
||||||
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
||||||
|
|||||||
Reference in New Issue
Block a user