Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a2b43175 | ||
|
|
f32216d17f | ||
|
|
9768b25d10 | ||
|
|
1d7d087cff | ||
|
|
435884039b | ||
|
|
2dfb1fd1c2 | ||
|
|
d464bb167e | ||
|
|
6daf36540c | ||
|
|
b169fae89d | ||
|
|
3d69412f5a | ||
|
|
3f27ef4b26 | ||
|
|
2f25714248 | ||
|
|
908fda8240 | ||
|
|
62903a7f05 |
@@ -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
|
||||
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
|
||||
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.
|
||||
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
|
||||
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
|
||||
|
||||
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
|
||||
`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):
|
||||
|
||||
|
||||
@@ -225,6 +225,11 @@ items:
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
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:
|
||||
name: Set WiFi
|
||||
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.
|
||||
|
||||
## Später (Lampen)
|
||||
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
|
||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
|
||||
- [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ß) — *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
|
||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
||||
- [ ] Farbtemperaturbereiche aller Varianten
|
||||
- [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 — *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
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
## 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_WY 0xC5u
|
||||
#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 */
|
||||
|
||||
@@ -76,7 +77,15 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
|
||||
s_have_last = true;
|
||||
|
||||
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);
|
||||
} else {
|
||||
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);
|
||||
w = cJSON_AddObjectToObject(o, "wifi");
|
||||
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);
|
||||
reply_json(resp, 200, o);
|
||||
}
|
||||
|
||||
+13
-4
@@ -94,10 +94,19 @@ static void ota_task(void *arg)
|
||||
(void)arg;
|
||||
log_url(s_url);
|
||||
rc = t_http_fwup(s_url);
|
||||
/* Only reached on failure - the SDK resets the chip itself after a good image. */
|
||||
if (s_error == NULL && !s_skipped)
|
||||
s_error = "download";
|
||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||
/* Only reached on failure or a deliberate skip - the SDK resets the chip itself after a good
|
||||
* image. rc is not a reliable success/failure signal here: the SDK aborts the transfer as
|
||||
* soon as the trailer hook returns false, which happens both for a real rejection and for the
|
||||
* deliberate "recovery image already up to date" skip. s_error/s_skipped are what actually
|
||||
* distinguish the two, so branch on those instead of folding everything into one printf that
|
||||
* would risk passing a NULL s_error to %s. */
|
||||
if (s_skipped) {
|
||||
printf("ota: skipped (rc=%d, image already up to date)\r\n", rc);
|
||||
} else {
|
||||
if (s_error == NULL)
|
||||
s_error = "download";
|
||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||
}
|
||||
{
|
||||
nl_settings_t *s;
|
||||
nl_app_lock();
|
||||
|
||||
@@ -233,6 +233,7 @@ static void test_settings_auth(void)
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
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("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
||||
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
||||
fake_open = true;
|
||||
@@ -240,6 +241,19 @@ static void test_settings_auth(void)
|
||||
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)
|
||||
{
|
||||
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_with_sta_ip_and_error);
|
||||
RUN_TEST(test_settings_auth);
|
||||
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
||||
RUN_TEST(test_settings_wifi);
|
||||
RUN_TEST(test_settings_name_token_auth);
|
||||
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
|
||||
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
|
||||
|
||||
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
||||
|
||||
+33
-2
@@ -27,7 +27,23 @@ if [ $# -gt 0 ]; then
|
||||
done
|
||||
else
|
||||
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; }
|
||||
fi
|
||||
|
||||
@@ -73,7 +89,22 @@ UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||
sudo install -d -m 0755 "$ROOT_DIR/images"
|
||||
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||
# 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/"
|
||||
else
|
||||
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||
|
||||
@@ -66,6 +66,13 @@ on)
|
||||
sleep 1
|
||||
if systemctl is-active --quiet ninlight-station; then
|
||||
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."
|
||||
else
|
||||
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
|
||||
@@ -113,7 +120,19 @@ PY
|
||||
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
|
||||
# ausgeliefert wurde - ohne das "|| true" waere der Exit-Code des Skripts hier der des
|
||||
# fehlgeschlagenen Tests (1), weil das die letzte Anweisung im Zweig ist.
|
||||
[ -r /run/ninlight-station/status.json ] && { echo "Zuletzt ausgeliefert:"; sed 's/^/ /' /run/ninlight-station/status.json; }
|
||||
#
|
||||
# "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
|
||||
# sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
|
||||
# "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
|
||||
# server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
|
||||
# "noch nie ausgeliefert".
|
||||
STATUS_FILE=/run/ninlight-station/status.json
|
||||
if [ -r "$STATUS_FILE" ]; then
|
||||
echo "Zuletzt ausgeliefert:"
|
||||
sed 's/^/ /' "$STATUS_FILE"
|
||||
elif [ -e "$STATUS_FILE" ]; then
|
||||
echo "Fehler: $STATUS_FILE existiert, ist aber nicht lesbar (Rechte pruefen)" >&2
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
||||
+75
-26
@@ -10,6 +10,7 @@ Standard library only - the Pi gets no extra packages.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -60,16 +61,35 @@ class Station:
|
||||
self.since = now_iso()
|
||||
self.served = 0
|
||||
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
|
||||
# can be downloading at once. Every mutation of served/recent and the snapshot
|
||||
# that gets serialized to disk must happen under this same lock, or concurrent
|
||||
# requests lose increments and stomp on each other's status.json writes.
|
||||
# can be downloading at once. Every mutation of served/recent/stall_sent and the
|
||||
# snapshot that gets serialized to disk must happen under this same lock, or
|
||||
# concurrent requests lose increments and stomp on each other's status.json writes.
|
||||
self.lock = threading.RLock()
|
||||
self.write_state()
|
||||
|
||||
def image_path(self, target: str) -> Path:
|
||||
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:
|
||||
with self.lock:
|
||||
if status == "ok":
|
||||
@@ -85,9 +105,24 @@ class Station:
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
os.replace(tmp, self.state_path)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
# The service runs as root and mkstemp() creates the file 0600, so
|
||||
# without this an operator reading status.json as a normal user (see
|
||||
# tools/station/ninlight-station) gets "permission denied" - and since
|
||||
# os.replace() preserves the mode it arrives with, that wrong mode
|
||||
# would otherwise survive onto the live file on every single write,
|
||||
# not just the first. os.chmod() ignores the process umask (unlike the
|
||||
# mode argument to mkstemp/open), so a restrictive umask on the Pi
|
||||
# can't quietly bring the 0600 back. Set it before the rename so
|
||||
# there's never a window where the path holds a 0600 file.
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, self.state_path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
except OSError as e:
|
||||
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||
|
||||
@@ -103,12 +138,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||
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:
|
||||
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
started = time.monotonic()
|
||||
self.reject(405, "method not allowed")
|
||||
self.log(405, 0, started)
|
||||
|
||||
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||
|
||||
@@ -159,31 +208,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
sent = 0
|
||||
status = "ok"
|
||||
remaining = size - start
|
||||
client_ip = self.client_address[0]
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
f.seek(start)
|
||||
while sent < remaining:
|
||||
if self.station.stall_after is not None and sent >= self.station.stall_after:
|
||||
# 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:
|
||||
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||
except OSError:
|
||||
peek = b""
|
||||
if not peek:
|
||||
raise ConnectionResetError("peer closed during stall")
|
||||
read_size = CHUNK
|
||||
read_size = min(CHUNK, remaining - sent)
|
||||
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))
|
||||
# Reserve against this client's cumulative budget, not just this
|
||||
# request's byte count - see Station.reserve_stall_bytes.
|
||||
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
|
||||
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:
|
||||
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||
except OSError:
|
||||
peek = b""
|
||||
if not peek:
|
||||
raise ConnectionResetError("peer closed during stall")
|
||||
chunk = f.read(read_size)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
@@ -275,6 +275,87 @@ else
|
||||
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
|
||||
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
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "Alle Tests bestanden."
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -90,6 +93,44 @@ class Station:
|
||||
self.proc.terminate()
|
||||
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
|
||||
def station(tmp_path):
|
||||
@@ -137,6 +178,43 @@ def test_other_methods_are_405(station):
|
||||
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):
|
||||
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||
c = station.conn()
|
||||
@@ -173,6 +251,43 @@ def test_state_file_records_a_missing_image(station):
|
||||
assert state["recent"][-1]["target"] == "g95_cw"
|
||||
|
||||
|
||||
def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
||||
"""status.json is written by a root service but read by a normal-user CLI.
|
||||
|
||||
tempfile.mkstemp() creates its file 0600 and os.replace() preserves whatever mode
|
||||
the temp file had, so the server must explicitly widen the mode before the rename -
|
||||
and must do so with chmod() rather than relying on the mode passed to mkstemp/open,
|
||||
because that one is still narrowed by the process umask. A restrictive umask (as
|
||||
root services on the Pi may well run under) must not bring the 0600 back.
|
||||
"""
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
s = Station(tmp_path)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
try:
|
||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
||||
|
||||
def world_readable() -> bool:
|
||||
mode = stat.S_IMODE(s.state.stat().st_mode)
|
||||
return bool(mode & stat.S_IROTH)
|
||||
|
||||
# Written once already at startup (Station.__init__ -> write_state()).
|
||||
s.read_state(lambda _: world_readable())
|
||||
assert world_readable(), "status.json is not world-readable after the initial write"
|
||||
|
||||
# A second write (a real delivery) must not lose the mode either - e.g. if the
|
||||
# widening were only applied to the file's very first creation instead of to
|
||||
# every temp file, mode 0600 would silently come back on every later write.
|
||||
c = s.conn()
|
||||
c.request("GET", "/ninlight_evk_ota.img")
|
||||
c.getresponse().read()
|
||||
s.read_state(lambda st: st["served"] >= 1)
|
||||
assert world_readable(), "status.json lost its mode on a later overwrite"
|
||||
finally:
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_recent_is_capped_at_eight(station):
|
||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
||||
for _ in range(12):
|
||||
@@ -239,6 +354,62 @@ def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||
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):
|
||||
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user