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