Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a2b43175 | ||
|
|
f32216d17f | ||
|
|
9768b25d10 | ||
|
|
1d7d087cff | ||
|
|
435884039b | ||
|
|
2dfb1fd1c2 | ||
|
|
d464bb167e | ||
|
|
6daf36540c | ||
|
|
b169fae89d | ||
|
|
3d69412f5a | ||
|
|
3f27ef4b26 | ||
|
|
2f25714248 |
@@ -95,11 +95,19 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
|
|||||||
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 endet bei `last_ota_error = download`. Die Firmware kann einen
|
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
|
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
|
||||||
darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang
|
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
|
||||||
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
|
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
|
||||||
über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab.
|
`RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
|
||||||
|
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
|
||||||
|
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
|
||||||
|
diesen Fall darf man sich also verlassen.
|
||||||
|
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
|
||||||
|
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
|
||||||
|
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
|
||||||
|
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
|
||||||
|
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
|
||||||
|
|
||||||
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,11 @@ items:
|
|||||||
timeout: 0
|
timeout: 0
|
||||||
followRedirects: true
|
followRedirects: true
|
||||||
maxRedirects: 5
|
maxRedirects: 5
|
||||||
|
docs: |-
|
||||||
|
`wifi.source` ist `"stored"`, solange ein per PUT onboardetes WLAN gespeichert ist,
|
||||||
|
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
|
||||||
|
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
|
||||||
|
eine verbundene SSID zeigen.
|
||||||
- info:
|
- info:
|
||||||
name: Set WiFi
|
name: Set WiFi
|
||||||
type: http
|
type: http
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
|
|||||||
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.
|
||||||
|
|
||||||
## Später (Lampen)
|
## Später (Lampen)
|
||||||
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
|
- [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
|
||||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
|
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
|
||||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: 2500 K deutlich warm, 5000 K deutlich kühler, Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
||||||
- [ ] Farbtemperaturbereiche aller Varianten
|
- [ ] Farbtemperaturbereiche aller Varianten — *g95_cw (2500–5000 K) und gu10_rgb (2700–6500 K) am 2026-09-13 an echten Lampen bestätigt; die übrigen sechs Varianten stehen aus*
|
||||||
|
|
||||||
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||||
|
|
||||||
@@ -73,3 +73,26 @@ unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läuf
|
|||||||
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
|
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.
|
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.
|
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
|
||||||
|
|
||||||
|
## Recovery gegen die installierte Pi-Station (2026-09-13)
|
||||||
|
|
||||||
|
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
|
||||||
|
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
|
||||||
|
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
|
||||||
|
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
|
||||||
|
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
|
||||||
|
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
|
||||||
|
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
|
||||||
|
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
|
||||||
|
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
|
||||||
|
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
|
||||||
|
|
||||||
|
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
|
||||||
|
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
|
||||||
|
sieht pro Boot einen vollen Transfer.
|
||||||
|
|
||||||
|
## Migration echter Lampen (Plan 4b, 2026-09-13)
|
||||||
|
|
||||||
|
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
|
||||||
|
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
|
||||||
|
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#define SM_ADDR_CURRENT 0xC0u
|
#define SM_ADDR_CURRENT 0xC0u
|
||||||
#define SM_ADDR_WY 0xC5u
|
#define SM_ADDR_WY 0xC5u
|
||||||
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
|
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
|
||||||
|
#define SM_CHANNEL_STBY 0x20u /* standby bit 5: powers the outputs down */
|
||||||
|
|
||||||
extern void delay_us(unsigned int us); /* provided by libwlan.a */
|
extern void delay_us(unsigned int us); /* provided by libwlan.a */
|
||||||
|
|
||||||
@@ -76,7 +77,15 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
|
|||||||
s_have_last = true;
|
s_have_last = true;
|
||||||
|
|
||||||
if (!white) {
|
if (!white) {
|
||||||
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, 0x00, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
/* All channels dark: put the driver into standby instead of just writing zero duty.
|
||||||
|
Without bit 5 the chip stays active and leaks enough current to keep the lamp
|
||||||
|
faintly glowing - measured on a migrated GU10 RGB, 2026-09-13. The old QConnex
|
||||||
|
firmware had this logic but left it commented out (qcl_lamp.c), which is why the
|
||||||
|
behaviour was inherited. Lamps driven straight from PWM pins (G95, the CW variants)
|
||||||
|
are unaffected. Turning back on writes 0x00 or SM_CHANNEL_WY here and clears it. */
|
||||||
|
bool dark = !ch[NL_CH_R] && !ch[NL_CH_G] && !ch[NL_CH_B];
|
||||||
|
uint8_t chan = dark ? SM_CHANNEL_STBY : 0x00u;
|
||||||
|
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||||
sm_send(d, 6);
|
sm_send(d, 6);
|
||||||
} else {
|
} else {
|
||||||
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY};
|
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY};
|
||||||
|
|||||||
@@ -262,6 +262,13 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
|
|||||||
cJSON_AddStringToObject(o, "name", s.name);
|
cJSON_AddStringToObject(o, "name", s.name);
|
||||||
w = cJSON_AddObjectToObject(o, "wifi");
|
w = cJSON_AddObjectToObject(o, "wifi");
|
||||||
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
|
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
|
||||||
|
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
|
||||||
|
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
|
||||||
|
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
|
||||||
|
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
|
||||||
|
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
|
||||||
|
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
|
||||||
|
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
|
||||||
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ static void test_settings_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
|
||||||
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||||
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
||||||
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
||||||
fake_open = true;
|
fake_open = true;
|
||||||
@@ -240,6 +241,19 @@ static void test_settings_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* F1: nie onboardet -> gespeicherte ssid ist leer, die Lampe laeuft auf den
|
||||||
|
* einkompilierten Zugangsdaten (siehe app_main.c). "source" muss das additiv
|
||||||
|
* sichtbar machen, ohne "ssid" umzudeuten - sonst widerspricht die Antwort
|
||||||
|
* scheinbar system/info, das die tatsaechlich verbundene SSID zeigt. */
|
||||||
|
static void test_settings_wifi_source_reflects_compiled_fallback(void)
|
||||||
|
{
|
||||||
|
fake_settings.wifi_ssid[0] = '\0';
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("compiled", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||||
|
}
|
||||||
|
|
||||||
static void test_settings_wifi(void)
|
static void test_settings_wifi(void)
|
||||||
{
|
{
|
||||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
|
||||||
@@ -356,6 +370,7 @@ int main(void)
|
|||||||
RUN_TEST(test_info_is_open);
|
RUN_TEST(test_info_is_open);
|
||||||
RUN_TEST(test_info_with_sta_ip_and_error);
|
RUN_TEST(test_info_with_sta_ip_and_error);
|
||||||
RUN_TEST(test_settings_auth);
|
RUN_TEST(test_settings_auth);
|
||||||
|
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
||||||
RUN_TEST(test_settings_wifi);
|
RUN_TEST(test_settings_wifi);
|
||||||
RUN_TEST(test_settings_name_token_auth);
|
RUN_TEST(test_settings_name_token_auth);
|
||||||
RUN_TEST(test_commands);
|
RUN_TEST(test_commands);
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ Lötkolben käme.
|
|||||||
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
||||||
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
||||||
|
|
||||||
|
## Voraussetzung
|
||||||
|
|
||||||
|
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
|
||||||
|
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
|
||||||
|
würde dort nur mit einer Fehlermeldung enden.
|
||||||
|
|
||||||
## Bedienung
|
## Bedienung
|
||||||
|
|
||||||
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
||||||
|
|||||||
+33
-2
@@ -27,7 +27,23 @@ if [ $# -gt 0 ]; then
|
|||||||
done
|
done
|
||||||
else
|
else
|
||||||
FULL_SYNC=1
|
FULL_SYNC=1
|
||||||
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
||||||
|
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
||||||
|
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
||||||
|
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
||||||
|
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
||||||
|
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
||||||
|
# kein Zufall.
|
||||||
|
declare -A SEEN_AT=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
||||||
|
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEEN_AT[$base]=$f
|
||||||
|
FILES+=("$f")
|
||||||
|
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||||
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -73,7 +89,22 @@ UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
|||||||
sudo install -d -m 0755 "$ROOT_DIR/images"
|
sudo install -d -m 0755 "$ROOT_DIR/images"
|
||||||
if [ "$FULL_SYNC" -eq 1 ]; then
|
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||||
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
||||||
echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt"
|
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
||||||
|
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
||||||
|
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
||||||
|
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
||||||
|
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
||||||
|
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
||||||
|
REMOVED=""
|
||||||
|
if [ -n "$OLD_LIST" ]; then
|
||||||
|
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
||||||
|
fi
|
||||||
|
if [ -n "$REMOVED" ]; then
|
||||||
|
echo " voller Abgleich - folgende Images werden entfernt:"
|
||||||
|
echo "$REMOVED" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " voller Abgleich - keine Images werden entfernt"
|
||||||
|
fi
|
||||||
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
else
|
else
|
||||||
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ on)
|
|||||||
sleep 1
|
sleep 1
|
||||||
if systemctl is-active --quiet ninlight-station; then
|
if systemctl is-active --quiet ninlight-station; then
|
||||||
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
|
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
|
||||||
|
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
|
||||||
|
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
|
||||||
|
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
|
||||||
|
# ausschalten) stand hier schon.
|
||||||
|
if [ "$PSK" = "ninlight-recovery" ]; then
|
||||||
|
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
|
||||||
|
fi
|
||||||
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
||||||
else
|
else
|
||||||
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
|
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
|
||||||
|
|||||||
+48
-15
@@ -61,16 +61,35 @@ class Station:
|
|||||||
self.since = now_iso()
|
self.since = now_iso()
|
||||||
self.served = 0
|
self.served = 0
|
||||||
self.recent: list[dict] = []
|
self.recent: list[dict] = []
|
||||||
|
# Cumulative bytes already delivered to each client while --stall-after is active,
|
||||||
|
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
|
||||||
|
# resumes with Range right where it left off, so a per-request counter would hand
|
||||||
|
# every reconnect a brand-new --stall-after allowance and the stall would never
|
||||||
|
# actually stick (see reserve_stall_bytes below).
|
||||||
|
self.stall_sent: dict[str, int] = {}
|
||||||
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
||||||
# can be downloading at once. Every mutation of served/recent and the snapshot
|
# can be downloading at once. Every mutation of served/recent/stall_sent and the
|
||||||
# that gets serialized to disk must happen under this same lock, or concurrent
|
# snapshot that gets serialized to disk must happen under this same lock, or
|
||||||
# requests lose increments and stomp on each other's status.json writes.
|
# concurrent requests lose increments and stomp on each other's status.json writes.
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.write_state()
|
self.write_state()
|
||||||
|
|
||||||
def image_path(self, target: str) -> Path:
|
def image_path(self, target: str) -> Path:
|
||||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
return self.image_dir / f"ninlight_{target}_ota.img"
|
||||||
|
|
||||||
|
def reserve_stall_bytes(self, ip: str, n: int) -> int:
|
||||||
|
"""How many of the next `n` bytes this client may still receive under --stall-after.
|
||||||
|
|
||||||
|
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
|
||||||
|
that the lamp abandons and resumes via Range does not get a fresh budget just
|
||||||
|
because it is a new TCP connection. Only called when stall_after is set.
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
sent_so_far = self.stall_sent.get(ip, 0)
|
||||||
|
allowed = max(0, min(n, self.stall_after - sent_so_far))
|
||||||
|
self.stall_sent[ip] = sent_so_far + allowed
|
||||||
|
return allowed
|
||||||
|
|
||||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if status == "ok":
|
if status == "ok":
|
||||||
@@ -119,12 +138,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def log_error(self, fmt: str, *args) -> None:
|
||||||
|
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
|
||||||
|
# silence above in favour of our own log() calls - but that also silences it for
|
||||||
|
# requests that never reach do_GET/do_POST (a malformed request line, a client
|
||||||
|
# timeout mid-request). The station is the only diagnostic tool available during a
|
||||||
|
# rescue, so those cases must still produce a line instead of nothing.
|
||||||
|
try:
|
||||||
|
client = self.client_address[0]
|
||||||
|
except (AttributeError, IndexError):
|
||||||
|
client = "?"
|
||||||
|
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
|
||||||
|
|
||||||
def log(self, status: int, nbytes: int, started: float) -> None:
|
def log(self, status: int, nbytes: int, started: float) -> None:
|
||||||
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||||
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
self.reject(405, "method not allowed")
|
self.reject(405, "method not allowed")
|
||||||
|
self.log(405, 0, started)
|
||||||
|
|
||||||
do_PUT = do_DELETE = do_HEAD = do_POST
|
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||||
|
|
||||||
@@ -175,16 +208,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
sent = 0
|
sent = 0
|
||||||
status = "ok"
|
status = "ok"
|
||||||
remaining = size - start
|
remaining = size - start
|
||||||
|
client_ip = self.client_address[0]
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
f.seek(start)
|
f.seek(start)
|
||||||
while sent < remaining:
|
while sent < remaining:
|
||||||
if self.station.stall_after is not None and sent >= self.station.stall_after:
|
read_size = min(CHUNK, remaining - sent)
|
||||||
# test aid: hold the connection open and send nothing more, so the
|
if self.station.stall_after is not None:
|
||||||
# firmware's stall handling can be exercised on real hardware. We
|
# Reserve against this client's cumulative budget, not just this
|
||||||
# still notice if the peer gives up and closes its side, so a test
|
# request's byte count - see Station.reserve_stall_bytes.
|
||||||
# (or an impatient client) that hangs up is recorded as "aborted"
|
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
|
||||||
# instead of leaking the thread forever.
|
if read_size == 0:
|
||||||
|
# test aid: hold the connection open and send nothing more, so
|
||||||
|
# the firmware's stall handling can be exercised on real
|
||||||
|
# hardware. We still notice if the peer gives up and closes its
|
||||||
|
# side, so a test (or an impatient client) that hangs up is
|
||||||
|
# recorded as "aborted" instead of leaking the thread forever.
|
||||||
while True:
|
while True:
|
||||||
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||||
if readable:
|
if readable:
|
||||||
@@ -194,12 +233,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
peek = b""
|
peek = b""
|
||||||
if not peek:
|
if not peek:
|
||||||
raise ConnectionResetError("peer closed during stall")
|
raise ConnectionResetError("peer closed during stall")
|
||||||
read_size = CHUNK
|
|
||||||
if self.station.stall_after is not None:
|
|
||||||
# Don't overshoot the stall boundary within a single read: without
|
|
||||||
# this a whole CHUNK could be sent before the check above ever
|
|
||||||
# notices we've passed --stall-after.
|
|
||||||
read_size = min(read_size, max(1, self.station.stall_after - sent))
|
|
||||||
chunk = f.read(read_size)
|
chunk = f.read(read_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -275,6 +275,87 @@ else
|
|||||||
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
|
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 7: Voll-Deploy mit zwei Build-Verzeichnissen fuer denselben Dateinamen -> Abbruch (B1) ==="
|
||||||
|
SANDBOX="$TMPROOT/s7"; STUBBIN="$TMPROOT/bin7"; LOG="$TMPROOT/s7.log"; SANDBOX_STATE="$TMPROOT/s7-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/evk" "$SANDBOX/build/evk-onb"
|
||||||
|
echo "evk-mit-wlan" >"$SANDBOX/build/evk/ninlight_evk_ota.img"
|
||||||
|
echo "evk-ohne-wlan" >"$SANDBOX/build/evk-onb/ninlight_evk_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0, obwohl zwei Fundorte denselben Dateinamen liefern"; fi
|
||||||
|
if echo "$OUT" | grep -q "ninlight_evk_ota.img mehrfach gefunden"; then
|
||||||
|
pass "Fehlermeldung nennt den doppelten Dateinamen"
|
||||||
|
else
|
||||||
|
fail "Fehlermeldung zum doppelten Dateinamen fehlt: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -q "build/evk/ninlight_evk_ota.img" && echo "$OUT" | grep -q "build/evk-onb/ninlight_evk_ota.img"; then
|
||||||
|
pass "Fehlermeldung nennt beide Fundorte"
|
||||||
|
else
|
||||||
|
fail "Fehlermeldung nennt nicht beide Fundorte: $OUT"
|
||||||
|
fi
|
||||||
|
if ! grep -q "STUB" "$LOG" 2>/dev/null; then
|
||||||
|
pass "kein Transfer-Kommando aufgerufen - nichts wurde stillschweigend uebertragen"
|
||||||
|
else
|
||||||
|
fail "ein Transfer-Kommando wurde trotz doppeltem Dateinamen aufgerufen: $(cat "$LOG")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 8: Voll-Deploy zeigt vor dem Loeschen, welche Images verschwinden (B2) ==="
|
||||||
|
SANDBOX="$TMPROOT/s8"; STUBBIN="$TMPROOT/bin8"; LOG="$TMPROOT/s8.log"; SANDBOX_STATE="$TMPROOT/s8-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/foo"
|
||||||
|
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
|
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
|
||||||
|
echo "veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img"
|
||||||
|
echo "auch-veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_e14_cw_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||||
|
if echo "$OUT" | grep -q "folgende Images werden entfernt"; then
|
||||||
|
pass "Ausgabe kuendigt die zu entfernenden Images an, bevor geloescht wird"
|
||||||
|
else
|
||||||
|
fail "Ausgabe nennt die zu entfernenden Images nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -q "ninlight_veraltet_ota.img" && echo "$OUT" | grep -q "ninlight_e14_cw_ota.img"; then
|
||||||
|
pass "beide zu entfernenden Dateien werden namentlich genannt"
|
||||||
|
else
|
||||||
|
fail "nicht alle zu entfernenden Dateien werden genannt: $OUT"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" ]; then
|
||||||
|
pass "veraltetes Image wurde nach der Ankuendigung tatsaechlich entfernt"
|
||||||
|
else
|
||||||
|
fail "veraltetes Image wurde trotz Ankuendigung nicht entfernt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 9: Voll-Deploy ohne verwaiste Images meldet das statt zu schweigen (B2) ==="
|
||||||
|
SANDBOX="$TMPROOT/s9"; STUBBIN="$TMPROOT/bin9"; LOG="$TMPROOT/s9.log"; SANDBOX_STATE="$TMPROOT/s9-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/foo"
|
||||||
|
echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||||
|
if echo "$OUT" | grep -q "keine Images werden entfernt"; then
|
||||||
|
pass "Ausgabe meldet explizit, dass nichts entfernt wird"
|
||||||
|
else
|
||||||
|
fail "Ausgabe meldet nicht, dass nichts entfernt wird: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
echo "Alle Tests bestanden."
|
echo "Alle Tests bestanden."
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import hashlib
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
import socket
|
import socket
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -92,6 +93,44 @@ class Station:
|
|||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
self.proc.wait(timeout=5)
|
self.proc.wait(timeout=5)
|
||||||
|
|
||||||
|
def wait_for_log_line(self, contains, timeout=3.0):
|
||||||
|
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
|
||||||
|
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
|
||||||
|
check that a request actually produced a log line (F3: every request must,
|
||||||
|
including rejected ones and errors the base handler catches before routing).
|
||||||
|
|
||||||
|
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
|
||||||
|
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
|
||||||
|
read() can slurp everything currently sitting in the pipe (banner *and* the log
|
||||||
|
line we're waiting for) into its own buffer on the very first call, while only
|
||||||
|
handing back the first line. select() then reports the fd as not-readable on the
|
||||||
|
next iteration - the data isn't gone, it is just buffered somewhere select() can't
|
||||||
|
see - and a fixed-size test timeout would flake on exactly the line it's checking
|
||||||
|
for.
|
||||||
|
"""
|
||||||
|
fd = self.proc.stdout.fileno()
|
||||||
|
buf = getattr(self, "_log_buf", "")
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
while "\n" in buf:
|
||||||
|
line, buf = buf.split("\n", 1)
|
||||||
|
self._log_buf = buf
|
||||||
|
if contains in line:
|
||||||
|
return line + "\n"
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
self._log_buf = buf
|
||||||
|
return None
|
||||||
|
r, _, _ = select.select([fd], [], [], remaining)
|
||||||
|
if not r:
|
||||||
|
self._log_buf = buf
|
||||||
|
return None
|
||||||
|
chunk = os.read(fd, 65536)
|
||||||
|
if not chunk:
|
||||||
|
self._log_buf = buf
|
||||||
|
return None
|
||||||
|
buf += chunk.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def station(tmp_path):
|
def station(tmp_path):
|
||||||
@@ -139,6 +178,43 @@ def test_other_methods_are_405(station):
|
|||||||
assert c.getresponse().status == 405
|
assert c.getresponse().status == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejected_method_produces_a_log_line(station):
|
||||||
|
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
|
||||||
|
the station is the only diagnostic tool during a rescue, and the spec requires a line
|
||||||
|
per request, rejections included."""
|
||||||
|
c = station.conn()
|
||||||
|
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
||||||
|
assert c.getresponse().status == 405
|
||||||
|
line = station.wait_for_log_line(" 405 ")
|
||||||
|
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
|
||||||
|
assert "/ninlight_evk_ota.img" in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_error_is_not_silenced_by_log_message(capsys):
|
||||||
|
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
|
||||||
|
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
|
||||||
|
server.py silences log_message() in favour of its own per-request log() calls - which
|
||||||
|
used to silence log_error() right along with it, since it wasn't overridden separately.
|
||||||
|
This exercises Handler.log_error() directly rather than over a raw socket: this
|
||||||
|
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
|
||||||
|
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
|
||||||
|
genuine malformed-request-line test unreliable here."""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
class _Stub:
|
||||||
|
client_address = ("203.0.113.5", 54321)
|
||||||
|
|
||||||
|
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "ERROR" in out
|
||||||
|
assert "203.0.113.5" in out
|
||||||
|
assert "code 400, message Bad request version" in out
|
||||||
|
|
||||||
|
|
||||||
def test_range_request_returns_206_and_the_right_slice(station):
|
def test_range_request_returns_206_and_the_right_slice(station):
|
||||||
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
@@ -278,6 +354,62 @@ def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
|||||||
s.stop()
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
|
||||||
|
"""--stall-after must stop a client for good, not just for one request.
|
||||||
|
|
||||||
|
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
|
||||||
|
on the connection, and opens a new one with a Range header picking up right where it
|
||||||
|
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
|
||||||
|
exercises for a single request. A per-request byte counter would hand that resumed
|
||||||
|
request a brand-new --stall-after allowance, so the "stall" would only ever delay
|
||||||
|
delivery instead of actually stopping it - which is what was observed on the EVK: the
|
||||||
|
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
|
||||||
|
away with the complete image. This test reproduces that reconnect and checks that the
|
||||||
|
second, resumed request gets no further bytes at all.
|
||||||
|
"""
|
||||||
|
s = Station(tmp_path, "--stall-after", "1000")
|
||||||
|
try:
|
||||||
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||||
|
|
||||||
|
# First request: burns through the whole --stall-after budget, then the server
|
||||||
|
# holds the connection open and sends nothing further.
|
||||||
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
|
got = b""
|
||||||
|
sock.settimeout(2)
|
||||||
|
try:
|
||||||
|
while len(got) < 200000:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
got += chunk
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
assert len(got) < 20000 # far less than the full image, same bound as above
|
||||||
|
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
|
||||||
|
|
||||||
|
# Second request, resuming with Range exactly like a real lamp would. This
|
||||||
|
# client's cumulative budget is already spent, so it must get zero more bytes of
|
||||||
|
# body - not a fresh --stall-after allowance just because it's a new connection.
|
||||||
|
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
|
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
|
||||||
|
sock2.settimeout(2)
|
||||||
|
header = b""
|
||||||
|
while b"\r\n\r\n" not in header:
|
||||||
|
header += sock2.recv(4096)
|
||||||
|
assert b" 206 " in header.split(b"\r\n", 1)[0]
|
||||||
|
body_after_header = header.split(b"\r\n\r\n", 1)[1]
|
||||||
|
assert body_after_header == b"", "resumed request must not receive any body bytes"
|
||||||
|
try:
|
||||||
|
more = sock2.recv(1)
|
||||||
|
except socket.timeout:
|
||||||
|
more = None
|
||||||
|
assert more in (None, b""), "server sent data after the client's stall budget was spent"
|
||||||
|
sock2.close()
|
||||||
|
finally:
|
||||||
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_downloads_are_all_recorded(station):
|
def test_concurrent_downloads_are_all_recorded(station):
|
||||||
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user