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