12 Commits
Author SHA1 Message Date
Michael SauerandClaude Opus 5 62a2b43175 docs: Migration zweier echter Lampen und den SM2135EH-Standby-Fund festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:37:08 +02:00
Michael SauerandClaude Opus 5 f32216d17f fix: SM2135EH beim Ausschalten in den Standby legen
Bei allen Kanaelen auf null schrieb der Treiber nur Nullen ins
Kanal-Register, liess den Chip aber aktiv - er laesst dann genug
Reststrom durch, dass die Lampe schwach weiterglimmt. An einer
migrierten GU10 RGB gemessen. Jetzt wird Bit 5 (Standby) gesetzt.

Die alte QConnex-Firmware hatte diese Logik, aber auskommentiert
(qcl_lamp.c) - das Verhalten ist von dort geerbt. Lampen an direkten
PWM-Pins (G95, CW-Varianten) sind nicht betroffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:32:31 +02:00
Michael SauerandClaude Sonnet 5 9768b25d10 docs: passwortloses sudo als Voraussetzung der Rettungsstation dokumentieren
CLI und beide Skripte (setup_pi.sh, deploy.sh) rufen sudo nicht-interaktiv
auf; ohne passwortloses sudo fuer den SSH-Benutzer bricht der gesamte
Ablauf mit einem Passwort-Prompt ab, den nichts davon behandeln kann. Das
war bisher eine unausgesprochene Voraussetzung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:30:03 +02:00
Michael SauerandClaude Sonnet 5 1d7d087cff fix: Rettungsstation loggt auch abgelehnte Methoden und Protokollfehler
do_POST (und PUT/DELETE/HEAD) riefen reject() auf, ohne eine Logzeile zu
schreiben, und das ueberschriebene log_message() legte damit unbeabsichtigt
auch log_error() lahm (BaseHTTPRequestHandler ruft es standardmaessig auf,
z.B. bei einer kaputten Request-Zeile oder einem Timeout, bevor ueberhaupt
ein Handler wie do_GET erreicht wird). Fuer das einzige Diagnosewerkzeug im
Rettungsfall darf keine Anfrage stumm bleiben.

do_POST loggt jetzt wie do_GET explizit, und log_error() ist separat
ueberschrieben statt implizit mit log_message() stillgelegt zu werden.

Die neuen Tests fuer den 405-Fall lesen den rohen Datei-Deskriptor des
Server-Prozesses statt ueber TextIOWrapper.readline() zu gehen - Letzteres
kann mehr Zeilen aus der Pipe in den eigenen Puffer lesen, als es
zurueckgibt, was select() auf dem naechsten Datei-Deskriptor faelschlich
"nichts da" melden liess. Der Test fuer die kaputte Request-Zeile ruft
log_error() direkt auf: ein echter kaputter Rohsocket wird in dieser
Sandbox von einer Netzwerkschicht abgefangen, bevor er den Server erreicht
(sichtbar an einem untergeschobenen "Server: aiohttp"-Header).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:56 +02:00
Michael SauerandClaude Sonnet 5 435884039b feat: ninlight-station warnt beim Einschalten vor dem oeffentlichen Default-Passwort
Die Spec (§9) verlangt beide Betriebsregeln in der Ausgabe von "on": die
Erinnerung ans Ausschalten stand schon da, die zum eigenen Passwort fehlte.
Bleibt PSK auf dem oeffentlichen Default 'ninlight-recovery' stehen, kann
jeder in Funkreichweite, der ihn kennt, jeder Lampe beliebige Firmware
unterschieben - das wird jetzt bei jedem Einschalten sichtbar gemacht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:43 +02:00
Michael SauerandClaude Sonnet 5 2dfb1fd1c2 feat: GET system/settings zeigt Herkunft der WLAN-Zugangsdaten
Die Antwort gab bisher nur die gespeicherte SSID aus. Wurde eine Lampe nie
per REST onboardet, ist die leer, und die Lampe laeuft in Wirklichkeit auf
den einkompilierten NINLIGHT_WIFI_SSID/-PASSWORD (siehe app_main.c) -
GET system/settings suggerierte damit "kein WLAN konfiguriert", obwohl die
Lampe zuverlaessig verbunden ist, und widersprach scheinbar system/info.

Additiv, ohne die Bedeutung von "ssid" zu aendern: wifi.source ist jetzt
"stored" oder "compiled". Spec (§7.2) und bruno/ninlight.yml dokumentieren
das Feld, ein neuer Hosttest deckt den compiled-Fall ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:34 +02:00
Michael SauerandClaude Sonnet 5 d464bb167e fix: Voll-Deploy der Rettungsstation gegen doppelte Images und stillen Loeschverlust absichern
Zwei Build-Verzeichnisse mit demselben Image-Dateinamen (z.B. build/evk und
build/evk-onb, letzteres ohne WLAN-Zugangsdaten) landeten flach im selben
Staging-Namen - die Sortierreihenfolge entschied zufaellig, welches gewinnt,
und die Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp
steht. deploy.sh bricht jetzt ab und nennt beide Fundorte, statt zu raten.

Der Voll-Deploy (deploy.sh <host> ohne target - der in README.md gezeigte
Normalfall) loeschte per rsync --delete zudem die Images aller gerade nicht
gebauten Geraetetypen, ohne je zu sagen, welche. Vor dem Loeschen wird jetzt
per Namensvergleich ermittelt und ausgegeben, welche Dateien verschwinden -
sichtbar statt still, ohne den Ablauf fuer Skripte zu blockieren.

Neue Testszenarien 7-9 in tools/station/tests/test_deploy.sh decken beide
Faelle ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:25 +02:00
Michael SauerandClaude Opus 5 6daf36540c docs: Rettungsfall und Abschlusspruefung der Station belegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:05:11 +02:00
Michael SauerandClaude Opus 5 b169fae89d docs: Stillstandstest und Wiederaufnahme am EVK belegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:54:15 +02:00
Michael SauerandClaude Sonnet 5 3d69412f5a docs: Wiederaufnahme-Verhalten bei Firmware-Updates differenzieren
Der Abschnitt beschrieb pauschal ein rund siebenminütiges TCP-Timeout ohne
Wiederaufnahme. Heute gemessen gilt das nur, wenn die Gegenstelle komplett
stirbt (Prozess weg). Bleibt sie erreichbar und liefert nur nichts mehr,
greift das 5-s-RECV_TIMEOUT der Firmware, und die Lampe setzt per Range
sauber fort – am EVK fünfmal hintereinander bis das Image vollständig war.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:40:55 +02:00
Michael SauerandClaude Sonnet 5 3f27ef4b26 fix: --stall-after Budget der Rettungsstation client-kumulativ zählen
Der Testschalter zählte bisher pro Anfrage, sodass eine Lampe nach ihrem
Empfangs-Timeout einfach per Range neu verband und ein frisches Budget bekam
– am EVK reichten fünf Reconnects, um trotz --stall-after das komplette
Image zu übertragen. Station verfolgt die ausgelieferten Bytes jetzt pro
Client-IP über alle Anfragen hinweg (Station.reserve_stall_bytes, unter dem
bestehenden Lock), sodass kein erneutes Verbinden das Budget auffüllt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:40:55 +02:00
Michael SauerandClaude Opus 5 2f25714248 docs: Abnahme der Pi-Station am EVK festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:32:22 +02:00
12 changed files with 392 additions and 35 deletions
+13 -5
View File
@@ -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):
+5
View File
@@ -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
+27 -4
View File
@@ -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)
+10 -1
View File
@@ -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};
+7
View File
@@ -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);
} }
+15
View File
@@ -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);
+6
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
+56 -23
View File
@@ -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,31 +208,31 @@ 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
# 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
if self.station.stall_after is not None: if self.station.stall_after is not None:
# Don't overshoot the stall boundary within a single read: without # Reserve against this client's cumulative budget, not just this
# this a whole CHUNK could be sent before the check above ever # request's byte count - see Station.reserve_stall_bytes.
# notices we've passed --stall-after. read_size = self.station.reserve_stall_bytes(client_ip, read_size)
read_size = min(read_size, max(1, self.station.stall_after - sent)) 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) chunk = f.read(read_size)
if not chunk: if not chunk:
break break
+81
View File
@@ -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."
+132
View File
@@ -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.