From 300edafc3e8f6997fe8694a5727c125595f58eea Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Tue, 15 Sep 2026 02:17:36 +0200 Subject: [PATCH] feat(station): Umschalter fuer Migrationsimages mit/ohne WLAN-Daten Die WLAN-Zugangsdaten stecken zur Bauzeit in der Firmware, es gibt also zwei Abzuege je Ziel. Die Station haelt jetzt beide vor - images/ migrate-nocreds (aus tools/build_nocreds.sh, Lampe startet im Onboarding-AP) und images/migrate-wlan (aus build/, Lampe haengt sofort im eigenen WLAN) - und der Bediener waehlt per Umschalter, welcher ausgeliefert wird: "ninlight-station bildsatz" oder der Menueeintrag am Taster. Der Zustand liegt in /var/lib/ninlight-station/imageset, ueberlebt Neustarts und steht dauerhaft in der Fusszeile des Panels - in jedem Modus, auch bei ausgeschalteter Station: man soll vor dem Einschalten sehen, was gilt. Vorgabe und Rueckfallwert in JEDEM Zweifelsfall ist "ohne-wlan". Im Automatikmodus wird jede Klyqa-Lampe in Reichweite migriert, auch fremde; ein Abzug ohne Zugangsdaten ist dann ein harmloser Fehlgriff, einer mit Zugangsdaten traegt das eigene WLAN-Passwort in ein fremdes Geraet. Fehlt der gewaehlte Satz (Verzeichnis weg oder leer), wird NICHTS ausgeliefert - kein stilles Ausweichen auf den anderen Satz. Der Server antwortet 404 mit Status "noimageset", CLI und Panel sagen "FEHLT". server.py und die Stations-Unit muessen mit: die Auswahl der ausgelieferten Datei ist genau die Aufgabe des Servers, und ein Symlink-Tausch haette "gewaehlt, aber nicht vorhanden" von "dieses Modell fehlt" nicht mehr unterscheidbar gemacht. --- tools/station/README.md | 35 +++++ tools/station/button/button.py | 48 +++++- tools/station/deploy.sh | 205 +++++++++++++++++-------- tools/station/display/display.py | 59 ++++++- tools/station/display/render.py | 95 +++++++++++- tools/station/ninlight-station | 152 +++++++++++++++++- tools/station/ninlight-station.service | 2 +- tools/station/server.py | 122 +++++++++++++-- tools/station/setup_pi.sh | 16 ++ tools/station/tests/test_button.py | 86 +++++++++-- tools/station/tests/test_cli.sh | 158 +++++++++++++++++-- tools/station/tests/test_deploy.sh | 204 +++++++++++++++++++----- tools/station/tests/test_render.py | 166 +++++++++++++++++++- tools/station/tests/test_server.py | 200 +++++++++++++++++++----- tools/station/tests/test_setup_pi.sh | 33 ++++ 15 files changed, 1381 insertions(+), 200 deletions(-) diff --git a/tools/station/README.md b/tools/station/README.md index 2651c25..b046325 100644 --- a/tools/station/README.md +++ b/tools/station/README.md @@ -23,8 +23,43 @@ würde dort nur mit einer Fehlermeldung enden. tools/station/deploy.sh [target] Images ausliefern ssh ninlight-station on einschalten ssh ninlight-station status Zustand und letzte Auslieferungen + ssh ninlight-station bildsatz welcher Migrations-Bildsatz gilt ssh ninlight-station off ausschalten +## Bildsatz: mit oder ohne WLAN-Zugangsdaten + +Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es +gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor: + +| Umschalterstellung | Verzeichnis auf dem Pi | gebaut mit | die migrierte Lampe … | +|---|---|---|---| +| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN | +| `mit-wlan` | `images/migrate-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` | + +Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster +(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration**: der +Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts und bleibt, bis +ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der Fußzeile des Panels** — +in jedem Modus, auch bei ausgeschalteter Station: man soll vor jedem Einschalten sehen, +was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu flashen, weil die +Einstellung von vorletzter Woche noch stand. + +**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt, +unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte +Klyqa-Lampe in Funkreichweite migriert, auch fremde. Mit dem Abzug ohne Zugangsdaten ist +ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und wartet. Mit +dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden +Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert. + +**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie gelaufen), +wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz. Der Server +antwortet mit 404 und `status: noimageset`, `ninlight-station status` sagt „FEHLT", und auf +dem Panel steht „SATZ FEHLT". „Vorhanden" heißt dabei überall dasselbe: Verzeichnis da +**und** mindestens ein Image darin. + +`deploy.sh` liefert beide Sätze aus. Fehlt `build-nocreds/`, bleibt der Satz `ohne-wlan` +auf dem Pi unangetastet (statt geleert zu werden) und der Lauf meldet, womit er entsteht. + ## Taster am Gerät Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**. diff --git a/tools/station/button/button.py b/tools/station/button/button.py index 3e89565..6c67957 100644 --- a/tools/station/button/button.py +++ b/tools/station/button/button.py @@ -97,6 +97,14 @@ RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station")) # Anzeigedienst nicht ueberdauern. UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json")) STATION_CLI = os.environ.get("NINLIGHT_STATION_BIN", "/usr/local/bin/ninlight-station") +# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" - dieselbe Datei, die +# das CLI schreibt, der Server liest und der Anzeigedienst auf dem Panel zeigt. Hier wird +# sie NUR GELESEN (fuer die Beschriftung des Menueeintrags); umgeschaltet wird wie jede +# andere Aktion ueber das CLI. +IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE", + "/var/lib/ninlight-station/imageset")) +IMAGESET_NAMES = ("ohne-wlan", "mit-wlan") +IMAGESET_DEFAULT = "ohne-wlan" STATION_SERVICE = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station") # Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige # Aktion dieses Dienstes, die an systemd selbst geht. @@ -178,6 +186,25 @@ def action_migrate_auto() -> Action: "in Reichweite - auch fremde"]) +def action_imageset(current: str) -> Action: + """Der Umschalter zwischen den beiden Migrations-Bildsaetzen. Der Eintrag nennt, WOHIN + umgeschaltet wird, nicht was gerade gilt: waehrend das Menue offen ist, ersetzt es das + Statusbild samt Fusszeile - "Bildsatz: mit WLAN" waere dann nicht zu entscheiden. + + Der Weg nach "mit-wlan" bekommt die deutlichere Rueckfrage, der Weg zurueck die + uebliche: nur die eine Richtung schreibt das WLAN-Passwort des Bedieners in jede + Lampe, die sie erwischt.""" + target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan" + if target == "mit-wlan": + return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"), + done_text="Bildsatz: mit WLAN", + confirm_title="ACHTUNG", + confirm_details=["Jede migrierte Lampe bekommt", + "dein WLAN-Passwort"]) + return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"), + done_text="Bildsatz: ohne WLAN") + + def action_cancel() -> Action: """Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss.""" @@ -190,6 +217,7 @@ def menu_actions(state: dict) -> list[Action]: running = bool(state.get("running")) mode = state.get("mode") target = state.get("target") + imageset = state.get("imageset") or IMAGESET_DEFAULT if not running: return [ @@ -199,6 +227,9 @@ def menu_actions(state: dict) -> list[Action]: # loslaesst, landet nicht im Automatikmodus. action_migrate_listen(), action_migrate_auto(), + # Der beste Zeitpunkt zum Umschalten ist VOR dem Einschalten - deshalb steht + # der Eintrag auch (und gerade) hier. + action_imageset(imageset), action_shutdown(), ] @@ -209,6 +240,7 @@ def menu_actions(state: dict) -> list[Action]: items.append(Action("Freigabe: {}".format(target), ("arm", str(target)), done_text="Freigegeben: {}".format(target))) items.append(Action("Station aus", ("off",), done_text="Station aus")) + items.append(action_imageset(imageset)) # Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage. items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False, done_text="Anzeige aufgefrischt")) @@ -268,7 +300,18 @@ def _service_active(service_name: str = STATION_SERVICE) -> bool: return result.returncode == 0 -def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE) -> dict: +def _read_imageset(path: Path = IMAGESET_FILE) -> str: + """Der eingestellte Bildsatz, oder IMAGESET_DEFAULT bei fehlender/unlesbarer Datei + bzw. unbekanntem Inhalt - dieselbe Vorgabe wie in server.py und im CLI.""" + try: + value = path.read_text().strip() + except OSError: + return IMAGESET_DEFAULT + return value if value in IMAGESET_NAMES else IMAGESET_DEFAULT + + +def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE, + imageset_path: Path = IMAGESET_FILE) -> dict: """Der Zustand, von dem die Menueeintraege abhaengen. Liest dieselben Quellen wie display.build_state() - RUN_DIR/mode (nicht status.json's "mode"-Feld, siehe dort) und status.json fuer das zuletzt erkannte Ziel - dupliziert aber keine Logik des @@ -284,7 +327,8 @@ def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE) last_seen = status.get("last_seen") target = last_seen.get("target") if isinstance(last_seen, dict) else None return {"running": running, "mode": mode, - "target": target if isinstance(target, str) and target else None} + "target": target if isinstance(target, str) and target else None, + "imageset": _read_imageset(imageset_path)} # --- Bedienzustand in eine Datei schreiben ------------------------------------------ diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh index 6de09fb..46f4148 100755 --- a/tools/station/deploy.sh +++ b/tools/station/deploy.sh @@ -85,64 +85,70 @@ NL_SIGN="$ROOT/tools/mkimage/nl_sign.py" # Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden, # die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1). MIGRATE_COMPLETE=0 +# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier +# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur +# einer, darf --delete auf dem Pi nicht laufen. +NOCREDS_COMPLETE=0 +# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh, +# dieselbe Variable wie dort und in tools/release.sh). +NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds} -if [ -d "$SIGN_DIR" ]; then - HAVE_PROD=0 - HAVE_DEV=0 - if [ -f "$KEY_PROD" ]; then - HAVE_PROD=1 - else - echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2 - fi - if [ -f "$KEY_DEV" ]; then - HAVE_DEV=1 - else - echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2 - fi - if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then - # Erst HIER, nachdem feststeht dass ueberhaupt signiert wird, STAGE/migrate anlegen - # und das fehlende _gz.img zum Abbruch machen (Fixrunde 1, C2+C3): ein - # Schluesselverzeichnis ohne jeden Schluessel darf weder ein leeres migrate/ - # erzeugen (das wuerde ein vorhandenes migrate/ auf dem Pi mit --delete leerraeumen) - # noch am Fehlen des _gz.img scheitern, wenn gar nicht signiert werden soll. - mkdir -p "$STAGE/migrate" - for f in "${FILES[@]}"; do - base=$(basename "$f") - target=${base#ninlight_}; target=${target%_ota.img} - # Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide - # belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein - # signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb - # annimmt. - gz="$ROOT/build/$target/ninlight_${target}_gz.img" - [ -f "$gz" ] || { - echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1; } - if [ "$HAVE_PROD" -eq 1 ]; then - python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/migrate/${target}_prod.img" \ - || { echo "Fehler: Signieren von $target (prod) fehlgeschlagen" >&2; exit 1; } +# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json. +# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut +# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds) +# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen. +# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen), +# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres +# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen. +sign_set() { + local src_root=$1 stage_sub=$2 strictness=$3 + local complete=1 produced=0 + local f base target gz + for f in "${FILES[@]}"; do + base=$(basename "$f") + target=${base#ninlight_}; target=${target%_ota.img} + # Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide + # belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein + # signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb + # annimmt. + gz="$src_root/$target/ninlight_${target}_gz.img" + if [ ! -f "$gz" ]; then + if [ "$strictness" = "strict" ]; then + echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1 fi - if [ "$HAVE_DEV" -eq 1 ]; then - python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/migrate/${target}_dev.img" \ - || { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; } - fi - done - if [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ]; then - MIGRATE_COMPLETE=1 + echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2 + complete=0 + continue fi - VERSION_ARGS=() - for t in "${!NINL_VERSION[@]}"; do - VERSION_ARGS+=("$t=${NINL_VERSION[$t]}") - done - # manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu - # berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit - # ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec - # (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich - # liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur) - # manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald - # der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue - # Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das - # manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur - # gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen. - python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY' + mkdir -p "$STAGE/$stage_sub" + if [ "$HAVE_PROD" -eq 1 ]; then + python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \ + || { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; } + produced=1 + fi + if [ "$HAVE_DEV" -eq 1 ]; then + python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \ + || { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; } + produced=1 + fi + done + [ "$produced" -eq 1 ] || return 1 + local VERSION_ARGS=() + local t + for t in "${!NINL_VERSION[@]}"; do + VERSION_ARGS+=("$t=${NINL_VERSION[$t]}") + done + # manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu + # berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit + # ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec + # (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich + # liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur) + # manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald + # der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue + # Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das + # manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur + # gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen. + python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY' import hashlib import json import os @@ -183,6 +189,51 @@ with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh: json.dump(manifest, fh, indent=2, sort_keys=True) fh.write("\n") PY + [ "$complete" -eq 1 ] +} + +HAVE_PROD=0 +HAVE_DEV=0 +if [ -d "$SIGN_DIR" ]; then + if [ -f "$KEY_PROD" ]; then + HAVE_PROD=1 + else + echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2 + fi + if [ -f "$KEY_DEV" ]; then + HAVE_DEV=1 + else + echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2 + fi + if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then + BOTH_KEYS=0 + [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1 + + # ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken + # und die Station beide vorhalten soll (der Bediener schaltet per + # "ninlight-station bildsatz" bzw. am Taster um): + # + # migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN + # migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP + # + # Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft + # wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus + # build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau + # (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch + # nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser + # Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen. + echo " Bildsatz migrate-wlan (aus build/)" + if sign_set "$ROOT/build" migrate-wlan strict; then + [ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1 + fi + echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)" + if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then + [ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1 + else + if [ ! -d "$STAGE/migrate-nocreds" ]; then + echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2 + fi + fi else echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2 fi @@ -216,10 +267,11 @@ rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/" # Pfade als Variablen mit Default: so kann ein Test die Installation gegen # ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. -"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" <<'REMOTE' +"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" <<'REMOTE' set -e FULL_SYNC=$1 MIGRATE_COMPLETE=$2 +NOCREDS_COMPLETE=$3 ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} @@ -249,26 +301,32 @@ if [ "$FULL_SYNC" -eq 1 ]; then # ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne # Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht # existiert. - sudo rsync -a -c --delete --exclude=/migrate /tmp/ninlight-images/ "$ROOT_DIR/images/" + sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/" else # Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle # anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete). echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten" - sudo rsync -a -c --exclude=/migrate /tmp/ninlight-images/ "$ROOT_DIR/images/" + sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/" fi +# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG +# beschreibt (beide Schluessel, alle Ziele) und --delete deshalb erlaubt ist. +# # Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf # dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel # vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden - # sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys # (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen # statt beim Ersetzen). -if [ -d /tmp/ninlight-images/migrate ]; then - sudo install -d -m 0755 "$ROOT_DIR/images/migrate" - if [ "$FULL_SYNC" -eq 1 ] && [ "$MIGRATE_COMPLETE" -eq 1 ]; then +sync_migrate_set() { +set=$1 +complete=$2 +if [ -d "/tmp/ninlight-images/$set" ]; then + sudo install -d -m 0755 "$ROOT_DIR/images/$set" + if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then # Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt # STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete # ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden. - sudo rsync -a -c --delete /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/" + sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/" else # Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel: # in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen @@ -278,9 +336,9 @@ if [ -d /tmp/ninlight-images/migrate ]; then # sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst # verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu # gebaut hat, obwohl ihre Images unveraendert liegen bleiben). - sudo rsync -a -c --exclude=/manifest.json /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/" - if [ -f /tmp/ninlight-images/migrate/manifest.json ]; then - sudo python3 - "$ROOT_DIR/images/migrate/manifest.json" /tmp/ninlight-images/migrate/manifest.json <<'MERGE' + sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/" + if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then + sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE' import json import sys @@ -340,7 +398,22 @@ else # sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von # diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau; # diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit). - echo " keine Migrationsimages von diesem Rechner - migrate/ auf dem Pi bleibt unangetastet" + echo " keine Migrationsimages fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet" +fi +} + +# Beide Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem Rechner +# vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der andere auf +# dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der fehlt, und die +# Station liefert in dieser Stellung sichtbar nichts aus. +sync_migrate_set migrate-wlan "$MIGRATE_COMPLETE" +sync_migrate_set migrate-nocreds "$NOCREDS_COMPLETE" +# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die +# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf +# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz. +if [ -d "$ROOT_DIR/images/migrate" ]; then + sudo rm -rf "$ROOT_DIR/images/migrate" + echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)" fi sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py" diff --git a/tools/station/display/display.py b/tools/station/display/display.py index a8765a1..fb1e531 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -58,6 +58,18 @@ SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station") # RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also # ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet. UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json")) +# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" (siehe +# ninlight-station, bildsatz_cmd, und server.py:read_image_set). NUR GELESEN, und +# absichtlich aus derselben Datei wie der Server - nicht aus status.json: der Zustand +# muss auch dann auf dem Panel stehen, wenn die Station AUS ist. Genau davor soll er +# schuetzen ("man soll vor jedem Einschalten sehen, was gilt"), und ausgeschaltet +# schreibt server.py nichts mehr. +IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE", + "/var/lib/ninlight-station/imageset")) +# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der +# Satz OHNE Zugangsdaten (dort steht die Begruendung). +IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"} +IMAGESET_DEFAULT = "ohne-wlan" POLL_INTERVAL_S = 5 # Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck @@ -197,8 +209,37 @@ def _service_active(service_name: str) -> bool: return True +def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool]: + """(Name des eingestellten Bildsatzes, liegt er ueberhaupt auf dem Pi?). + + Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt + IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das + Panel etwas anderes behaupten als der Server tut. + + `ok` ist False, wenn der GEWAEHLTE Satz nicht auf dem Pi liegt - nach derselben + Regel wie server.py:migrate_set_present(): Verzeichnis vorhanden UND mindestens ein + Image darin. Dann liefert server.py nichts aus (Status "noimageset"), und genau das + muss am Panel stehen, statt dass der Bediener vor einer Lampe steht, die nichts + bekommt. Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts + behauptet: dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine.""" + try: + value = imageset_path.read_text().strip() + except OSError: + value = "" + name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT + image_dir = conf.get("IMAGE_DIR") + if not image_dir: + return name, True + set_dir = Path(image_dir) / IMAGESET_DIRS[name] + try: + return name, any(p.name.endswith(".img") for p in set_dir.iterdir()) + except OSError: + return name, False + + def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, - service_name: str = SERVICE_NAME) -> dict: + service_name: str = SERVICE_NAME, + imageset_path: Path = IMAGESET_FILE) -> dict: """Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten. Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie @@ -211,14 +252,22 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt - alles, wenn der Hauptdienst gar nicht laeuft.""" + alles, wenn der Hauptdienst gar nicht laeuft. + + Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in + den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem + Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im + Menue auffaellt, hat genau den Zweck verfehlt.""" + conf = _read_conf(conf_path) + imageset, imageset_ok = _read_imageset(imageset_path, conf) + if not _service_active(service_name): - return {"mode": "off"} + return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok} mode = _read_mode_file(run_dir) status = _read_json(run_dir / "status.json") - conf = _read_conf(conf_path) - state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")} + state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"), + "imageset": imageset, "imageset_ok": imageset_ok} if mode == "recovery": state["ssid"] = conf.get("SSID") diff --git a/tools/station/display/render.py b/tools/station/display/render.py index fb7dc83..b123c07 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -64,17 +64,29 @@ TEXT_MARGIN_PX = 2 # big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"): # gross und mittig, damit man quer durch den Raum sieht, dass nichts # laeuft. +# footer - eine Zeile am UNTEREN Panelrand, ueber einen Trennstrich abgesetzt, +# unabhaengig vom Zeilenfluss darueber. Genau ein Block benutzt sie: der +# Bildsatz-Umschalter (mit oder ohne einkompilierte WLAN-Zugangsdaten). +# Er gehoert dorthin, weil er ein DAUERZUSTAND ist und nicht zum +# wechselnden Inhalt: er gilt in jedem Modus, auch bei ausgeschalteter +# Station, und man soll ihn vor dem Einschalten sehen - nicht erst, +# wenn zwanzig Lampen mit den eigenen Zugangsdaten geflasht sind. +# Waere er eine gewoehnliche Beiwerkzeile, fiele er im vollsten Zustand +# als letzte unten heraus, also ausgerechnet waehrend einer laufenden +# Migration. ROLE_HEADER = "header" ROLE_HEADER_PHASE = "header_phase" ROLE_HERO = "hero" ROLE_DETAIL = "detail" ROLE_BIG = "big" +ROLE_FOOTER = "footer" HEADER_FONT_PX = 20 HEADER_PHASE_FONT_PX = 13 HERO_FONT_PX = 16 DETAIL_FONT_PX = 11 BIG_FONT_PX = 26 +FOOTER_FONT_PX = 11 # Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe: # Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem @@ -94,6 +106,16 @@ DETAIL_LINE_HEIGHT_PX = 13 # bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten. BOTTOM_MARGIN_PX = 10 +# Das Band am unteren Rand fuer ROLE_FOOTER (Trennstrich + eine 11-px-Zeile). Es liegt +# UEBER BOTTOM_MARGIN_PX, der Rand bleibt also weiterhin tintenfrei; der Zeilenfluss +# darueber bricht entsprechend frueher ab. Das kostet im vollsten Zustand (Migration mit +# erkannter Lampe) eine Beiwerkzeile - die unterste, also die unwichtigste nach der +# Sortierung in blocks(). Der Preis ist gewollt: welcher Bildsatz gilt, ist die einzige +# Auskunft auf diesem Panel, die man BRAUCHT, bevor etwas passiert. +FOOTER_BAND_HEIGHT_PX = 14 +# Abstand des Trennstrichs zur Fusszeile. +FOOTER_RULE_GAP_PX = 2 + # Mindestabstand zwischen dem Ende des Modus-Texts und dem Anfang der Phase in der # Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten # beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: ") @@ -167,6 +189,17 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"} ARMED_LABEL = "FREIGEGEBEN" ARMED_DETAIL_PREFIX = "Freigabe: " +# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert +# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts, +# "Lampe: sofort ins WLAN" sagt ihm genau das, was er wissen muss, bevor er einschaltet. +# Der gefaehrliche Zustand steht in Grossbuchstaben - er ist der, den man uebersehen soll +# und darf. +IMAGESET_LABELS = { + "ohne-wlan": "Lampe -> Onboarding-AP (ohne WLAN-Daten)", + "mit-wlan": "Lampe -> SOFORT INS EIGENE WLAN", +} +IMAGESET_MISSING_SUFFIX = " - SATZ FEHLT" + # Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant): # "-dev" kann in der Produkt-ID stehen, in der Version, oder in beiden. Die Quelle steht # mit auf dem Panel, weil genau hier am 2026-09-15 ein falsch signiertes Image @@ -217,6 +250,10 @@ def blocks(state: dict) -> list[tuple[str, str]]: mode = state.get("mode") out: list[tuple[str, str]] = [] + # Die Fusszeile haengt an JEDEM Statusbild - auch an "Station aus" und an "Status + # unbekannt", die beide sofort zurueckkehren. Sie wird deshalb hier gebaut und unten + # bei jedem Rueckgabepunkt angehaengt. + footer = _imageset_blocks(state) if mode == "off": # Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt @@ -224,14 +261,14 @@ def blocks(state: dict) -> list[tuple[str, str]]: # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und # gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt. out.append(("Station aus", ROLE_BIG)) - return out + return out + footer if mode not in ("recovery", "migration"): # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # unterstellen. out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG)) - return out + return out + footer out.append((MODE_LABELS[mode], ROLE_HEADER)) @@ -338,7 +375,22 @@ def blocks(state: dict) -> list[tuple[str, str]]: out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} " f"{last.get('status') or '?'}", ROLE_DETAIL)) - return out + return out + footer + + +def _imageset_blocks(state: dict) -> list[tuple[str, str]]: + """Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt + (aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test). + Fehlt das Verzeichnis des GEWAEHLTEN Satzes, sagt die Zeile das - dann liefert + server.py naemlich nichts aus, und ein Panel, das trotzdem nur den Namen zeigt, + behauptet einen Betrieb, den es nicht gibt.""" + name = state.get("imageset") + label = IMAGESET_LABELS.get(name) + if not label: + return [] + if state.get("imageset_ok") is False: + label += IMAGESET_MISSING_SUFFIX + return [(label, ROLE_FOOTER)] def _ui_blocks(ui: dict) -> list[tuple[str, str]]: @@ -501,6 +553,7 @@ def compose(state: dict): ROLE_HERO: (HERO_FONT_PX, True), ROLE_DETAIL: (DETAIL_FONT_PX, False), ROLE_BIG: (BIG_FONT_PX, True), + ROLE_FOOTER: (FOOTER_FONT_PX, True), } loaded: dict = {} @@ -532,6 +585,36 @@ def compose(state: dict): has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list) header_mode_right_edge_px = 0.0 + # Die Fusszeile sitzt an einem FESTEN Platz am unteren Rand, nicht im Zeilenfluss - + # der Zeilenfluss darueber muss deshalb frueher abbrechen. Ohne Fusszeile bleibt die + # Grenze exakt die alte (BOTTOM_MARGIN_PX), damit sich an allen bisherigen Bildern + # nichts verschiebt. + footer_texts = [text for text, role in block_list if role == ROLE_FOOTER] + block_list = [(text, role) for text, role in block_list if role != ROLE_FOOTER] + has_footer = bool(footer_texts) + footer_top_px = PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX - FOOTER_BAND_HEIGHT_PX + flow_limit_px = footer_top_px if has_footer else PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX + # ROLE_BIG bekommt das ganze Panel (bisheriges Verhalten) bzw. alles oberhalb der + # Fusszeile - der untere Rand ist dort kein Abbruchkriterium, sondern nur eine + # Reserve fuer den Zeilenfluss. + big_height_px = footer_top_px if has_footer else PANEL_HEIGHT_PX + + # Die Fusszeile wird VOR dem Zeilenfluss gezeichnet und ist aus block_list bereits + # herausgenommen - sonst haette sie der "break" der Abbruchbedingung weiter unten + # verschluckt, sobald der Zeilenfluss vorher voll ist (also ausgerechnet im vollsten + # Zustand, waehrend einer laufenden Migration). Sie sitzt an festem Platz; ihre + # Reihenfolge innerhalb von blocks() spielt fuer das Bild keine Rolle. + if has_footer: + footer_font = font_for(ROLE_FOOTER) + # Trennstrich ueber die volle Breite, darunter die Zeile - so ist auf einen + # Blick klar, dass das keine weitere Beiwerkzeile ist, sondern ein Zustand, + # der unabhaengig vom Rest gilt. + draw.line((0, footer_top_px, PANEL_WIDTH_PX - 1, footer_top_px), fill=0) + _draw_text(draw, fit(footer_texts[0], footer_font), footer_font, + top=footer_top_px + FOOTER_RULE_GAP_PX, + height=FOOTER_BAND_HEIGHT_PX - FOOTER_RULE_GAP_PX, + fill=0, align="center") + y = TEXT_MARGIN_PX for raw_text, role in block_list: font = font_for(role) @@ -540,7 +623,9 @@ def compose(state: dict): # Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze # Panel. text = fit(raw_text, font) - _draw_text(draw, text, font, top=0, height=PANEL_HEIGHT_PX, fill=0, + # Mittig im Bereich UEBER der Fusszeile (ohne Fusszeile: im ganzen Panel) - + # sonst sitzt "Station aus" sichtbar zu tief und beruehrt den Trennstrich. + _draw_text(draw, text, font, top=0, height=big_height_px, fill=0, align="center") continue @@ -573,7 +658,7 @@ def compose(state: dict): text = fit(raw_text, font) line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX - if y + line_height > PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX: + if y + line_height > flow_limit_px: # Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX # reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die # Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert. diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 25f40fa..1b47493 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -22,6 +22,31 @@ CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station} STATION_LIB=${NINLIGHT_STATION_LIB:-/opt/ninlight-station} +# --- Umschalter: Migrationsimages mit oder ohne WLAN-Zugangsdaten -------------------- +# +# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware. Es liegen deshalb zwei +# Saetze Migrationsimages auf dem Pi: +# +# ohne-wlan -> $IMAGE_DIR/migrate-nocreds (tools/build_nocreds.sh): die migrierte +# Lampe startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN +# mit-wlan -> $IMAGE_DIR/migrate-wlan (build/): die Lampe haengt sofort im WLAN +# des Bedieners +# +# Der Umschalter ist ein ZUSTAND, keine Abfrage je Migration: er bleibt, bis jemand ihn +# aendert, und er steht dauerhaft auf dem Panel (display.py liest dieselbe Datei). +# Deshalb liegt sie NICHT in $RUN_DIR (tmpfs, weg nach jedem Neustart) und nicht in +# $CONF (0600 root:root, und setup_pi.sh traegt dort Schluessel nach) - sondern in einer +# eigenen, dauerhaften Datei mit 0644, die auch der Anzeigedienst lesen kann. +IMAGESET_FILE=${NINLIGHT_IMAGESET_FILE:-/var/lib/ninlight-station/imageset} +# Die sichere Vorgabe, und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar, unbekannter +# Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite +# migriert, auch fremde. Mit dem Abzug OHNE Zugangsdaten ist ein Fehlgriff aergerlich, +# aber harmlos - die Lampe steht im Onboarding-AP und wartet. Mit dem Abzug MIT +# Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden Geraet. +# Der teurere Fehler darf nie der sein, der ohne Zutun passiert. server.py:read_image_set() +# haelt dieselbe Vorgabe unabhaengig ein. +IMAGESET_DEFAULT=ohne-wlan + # --- Zeitschalter ("Auto-Aus") ----------------------------------------------------- # # Rein OPTIONAL: nur "--for " legt einen an. Ohne die Angabe passiert hier @@ -109,8 +134,111 @@ images() { find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort } +# --- Bildsatz lesen, schreiben, benennen -------------------------------------------- + +# Der rohe Inhalt der Umschalterdatei (ohne Leerraum), oder leer. Nur fuer die Meldung +# unten gedacht - entschieden wird ausschliesslich mit imageset_get(). +imageset_raw() { + cat "$IMAGESET_FILE" 2>/dev/null | tr -d ' \t\r\n' || true +} + +# Der eingestellte Bildsatz, oder $IMAGESET_DEFAULT bei fehlender/unlesbarer Datei bzw. +# unbekanntem Inhalt. Gibt IMMER einen gueltigen Namen aus und scheitert nie - ein +# kaputter Umschalter darf weder "off" noch das Panel aufhalten. +imageset_get() { + _value=$(imageset_raw) + case "$_value" in + ohne-wlan|mit-wlan) printf '%s' "$_value" ;; + *) printf '%s' "$IMAGESET_DEFAULT" ;; + esac +} + +# Das Verzeichnis zum Namen des Bildsatzes ($1). +imageset_dir() { + case "$1" in + mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;; + *) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;; + esac +} + +imageset_label() { + case "$1" in + mit-wlan) printf 'mit-wlan (Lampe haengt sofort im eigenen WLAN)' ;; + *) printf 'ohne-wlan (Lampe startet im Onboarding-Zugangspunkt)' ;; + esac +} + +# Schreibt den Umschalter atomar mit 0644 - dasselbe Muster wie write_mode()/ +# write_control(), aber in ein DAUERHAFTES Verzeichnis: der Zustand soll einen Neustart +# ueberleben (das ist der Sinn eines Umschalters), und der Anzeigedienst muss ihn lesen +# koennen. +imageset_set() { + sudo mkdir -p "$(dirname "$IMAGESET_FILE")" + _tmp="$(dirname "$IMAGESET_FILE")/.imageset-$$" + printf '%s\n' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 + sudo mv -f "$_tmp" "$IMAGESET_FILE" +} + migrate_images() { - find "$IMAGE_DIR/migrate" -maxdepth 1 -name '*.img' 2>/dev/null | sort + find "$(imageset_dir "$(imageset_get)")" -maxdepth 1 -name '*.img' 2>/dev/null | sort +} + +# "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten. +bildsatz_cmd() { + _want=${1:-} + _now=$(imageset_get) + if [ -z "$_want" ]; then + imageset_status_lines + return 0 + fi + case "$_want" in + ohne-wlan|mit-wlan) ;; + *) + echo "Fehler: unbekannter Bildsatz '$_want' - erlaubt sind 'ohne-wlan' und 'mit-wlan'" >&2 + exit 2 + ;; + esac + imageset_set "$_want" || { + echo "Fehler: $IMAGESET_FILE konnte nicht geschrieben werden" >&2 + exit 1 + } + echo "Bildsatz: $(imageset_label "$_want")" + [ "$_now" = "$_want" ] && echo " (war schon eingestellt)" + _dir=$(imageset_dir "$_want") + if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then + # Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts + # aus (Status "noimageset"), und das Panel sagt "FEHLT". Hier trotzdem sofort + # warnen, damit niemand erst an der Lampe merkt, dass nichts kommt. + echo "Warnung: $_dir fehlt oder ist leer - in diesem Bildsatz wird NICHTS ausgeliefert. tools/station/deploy.sh erneut laufen lassen." >&2 + fi + if [ "$_want" = "mit-wlan" ]; then + echo "Achtung: migrierte Lampen haengen damit sofort im WLAN aus config.local.cmake - auch fremde Lampen, die im Automatikmodus mitgenommen werden." >&2 + fi +} + +# Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben. +imageset_status_lines() { + _set=$(imageset_get) + _dir=$(imageset_dir "$_set") + # "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie + # server.py:migrate_set_present() und display.py:_read_imageset(). Ein leeres + # Verzeichnis liefert genauso nichts aus wie ein fehlendes. + _n=$(migrate_images | wc -l | tr -d ' ') + if [ "$_n" -gt 0 ]; then + _state="$_n Image(s)" + else + _state="FEHLT - es wird nichts ausgeliefert" + fi + printf 'Bildsatz: %s\n' "$(imageset_label "$_set")" + printf ' %s (%s)\n' "$_dir" "$_state" + # Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die + # Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst + # glaubt er, sein Eintrag von Hand haette gewirkt. + _raw=$(imageset_raw) + if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then + printf " (unbekannte Einstellung '%s' in %s - Vorgabe gilt)\n" \ + "$_raw" "$IMAGESET_FILE" + fi } # Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist) @@ -518,8 +646,10 @@ migrate_on() { done [ "$_for_seen" -eq 1 ] || _for_minutes="" need_profile "$MIGRATE_AP_CONNECTION" + _set=$(imageset_get) + _setdir=$(imageset_dir "$_set") n=$(migrate_images | wc -l | tr -d ' ') - [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR/migrate enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 + [ "$n" -gt 0 ] || echo "Warnung: $_setdir enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. ap_switch "$MIGRATE_AP_CONNECTION" @@ -530,6 +660,10 @@ migrate_on() { } start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase" echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)" + # Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte + # Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche + # stehen. + echo "Bildsatz: $(imageset_label "$_set")" echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2 echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." autooff_arm_if_requested @@ -861,6 +995,7 @@ PY fi printf 'Modus : %s\n' "$_modus" printf 'Phase : %s\n' "$_phase" + imageset_status_lines printf 'Erkannt: %s\n' "$_erkannt" printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" autooff_status_line @@ -898,8 +1033,16 @@ display_cmd() { } usage() { - echo "Aufruf: ninlight-station on [--for ]|recovery {on [--for ]|off}|migrate {on [--listen] [--for ]|off}|arm []|listen|renew []|hold|display {test|clear|off}|off|status" >&2 + echo "Aufruf: ninlight-station on [--for ]|recovery {on [--for ]|off}|migrate {on [--listen] [--for ]|off}|arm []|listen|bildsatz [ohne-wlan|mit-wlan]|renew []|hold|display {test|clear|off}|off|status" >&2 echo "" >&2 + echo " bildsatz Umschalter: welcher Satz Migrationsimages ausgeliefert wird." >&2 + echo " Ohne Argument nur berichten. 'ohne-wlan' (Vorgabe) liefert die" >&2 + echo " Abzuege aus tools/build_nocreds.sh - die migrierte Lampe startet" >&2 + echo " im eigenen Onboarding-Zugangspunkt. 'mit-wlan' liefert die" >&2 + echo " Abzuege aus build/ - die Lampe haengt sofort im WLAN aus" >&2 + echo " config.local.cmake, auch wenn es eine fremde Lampe ist, die im" >&2 + echo " Automatikmodus mitgenommen wurde. Der Zustand bleibt, bis ihn" >&2 + echo " jemand aendert, und steht dauerhaft auf dem Panel." >&2 echo " --for Zeitschalter: die Station schaltet sich nach selbst" >&2 echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&2 echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2 @@ -956,6 +1099,9 @@ case "$_cmd" in listen) listen_cmd ;; + bildsatz) + bildsatz_cmd "${1:-}" + ;; renew) renew_cmd "${1:-}" ;; diff --git a/tools/station/ninlight-station.service b/tools/station/ninlight-station.service index bda200e..6c32d19 100644 --- a/tools/station/ninlight-station.service +++ b/tools/station/ninlight-station.service @@ -20,7 +20,7 @@ StartLimitBurst=5 [Service] Type=simple EnvironmentFile=/etc/ninlight-station.conf -ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode +ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode --imageset-file /var/lib/ninlight-station/imageset Restart=always RestartSec=2 diff --git a/tools/station/server.py b/tools/station/server.py index 11d9cb1..2ec9fd1 100755 --- a/tools/station/server.py +++ b/tools/station/server.py @@ -62,10 +62,54 @@ def trailer_version(path: Path) -> str: return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace") +# --- Bildsatz: mit oder ohne einkompilierte WLAN-Zugangsdaten ----------------------- +# +# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware (config.local.cmake). Es gibt +# deshalb zwei Abzuege je Ziel, und beide liegen auf dem Pi: +# +# "ohne-wlan" -> images/migrate-nocreds/ aus tools/build_nocreds.sh: die migrierte +# Lampe startet im eigenen Onboarding-Zugangspunkt (ninlight-) +# und wartet, bis ihr jemand ein WLAN nennt. +# "mit-wlan" -> images/migrate-wlan/ aus build/: die Lampe haengt sofort im WLAN +# des Bedieners. +# +# Welcher ausgeliefert wird, entscheidet ein UMSCHALTER (ninlight-station bildsatz bzw. +# der Menueeintrag am Taster), nicht eine Abfrage je Migration: sein Zustand bleibt, bis +# ihn jemand aendert, und er steht dauerhaft auf dem Panel. +# +# Vorgabe ist "ohne-wlan", und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar, +# unbekannter Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in +# Funkreichweite migriert, auch fremde. Ein Abzug OHNE Zugangsdaten ist dann ein +# aergerlicher, aber harmloser Fehlgriff - die Lampe steht im Onboarding-AP und wartet. +# Ein Abzug MIT Zugangsdaten traegt das WLAN-Passwort des Bedieners unwiderruflich in +# ein fremdes Geraet. Der teurere Fehler darf nie der sein, der ohne Zutun passiert. +IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"} +DEFAULT_IMAGE_SET = "ohne-wlan" + + +def read_image_set(path: Path | None) -> str: + """Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar + ist oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei darf + den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der + beiden Saetze zeigen (siehe IMAGE_SET_DIRS).""" + if path is None: + return DEFAULT_IMAGE_SET + try: + value = path.read_text().strip() + except OSError: + return DEFAULT_IMAGE_SET + return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET + + class Station: def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None, - mode: str = "recovery", control_path: Path | None = None) -> None: + mode: str = "recovery", control_path: Path | None = None, + imageset_path: Path | None = None) -> None: self.image_dir = image_dir + # Bei JEDER Anfrage neu gelesen, nicht einmal beim Start: der Umschalter soll + # wirken, ohne den Dienst neu zu starten - ein Neustart mitten in einer + # laufenden Auslieferung waere genau das, was die Station nicht tun darf. + self.imageset_path = imageset_path self.state_path = state_path self.stall_after = stall_after self.mode = mode @@ -77,6 +121,8 @@ class Station: # wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2). self.last_seen: dict | None = None self._manifest_mtime: float | None = None + # Zu welchem Bildsatz der Cache gehoert - siehe _load_manifest(). + self._manifest_path: Path | None = None # Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch # geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt # jeder Eintrag seine eigene Version, image_version() muss also den ganzen @@ -105,24 +151,52 @@ class Station: def image_path(self, target: str) -> Path: return self.image_dir / f"ninlight_{target}_ota.img" + def image_set(self) -> str: + """Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan").""" + return read_image_set(self.imageset_path) + + def migrate_dir(self) -> Path: + """Das Verzeichnis des EINGESTELLTEN Bildsatzes. Es kann fehlen (der Satz wurde + nie ausgeliefert) - dann wird NICHTS ausgeliefert, siehe Handler: auf den anderen + Satz auszuweichen waere genau der Fehlgriff, gegen den der Umschalter da ist.""" + return self.image_dir / IMAGE_SET_DIRS[self.image_set()] + def migrate_image_path(self, target: str, variant: str) -> Path: - return self.image_dir / "migrate" / f"{target}_{variant}.img" + return self.migrate_dir() / f"{target}_{variant}.img" + + def migrate_set_present(self) -> bool: + """Liegt der eingestellte Bildsatz ueberhaupt auf dem Pi? "Vorhanden" heisst: das + Verzeichnis gibt es UND es enthaelt mindestens ein Image. Ein leeres Verzeichnis + zaehlt bewusst als nicht vorhanden - es entsteht genauso leicht (angelegt, aber nie + bestueckt) und unterscheidet sich fuer den Bediener in nichts; das Panel und das + CLI beurteilen es nach derselben Regel, sonst behaupten drei Stellen dreierlei.""" + try: + return any(p.name.endswith(".img") for p in self.migrate_dir().iterdir()) + except OSError: + return False def _load_manifest(self) -> dict: - """Geparster Inhalt von /migrate/manifest.json, neu eingelesen bei - jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie unlesbar/kaputtes - JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis {} - dieselbe - Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit eine falsch - geformte, aber syntaktisch gueltige Datei nicht mit einem AttributeError den - Server abschiesst.""" - path = self.image_dir / "migrate" / "manifest.json" + """Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen + bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie + unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis + {} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit + eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem + AttributeError den Server abschiesst.""" + path = self.migrate_dir() / "manifest.json" try: mtime = path.stat().st_mtime except OSError: self._manifest_mtime = None + self._manifest_path = path self._manifest_cache = {} return self._manifest_cache - if mtime != self._manifest_mtime: + # Der Pfad gehoert mit in den Vergleich: nach einem Umschalten zeigt er auf eine + # ANDERE Datei, die zufaellig dieselbe mtime tragen kann (beide entstehen im + # selben Deploy, oft in derselben Sekunde). Ohne ihn behielte der Cache die + # Versionen des vorigen Satzes - und die stuenden dann in status.json und am + # Panel neben einem Image, das gar nicht von dort kommt. + if mtime != self._manifest_mtime or path != getattr(self, "_manifest_path", None): + self._manifest_path = path try: data = json.loads(path.read_text()) except (OSError, ValueError): @@ -428,6 +502,19 @@ class Handler(BaseHTTPRequestHandler): return path = self.station.migrate_image_path(target, variant) + if not self.station.migrate_set_present(): + # Der EINGESTELLTE Bildsatz liegt gar nicht auf dem Pi. Hier wird bewusst + # nichts ausgeliefert und auch nicht auf den anderen Satz ausgewichen: der + # Umschalter existiert genau dafuer, dass niemand versehentlich Lampen mit + # den eigenen WLAN-Zugangsdaten flasht. Ein eigener Status, damit der + # Bediener am Panel den Unterschied zu "dieses Modell fehlt" sieht. + if phase == "armed": + self.station.restore_armed(target) + self.station.record_migration(ip, target, variant, pid, lamp_version, name, + version, 0, "noimageset", variant_source) + self.reject(404, "selected image set not available") + self.log(404, 0, started) + return if not path.is_file(): if phase == "armed": # claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten, @@ -552,6 +639,10 @@ def main(argv: list[str] | None = None) -> int: "Vorgabe bei fehlender/kaputter Datei ist 'recovery'") p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"), help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus") + p.add_argument("--imageset-file", type=Path, default=None, + help="Datei mit 'ohne-wlan' oder 'mit-wlan' (Umschalter des " + "Migrations-Bildsatzes); Vorgabe bei fehlender/kaputter Datei " + "ist 'ohne-wlan'") p.add_argument("--stall-after", type=int, default=None, help="test aid: stop sending after N bytes but keep the connection open") a = p.parse_args(argv) @@ -561,13 +652,22 @@ def main(argv: list[str] | None = None) -> int: return 2 mode = resolve_mode(a.mode, a.mode_file) - station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control) + station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control, + imageset_path=a.imageset_file) httpd = ThreadingHTTPServer((a.bind, a.port), Handler) httpd.daemon_threads = True httpd.station = station # type: ignore[attr-defined] images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img")) print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): " f"{', '.join(images) or 'none'}", flush=True) + if mode == "migration": + # Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images. + # Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends + # nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand. + selected = station.image_set() + present = "vorhanden" if station.migrate_set_present() else "FEHLT" + print(f"{now_iso()} image set: {selected} ({station.migrate_dir()}, {present})", + flush=True) if a.stall_after is not None: print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True) try: diff --git a/tools/station/setup_pi.sh b/tools/station/setup_pi.sh index cb58833..c46cdb4 100755 --- a/tools/station/setup_pi.sh +++ b/tools/station/setup_pi.sh @@ -154,6 +154,22 @@ if [ ! -f "$STAGE_DIR/ninlight-button/button.py" ]; then fi sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" "$ROOT_DIR/display" "$ROOT_DIR/button" +# Dauerhafter Zustand, der einen Neustart ueberleben muss - bisher nur der Umschalter des +# Migrations-Bildsatzes (/var/lib/ninlight-station/imageset). BEWUSST NICHT in /run: der +# Umschalter ist ein Zustand, den der Bediener setzt und der bis zur naechsten Aenderung +# gilt; ein tmpfs wuerde ihn bei jedem Neustart stillschweigend zuruecksetzen. +# +# 0755, weil der Anzeigedienst die Datei lesen muss (er zeigt den Zustand dauerhaft auf +# dem Panel). Die Datei SELBST wird hier nicht angelegt: fehlt sie, gilt in CLI, Server +# und Anzeige derselbe sichere Vorgabewert "ohne-wlan" - eine hier vorgeschriebene Datei +# waere eine zweite Quelle fuer denselben Wert. +# +# Die beiden Bildsatzverzeichnisse werden hier ebenfalls NICHT angelegt: ein leeres +# Verzeichnis ist fuer den Bediener nicht von einem bestueckten zu unterscheiden, und +# genau das soll das Panel melden koennen ("SATZ FEHLT"). Sie entstehen in deploy.sh, +# sobald wirklich Images hineinkommen. +STATE_DIR=${NINLIGHT_STATION_STATE_DIR:-/var/lib/ninlight-station} +sudo install -d -m 0755 "$STATE_DIR" sudo install -m 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py" sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py" sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH" diff --git a/tools/station/tests/test_button.py b/tools/station/tests/test_button.py index aa80edc..98949fe 100644 --- a/tools/station/tests/test_button.py +++ b/tools/station/tests/test_button.py @@ -26,10 +26,14 @@ import button # noqa: E402 # --- Zustaende, von denen die Menueeintraege abhaengen ------------------------------- -STATE_OFF = {"running": False, "mode": "recovery", "target": None} -STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None} -STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb"} -STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None} +STATE_OFF = {"running": False, "mode": "recovery", "target": None, + "imageset": "ohne-wlan"} +STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None, + "imageset": "ohne-wlan"} +STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb", + "imageset": "ohne-wlan"} +STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None, + "imageset": "ohne-wlan"} # --- Fake-Uhr, Taster-Doppel, Protokoll --------------------------------------------- @@ -164,7 +168,8 @@ def _labels(state): def test_menu_off_offers_starting_and_shutdown(): assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen", - "Migration: AUTOMATIK", "Herunterfahren"] + "Migration: AUTOMATIK", "Umschalten: mit WLAN", + "Herunterfahren"] def test_the_listening_migration_comes_before_the_automatic_one(): @@ -175,21 +180,22 @@ def test_the_listening_migration_comes_before_the_automatic_one(): def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown(): - assert _labels(STATE_RECOVERY) == ["Station aus", "Anzeige auffrischen", - "Herunterfahren"] + assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN", + "Anzeige auffrischen", "Herunterfahren"] def test_menu_in_migration_offers_the_detected_target(): assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus", - "Anzeige auffrischen", "Herunterfahren"] + "Umschalten: mit WLAN", "Anzeige auffrischen", + "Herunterfahren"] def test_menu_in_migration_without_a_detected_target_offers_no_release(): """Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde).""" assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND)) - assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Anzeige auffrischen", - "Herunterfahren"] + assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN", + "Anzeige auffrischen", "Herunterfahren"] def test_only_the_refresh_entry_skips_the_confirmation(): @@ -231,7 +237,8 @@ def test_a_short_press_steps_to_the_next_entry(): def test_a_short_press_wraps_around_the_last_entry(): - rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0)], 7.0) + rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0), + _short(6.0)], 8.0) assert rec.views()[-1][1]["hero"] == "> Rettung starten" @@ -257,7 +264,8 @@ def test_a_short_press_in_the_confirmation_cancels_and_returns_to_the_menu(): def test_the_refresh_entry_runs_without_a_confirmation(): - rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0, state=STATE_RECOVERY) + rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0, + state=STATE_RECOVERY) assert rec.argvs() == [["/usr/local/bin/ninlight-station", "display", "test"]] assert "confirm" not in rec.kinds() @@ -342,8 +350,8 @@ def test_a_blocking_action_does_not_break_the_hold_gesture(): def test_the_waiting_screen_is_written_before_the_slow_call(): - rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 8.0, state=STATE_RECOVERY, - action_duration_s=4.0) + rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 9.0, + state=STATE_RECOVERY, action_duration_s=4.0) kinds = [k for _t, k, _p in rec.events] run_at = kinds.index("run") assert rec.events[run_at - 1][2].get("view") == "busy" @@ -493,3 +501,53 @@ def test_an_unreadable_station_state_keeps_the_menu_closed(caplog): ui.update(0.0, True) assert ui.update(0.2, False) is None assert ui.view(0.3) == {"view": "idle", "active": False} + + +# --- Bildsatz-Umschalter im Menue ---------------------------------------------------- + + +def test_the_image_set_entry_names_where_it_switches_to(): + """Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der + Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand.""" + assert "Umschalten: mit WLAN" in _labels(STATE_OFF) + ohne = dict(STATE_OFF, imageset="mit-wlan") + assert "Umschalten: ohne WLAN" in _labels(ohne) + assert "Umschalten: mit WLAN" not in _labels(ohne) + + +def test_the_image_set_entry_switches_through_the_cli(): + actions = button.CliActions(cli="/usr/local/bin/ninlight-station") + assert actions.argv_for(button.action_imageset("ohne-wlan")) == [ + "/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"] + assert actions.argv_for(button.action_imageset("mit-wlan")) == [ + "/usr/local/bin/ninlight-station", "bildsatz", "ohne-wlan"] + + +def test_switching_to_wifi_credentials_asks_louder_than_switching_back(): + loud = button.action_imageset("ohne-wlan") + back = button.action_imageset("mit-wlan") + assert loud.confirm and back.confirm + assert loud.confirm_title == "ACHTUNG" + assert back.confirm_title == "RUECKFRAGE" + assert "WLAN-Passwort" in " ".join(loud.confirm_details) + assert back.confirm_details is None + + +def test_the_image_set_entry_really_runs_the_switch(tmp_path): + """Durch die echte Hauptschleife, nicht nur ueber menu_actions(): vierter Eintrag im + ausgeschalteten Zustand, Auswahl halten, Rueckfrage halten.""" + rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), + (5.0, 7.0), (8.0, 10.0)], 12.0) + assert rec.argvs() == [["/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]] + + +def test_station_state_reads_the_same_switch_file_as_the_cli(tmp_path, monkeypatch): + monkeypatch.setattr(button, "_service_active", lambda service_name: False) + switch = tmp_path / "imageset" + state = lambda: button.station_state(run_dir=tmp_path, service_name="x", + imageset_path=switch) + assert state()["imageset"] == "ohne-wlan" # Datei fehlt -> sichere Vorgabe + switch.write_text("mit-wlan\n") + assert state()["imageset"] == "mit-wlan" + switch.write_text("quatsch") + assert state()["imageset"] == "ohne-wlan" diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index e3a362a..aa802f6 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -217,8 +217,12 @@ EOF setup_sandbox() { local sandbox=$1 unitfile=${2:-$REAL_UNIT} rm -rf "$sandbox" - mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate" \ - "$sandbox/lib/display" + mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \ + "$sandbox/bin" "$sandbox/nmstate" "$sandbox/lib/display" "$sandbox/var" + # Je ein Image je Bildsatz - "vorhanden" heisst fuer CLI, Server und Anzeige + # gleichermassen "mindestens ein Image darin". + echo "ohne" >"$sandbox/images/migrate-nocreds/foo_dev.img" + echo "mit" >"$sandbox/images/migrate-wlan/foo_dev.img" echo "dummy" >"$sandbox/images/ninlight_foo_ota.img" # NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst. @@ -292,6 +296,7 @@ run_cli() { NINLIGHT_STATION_CONF="$sandbox/station.conf" \ NINLIGHT_RUN_DIR="$sandbox/run" \ NINLIGHT_STATION_LIB="$sandbox/lib" \ + NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \ "$CLI" "$@" } @@ -334,7 +339,7 @@ echo echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ===" SBX="$TMPROOT/t3" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" on >/dev/null 2>&1 # Recovery vorher an : >"$SBX/cmd.log" set +e @@ -356,7 +361,7 @@ echo echo "=== Fall 4: 'migrate on --listen' wie Fall 3, aber phase=listen ===" SBX="$TMPROOT/t4" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" on >/dev/null 2>&1 set +e OUT=$(run_cli "$SBX" migrate on --listen 2>&1) @@ -369,7 +374,8 @@ echo echo "=== Fall 5: 'migrate on' ohne Migrationsimages -> Warnung auf stderr, Rueckgabewert 0 ===" SBX="$TMPROOT/t5" setup_sandbox "$SBX" -# images/migrate/ bleibt leer +# der gewaehlte Bildsatz ("ohne-wlan") wird geleert - setup_sandbox bestueckt beide +rm -f "$SBX/images/migrate-nocreds"/*.img set +e OUT=$(run_cli "$SBX" migrate on 2>"$SBX/stderr.log") RC=$? @@ -468,7 +474,7 @@ echo echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" SBX="$TMPROOT/t12" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" migrate on >/dev/null 2>&1 # phase=auto in control.json, wie es der echte Ablauf waere # status.json so, wie server.py sie beim Start geschrieben haette (mode/last_seen). cat >"$SBX/run/status.json" <<'JSON' @@ -513,7 +519,7 @@ echo echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ===" SBX="$TMPROOT/t15" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" on >/dev/null 2>&1 ACTIVE_BEFORE=$(cat "$SBX/nmstate/active") echo "$ACTIVE_BEFORE" | grep -qx "ninlight-recovery" && pass "Vorbedingung: ninlight-recovery laeuft" || fail "Vorbedingung nicht erfuellt: $ACTIVE_BEFORE" @@ -538,7 +544,7 @@ SBX="$TMPROOT/tC1" BROKEN_UNIT="$TMPROOT/broken-ninlight-station.service" grep -v '^RuntimeDirectoryPreserve=restart' "$REAL_UNIT" >"$BROKEN_UNIT" setup_sandbox "$SBX" "$BROKEN_UNIT" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" set +e OUT=$(run_cli "$SBX" migrate on --listen 2>"$SBX/stderr.log") RC=$? @@ -555,7 +561,7 @@ ACTIVE_AFTER=$(cat "$SBX/nmstate/active") # in tools/station/ninlight-station.service tatsaechlich steht und wirkt. SBX2="$TMPROOT/tC1-fixed" setup_sandbox "$SBX2" -echo "dummy" >"$SBX2/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX2/images/migrate-nocreds/gu10_rgb_prod.img" set +e OUT2=$(run_cli "$SBX2" migrate on --listen 2>&1) RC2=$? @@ -567,7 +573,7 @@ echo echo "=== Zusatzfall I1: 'status' zeigt die Phase aus control.json, nicht die veraltete Phase aus status.json ===" SBX="$TMPROOT/tI1" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" migrate on --listen >/dev/null 2>&1 # status.json wird nur beim Dienststart/bei einer Auslieferung geschrieben - hier # simuliert, wie es nach dem Start aussehen wuerde (Phase "listen", wie beim Start). @@ -604,7 +610,7 @@ echo echo "=== Zusatzfall I3: ap_switch bricht ab und faehrt beide Profile herunter, wenn 'device show wlan0' nach dem Umschalten nicht passt ===" SBX="$TMPROOT/tI3" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" set +e OUT=$(FAKE_NMCLI_DEVSHOW=fremdes-profil run_cli "$SBX" migrate on 2>"$SBX/stderr.log") RC=$? @@ -619,7 +625,7 @@ echo echo "=== Zusatzfall N2 (Fixrunde 2): 'status' zeigt keine veraltete Migrationsphase mehr, nachdem in den Rettungsmodus zurueckgeschaltet wurde ===" SBX="$TMPROOT/tN2" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" migrate on --listen >/dev/null 2>&1 run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 # control.json bleibt mit phase=armed liegen run_cli "$SBX" on >/dev/null 2>&1 # zurueck in den Rettungsmodus - control.json wird bewusst NICHT aufgeraeumt @@ -638,7 +644,7 @@ echo echo "=== Zusatzfall N2b (Fixrunde 2): dieselbe veraltete Freigabe, aber ohne aktualisierte status.json (Modus '-') - genau das woertliche Repro aus dem Review ===" SBX="$TMPROOT/tN2b" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" migrate on --listen >/dev/null 2>&1 run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 run_cli "$SBX" on >/dev/null 2>&1 @@ -819,7 +825,7 @@ echo "=== Fall D4 (Befund Task 7f): 'off' fasst den Anzeigedienst nicht mehr an # naechsten Pi-Neustart dunkel. Jetzt laesst 'off' den Dienst laufen. SBX="$TMPROOT/tD4" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" run_cli "$SBX" migrate on >/dev/null 2>&1 : >"$SBX/cmd.log" set +e @@ -997,7 +1003,7 @@ echo echo "=== Fall Z4: 'migrate on --listen --for 5' - beides zusammen ===" SBX="$TMPROOT/tZ4" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" set +e OUT=$(run_cli "$SBX" migrate on --listen --for 5 2>&1) RC=$? @@ -1008,7 +1014,7 @@ set -e # Umgekehrte Reihenfolge der Schalter muss genauso gehen. SBX="$TMPROOT/tZ4b" setup_sandbox "$SBX" -echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img" set +e OUT=$(run_cli "$SBX" migrate on --for 5 --listen 2>&1) RC=$? @@ -1182,6 +1188,126 @@ echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "kein [ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")" echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR" +echo +echo "=== Bildsatz A: die Vorgabe ohne Umschalterdatei ist der Satz OHNE WLAN-Zugangsdaten ===" +# Die sichere Richtung: ein Fehlgriff kostet einen Onboarding-Durchgang, kein +# WLAN-Passwort in einer fremden Lampe. +SBX="$TMPROOT/bs1" +setup_sandbox "$SBX" +set +e +OUT=$(run_cli "$SBX" bildsatz 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +echo "$OUT" | grep -q "ohne-wlan" && pass "ohne Datei gilt 'ohne-wlan'" || fail "Vorgabe ist nicht 'ohne-wlan': $OUT" +echo "$OUT" | grep -q "migrate-nocreds" && pass "und das zugehoerige Verzeichnis wird genannt" || fail "Verzeichnis fehlt in der Ausgabe: $OUT" +[ ! -e "$SBX/var/imageset" ] && pass "das blosse Berichten legt keine Datei an" || fail "'bildsatz' ohne Argument hat geschrieben" + +echo +echo "=== Bildsatz B: umschalten schreibt die Datei atomar mit 0644 und bleibt stehen ===" +SBX="$TMPROOT/bs2" +setup_sandbox "$SBX" +set +e +OUT=$(run_cli "$SBX" bildsatz mit-wlan 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +[ "$(cat "$SBX/var/imageset" 2>/dev/null | tr -d '\n')" = "mit-wlan" ] && pass "die Datei enthaelt 'mit-wlan'" || fail "Dateiinhalt: $(cat "$SBX/var/imageset" 2>&1)" +MODE=$(file_mode "$SBX/var/imageset") +[ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644" +[ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da" +echo "$OUT" | grep -qi "achtung" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT" +# ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn. +OUT2=$(run_cli "$SBX" bildsatz 2>&1) +echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2" +# ... und zurueck. +run_cli "$SBX" bildsatz ohne-wlan >/dev/null 2>&1 +[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "ohne-wlan" ] && pass "zurueckschalten geht auch" || fail "zurueckschalten hat nicht gewirkt" + +echo +echo "=== Bildsatz C: ein unbekannter Name schaltet nichts um ===" +SBX="$TMPROOT/bs3" +setup_sandbox "$SBX" +printf 'mit-wlan\n' >"$SBX/var/imageset" +set +e +ERR=$(run_cli "$SBX" bildsatz irgendwas 2>&1 >/dev/null) +RC=$? +set -e +[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz" +[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")" +echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR" + +echo +echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ===" +# Nie auf den teureren Satz - das ist die eine Richtung, die kein Zweifelsfall nehmen darf. +SBX="$TMPROOT/bs4" +setup_sandbox "$SBX" +printf 'voellig-anderes\n' >"$SBX/var/imageset" +OUT=$(run_cli "$SBX" bildsatz 2>&1) +echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fail "kaputter Inhalt fuehrte nicht auf die Vorgabe: $OUT" +echo "$OUT" | grep -q "migrate-nocreds" && pass "und das Verzeichnis des sicheren Satzes" || fail "falsches Verzeichnis: $OUT" +# Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die +# Station fuer umgeschaltet. +echo "$OUT" | grep -q "unbekannte Einstellung 'voellig-anderes'" \ + && pass "die unbekannte Einstellung wird beim Namen genannt" \ + || fail "die unbekannte Einstellung wird verschwiegen: $OUT" +# Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen. +run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 +OUT=$(run_cli "$SBX" bildsatz 2>&1) +echo "$OUT" | grep -q "unbekannte Einstellung" \ + && fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \ + || pass "kein Fehlalarm bei gueltigem Eintrag" + +echo +echo "=== Bildsatz E: ein gewaehlter, aber fehlender Satz wird beim Umschalten UND im Status gemeldet ===" +SBX="$TMPROOT/bs5" +setup_sandbox "$SBX" +rm -rf "$SBX/images/migrate-wlan" +set +e +ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC" +echo "$ERR" | grep -qi "warnung" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR" +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -q "FEHLT" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT" + +echo +echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ===" +SBX="$TMPROOT/bs6" +setup_sandbox "$SBX" +rm -f "$SBX/images/migrate-nocreds"/*.img +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -q "FEHLT" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT" + +echo +echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ===" +# Er kann von vorletzter Woche stammen - wer einschaltet, muss ihn sehen. +SBX="$TMPROOT/bs7" +setup_sandbox "$SBX" +run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 +set +e +OUT=$(run_cli "$SBX" migrate on --listen 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +echo "$OUT" | grep -q "Bildsatz: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT" + +echo +echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ===" +SBX="$TMPROOT/bs8" +setup_sandbox "$SBX" +rm -f "$SBX/images/migrate-wlan"/*.img # nur der andere Satz ist leer +set +e +ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) +set -e +echo "$ERR" | grep -q "keine Migrationsimages" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz" +run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 +set +e +ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) +set -e +echo "$ERR" | grep -q "keine Migrationsimages" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_deploy.sh b/tools/station/tests/test_deploy.sh index db37bcd..ce5722b 100755 --- a/tools/station/tests/test_deploy.sh +++ b/tools/station/tests/test_deploy.sh @@ -504,8 +504,8 @@ RC=$? set -e if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi GZ_SIZE=$(wc -c <"$SANDBOX/build/foo/ninlight_foo_gz.img" | tr -d ' ') -PROD_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" -DEV_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" +PROD_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" +DEV_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" if [ -f "$PROD_IMG" ] && [ -f "$DEV_IMG" ]; then pass "beide Migrationsimages (prod, dev) wurden angelegt" else @@ -535,7 +535,7 @@ set +e OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) RC=$? set -e -MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" +MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json" if [ "$RC" -eq 0 ] && [ -f "$MANIFEST" ]; then pass "manifest.json wurde angelegt" else @@ -592,8 +592,8 @@ if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; the else fail "Rettungsimage fehlt: $OUT" fi -if [ ! -d "$SANDBOX_STATE/opt/ninlight-station/images/migrate" ] \ - || [ -z "$(ls -A "$SANDBOX_STATE/opt/ninlight-station/images/migrate" 2>/dev/null)" ]; then +if [ ! -d "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" ] \ + || [ -z "$(ls -A "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" 2>/dev/null)" ]; then pass "keine Migrationsimages entstanden" else fail "Migrationsimages entstanden, obwohl kein Schluesselverzeichnis vorhanden war" @@ -618,12 +618,12 @@ OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test foo 2>&1) RC=$? set -e if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi -if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" ]; then +if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" ]; then pass "dev-Migrationsimage wurde angelegt" else fail "dev-Migrationsimage fehlt: $OUT" fi -if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" ]; then +if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" ]; then pass "prod-Migrationsimage entfiel wie erwartet" else fail "prod-Migrationsimage wurde trotz fehlendem Produktionsschluessel angelegt" @@ -644,15 +644,15 @@ make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" A make_gz_file "$SANDBOX/build/bar/ninlight_bar_gz.img" C echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "bar-ota" >"$SANDBOX/build/bar/ninlight_bar_ota.img" -mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" -echo "vorhandenes-bar-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_prod.img" -echo "vorhandenes-bar-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_dev.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" +echo "vorhandenes-bar-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/bar_prod.img" +echo "vorhandenes-bar-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/bar_dev.img" # Manifest eines frueheren Deploys, das bar beschrieben hat - genau das darf ein # gezielter Deploy von foo nicht einfach wegschreiben (Fixrunde 1, I1). bar traegt # absichtlich eine ANDERE Firmware-Version als das, was dieser Lauf fuer foo signiert # (nl_mkimage-Stub liefert immer "1.0.0") - das Manifest nach dem Merge muss beide # Versionen unveraendert nebeneinander stehen lassen (Fixrunde 2, N2). -cat >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" <<'JSON' +cat >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json" <<'JSON' {"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.9.0", "images": [ {"target": "bar", "variant": "prod", "file": "bar_prod.img", "bytes": 20, "sha256": "x", @@ -667,14 +667,14 @@ OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) RC=$? set -e if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi -if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" ] \ - && [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" ]; then +if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" ] \ + && [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" ]; then pass "Migrationsimages fuer das angegebene Ziel (foo) wurden angelegt" else fail "Migrationsimages fuer foo fehlen: $OUT" fi -if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_prod.img" 2>/dev/null)" = "vorhandenes-bar-prod" ] \ - && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_dev.img" 2>/dev/null)" = "vorhandenes-bar-dev" ]; then +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/bar_prod.img" 2>/dev/null)" = "vorhandenes-bar-prod" ] \ + && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/bar_dev.img" 2>/dev/null)" = "vorhandenes-bar-dev" ]; then pass "Migrationsimages des NICHT ausgewaehlten Geraetetyps (bar) blieben unangetastet" else fail "Migrationsimages von bar wurden veraendert oder geloescht - 4a-Fehler fuer migrate/" @@ -682,7 +682,7 @@ fi set +e MANIFEST_CHECK=$(python3 -c " import json -with open('$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json') as fh: +with open('$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json') as fh: data = json.load(fh) entries = data.get('images', []) keys = {(e.get('target'), e.get('variant')) for e in entries} @@ -768,7 +768,7 @@ OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) RC=$? set -e if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi -DEV_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" +DEV_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" if [ -f "$DEV_IMG" ]; then set +e python3 -c " @@ -797,22 +797,22 @@ setup_sandbox "$SANDBOX" setup_stubbin "$STUBBIN" "$LOG" mkdir -p "$SANDBOX/build/foo" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" -mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" -echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" -echo "vorhandenes-foo-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" +echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" +echo "vorhandenes-foo-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.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 [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 2>/dev/null)" = "vorhandenes-foo-prod" ] \ - && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ]; then +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" 2>/dev/null)" = "vorhandenes-foo-prod" ] \ + && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ]; then pass "vorhandenes migrate/ blieb beim Voll-Deploy ohne Schluesselverzeichnis unangetastet" else fail "migrate/ wurde geleert, obwohl kein Schluesselverzeichnis vorhanden war - zerstoert die Bestueckung" fi -if echo "$OUT" | grep -q "migrate/ auf dem Pi bleibt unangetastet"; then +if echo "$OUT" | grep -q "migrate-wlan/ auf dem Pi bleibt unangetastet"; then pass "Ausgabe kuendigt an, dass migrate/ unangetastet bleibt" else fail "Ausgabe erwaehnt nicht, dass migrate/ unangetastet bleibt: $OUT" @@ -831,11 +831,11 @@ setup_sandbox "$SANDBOX" setup_stubbin "$STUBBIN" "$LOG" mkdir -p "$SANDBOX/build/foo" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" -mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" -echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" -echo "vorhandenes-foo-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" +echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" +echo "vorhandenes-foo-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" echo '{"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.0.1", "images": []}' \ - >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" + >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json" : >"$LOG" set +e OUT=$(TEST_SIGN_DIR="$SIGN_EMPTY" run_deploy "$SANDBOX" pi-test 2>&1) @@ -847,9 +847,9 @@ if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; the else fail "Rettungsimage fehlt: $OUT" fi -if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 2>/dev/null)" = "vorhandenes-foo-prod" ] \ - && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ] \ - && grep -q '"images": \[\]' "$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json"; then +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" 2>/dev/null)" = "vorhandenes-foo-prod" ] \ + && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ] \ + && grep -q '"images": \[\]' "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json"; then pass "vorhandenes migrate/ (Bilder UND manifest.json) blieb unangetastet - Verzeichnis war da, aber leer" else fail "migrate/ wurde trotz vorhandenem, aber leerem Schluesselverzeichnis veraendert/geleert - C2 nicht behoben" @@ -868,20 +868,20 @@ setup_stubbin "$STUBBIN" "$LOG" mkdir -p "$SANDBOX/build/foo" make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" -mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" -echo "echtes-altes-prod-image" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" +echo "echtes-altes-prod-image" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" : >"$LOG" set +e OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test 2>&1) # Voll-Deploy, kein Ziel-Argument RC=$? set -e if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi -if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" ]; then +if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" ]; then pass "neues dev-Migrationsimage wurde signiert und ausgeliefert" else fail "dev-Migrationsimage fehlt: $OUT" fi -if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 2>/dev/null)" = "echtes-altes-prod-image" ]; then +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" 2>/dev/null)" = "echtes-altes-prod-image" ]; then pass "das vorhandene _prod.img blieb unangetastet, obwohl dieser Voll-Deploy nur den dev-Schluessel hatte" else fail "das vorhandene _prod.img wurde beim Voll-Deploy mit nur dev-Schluessel entfernt - C2 (Teilfall) nicht behoben" @@ -907,12 +907,13 @@ set +e OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) RC=$? set -e -MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" +MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json" if [ "$RC" -eq 0 ] && [ -f "$MANIFEST" ]; then pass "manifest.json wurde angelegt" else fail "manifest.json fehlt oder Exit-Code $RC: $OUT" fi +printf 'mit-wlan\n' >"$TMPROOT/s20-imageset" set +e VERSION=$(python3 -c " import importlib.util @@ -929,6 +930,9 @@ st = module.Station( stall_after=None, mode='migration', control_path=Path('$TMPROOT/s20-control.json'), + # Das Manifest dieses Szenarios gehoert zum Satz MIT WLAN-Zugangsdaten - der + # Umschalter muss also darauf stehen, sonst liest der Server im anderen Satz nach. + imageset_path=Path('$TMPROOT/s20-imageset'), ) print(st.image_version('foo', 'dev')) " 2>&1) @@ -965,8 +969,8 @@ for label in truncated empty array null string null-entry; do mkdir -p "$SANDBOX/build/foo" make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" - mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" - cp "$TMPROOT/bad-manifests/$label.json" "$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" + mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" + cp "$TMPROOT/bad-manifests/$label.json" "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json" : >"$LOG" set +e # Nur EIN Schluessel (dev) -> MIGRATE_COMPLETE=0 -> der Merge-Zweig laeuft, nicht der @@ -980,7 +984,7 @@ for label in truncated empty array null string null-entry; do fail "Bestandsmanifest '$label': Exit-Code $RC - Deploy abgebrochen. Ausgabe: $OUT" fi if [ -f "$SANDBOX_STATE/opt/ninlight-station/server.py" ] \ - && [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" ]; then + && [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" ]; then pass "Bestandsmanifest '$label': Deploy lief vollstaendig durch (server.py installiert, foo_dev.img da)" else fail "Bestandsmanifest '$label': Deploy blieb auf halbem Weg stehen (server.py oder foo_dev.img fehlt)" @@ -1112,6 +1116,130 @@ ERR=$(cat "$TMPROOT/s27.err") echo "$ERR" | grep -q "Warnung" && pass "Warnung (keine Fehlermeldung) auf stderr" || fail "keine Warnung auf stderr: $ERR" [ -f "$SANDBOX_STATE/opt/ninlight-station/server.py" ] && pass "der Deploy selbst lief vollstaendig durch" || fail "server.py fehlt - der Deploy brach ab" +echo +echo "=== Szenario 28: beide Bildsaetze werden ausgeliefert, und zwar aus verschiedenen Bauten ===" +# Der Umschalter auf der Station ist nur so gut wie die Bestueckung: liegen beide Saetze +# nicht getrennt auf dem Pi, kann er nichts umschalten. Geprueft wird am INHALT, nicht am +# Verzeichnisnamen - die Fuellzeichen der beiden _gz.img sind verschieden, ein +# vertauschter Quellbaum faellt also auf. +SANDBOX="$TMPROOT/s28"; STUBBIN="$TMPROOT/bin28"; LOG="$TMPROOT/s28.log"; SANDBOX_STATE="$TMPROOT/s28-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo" +make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" W +echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +make_gz_file "$SANDBOX/build-nocreds/foo/ninlight_foo_gz.img" N +: >"$LOG" +set +e +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +WLAN_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" +NOCR_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds/foo_dev.img" +if [ -f "$WLAN_IMG" ] && [ -f "$NOCR_IMG" ]; then + pass "beide Bildsaetze liegen getrennt auf dem Pi" +else + fail "mindestens ein Bildsatz fehlt: $OUT" +fi +if [ -f "$WLAN_IMG" ] && [ -f "$NOCR_IMG" ]; then + # Die ersten Bytes sind das Fuellzeichen des jeweiligen _gz.img. + W_HEAD=$(head -c 8 "$WLAN_IMG") + N_HEAD=$(head -c 8 "$NOCR_IMG") + [ "$W_HEAD" = "WWWWWWWW" ] && pass "migrate-wlan/ kommt wirklich aus build/" || fail "migrate-wlan/ enthaelt '$W_HEAD' statt der build/-Bytes" + [ "$N_HEAD" = "NNNNNNNN" ] && pass "migrate-nocreds/ kommt wirklich aus build-nocreds/" || fail "migrate-nocreds/ enthaelt '$N_HEAD' statt der build-nocreds/-Bytes" +fi +for m in migrate-wlan migrate-nocreds; do + if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/$m/manifest.json" ]; then + pass "$m/manifest.json wurde angelegt" + else + fail "$m/manifest.json fehlt" + fi +done + +echo +echo "=== Szenario 29: ohne build-nocreds/ bleibt der Satz 'ohne-wlan' auf dem Pi unangetastet ===" +# tools/build_nocreds.sh ist ein eigener, optionaler Bau. Ein Deploy darf nicht daran +# scheitern, dass er nicht gelaufen ist - und er darf die Bestueckung frueherer Deploys +# nicht wegraeumen (derselbe Fehler wie frueher bei migrate/, siehe Szenario 17). +SANDBOX="$TMPROOT/s29"; STUBBIN="$TMPROOT/bin29"; LOG="$TMPROOT/s29.log"; SANDBOX_STATE="$TMPROOT/s29-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" +echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds" +echo "alter-nocreds-abzug" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds/foo_dev.img" +: >"$LOG" +set +e +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlendem build-nocreds/" || fail "Exit-Code $RC: $OUT" +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds/foo_dev.img" 2>/dev/null)" = "alter-nocreds-abzug" ]; then + pass "vorhandener Satz 'ohne-wlan' blieb unangetastet" +else + fail "der Satz 'ohne-wlan' wurde geleert oder ueberschrieben, obwohl build-nocreds/ fehlte" +fi +[ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img" ] \ + && pass "der Satz 'mit-wlan' wurde trotzdem ausgeliefert" \ + || fail "der Satz 'mit-wlan' fehlt: $OUT" +echo "$OUT" | grep -q "build_nocreds.sh" \ + && pass "der Hinweis nennt, womit der fehlende Satz entsteht" \ + || fail "kein Hinweis auf tools/build_nocreds.sh: $OUT" + +echo +echo "=== Szenario 30: ein unvollstaendiger Satz 'ohne-wlan' raeumt nicht mit --delete auf ===" +# build-nocreds/ hat nur eines von zwei Zielen - dieser Lauf beschreibt den Sollzustand +# also NICHT vollstaendig, und --delete wuerde das zweite Ziel des vorigen Deploys +# wegraeumen. +SANDBOX="$TMPROOT/s30"; STUBBIN="$TMPROOT/bin30"; LOG="$TMPROOT/s30.log"; SANDBOX_STATE="$TMPROOT/s30-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar" "$SANDBOX/build-nocreds/foo" +make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" +make_gz_file "$SANDBOX/build/bar/ninlight_bar_gz.img" +echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +echo "bar-ota" >"$SANDBOX/build/bar/ninlight_bar_ota.img" +make_gz_file "$SANDBOX/build-nocreds/foo/ninlight_foo_gz.img" N +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds" +echo "alter-bar-abzug" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds/bar_dev.img" +: >"$LOG" +set +e +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds/bar_dev.img" 2>/dev/null)" = "alter-bar-abzug" ]; then + pass "das nicht neu gebaute Ziel blieb im Satz 'ohne-wlan' erhalten" +else + fail "das nicht neu gebaute Ziel wurde aus dem Satz 'ohne-wlan' entfernt - --delete lief trotz unvollstaendigem Satz" +fi +[ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-nocreds/foo_dev.img" ] \ + && pass "das gebaute Ziel kam dazu" || fail "das gebaute Ziel fehlt im Satz 'ohne-wlan'" + +echo +echo "=== Szenario 31: das alte, namenlose migrate/ wird beim Deploy entfernt ===" +# Bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die Abzuege MIT +# Zugangsdaten. Es wird von niemandem mehr gelesen - bliebe es liegen, haelt der naechste +# Mensch am Pi es fuer den gueltigen Satz. +SANDBOX="$TMPROOT/s31"; STUBBIN="$TMPROOT/bin31"; LOG="$TMPROOT/s31.log"; SANDBOX_STATE="$TMPROOT/s31-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" +echo "altbestand" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" +: >"$LOG" +set +e +OUT=$(run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +[ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/migrate" ] \ + && pass "der Altbestand images/migrate wurde entfernt" \ + || fail "images/migrate liegt noch da" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index cef9132..0294222 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -861,9 +861,11 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch): {"minutes": 30, "deadline": time.time() + 600, "deferrals": 0})) state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", - service_name="x") + service_name="x", + imageset_path=tmp_path / "imageset") - assert state == {"mode": "off"} + assert state["mode"] == "off" + assert "autooff_minutes" not in state assert not any("Auto-Aus" in line for line in render.lines(state)) @@ -897,9 +899,13 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch): (tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5})) state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", - service_name="x") + service_name="x", + imageset_path=tmp_path / "imageset") - assert state == {"mode": "off"} + # "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im + # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll). + assert state["mode"] == "off" + assert set(state) == {"mode", "imageset", "imageset_ok"} def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch): @@ -1707,3 +1713,155 @@ def test_the_expensive_station_state_is_still_only_read_every_five_seconds(): _drive_daemon(state_at, duration_s=10.0) assert calls == [0.0, 5.0, 10.0] + + +# --- Bildsatz-Umschalter in der Fusszeile ------------------------------------------- +# +# Zusage: welcher Migrations-Bildsatz gilt, steht DAUERHAFT auf dem Panel - nicht nur im +# Menue. Der Zustand ueberlebt Neustarts und kann von vorletzter Woche stammen; wer ihn +# erst beim Blaettern im Menue saehe, hat womoeglich schon zwanzig Lampen mit den eigenen +# WLAN-Zugangsdaten geflasht. + +def _flat(rows): + """_band_pixels() liefert Zeilen-Tupel - hier interessieren die einzelnen Pixel.""" + return [px for row in rows for px in row] + + +def _imageset_line(state): + texts = [text for text, role in render.blocks(state) if role == render.ROLE_FOOTER] + assert len(texts) <= 1, texts + return texts[0] if texts else None + + +def test_the_image_set_is_shown_in_every_status_screen(): + for state in ({"mode": "off"}, + {"mode": "recovery", "ssid": "ninlight-recovery"}, + dict(_migration_state()), + {"mode": None}): + for name in ("ohne-wlan", "mit-wlan"): + s = dict(state, imageset=name, imageset_ok=True) + line = _imageset_line(s) + assert line == render.IMAGESET_LABELS[name], (state.get("mode"), name) + + +def test_the_two_image_sets_read_differently(): + """Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine + Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos.""" + assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"] + assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"] + + +def test_a_missing_image_set_says_so_on_the_panel(): + state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False) + line = _imageset_line(state) + assert line is not None and "FEHLT" in line + + +def test_without_an_image_set_field_nothing_is_claimed(): + """Ein Zustand aus einer aelteren display.py-Fassung bekommt keine erfundene + Fusszeile.""" + assert _imageset_line({"mode": "off"}) is None + + +def test_the_image_set_line_is_actually_drawn_at_the_bottom(): + """Nicht nur in blocks(): auf dem Panel, unterhalb aller Beiwerkzeilen und ueber dem + unteren Rand.""" + state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True) + footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX + - render.FOOTER_BAND_HEIGHT_PX) + + with_footer = render.compose(state) + without = render.compose({k: v for k, v in state.items() + if k not in ("imageset", "imageset_ok")}) + + bottom = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX + band = _band_pixels(with_footer, footer_top, bottom) + assert 0 in _flat(band), "im Fussband ist gar keine Tinte" + assert band != _band_pixels(without, footer_top, bottom), \ + "Fussband sieht mit und ohne Bildsatz gleich aus" + + +def test_the_image_set_line_keeps_the_bottom_margin_free(): + state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False) + image = render.compose(state) + margin = _flat(_band_pixels(image, render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX, + render.PANEL_HEIGHT_PX)) + assert set(margin) <= {255}, "der untere Rand ist nicht mehr frei" + + +def test_the_image_set_line_survives_the_fullest_state(): + """Im vollsten Zustand faellt eine Beiwerkzeile heraus - aber nie die Fusszeile: + sie steht an festem Platz und nicht im Zeilenfluss.""" + state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True, + autooff_minutes=17) + assert _imageset_line(state) is not None + # ... und der Zeilenfluss endet wirklich vor dem Fussband. + image = render.compose(state) + footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX + - render.FOOTER_BAND_HEIGHT_PX) + # Die Zeile direkt ueber dem Trennstrich darf ihn nicht beruehren. + assert set(_flat(_band_pixels(image, footer_top - 1, footer_top))) <= {255} + + +def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch): + """Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg.""" + monkeypatch.setattr(display, "_service_active", lambda service_name: True) + (tmp_path / "mode").write_text("migration") + images = tmp_path / "images" + (images / "migrate-nocreds").mkdir(parents=True) + (images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") + (images / "migrate-wlan").mkdir() + (images / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"IMG") + conf = tmp_path / "station.conf" + conf.write_text(f"MIGRATE_SSID='qconnex-firmware'\nIMAGE_DIR='{images}'\n") + switch = tmp_path / "imageset" + + def state(): + return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", + imageset_path=switch) + + # Ohne Datei: die sichere Vorgabe. + assert state()["imageset"] == "ohne-wlan" + switch.write_text("mit-wlan\n") + assert state()["imageset"] == "mit-wlan" + # Unbekannter Inhalt darf NIE auf den teureren Satz zeigen. + switch.write_text("irgendwas") + assert state()["imageset"] == "ohne-wlan" + + +def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch): + """"Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel + wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts.""" + monkeypatch.setattr(display, "_service_active", lambda service_name: True) + images = tmp_path / "images" + (images / "migrate-nocreds").mkdir(parents=True) + (images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") + conf = tmp_path / "station.conf" + conf.write_text(f"IMAGE_DIR='{images}'\n") + switch = tmp_path / "imageset" + + switch.write_text("ohne-wlan") + assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", + imageset_path=switch)["imageset_ok"] is True + switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht + state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", + imageset_path=switch) + assert state["imageset_ok"] is False + assert "FEHLT" in _imageset_line(state) + + # Und ein LEERES Verzeichnis zaehlt genauso als fehlend. + (images / "migrate-wlan").mkdir() + assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", + imageset_path=switch)["imageset_ok"] is False + + +def test_the_display_and_the_server_agree_on_the_set_names(): + """Zwei Prozesse, eine Wahrheit: waeren die Namen oder die Vorgabe verschieden, + behauptete das Panel etwas anderes, als der Server tut.""" + import importlib.util + spec = importlib.util.spec_from_file_location( + "server_for_names", Path(__file__).resolve().parents[1] / "server.py") + server = importlib.util.module_from_spec(spec) + spec.loader.exec_module(server) + assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS + assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET diff --git a/tools/station/tests/test_server.py b/tools/station/tests/test_server.py index 89c53b6..a461fea 100644 --- a/tools/station/tests/test_server.py +++ b/tools/station/tests/test_server.py @@ -494,12 +494,16 @@ class MigrationTestServer: control_dir = tmp_path / "control" control_dir.mkdir() self.control_path = control_dir / "control.json" + # Der Umschalter des Bildsatzes. Die Datei existiert zunaechst NICHT - das ist + # der Auslieferungszustand und muss "ohne-wlan" (migrate-nocreds/) bedeuten. + self.imageset_path = tmp_path / "imageset" self.port = free_port() self.proc = subprocess.Popen( [sys.executable, str(SERVER), "--image-dir", str(self.image_dir), "--bind", "127.0.0.1", "--port", str(self.port), "--state", str(self.state_path), "--mode", mode, - "--control", str(self.control_path)], + "--control", str(self.control_path), + "--imageset-file", str(self.imageset_path)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) deadline = time.time() + 10 @@ -563,6 +567,17 @@ class MigrationTestServer: def write_raw_control(self, text): self.control_path.write_text(text) + def set_image_set(self, value): + """Stellt den Umschalter so um, wie es das CLI tut: eine Datei mit dem Namen des + Bildsatzes. `None` loescht sie wieder (= Auslieferungszustand).""" + if value is None: + try: + self.imageset_path.unlink() + except FileNotFoundError: + pass + return + self.imageset_path.write_text(value) + def stop(self): self.proc.terminate() self.proc.wait(timeout=5) @@ -584,8 +599,8 @@ def migration_server(tmp_path): def test_migration_auto_serves_known_product(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000) + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000) r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img") assert r.status == 200 assert r.body == b"IMG" * 1000 @@ -599,9 +614,9 @@ def test_migration_dev_only_in_version_serves_the_dev_image(migration_server): ging hier das produktionssignierte Image raus (200, und die Lampe lehnte es ab) - jetzt muss das dev-signierte gehen, und status.json muss die Quelle mitfuehren.""" srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000) - (imgdir / "migrate" / "g95_cw_prod.img").write_bytes(b"PROD" * 1000) + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000) + (imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"PROD" * 1000) r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img") assert r.status == 200 assert r.body == b"DEV" * 1000 @@ -617,9 +632,9 @@ def test_migration_production_lamp_still_gets_the_prod_image(migration_server): """Die erfolgreich migrierte GU10: nirgends ein "-dev", also prod - und die Quelle "none", damit am Panel/in status.json steht, worauf die Einstufung beruht.""" srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000) - (imgdir / "migrate" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 1000) + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000) + (imgdir / "migrate-nocreds" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 1000) r = srv.get("/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img") assert r.status == 200 assert r.body == b"PROD" * 1000 @@ -630,8 +645,8 @@ def test_migration_production_lamp_still_gets_the_prod_image(migration_server): def test_migration_listen_records_but_serves_nothing(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.set_control(phase="listen") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") assert r.status == 404 @@ -643,8 +658,8 @@ def test_migration_listen_records_but_serves_nothing(migration_server): def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.set_control(phase="armed", armed_target="g95_cw") p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" assert srv.get(p).status == 200 @@ -654,8 +669,8 @@ def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server): def test_armed_for_other_target_does_not_serve(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.set_control(phase="armed", armed_target="gu10_rgb") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") assert r.status == 404 @@ -671,7 +686,12 @@ def test_unknown_product_id_is_404_and_logged(migration_server): def test_missing_migrate_image_is_404(migration_server): - srv, _ = migration_server + srv, imgdir = migration_server + # Der Bildsatz ist da (er enthaelt ein anderes Modell), nur dieses eine Modell fehlt + # darin - ein anderer Zustand als ein fehlender Bildsatz (siehe + # test_a_selected_but_missing_image_set_delivers_nothing). + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img") assert r.status == 404 st = srv.status(lambda s: len(s["recent"]) >= 1) @@ -692,8 +712,8 @@ def test_migration_path_is_404_in_recovery_mode(server): def test_missing_control_file_means_auto(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.delete_control() assert srv.get( "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200 @@ -701,8 +721,8 @@ def test_missing_control_file_means_auto(migration_server): def test_corrupt_control_file_means_auto(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.write_raw_control("{das ist kein json") assert srv.get( "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200 @@ -710,8 +730,8 @@ def test_corrupt_control_file_means_auto(migration_server): def test_range_resume_on_migration_image(migration_server): srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40) + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40) p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" r = srv.get(p, headers={"Range": "bytes=1000-"}) assert r.status == 206 @@ -724,9 +744,9 @@ def test_version_comes_from_manifest(migration_server): Version traegt - Bestandskompatibilitaet mit einem Manifest aus der Zeit vor Fixrunde 2 (N2).""" srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") - (imgdir / "migrate" / "manifest.json").write_text(json.dumps({ + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({ "ninlight_version": "0.3.9", "images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]})) srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") @@ -742,10 +762,10 @@ def test_version_prefers_the_delivered_entry_over_the_manifest_wide_field(migrat darf dann nicht das manifestweite Feld fuer beide protokollieren, sondern muss die Version DES AUSGELIEFERTEN Eintrags nehmen.""" srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG-DEV") - (imgdir / "migrate" / "g95_cw_prod.img").write_bytes(b"IMG-PROD") - (imgdir / "migrate" / "manifest.json").write_text(json.dumps({ + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG-DEV") + (imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"IMG-PROD") + (imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({ # Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen # darauf im Test auffallen wuerde. "ninlight_version": "1.2.0", @@ -770,8 +790,8 @@ def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path) finally: os.umask(old_umask) try: - (srv.image_dir / "migrate").mkdir(exist_ok=True) - (srv.image_dir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (srv.image_dir / "migrate-nocreds").mkdir(exist_ok=True) + (srv.image_dir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.set_control(phase="armed", armed_target="g95_cw") def world_readable(): @@ -799,8 +819,8 @@ def test_armed_serves_exactly_once_under_concurrency(migration_server): Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt.""" srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000) + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000) srv.set_control(phase="armed", armed_target="g95_cw") p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" @@ -835,8 +855,8 @@ def test_control_write_failure_blocks_further_armed_deliveries(migration_server) ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht mehr wirkt.""" srv, imgdir = migration_server - (imgdir / "migrate").mkdir(exist_ok=True) - (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") srv.set_control(phase="armed", armed_target="g95_cw") p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" @@ -899,3 +919,113 @@ def test_resolve_mode_garbage_file_means_recovery(tmp_path): def test_resolve_mode_default_without_mode_file_is_recovery(): module = _load_server_module() assert module.resolve_mode(None, None) == "recovery" + + +# --- Umschalter: mit oder ohne einkompilierte WLAN-Zugangsdaten --------------------- +# +# Zwei Bildsaetze liegen auf dem Pi (images/migrate-nocreds und images/migrate-wlan); +# welcher ausgeliefert wird, entscheidet allein der Umschalter. Geprueft wird deshalb +# immer an der TATSAECHLICH AUSGELIEFERTEN DATEI, nicht an einem Pfad oder einem +# Statusfeld - der Fehlgriff, gegen den der Umschalter da ist, ist ein falscher Inhalt +# in einer Lampe. + +def _both_sets(imgdir): + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100) + (imgdir / "migrate-wlan").mkdir(exist_ok=True) + (imgdir / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"MITW" * 100) + + +G95_DEV = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" + + +def test_the_default_image_set_is_the_one_without_wifi_credentials(migration_server): + """Ohne Umschalterdatei (Auslieferungszustand) geht der Abzug OHNE Zugangsdaten + raus. Die sichere Vorgabe ist die, bei der ein Fehlgriff niemandem das eigene + WLAN-Passwort in ein fremdes Geraet schreibt.""" + srv, imgdir = migration_server + _both_sets(imgdir) + r = srv.get(G95_DEV) + assert r.status == 200 + assert r.body == b"OHNE" * 100 + + +def test_the_switch_decides_which_file_is_actually_delivered(migration_server): + srv, imgdir = migration_server + _both_sets(imgdir) + srv.set_image_set("mit-wlan") + assert srv.get(G95_DEV).body == b"MITW" * 100 + # ... und wieder zurueck, ohne Dienstneustart: der Umschalter wirkt sofort. + srv.set_image_set("ohne-wlan") + assert srv.get(G95_DEV).body == b"OHNE" * 100 + + +def test_a_broken_switch_file_falls_back_to_the_safe_set(migration_server): + """Unbekannter Inhalt ist kein Grund, den teureren Satz zu nehmen.""" + srv, imgdir = migration_server + _both_sets(imgdir) + srv.set_image_set("voellig-anderes") + assert srv.get(G95_DEV).body == b"OHNE" * 100 + + +def test_a_selected_but_missing_image_set_delivers_nothing(migration_server): + """Der gewaehlte Satz liegt nicht auf dem Pi: es darf NICHTS rausgehen - insbesondere + nicht die Datei des anderen Satzes, die daneben liegt und genauso heisst.""" + srv, imgdir = migration_server + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100) + srv.set_image_set("mit-wlan") # migrate-wlan/ existiert nicht + + r = srv.get(G95_DEV) + assert r.status == 404 + assert b"OHNE" not in r.body + st = srv.status(lambda s: len(s["recent"]) >= 1) + assert st["served"] == 0 + # Eigener Status, damit am Panel/in status.json "Satz fehlt" von "dieses Modell + # fehlt" unterscheidbar bleibt. + assert st["recent"][-1]["status"] == "noimageset" + + +def test_a_missing_image_set_does_not_burn_the_release(migration_server): + """Wie beim fehlenden Einzelimage: eine Freigabe darf nicht daran verfallen, dass es + gar nichts auszuliefern gab.""" + srv, imgdir = migration_server + (imgdir / "migrate-nocreds").mkdir(exist_ok=True) + srv.set_image_set("mit-wlan") + srv.set_control(phase="armed", armed_target="g95_cw") + assert srv.get(G95_DEV).status == 404 + assert srv.read_control()["phase"] == "armed" + + +def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_server): + """Nach dem Umschalten darf nicht die Version des vorigen Satzes stehenbleiben - die + beiden manifest.json entstehen im selben Deploy und koennen dieselbe mtime tragen.""" + srv, imgdir = migration_server + _both_sets(imgdir) + for name, version in (("migrate-nocreds", "0.4.0-onb"), ("migrate-wlan", "0.4.0-wlan")): + (imgdir / name / "manifest.json").write_text(json.dumps({ + "images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img", + "ninlight_version": version}]})) + # Beiden Manifesten dieselbe mtime geben - genau der Fall, den ein reiner + # mtime-Vergleich nicht bemerkt haette. + stamp = 1600000000 + for name in ("migrate-nocreds", "migrate-wlan"): + os.utime(imgdir / name / "manifest.json", (stamp, stamp)) + + assert srv.get(G95_DEV).status == 200 + assert srv.status(lambda s: s["recent"])["recent"][-1]["version"] == "0.4.0-onb" + + srv.set_image_set("mit-wlan") + assert srv.get(G95_DEV).status == 200 + assert srv.status( + lambda s: len(s["recent"]) >= 2 + )["recent"][-1]["version"] == "0.4.0-wlan" + + +def test_read_image_set_defaults_without_a_file(tmp_path): + module = _load_server_module() + assert module.read_image_set(None) == "ohne-wlan" + assert module.read_image_set(tmp_path / "fehlt") == "ohne-wlan" + p = tmp_path / "imageset" + p.write_text("mit-wlan\n") + assert module.read_image_set(p) == "mit-wlan" diff --git a/tools/station/tests/test_setup_pi.sh b/tools/station/tests/test_setup_pi.sh index d9c4e62..cc877fc 100755 --- a/tools/station/tests/test_setup_pi.sh +++ b/tools/station/tests/test_setup_pi.sh @@ -212,6 +212,7 @@ run_setup() { NINLIGHT_STAGE_DIR="$state/tmp" \ NINLIGHT_STATION_CONF="$state/etc/ninlight-station.conf" \ NINLIGHT_STATION_ROOT="$state/opt/ninlight-station" \ + NINLIGHT_STATION_STATE_DIR="$state/var/lib/ninlight-station" \ NINLIGHT_STATION_BIN="$state/usr/local/bin/ninlight-station" \ NINLIGHT_STATION_UNIT="$state/etc/systemd/system/ninlight-station.service" \ NINLIGHT_DISPLAY_UNIT="$state/etc/systemd/system/ninlight-display.service" \ @@ -1047,6 +1048,38 @@ SUM_AFTER=$(find "$STATE/opt/ninlight-station/button" "$STATE/etc/systemd/system grep -Fq "systemctl enable ninlight-button.service" "$LOG" && pass "der zweite Lauf aktiviert den Tasterdienst erneut (idempotent, kein Fehler)" || fail "kein erneutes 'systemctl enable ninlight-button' im zweiten Lauf" echo "$OUT" | grep -q "veraltete Datei entfernt: button/" && fail "der zweite Lauf meldet eine veraltete Datei, obwohl nichts geloescht wurde" || pass "keine falsche 'veraltete Datei entfernt'-Meldung im zweiten Lauf" +echo +echo "=== Szenario: das dauerhafte Zustandsverzeichnis entsteht, die Bildsatzverzeichnisse nicht ===" +# /var/lib/ninlight-station traegt den Umschalter des Migrations-Bildsatzes - er muss +# Neustarts ueberleben, darf also nicht in /run liegen. Die beiden Bildsatzverzeichnisse +# dagegen duerfen hier NICHT entstehen: ein leeres Verzeichnis ist von einem bestueckten +# nicht zu unterscheiden, und genau das soll das Panel melden koennen ("SATZ FEHLT"). +STATE="$TMPROOT/sstate-state"; STUBBIN="$TMPROOT/binstate"; LOG="$TMPROOT/sstate.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +[ -d "$STATE/var/lib/ninlight-station" ] \ + && pass "/var/lib/ninlight-station wurde angelegt" \ + || fail "das dauerhafte Zustandsverzeichnis fehlt - der Umschalter ueberlebt keinen Neustart" +if [ -d "$STATE/var/lib/ninlight-station" ]; then + MODE=$(file_mode "$STATE/var/lib/ninlight-station") + [ "$MODE" = "755" ] && pass "Rechte 0755 (der Anzeigedienst muss den Umschalter lesen)" \ + || fail "Rechte $MODE statt 755" +fi +[ ! -e "$STATE/var/lib/ninlight-station/imageset" ] \ + && pass "die Umschalterdatei selbst wird nicht vorgeschrieben (Vorgabe gilt ohne sie)" \ + || fail "setup_pi.sh legt eine Umschalterdatei an - zweite Quelle fuer denselben Wert" +for d in migrate-wlan migrate-nocreds; do + [ ! -e "$STATE/opt/ninlight-station/images/$d" ] \ + && pass "images/$d bleibt ungeschaffen (leer waere nicht von bestueckt zu unterscheiden)" \ + || fail "images/$d wurde leer angelegt" +done + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden."