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.
This commit is contained in:
Michael Sauer
2026-09-15 02:17:36 +02:00
parent 3642672c71
commit 300edafc3e
15 changed files with 1381 additions and 200 deletions
+35
View File
@@ -23,8 +23,43 @@ würde dort nur mit einer Fehlermeldung enden.
tools/station/deploy.sh <pi-host> [target] Images ausliefern tools/station/deploy.sh <pi-host> [target] Images ausliefern
ssh <pi-host> ninlight-station on einschalten ssh <pi-host> ninlight-station on einschalten
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
ssh <pi-host> ninlight-station bildsatz welcher Migrations-Bildsatz gilt
ssh <pi-host> ninlight-station off ausschalten ssh <pi-host> 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 ## Taster am Gerät
Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**. Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**.
+46 -2
View File
@@ -97,6 +97,14 @@ RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
# Anzeigedienst nicht ueberdauern. # Anzeigedienst nicht ueberdauern.
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json")) 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") 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") 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 # Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige
# Aktion dieses Dienstes, die an systemd selbst geht. # Aktion dieses Dienstes, die an systemd selbst geht.
@@ -178,6 +186,25 @@ def action_migrate_auto() -> Action:
"in Reichweite - auch fremde"]) "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: def action_cancel() -> Action:
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die """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.""" 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")) running = bool(state.get("running"))
mode = state.get("mode") mode = state.get("mode")
target = state.get("target") target = state.get("target")
imageset = state.get("imageset") or IMAGESET_DEFAULT
if not running: if not running:
return [ return [
@@ -199,6 +227,9 @@ def menu_actions(state: dict) -> list[Action]:
# loslaesst, landet nicht im Automatikmodus. # loslaesst, landet nicht im Automatikmodus.
action_migrate_listen(), action_migrate_listen(),
action_migrate_auto(), 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(), action_shutdown(),
] ]
@@ -209,6 +240,7 @@ def menu_actions(state: dict) -> list[Action]:
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)), items.append(Action("Freigabe: {}".format(target), ("arm", str(target)),
done_text="Freigegeben: {}".format(target))) done_text="Freigegeben: {}".format(target)))
items.append(Action("Station aus", ("off",), done_text="Station aus")) items.append(Action("Station aus", ("off",), done_text="Station aus"))
items.append(action_imageset(imageset))
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage. # Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False, items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False,
done_text="Anzeige aufgefrischt")) done_text="Anzeige aufgefrischt"))
@@ -268,7 +300,18 @@ def _service_active(service_name: str = STATION_SERVICE) -> bool:
return result.returncode == 0 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 """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) 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 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") last_seen = status.get("last_seen")
target = last_seen.get("target") if isinstance(last_seen, dict) else None target = last_seen.get("target") if isinstance(last_seen, dict) else None
return {"running": running, "mode": mode, 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 ------------------------------------------ # --- Bedienzustand in eine Datei schreiben ------------------------------------------
+139 -66
View File
@@ -85,64 +85,70 @@ NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden, # Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1). # die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
MIGRATE_COMPLETE=0 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 # Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
HAVE_PROD=0 # $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
HAVE_DEV=0 # $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
if [ -f "$KEY_PROD" ]; then # $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
HAVE_PROD=1 # Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
else # sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2 # Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
fi sign_set() {
if [ -f "$KEY_DEV" ]; then local src_root=$1 stage_sub=$2 strictness=$3
HAVE_DEV=1 local complete=1 produced=0
else local f base target gz
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2 for f in "${FILES[@]}"; do
fi base=$(basename "$f")
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then target=${base#ninlight_}; target=${target%_ota.img}
# Erst HIER, nachdem feststeht dass ueberhaupt signiert wird, STAGE/migrate anlegen # Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
# und das fehlende _gz.img zum Abbruch machen (Fixrunde 1, C2+C3): ein # belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
# Schluesselverzeichnis ohne jeden Schluessel darf weder ein leeres migrate/ # signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
# erzeugen (das wuerde ein vorhandenes migrate/ auf dem Pi mit --delete leerraeumen) # annimmt.
# noch am Fehlen des _gz.img scheitern, wenn gar nicht signiert werden soll. gz="$src_root/$target/ninlight_${target}_gz.img"
mkdir -p "$STAGE/migrate" if [ ! -f "$gz" ]; then
for f in "${FILES[@]}"; do if [ "$strictness" = "strict" ]; then
base=$(basename "$f") echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
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; }
fi fi
if [ "$HAVE_DEV" -eq 1 ]; then echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/migrate/${target}_dev.img" \ complete=0
|| { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; } continue
fi
done
if [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ]; then
MIGRATE_COMPLETE=1
fi fi
VERSION_ARGS=() mkdir -p "$STAGE/$stage_sub"
for t in "${!NINL_VERSION[@]}"; do if [ "$HAVE_PROD" -eq 1 ]; then
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}") python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
done || { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu produced=1
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit fi
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec if [ "$HAVE_DEV" -eq 1 ]; then
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur) || { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald produced=1
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue fi
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das done
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur [ "$produced" -eq 1 ] || return 1
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen. local VERSION_ARGS=()
python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY' 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 hashlib
import json import json
import os 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) json.dump(manifest, fh, indent=2, sort_keys=True)
fh.write("\n") fh.write("\n")
PY 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 else
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2 echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
fi 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/" 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 # Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. # 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 set -e
FULL_SYNC=$1 FULL_SYNC=$1
MIGRATE_COMPLETE=$2 MIGRATE_COMPLETE=$2
NOCREDS_COMPLETE=$3
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} 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 # ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne
# Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht # Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht
# existiert. # 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 else
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle # Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete). # anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten" 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 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 # Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel # dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden - # vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys # sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen # (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
# statt beim Ersetzen). # statt beim Ersetzen).
if [ -d /tmp/ninlight-images/migrate ]; then sync_migrate_set() {
sudo install -d -m 0755 "$ROOT_DIR/images/migrate" set=$1
if [ "$FULL_SYNC" -eq 1 ] && [ "$MIGRATE_COMPLETE" -eq 1 ]; then 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 # Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete # STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden. # 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 else
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel: # 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 # 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 # sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu # verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben). # gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
sudo rsync -a -c --exclude=/manifest.json /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/" sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
if [ -f /tmp/ninlight-images/migrate/manifest.json ]; then if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
sudo python3 - "$ROOT_DIR/images/migrate/manifest.json" /tmp/ninlight-images/migrate/manifest.json <<'MERGE' sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
import json import json
import sys import sys
@@ -340,7 +398,22 @@ else
# sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von # sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von
# diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau; # diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau;
# diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit). # 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 fi
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py" sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
+54 -5
View File
@@ -58,6 +58,18 @@ SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
# RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also # RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also
# ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet. # ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet.
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json")) 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 POLL_INTERVAL_S = 5
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck # 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 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, 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. """Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie 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 Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt 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): if not _service_active(service_name):
return {"mode": "off"} return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok}
mode = _read_mode_file(run_dir) mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json") 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": if mode == "recovery":
state["ssid"] = conf.get("SSID") state["ssid"] = conf.get("SSID")
+90 -5
View File
@@ -64,17 +64,29 @@ TEXT_MARGIN_PX = 2
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"): # big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"):
# gross und mittig, damit man quer durch den Raum sieht, dass nichts # gross und mittig, damit man quer durch den Raum sieht, dass nichts
# laeuft. # 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 = "header"
ROLE_HEADER_PHASE = "header_phase" ROLE_HEADER_PHASE = "header_phase"
ROLE_HERO = "hero" ROLE_HERO = "hero"
ROLE_DETAIL = "detail" ROLE_DETAIL = "detail"
ROLE_BIG = "big" ROLE_BIG = "big"
ROLE_FOOTER = "footer"
HEADER_FONT_PX = 20 HEADER_FONT_PX = 20
HEADER_PHASE_FONT_PX = 13 HEADER_PHASE_FONT_PX = 13
HERO_FONT_PX = 16 HERO_FONT_PX = 16
DETAIL_FONT_PX = 11 DETAIL_FONT_PX = 11
BIG_FONT_PX = 26 BIG_FONT_PX = 26
FOOTER_FONT_PX = 11
# Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe: # Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe:
# Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem # 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. # bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten.
BOTTOM_MARGIN_PX = 10 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 # 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 # Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten
# beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: <langer Zielname>") # beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: <langer Zielname>")
@@ -167,6 +189,17 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
ARMED_LABEL = "FREIGEGEBEN" ARMED_LABEL = "FREIGEGEBEN"
ARMED_DETAIL_PREFIX = "Freigabe: " 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): # 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 # "-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 # 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") mode = state.get("mode")
out: list[tuple[str, str]] = [] 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": if mode == "off":
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt # 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 # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt. # gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
out.append(("Station aus", ROLE_BIG)) out.append(("Station aus", ROLE_BIG))
return out return out + footer
if mode not in ("recovery", "migration"): if mode not in ("recovery", "migration"):
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
# unterstellen. # unterstellen.
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG)) 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)) 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 '?'} " out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}", ROLE_DETAIL)) 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]]: def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
@@ -501,6 +553,7 @@ def compose(state: dict):
ROLE_HERO: (HERO_FONT_PX, True), ROLE_HERO: (HERO_FONT_PX, True),
ROLE_DETAIL: (DETAIL_FONT_PX, False), ROLE_DETAIL: (DETAIL_FONT_PX, False),
ROLE_BIG: (BIG_FONT_PX, True), ROLE_BIG: (BIG_FONT_PX, True),
ROLE_FOOTER: (FOOTER_FONT_PX, True),
} }
loaded: dict = {} loaded: dict = {}
@@ -532,6 +585,36 @@ def compose(state: dict):
has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list) has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list)
header_mode_right_edge_px = 0.0 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 y = TEXT_MARGIN_PX
for raw_text, role in block_list: for raw_text, role in block_list:
font = font_for(role) font = font_for(role)
@@ -540,7 +623,9 @@ def compose(state: dict):
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze # Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
# Panel. # Panel.
text = fit(raw_text, font) 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") align="center")
continue continue
@@ -573,7 +658,7 @@ def compose(state: dict):
text = fit(raw_text, font) text = fit(raw_text, font)
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX 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 # Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX
# reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die # reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert. # Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
+149 -3
View File
@@ -22,6 +22,31 @@ CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station} RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station}
STATION_LIB=${NINLIGHT_STATION_LIB:-/opt/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") ----------------------------------------------------- # --- Zeitschalter ("Auto-Aus") -----------------------------------------------------
# #
# Rein OPTIONAL: nur "--for <minuten>" legt einen an. Ohne die Angabe passiert hier # Rein OPTIONAL: nur "--for <minuten>" 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 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() { 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) # Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist)
@@ -518,8 +646,10 @@ migrate_on() {
done done
[ "$_for_seen" -eq 1 ] || _for_minutes="" [ "$_for_seen" -eq 1 ] || _for_minutes=""
need_profile "$MIGRATE_AP_CONNECTION" need_profile "$MIGRATE_AP_CONNECTION"
_set=$(imageset_get)
_setdir=$(imageset_dir "$_set")
n=$(migrate_images | wc -l | tr -d ' ') 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 # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
ap_switch "$MIGRATE_AP_CONNECTION" ap_switch "$MIGRATE_AP_CONNECTION"
@@ -530,6 +660,10 @@ migrate_on() {
} }
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase" start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)" 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 "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." echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
autooff_arm_if_requested autooff_arm_if_requested
@@ -861,6 +995,7 @@ PY
fi fi
printf 'Modus : %s\n' "$_modus" printf 'Modus : %s\n' "$_modus"
printf 'Phase : %s\n' "$_phase" printf 'Phase : %s\n' "$_phase"
imageset_status_lines
printf 'Erkannt: %s\n' "$_erkannt" printf 'Erkannt: %s\n' "$_erkannt"
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
autooff_status_line autooff_status_line
@@ -898,8 +1033,16 @@ display_cmd() {
} }
usage() { usage() {
echo "Aufruf: ninlight-station on [--for <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2 echo "Aufruf: ninlight-station on [--for <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2
echo "" >&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 <minuten> Zeitschalter: die Station schaltet sich nach <minuten> selbst" >&2 echo " --for <minuten> Zeitschalter: die Station schaltet sich nach <minuten> selbst" >&2
echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&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 echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2
@@ -956,6 +1099,9 @@ case "$_cmd" in
listen) listen)
listen_cmd listen_cmd
;; ;;
bildsatz)
bildsatz_cmd "${1:-}"
;;
renew) renew)
renew_cmd "${1:-}" renew_cmd "${1:-}"
;; ;;
+1 -1
View File
@@ -20,7 +20,7 @@ StartLimitBurst=5
[Service] [Service]
Type=simple Type=simple
EnvironmentFile=/etc/ninlight-station.conf 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 Restart=always
RestartSec=2 RestartSec=2
+111 -11
View File
@@ -62,10 +62,54 @@ def trailer_version(path: Path) -> str:
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace") 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-<mac4>)
# 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: class Station:
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None, 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 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.state_path = state_path
self.stall_after = stall_after self.stall_after = stall_after
self.mode = mode self.mode = mode
@@ -77,6 +121,8 @@ class Station:
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2). # wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
self.last_seen: dict | None = None self.last_seen: dict | None = None
self._manifest_mtime: float | 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 # Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch
# geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt # geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt
# jeder Eintrag seine eigene Version, image_version() muss also den ganzen # jeder Eintrag seine eigene Version, image_version() muss also den ganzen
@@ -105,24 +151,52 @@ class Station:
def image_path(self, target: str) -> Path: def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img" return self.image_dir / f"ninlight_{target}_ota.img"
def 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: 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: def _load_manifest(self) -> dict:
"""Geparster Inhalt von <image_dir>/migrate/manifest.json, neu eingelesen bei """Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen
jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie unlesbar/kaputtes bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie
JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis {} - dieselbe unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis
Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit eine falsch {} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit
geformte, aber syntaktisch gueltige Datei nicht mit einem AttributeError den eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem
Server abschiesst.""" AttributeError den Server abschiesst."""
path = self.image_dir / "migrate" / "manifest.json" path = self.migrate_dir() / "manifest.json"
try: try:
mtime = path.stat().st_mtime mtime = path.stat().st_mtime
except OSError: except OSError:
self._manifest_mtime = None self._manifest_mtime = None
self._manifest_path = path
self._manifest_cache = {} self._manifest_cache = {}
return 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: try:
data = json.loads(path.read_text()) data = json.loads(path.read_text())
except (OSError, ValueError): except (OSError, ValueError):
@@ -428,6 +502,19 @@ class Handler(BaseHTTPRequestHandler):
return return
path = self.station.migrate_image_path(target, variant) 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 not path.is_file():
if phase == "armed": if phase == "armed":
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten, # 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'") "Vorgabe bei fehlender/kaputter Datei ist 'recovery'")
p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"), p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"),
help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus") 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, p.add_argument("--stall-after", type=int, default=None,
help="test aid: stop sending after N bytes but keep the connection open") help="test aid: stop sending after N bytes but keep the connection open")
a = p.parse_args(argv) a = p.parse_args(argv)
@@ -561,13 +652,22 @@ def main(argv: list[str] | None = None) -> int:
return 2 return 2
mode = resolve_mode(a.mode, a.mode_file) 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 = ThreadingHTTPServer((a.bind, a.port), Handler)
httpd.daemon_threads = True httpd.daemon_threads = True
httpd.station = station # type: ignore[attr-defined] httpd.station = station # type: ignore[attr-defined]
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img")) 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): " print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
f"{', '.join(images) or 'none'}", flush=True) 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: if a.stall_after is not None:
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True) print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
try: try:
+16
View File
@@ -154,6 +154,22 @@ if [ ! -f "$STAGE_DIR/ninlight-button/button.py" ]; then
fi fi
sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" "$ROOT_DIR/display" "$ROOT_DIR/button" 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 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py"
sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py" sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py"
sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH" sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH"
+72 -14
View File
@@ -26,10 +26,14 @@ import button # noqa: E402
# --- Zustaende, von denen die Menueeintraege abhaengen ------------------------------- # --- Zustaende, von denen die Menueeintraege abhaengen -------------------------------
STATE_OFF = {"running": False, "mode": "recovery", "target": None} STATE_OFF = {"running": False, "mode": "recovery", "target": None,
STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None} "imageset": "ohne-wlan"}
STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb"} STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None,
STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "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 --------------------------------------------- # --- Fake-Uhr, Taster-Doppel, Protokoll ---------------------------------------------
@@ -164,7 +168,8 @@ def _labels(state):
def test_menu_off_offers_starting_and_shutdown(): def test_menu_off_offers_starting_and_shutdown():
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen", 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(): 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(): def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station aus", "Anzeige auffrischen", assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN",
"Herunterfahren"] "Anzeige auffrischen", "Herunterfahren"]
def test_menu_in_migration_offers_the_detected_target(): def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus", 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(): 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 """Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde).""" eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND)) assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Anzeige auffrischen", assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN",
"Herunterfahren"] "Anzeige auffrischen", "Herunterfahren"]
def test_only_the_refresh_entry_skips_the_confirmation(): 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(): 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" 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(): 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 rec.argvs() == [["/usr/local/bin/ninlight-station", "display", "test"]]
assert "confirm" not in rec.kinds() 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(): 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, rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 9.0,
action_duration_s=4.0) state=STATE_RECOVERY, action_duration_s=4.0)
kinds = [k for _t, k, _p in rec.events] kinds = [k for _t, k, _p in rec.events]
run_at = kinds.index("run") run_at = kinds.index("run")
assert rec.events[run_at - 1][2].get("view") == "busy" 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) ui.update(0.0, True)
assert ui.update(0.2, False) is None assert ui.update(0.2, False) is None
assert ui.view(0.3) == {"view": "idle", "active": False} 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"
+142 -16
View File
@@ -217,8 +217,12 @@ EOF
setup_sandbox() { setup_sandbox() {
local sandbox=$1 unitfile=${2:-$REAL_UNIT} local sandbox=$1 unitfile=${2:-$REAL_UNIT}
rm -rf "$sandbox" rm -rf "$sandbox"
mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate" \ mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \
"$sandbox/lib/display" "$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" echo "dummy" >"$sandbox/images/ninlight_foo_ota.img"
# NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst. # 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_STATION_CONF="$sandbox/station.conf" \
NINLIGHT_RUN_DIR="$sandbox/run" \ NINLIGHT_RUN_DIR="$sandbox/run" \
NINLIGHT_STATION_LIB="$sandbox/lib" \ NINLIGHT_STATION_LIB="$sandbox/lib" \
NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \
"$CLI" "$@" "$CLI" "$@"
} }
@@ -334,7 +339,7 @@ echo
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ===" echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
SBX="$TMPROOT/t3" SBX="$TMPROOT/t3"
setup_sandbox "$SBX" 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 run_cli "$SBX" on >/dev/null 2>&1 # Recovery vorher an
: >"$SBX/cmd.log" : >"$SBX/cmd.log"
set +e set +e
@@ -356,7 +361,7 @@ echo
echo "=== Fall 4: 'migrate on --listen' wie Fall 3, aber phase=listen ===" echo "=== Fall 4: 'migrate on --listen' wie Fall 3, aber phase=listen ==="
SBX="$TMPROOT/t4" SBX="$TMPROOT/t4"
setup_sandbox "$SBX" 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 run_cli "$SBX" on >/dev/null 2>&1
set +e set +e
OUT=$(run_cli "$SBX" migrate on --listen 2>&1) 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 ===" echo "=== Fall 5: 'migrate on' ohne Migrationsimages -> Warnung auf stderr, Rueckgabewert 0 ==="
SBX="$TMPROOT/t5" SBX="$TMPROOT/t5"
setup_sandbox "$SBX" 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 set +e
OUT=$(run_cli "$SBX" migrate on 2>"$SBX/stderr.log") OUT=$(run_cli "$SBX" migrate on 2>"$SBX/stderr.log")
RC=$? RC=$?
@@ -468,7 +474,7 @@ echo
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ==="
SBX="$TMPROOT/t12" SBX="$TMPROOT/t12"
setup_sandbox "$SBX" 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 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). # status.json so, wie server.py sie beim Start geschrieben haette (mode/last_seen).
cat >"$SBX/run/status.json" <<'JSON' 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 ===" echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ==="
SBX="$TMPROOT/t15" SBX="$TMPROOT/t15"
setup_sandbox "$SBX" 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 run_cli "$SBX" on >/dev/null 2>&1
ACTIVE_BEFORE=$(cat "$SBX/nmstate/active") ACTIVE_BEFORE=$(cat "$SBX/nmstate/active")
echo "$ACTIVE_BEFORE" | grep -qx "ninlight-recovery" && pass "Vorbedingung: ninlight-recovery laeuft" || fail "Vorbedingung nicht erfuellt: $ACTIVE_BEFORE" 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" BROKEN_UNIT="$TMPROOT/broken-ninlight-station.service"
grep -v '^RuntimeDirectoryPreserve=restart' "$REAL_UNIT" >"$BROKEN_UNIT" grep -v '^RuntimeDirectoryPreserve=restart' "$REAL_UNIT" >"$BROKEN_UNIT"
setup_sandbox "$SBX" "$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 set +e
OUT=$(run_cli "$SBX" migrate on --listen 2>"$SBX/stderr.log") OUT=$(run_cli "$SBX" migrate on --listen 2>"$SBX/stderr.log")
RC=$? RC=$?
@@ -555,7 +561,7 @@ ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
# in tools/station/ninlight-station.service tatsaechlich steht und wirkt. # in tools/station/ninlight-station.service tatsaechlich steht und wirkt.
SBX2="$TMPROOT/tC1-fixed" SBX2="$TMPROOT/tC1-fixed"
setup_sandbox "$SBX2" setup_sandbox "$SBX2"
echo "dummy" >"$SBX2/images/migrate/gu10_rgb_prod.img" echo "dummy" >"$SBX2/images/migrate-nocreds/gu10_rgb_prod.img"
set +e set +e
OUT2=$(run_cli "$SBX2" migrate on --listen 2>&1) OUT2=$(run_cli "$SBX2" migrate on --listen 2>&1)
RC2=$? RC2=$?
@@ -567,7 +573,7 @@ echo
echo "=== Zusatzfall I1: 'status' zeigt die Phase aus control.json, nicht die veraltete Phase aus status.json ===" echo "=== Zusatzfall I1: 'status' zeigt die Phase aus control.json, nicht die veraltete Phase aus status.json ==="
SBX="$TMPROOT/tI1" SBX="$TMPROOT/tI1"
setup_sandbox "$SBX" 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" migrate on --listen >/dev/null 2>&1
# status.json wird nur beim Dienststart/bei einer Auslieferung geschrieben - hier # status.json wird nur beim Dienststart/bei einer Auslieferung geschrieben - hier
# simuliert, wie es nach dem Start aussehen wuerde (Phase "listen", wie beim Start). # 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 ===" echo "=== Zusatzfall I3: ap_switch bricht ab und faehrt beide Profile herunter, wenn 'device show wlan0' nach dem Umschalten nicht passt ==="
SBX="$TMPROOT/tI3" SBX="$TMPROOT/tI3"
setup_sandbox "$SBX" setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e set +e
OUT=$(FAKE_NMCLI_DEVSHOW=fremdes-profil run_cli "$SBX" migrate on 2>"$SBX/stderr.log") OUT=$(FAKE_NMCLI_DEVSHOW=fremdes-profil run_cli "$SBX" migrate on 2>"$SBX/stderr.log")
RC=$? RC=$?
@@ -619,7 +625,7 @@ echo
echo "=== Zusatzfall N2 (Fixrunde 2): 'status' zeigt keine veraltete Migrationsphase mehr, nachdem in den Rettungsmodus zurueckgeschaltet wurde ===" echo "=== Zusatzfall N2 (Fixrunde 2): 'status' zeigt keine veraltete Migrationsphase mehr, nachdem in den Rettungsmodus zurueckgeschaltet wurde ==="
SBX="$TMPROOT/tN2" SBX="$TMPROOT/tN2"
setup_sandbox "$SBX" 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" 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" 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 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 ===" echo "=== Zusatzfall N2b (Fixrunde 2): dieselbe veraltete Freigabe, aber ohne aktualisierte status.json (Modus '-') - genau das woertliche Repro aus dem Review ==="
SBX="$TMPROOT/tN2b" SBX="$TMPROOT/tN2b"
setup_sandbox "$SBX" 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" migrate on --listen >/dev/null 2>&1
run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1
run_cli "$SBX" on >/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. # naechsten Pi-Neustart dunkel. Jetzt laesst 'off' den Dienst laufen.
SBX="$TMPROOT/tD4" SBX="$TMPROOT/tD4"
setup_sandbox "$SBX" 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 run_cli "$SBX" migrate on >/dev/null 2>&1
: >"$SBX/cmd.log" : >"$SBX/cmd.log"
set +e set +e
@@ -997,7 +1003,7 @@ echo
echo "=== Fall Z4: 'migrate on --listen --for 5' - beides zusammen ===" echo "=== Fall Z4: 'migrate on --listen --for 5' - beides zusammen ==="
SBX="$TMPROOT/tZ4" SBX="$TMPROOT/tZ4"
setup_sandbox "$SBX" setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e set +e
OUT=$(run_cli "$SBX" migrate on --listen --for 5 2>&1) OUT=$(run_cli "$SBX" migrate on --listen --for 5 2>&1)
RC=$? RC=$?
@@ -1008,7 +1014,7 @@ set -e
# Umgekehrte Reihenfolge der Schalter muss genauso gehen. # Umgekehrte Reihenfolge der Schalter muss genauso gehen.
SBX="$TMPROOT/tZ4b" SBX="$TMPROOT/tZ4b"
setup_sandbox "$SBX" setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e set +e
OUT=$(run_cli "$SBX" migrate on --for 5 --listen 2>&1) OUT=$(run_cli "$SBX" migrate on --for 5 --listen 2>&1)
RC=$? 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")" [ ! -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 "$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 echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden." echo "Alle Tests bestanden."
+166 -38
View File
@@ -504,8 +504,8 @@ RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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 ' ') 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" PROD_IMG="$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img"
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 "$PROD_IMG" ] && [ -f "$DEV_IMG" ]; then if [ -f "$PROD_IMG" ] && [ -f "$DEV_IMG" ]; then
pass "beide Migrationsimages (prod, dev) wurden angelegt" pass "beide Migrationsimages (prod, dev) wurden angelegt"
else else
@@ -535,7 +535,7 @@ set +e
OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$? RC=$?
set -e 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 if [ "$RC" -eq 0 ] && [ -f "$MANIFEST" ]; then
pass "manifest.json wurde angelegt" pass "manifest.json wurde angelegt"
else else
@@ -592,8 +592,8 @@ if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; the
else else
fail "Rettungsimage fehlt: $OUT" fail "Rettungsimage fehlt: $OUT"
fi fi
if [ ! -d "$SANDBOX_STATE/opt/ninlight-station/images/migrate" ] \ if [ ! -d "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" ] \
|| [ -z "$(ls -A "$SANDBOX_STATE/opt/ninlight-station/images/migrate" 2>/dev/null)" ]; then || [ -z "$(ls -A "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan" 2>/dev/null)" ]; then
pass "keine Migrationsimages entstanden" pass "keine Migrationsimages entstanden"
else else
fail "Migrationsimages entstanden, obwohl kein Schluesselverzeichnis vorhanden war" 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" pass "dev-Migrationsimage wurde angelegt"
else else
fail "dev-Migrationsimage fehlt: $OUT" fail "dev-Migrationsimage fehlt: $OUT"
fi 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" pass "prod-Migrationsimage entfiel wie erwartet"
else else
fail "prod-Migrationsimage wurde trotz fehlendem Produktionsschluessel angelegt" 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 make_gz_file "$SANDBOX/build/bar/ninlight_bar_gz.img" C
echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
echo "bar-ota" >"$SANDBOX/build/bar/ninlight_bar_ota.img" echo "bar-ota" >"$SANDBOX/build/bar/ninlight_bar_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan"
echo "vorhandenes-bar-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_prod.img" 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/bar_dev.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 # Manifest eines frueheren Deploys, das bar beschrieben hat - genau das darf ein
# gezielter Deploy von foo nicht einfach wegschreiben (Fixrunde 1, I1). bar traegt # 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 # 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 # (nl_mkimage-Stub liefert immer "1.0.0") - das Manifest nach dem Merge muss beide
# Versionen unveraendert nebeneinander stehen lassen (Fixrunde 2, N2). # 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", {"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.9.0",
"images": [ "images": [
{"target": "bar", "variant": "prod", "file": "bar_prod.img", "bytes": 20, "sha256": "x", {"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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" ] \ if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img" ] \
&& [ -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 "Migrationsimages fuer das angegebene Ziel (foo) wurden angelegt" pass "Migrationsimages fuer das angegebene Ziel (foo) wurden angelegt"
else else
fail "Migrationsimages fuer foo fehlen: $OUT" fail "Migrationsimages fuer foo fehlen: $OUT"
fi fi
if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_prod.img" 2>/dev/null)" = "vorhandenes-bar-prod" ] \ 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/bar_dev.img" 2>/dev/null)" = "vorhandenes-bar-dev" ]; then && [ "$(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" pass "Migrationsimages des NICHT ausgewaehlten Geraetetyps (bar) blieben unangetastet"
else else
fail "Migrationsimages von bar wurden veraendert oder geloescht - 4a-Fehler fuer migrate/" fail "Migrationsimages von bar wurden veraendert oder geloescht - 4a-Fehler fuer migrate/"
@@ -682,7 +682,7 @@ fi
set +e set +e
MANIFEST_CHECK=$(python3 -c " MANIFEST_CHECK=$(python3 -c "
import json 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) data = json.load(fh)
entries = data.get('images', []) entries = data.get('images', [])
keys = {(e.get('target'), e.get('variant')) for e in entries} 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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 if [ -f "$DEV_IMG" ]; then
set +e set +e
python3 -c " python3 -c "
@@ -797,22 +797,22 @@ setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG" setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo" mkdir -p "$SANDBOX/build/foo"
echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan"
echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 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/foo_dev.img" echo "vorhandenes-foo-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_dev.img"
: >"$LOG" : >"$LOG"
set +e set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1) OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" ] \ 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/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ]; then && [ "$(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" pass "vorhandenes migrate/ blieb beim Voll-Deploy ohne Schluesselverzeichnis unangetastet"
else else
fail "migrate/ wurde geleert, obwohl kein Schluesselverzeichnis vorhanden war - zerstoert die Bestueckung" fail "migrate/ wurde geleert, obwohl kein Schluesselverzeichnis vorhanden war - zerstoert die Bestueckung"
fi 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" pass "Ausgabe kuendigt an, dass migrate/ unangetastet bleibt"
else else
fail "Ausgabe erwaehnt nicht, dass migrate/ unangetastet bleibt: $OUT" fail "Ausgabe erwaehnt nicht, dass migrate/ unangetastet bleibt: $OUT"
@@ -831,11 +831,11 @@ setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG" setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo" mkdir -p "$SANDBOX/build/foo"
echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan"
echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 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/foo_dev.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": []}' \ 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" : >"$LOG"
set +e set +e
OUT=$(TEST_SIGN_DIR="$SIGN_EMPTY" run_deploy "$SANDBOX" pi-test 2>&1) 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 else
fail "Rettungsimage fehlt: $OUT" fail "Rettungsimage fehlt: $OUT"
fi fi
if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 2>/dev/null)" = "vorhandenes-foo-prod" ] \ 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/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ] \ && [ "$(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/manifest.json"; then && 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" pass "vorhandenes migrate/ (Bilder UND manifest.json) blieb unangetastet - Verzeichnis war da, aber leer"
else else
fail "migrate/ wurde trotz vorhandenem, aber leerem Schluesselverzeichnis veraendert/geleert - C2 nicht behoben" 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" mkdir -p "$SANDBOX/build/foo"
make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img"
echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan"
echo "echtes-altes-prod-image" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" echo "echtes-altes-prod-image" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/foo_prod.img"
: >"$LOG" : >"$LOG"
set +e set +e
OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test 2>&1) # Voll-Deploy, kein Ziel-Argument OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test 2>&1) # Voll-Deploy, kein Ziel-Argument
RC=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" pass "neues dev-Migrationsimage wurde signiert und ausgeliefert"
else else
fail "dev-Migrationsimage fehlt: $OUT" fail "dev-Migrationsimage fehlt: $OUT"
fi 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" pass "das vorhandene _prod.img blieb unangetastet, obwohl dieser Voll-Deploy nur den dev-Schluessel hatte"
else else
fail "das vorhandene _prod.img wurde beim Voll-Deploy mit nur dev-Schluessel entfernt - C2 (Teilfall) nicht behoben" 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) OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$? RC=$?
set -e 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 if [ "$RC" -eq 0 ] && [ -f "$MANIFEST" ]; then
pass "manifest.json wurde angelegt" pass "manifest.json wurde angelegt"
else else
fail "manifest.json fehlt oder Exit-Code $RC: $OUT" fail "manifest.json fehlt oder Exit-Code $RC: $OUT"
fi fi
printf 'mit-wlan\n' >"$TMPROOT/s20-imageset"
set +e set +e
VERSION=$(python3 -c " VERSION=$(python3 -c "
import importlib.util import importlib.util
@@ -929,6 +930,9 @@ st = module.Station(
stall_after=None, stall_after=None,
mode='migration', mode='migration',
control_path=Path('$TMPROOT/s20-control.json'), 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')) print(st.image_version('foo', 'dev'))
" 2>&1) " 2>&1)
@@ -965,8 +969,8 @@ for label in truncated empty array null string null-entry; do
mkdir -p "$SANDBOX/build/foo" mkdir -p "$SANDBOX/build/foo"
make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img"
echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan"
cp "$TMPROOT/bad-manifests/$label.json" "$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" cp "$TMPROOT/bad-manifests/$label.json" "$SANDBOX_STATE/opt/ninlight-station/images/migrate-wlan/manifest.json"
: >"$LOG" : >"$LOG"
set +e set +e
# Nur EIN Schluessel (dev) -> MIGRATE_COMPLETE=0 -> der Merge-Zweig laeuft, nicht der # 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" fail "Bestandsmanifest '$label': Exit-Code $RC - Deploy abgebrochen. Ausgabe: $OUT"
fi fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/server.py" ] \ 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)" pass "Bestandsmanifest '$label': Deploy lief vollstaendig durch (server.py installiert, foo_dev.img da)"
else else
fail "Bestandsmanifest '$label': Deploy blieb auf halbem Weg stehen (server.py oder foo_dev.img fehlt)" 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" 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" [ -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 echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden." echo "Alle Tests bestanden."
+162 -4
View File
@@ -861,9 +861,11 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0})) {"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", 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)) 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})) (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", 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): 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) _drive_daemon(state_at, duration_s=10.0)
assert calls == [0.0, 5.0, 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
+165 -35
View File
@@ -494,12 +494,16 @@ class MigrationTestServer:
control_dir = tmp_path / "control" control_dir = tmp_path / "control"
control_dir.mkdir() control_dir.mkdir()
self.control_path = control_dir / "control.json" 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.port = free_port()
self.proc = subprocess.Popen( self.proc = subprocess.Popen(
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir), [sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
"--bind", "127.0.0.1", "--port", str(self.port), "--bind", "127.0.0.1", "--port", str(self.port),
"--state", str(self.state_path), "--mode", mode, "--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, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
) )
deadline = time.time() + 10 deadline = time.time() + 10
@@ -563,6 +567,17 @@ class MigrationTestServer:
def write_raw_control(self, text): def write_raw_control(self, text):
self.control_path.write_text(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): def stop(self):
self.proc.terminate() self.proc.terminate()
self.proc.wait(timeout=5) self.proc.wait(timeout=5)
@@ -584,8 +599,8 @@ def migration_server(tmp_path):
def test_migration_auto_serves_known_product(migration_server): def test_migration_auto_serves_known_product(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000) (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") 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.status == 200
assert r.body == b"IMG" * 1000 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) - 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.""" jetzt muss das dev-signierte gehen, und status.json muss die Quelle mitfuehren."""
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000) (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000)
(imgdir / "migrate" / "g95_cw_prod.img").write_bytes(b"PROD" * 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") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
assert r.status == 200 assert r.status == 200
assert r.body == b"DEV" * 1000 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 """Die erfolgreich migrierte GU10: nirgends ein "-dev", also prod - und die Quelle
"none", damit am Panel/in status.json steht, worauf die Einstufung beruht.""" "none", damit am Panel/in status.json steht, worauf die Einstufung beruht."""
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000) (imgdir / "migrate-nocreds" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000)
(imgdir / "migrate" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 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") r = srv.get("/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
assert r.status == 200 assert r.status == 200
assert r.body == b"PROD" * 1000 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): def test_migration_listen_records_but_serves_nothing(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="listen") srv.set_control(phase="listen")
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
assert r.status == 404 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): def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="g95_cw") srv.set_control(phase="armed", armed_target="g95_cw")
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
assert srv.get(p).status == 200 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): def test_armed_for_other_target_does_not_serve(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="gu10_rgb") srv.set_control(phase="armed", armed_target="gu10_rgb")
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
assert r.status == 404 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): 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") r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
assert r.status == 404 assert r.status == 404
st = srv.status(lambda s: len(s["recent"]) >= 1) 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): def test_missing_control_file_means_auto(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.delete_control() srv.delete_control()
assert srv.get( assert srv.get(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200 "/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): def test_corrupt_control_file_means_auto(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.write_raw_control("{das ist kein json") srv.write_raw_control("{das ist kein json")
assert srv.get( assert srv.get(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200 "/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): def test_range_resume_on_migration_image(migration_server):
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40) (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" p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
r = srv.get(p, headers={"Range": "bytes=1000-"}) r = srv.get(p, headers={"Range": "bytes=1000-"})
assert r.status == 206 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 Version traegt - Bestandskompatibilitaet mit einem Manifest aus der Zeit vor
Fixrunde 2 (N2).""" Fixrunde 2 (N2)."""
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
(imgdir / "migrate" / "manifest.json").write_text(json.dumps({ (imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
"ninlight_version": "0.3.9", "ninlight_version": "0.3.9",
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]})) "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") 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 darf dann nicht das manifestweite Feld fuer beide protokollieren, sondern muss die
Version DES AUSGELIEFERTEN Eintrags nehmen.""" Version DES AUSGELIEFERTEN Eintrags nehmen."""
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG-DEV") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG-DEV")
(imgdir / "migrate" / "g95_cw_prod.img").write_bytes(b"IMG-PROD") (imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"IMG-PROD")
(imgdir / "migrate" / "manifest.json").write_text(json.dumps({ (imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
# Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen # Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen
# darauf im Test auffallen wuerde. # darauf im Test auffallen wuerde.
"ninlight_version": "1.2.0", "ninlight_version": "1.2.0",
@@ -770,8 +790,8 @@ def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path)
finally: finally:
os.umask(old_umask) os.umask(old_umask)
try: try:
(srv.image_dir / "migrate").mkdir(exist_ok=True) (srv.image_dir / "migrate-nocreds").mkdir(exist_ok=True)
(srv.image_dir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (srv.image_dir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="g95_cw") srv.set_control(phase="armed", armed_target="g95_cw")
def world_readable(): 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 Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen
Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt.""" Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt."""
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000) (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000)
srv.set_control(phase="armed", armed_target="g95_cw") srv.set_control(phase="armed", armed_target="g95_cw")
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" 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 ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht
mehr wirkt.""" mehr wirkt."""
srv, imgdir = migration_server srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True) (imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") (imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="g95_cw") srv.set_control(phase="armed", armed_target="g95_cw")
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" 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(): def test_resolve_mode_default_without_mode_file_is_recovery():
module = _load_server_module() module = _load_server_module()
assert module.resolve_mode(None, None) == "recovery" 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"
+33
View File
@@ -212,6 +212,7 @@ run_setup() {
NINLIGHT_STAGE_DIR="$state/tmp" \ NINLIGHT_STAGE_DIR="$state/tmp" \
NINLIGHT_STATION_CONF="$state/etc/ninlight-station.conf" \ NINLIGHT_STATION_CONF="$state/etc/ninlight-station.conf" \
NINLIGHT_STATION_ROOT="$state/opt/ninlight-station" \ 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_BIN="$state/usr/local/bin/ninlight-station" \
NINLIGHT_STATION_UNIT="$state/etc/systemd/system/ninlight-station.service" \ NINLIGHT_STATION_UNIT="$state/etc/systemd/system/ninlight-station.service" \
NINLIGHT_DISPLAY_UNIT="$state/etc/systemd/system/ninlight-display.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" 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 "$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 echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden." echo "Alle Tests bestanden."