feat(station): Unterbefehle fuer Migrationsmodus und Freigabe

ninlight-station bekommt recovery/migrate/arm/listen als Unterbefehle statt nur
on/off/status; "on" bleibt Kurzform fuer "recovery on". Eine gemeinsame
ap_switch()-Funktion faehrt vor jedem Wechsel erst jedes andere Stationsprofil
herunter und prueft per nmcli nach, dass auf wlan0 wirklich nur eines aktiv
ist - die beiden Zugangspunkte (Rettung/Migration) schliessen sich damit
gegenseitig aus. Der Modus wandert per /run/ninlight-station/mode an den
(ohnehin neu gestarteten) Dienst statt ueber die Konfigurationsdatei; arm/listen
schreiben die Steuerdatei aus Task 3 mit demselben atomaren 0644-Muster wie
server.py selbst. station.conf.example und die Pflichtschluesselpruefung
bekommen die neuen Migrations- und Anzeige-Schluessel dazu.
This commit is contained in:
Michael Sauer
2026-09-13 18:39:48 +02:00
parent d3e608dca0
commit 1d3627bacd
5 changed files with 688 additions and 57 deletions
+285 -56
View File
@@ -1,14 +1,27 @@
#!/bin/sh #!/bin/sh
# Schaltet die ninlight-Recovery-Station an und aus. # Schaltet die ninlight-Station (Rettungs- oder Migrationsmodus) an und aus.
# #
# Die Station ist absichtlich nicht dauerhaft an: Solange sie laeuft, kann jeder # Die Station ist absichtlich nicht dauerhaft an: Solange sie laeuft, kann jeder
# in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige # in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige
# Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert # Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert
# bei keiner Lampe in Reichweite mehr (die Firmware loescht den Zaehler vor # bei keiner Lampe in Reichweite mehr (die Firmware loescht den Zaehler vor
# jedem Recovery-Versuch). # jedem Recovery-Versuch). Der Migrationsmodus (Zugangspunkt "qconnex-firmware",
# oeffentlich bekanntes Passwort aus der alten Klyqa-Firmware) verschiebt diese
# Vertrauensgrenze noch weiter nach aussen - siehe README.md.
#
# Die beiden Zugangspunkte schliessen sich gegenseitig aus: ap_switch() faehrt
# immer erst jedes andere Stationsprofil herunter, bevor es das gewuenschte
# hochfaehrt, und prueft danach per nmcli nach, dass wirklich nur eines aktiv
# ist.
set -eu set -eu
CONF=/etc/ninlight-station.conf # Ueberschreibbar fuer Tests (dasselbe Prinzip wie NINLIGHT_STATION_ROOT/_BIN/_UNIT
# in deploy.sh): so kann tests/test_cli.sh gegen ein Sandbox-Verzeichnis statt
# gegen echte Systempfade laufen.
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station}
STATION_LIB=${NINLIGHT_STATION_LIB:-/opt/ninlight-station}
if [ -r "$CONF" ]; then if [ -r "$CONF" ]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$CONF" . "$CONF"
@@ -23,7 +36,8 @@ else
exit 1 exit 1
fi fi
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION; do for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
eval "test -n \"\${$_key-}\"" || { eval "test -n \"\${$_key-}\"" || {
echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2 echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2
exit 1 exit 1
@@ -32,8 +46,9 @@ done
unset _key unset _key
need_profile() { need_profile() {
nmcli -t -f NAME con show | grep -qx "$AP_CONNECTION" || { # $1 = NetworkManager-Profilname, der existieren muss.
echo "Fehler: NetworkManager-Profil '$AP_CONNECTION' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 nmcli -t -f NAME con show | grep -qx "$1" || {
echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
exit 1 exit 1
} }
} }
@@ -42,51 +57,142 @@ 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
} }
case "${1:-}" in migrate_images() {
on) find "$IMAGE_DIR/migrate" -maxdepth 1 -name '*.img' 2>/dev/null | sort
need_profile }
n=$(images | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2 # Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist)
sudo nmcli con up "$AP_CONNECTION" >/dev/null # das gewuenschte hoch, und prueft danach per nmcli nach, dass wirklich genau
# dieses Profil auf wlan0 aktiv ist. Weicht es ab, geht alles wieder herunter und
# die Funktion bricht mit Fehler ab - das ist die Absicherung fuer Fall 15 der
# Testliste: zwei gleichzeitig aktive Stationsprofile auf demselben Interface
# duerfen nie vorkommen.
#
# $1 leer bedeutet "nur alles herunterfahren" (Grundlage fuer "off").
ap_switch() {
_target=${1:-}
for _p in "$AP_CONNECTION" "$MIGRATE_AP_CONNECTION"; do
if [ -n "$_target" ] && [ "$_p" = "$_target" ]; then
continue
fi
sudo nmcli con down "$_p" >/dev/null 2>&1 || true
done
[ -n "$_target" ] || return 0
sudo nmcli con up "$_target" >/dev/null
_active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
if [ "$_active" != "$_target" ]; then
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true
echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2
return 1
fi
}
# Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann
# umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file.
write_mode() {
sudo mkdir -p "$RUN_DIR"
_tmp="$RUN_DIR/.mode-$$"
printf '%s' "$1" | sudo install -m 0644 /dev/stdin "$_tmp" || return 1
sudo mv -f "$_tmp" "$RUN_DIR/mode"
}
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei per
# "install" anlegen, dann umbenennen) und dieselben Rechte wie server.py's eigenes
# write_control(), sonst kann der Dienst die Datei anschliessend nicht mehr
# ueberschreiben oder das CLI sie nicht mehr lesen.
#
# $1 = phase (auto|listen|armed), $2 = armed_target oder leer (-> null im JSON).
write_control() {
sudo mkdir -p "$RUN_DIR"
_tmp="$RUN_DIR/.control-$$.json"
if [ -n "${2:-}" ]; then
_json=$(printf '{"phase": "%s", "armed_target": "%s"}' "$1" "$2")
else
_json=$(printf '{"phase": "%s", "armed_target": null}' "$1")
fi
printf '%s' "$_json" | sudo install -m 0644 /dev/stdin "$_tmp" || return 1
sudo mv -f "$_tmp" "$RUN_DIR/control.json"
}
# Startet (neu) und wartet kurz auf Lebenszeichen. $1 = das gerade hochgefahrene
# AP-Profil, das bei einem Fehlschlag wieder heruntergefahren wird.
start_and_verify() {
# Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht # Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht
# blockieren (systemd verweigert "start" sonst sofort mit "start-limit-hit"). # blockieren (systemd verweigert "start"/"restart" sonst sofort mit
# "start-limit-hit").
sudo systemctl reset-failed ninlight-station 2>/dev/null || true sudo systemctl reset-failed ninlight-station 2>/dev/null || true
if ! sudo systemctl start ninlight-station; then if ! sudo systemctl restart ninlight-station; then
# Der Start-Aufruf selbst ist gescheitert (z.B. start-limit-hit) - unter # Der Neustart selbst ist gescheitert (z.B. start-limit-hit) - unter
# set -eu wuerde das Skript hier sonst abbrechen, bevor die # set -eu wuerde das Skript hier sonst abbrechen, bevor die
# Lebendigkeitspruefung unten je erreicht wird, und der eben # Lebendigkeitspruefung unten je erreicht wird, und der eben
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen. # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$1" >/dev/null 2>&1 || true
echo "Fehler: ninlight-station liess sich nicht starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
exit 1 exit 1
fi fi
# "systemctl start" meldet bei Type=simple schon Erfolg, sobald der Prozess # "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess
# gestartet wurde - nicht, sobald er ueberlebt hat. Kurz warten und wirklich # gestartet wurde - nicht, sobald er ueberlebt hat. Kurz warten und wirklich
# nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen. # nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen.
sleep 1 sleep 1
if systemctl is-active --quiet ninlight-station; then if ! systemctl is-active --quiet ninlight-station; then
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
# ausschalten) stand hier schon.
if [ "$PSK" = "ninlight-recovery" ]; then
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
fi
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
else
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund # Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
# weiter in seiner Neustartschleife - erst stoppen, dann den AP runter, # weiter in seiner Neustartschleife - erst stoppen, dann den AP runter,
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist. # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$1" >/dev/null 2>&1 || true
echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
exit 1 exit 1
fi fi
;; }
off)
recovery_on() {
need_profile "$AP_CONNECTION"
n=$(images | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2
ap_switch "$AP_CONNECTION"
write_mode "recovery"
start_and_verify "$AP_CONNECTION"
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
# ausschalten) steht unten.
if [ "$PSK" = "ninlight-recovery" ]; then
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
fi
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
}
migrate_on() {
# $1 = "--listen" oder leer.
_phase=auto
if [ "${1:-}" = "--listen" ]; then
_phase=listen
elif [ -n "${1:-}" ]; then
usage
exit 2
fi
need_profile "$MIGRATE_AP_CONNECTION"
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
# Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
ap_switch "$MIGRATE_AP_CONNECTION"
write_mode "migration"
write_control "$_phase" "" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
exit 1
}
start_and_verify "$MIGRATE_AP_CONNECTION"
echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)"
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."
}
full_off() {
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true ap_switch ""
if nmcli -t -f NAME con show | grep -qx "$HOME_CONNECTION"; then if nmcli -t -f NAME con show | grep -qx "$HOME_CONNECTION"; then
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)" echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
@@ -96,14 +202,109 @@ off)
else else
echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2 echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2
fi fi
;; }
status)
listen_cmd() {
write_control "listen" "" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
exit 1
}
echo "Phase: listen"
}
arm_cmd() {
_target=${1:-}
if [ -z "$_target" ]; then
_status_file="$RUN_DIR/status.json"
if ! _target=$(python3 - "$_status_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1]) as f:
d = json.load(f)
except (OSError, ValueError):
sys.exit(1)
t = (d.get("last_seen") or {}).get("target")
if not t:
sys.exit(1)
print(t)
PY
); then
echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm <target>" >&2
exit 1
fi
fi
if ! python3 - "$STATION_LIB" "$_target" <<'PY'
import sys
sys.path.insert(0, sys.argv[1])
import products
target = sys.argv[2]
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
PY
then
echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2
exit 1
fi
write_control "armed" "$_target" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
exit 1
}
echo "Freigegeben: $_target"
}
status_cmd() {
# "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und # "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und
# liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist - # liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist -
# ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide # ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide
# landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit # landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
# erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand - deshalb # erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand.
# am Ende explizit exit 0. _status_file="$RUN_DIR/status.json"
# "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
# sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
# "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
# server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
# "noch nie ausgeliefert". Das gilt auch fuer Modus/Phase/Erkannt unten.
if [ -r "$_status_file" ]; then
_info=$(python3 - "$_status_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1]) as f:
d = json.load(f)
except (OSError, ValueError):
d = {}
last_seen = d.get("last_seen")
if last_seen:
seen = "{} ({})".format(
last_seen.get("product_name") or last_seen.get("target", "?"),
last_seen.get("lamp_version", "?"),
)
else:
seen = "-"
print(d.get("mode", "-"))
print(d.get("phase", "-"))
print(seen)
PY
)
_modus=$(printf '%s\n' "$_info" | sed -n '1p')
_phase=$(printf '%s\n' "$_info" | sed -n '2p')
_erkannt=$(printf '%s\n' "$_info" | sed -n '3p')
elif [ -e "$_status_file" ]; then
_modus="unbekannt (Datei nicht lesbar)"
_phase="unbekannt (Datei nicht lesbar)"
_erkannt="unbekannt (Datei nicht lesbar)"
else
_modus="-"
_phase="-"
_erkannt="-"
fi
printf 'Modus : %s\n' "$_modus"
printf 'Phase : %s\n' "$_phase"
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)"
printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)" printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)"
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')" printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
@@ -117,26 +318,54 @@ print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" el
PY PY
)" )"
done done
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie if [ -r "$_status_file" ]; then
# ausgeliefert wurde - ohne das "|| true" waere der Exit-Code des Skripts hier der des
# fehlgeschlagenen Tests (1), weil das die letzte Anweisung im Zweig ist.
#
# "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
# sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
# "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
# server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
# "noch nie ausgeliefert".
STATUS_FILE=/run/ninlight-station/status.json
if [ -r "$STATUS_FILE" ]; then
echo "Zuletzt ausgeliefert:" echo "Zuletzt ausgeliefert:"
sed 's/^/ /' "$STATUS_FILE" sed 's/^/ /' "$_status_file"
elif [ -e "$STATUS_FILE" ]; then elif [ -e "$_status_file" ]; then
echo "Fehler: $STATUS_FILE existiert, ist aber nicht lesbar (Rechte pruefen)" >&2 echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2
fi fi
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
# ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier
# der des fehlgeschlagenen "-r"-Tests, weil das die letzte Anweisung im Zweig ist.
exit 0 exit 0
;; }
*)
echo "Aufruf: ninlight-station on|off|status" >&2 usage() {
exit 2 echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm [<target>]|listen|off|status" >&2
;; }
case "${1:-}" in
on)
recovery_on
;;
recovery)
case "${2:-}" in
on) recovery_on ;;
off) full_off ;;
*) usage; exit 2 ;;
esac
;;
migrate)
case "${2:-}" in
on) migrate_on "${3:-}" ;;
off) full_off ;;
*) usage; exit 2 ;;
esac
;;
arm)
arm_cmd "${2:-}"
;;
listen)
listen_cmd
;;
off)
full_off
;;
status)
status_cmd
;;
*)
usage
exit 2
;;
esac esac
+1 -1
View File
@@ -20,7 +20,7 @@ StartLimitBurst=5
[Service] [Service]
Type=simple Type=simple
EnvironmentFile=/etc/ninlight-station.conf EnvironmentFile=/etc/ninlight-station.conf
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode
Restart=always Restart=always
RestartSec=2 RestartSec=2
+12
View File
@@ -24,3 +24,15 @@ HOME_CONNECTION='preconfigured'
# NetworkManager-Profil der Station selbst. # NetworkManager-Profil der Station selbst.
AP_CONNECTION='ninlight-recovery' AP_CONNECTION='ninlight-recovery'
# Von der alten Klyqa-Firmware fest vorgegeben (qcx-w600-firmware) - nicht
# aenderbar, ohne dass alte Lampen die Migrationsstation nicht mehr finden.
MIGRATE_SSID='qconnex-firmware'
MIGRATE_PSK='qconnex-firmware'
# NetworkManager-Profil der Migrations-Station.
MIGRATE_AP_CONNECTION='qconnex-firmware'
# Panel-Revision der E-Paper-Anzeige (V2 oder V3) - am Geraet nicht auslesbar,
# siehe README.md.
EPD_REVISION='V3'
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
# Stub-Tests fuer ninlight-station (das CLI, nicht server.py) - ohne Pi und ohne
# echte NetworkManager-/systemd-Rechte ausfuehrbar.
#
# nmcli, systemctl und sudo werden im PATH durch Stubs ersetzt, die ihre Aufrufe
# in eine Logdatei schreiben (Muster wie tests/test_deploy.sh fuer ssh/rsync).
# Der nmcli-Stub haelt zusaetzlich einen kleinen Zustand (welche Profile
# "bekannt" sind, welche gerade per "con up" aktiv sind) in Dateien, damit
# "con up"/"con down"/"device show wlan0" konsistent zueinander antworten -
# das ist die Grundlage fuer Fall 15, den wichtigsten Test hier.
#
# Aufruf: tools/station/tests/test_cli.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
STATION_DIR=$(cd "$HERE/.." && pwd)
CLI="$STATION_DIR/ninlight-station"
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
FAILED=0
pass() { echo "ok - $1"; }
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
# --- Stub-Kommandos fuer nmcli/systemctl/sudo im PATH ---
# $1 = bindir, $2 = logdatei, $3 = nmcli-Zustandsverzeichnis
setup_stubbin() {
local bindir=$1 log=$2 nmstate=$3
mkdir -p "$bindir" "$nmstate"
: >"$nmstate/active" # Reihenfolge der per "con up" aktiven Profile, eine Zeile je Profil
cat >"$bindir/nmcli" <<EOF
#!/usr/bin/env bash
echo "STUB nmcli \$*" >>"$log"
NMSTATE="$nmstate"
if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "NAME" ] && [ "\$4" = "con" ] && [ "\$5" = "show" ]; then
cat "\$NMSTATE/profiles" 2>/dev/null
exit 0
fi
if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "GENERAL.CONNECTION" ] && [ "\$4" = "device" ] && [ "\$5" = "show" ]; then
last=\$(tail -n1 "\$NMSTATE/active" 2>/dev/null)
echo "GENERAL.CONNECTION:\${last:---}"
exit 0
fi
if [ "\$1" = "con" ] && [ "\$2" = "up" ]; then
profile=\$3
grep -qxF "\$profile" "\$NMSTATE/profiles" 2>/dev/null || { echo "Error: unknown connection '\$profile'." >&2; exit 1; }
grep -qxF "\$profile" "\$NMSTATE/active" 2>/dev/null || echo "\$profile" >>"\$NMSTATE/active"
exit 0
fi
if [ "\$1" = "con" ] && [ "\$2" = "down" ]; then
profile=\$3
grep -vxF "\$profile" "\$NMSTATE/active" 2>/dev/null >"\$NMSTATE/active.tmp" || true
mv "\$NMSTATE/active.tmp" "\$NMSTATE/active"
exit 0
fi
exit 0
EOF
cat >"$bindir/systemctl" <<EOF
#!/usr/bin/env bash
echo "STUB systemctl \$*" >>"$log"
case "\$1" in
is-active)
if [ "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" -eq 0 ]; then
echo active
exit 0
else
echo inactive
exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT}"
fi
;;
restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;;
*) exit 0 ;;
esac
EOF
cat >"$bindir/sudo" <<EOF
#!/usr/bin/env bash
echo "STUB sudo \$*" >>"$log"
exec "\$@"
EOF
# BSD-"install" (macOS) kennt kein /dev/stdin als Quelle ("Inappropriate file
# type or format") - GNU coreutils auf dem Pi schon. Das Skript nutzt genau
# dieses Muster (sudo install -m 0644 /dev/stdin <ziel>), also braucht der
# Test einen kleinen Ersatz, der nur dieses eine Aufrufmuster kennt.
cat >"$bindir/install" <<'EOF'
#!/usr/bin/env bash
mode=""
args=()
while [ $# -gt 0 ]; do
case "$1" in
-m) mode=$2; shift 2 ;;
*) args+=("$1"); shift ;;
esac
done
src=${args[0]}
dest=${args[1]}
if [ "$src" = "/dev/stdin" ]; then
cat >"$dest"
else
cp "$src" "$dest"
fi
[ -n "$mode" ] && chmod "$mode" "$dest"
EOF
chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo "$bindir"/install
}
# --- Sandbox fuer einen Testfall: eigene conf/RUN_DIR/IMAGE_DIR/nmcli-Zustand ---
# $1 = sandbox-Verzeichnis
setup_sandbox() {
local sandbox=$1
rm -rf "$sandbox"
mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate"
echo "dummy" >"$sandbox/images/ninlight_foo_ota.img"
cat >"$sandbox/station.conf" <<EOF
SSID='test-recovery'
PSK='test-psk'
IMAGE_DIR='$sandbox/images'
HOME_CONNECTION='home-wifi'
AP_CONNECTION='ninlight-recovery'
MIGRATE_SSID='qconnex-firmware'
MIGRATE_PSK='qconnex-firmware'
MIGRATE_AP_CONNECTION='qconnex-firmware'
EPD_REVISION='V3'
EOF
printf 'ninlight-recovery\nqconnex-firmware\nhome-wifi\n' >"$sandbox/nmstate/profiles"
: >"$sandbox/nmstate/active"
setup_stubbin "$sandbox/bin" "$sandbox/cmd.log" "$sandbox/nmstate"
}
# Fuehrt ninlight-station mit gestubbtem PATH und ueberschriebenen Pfaden aus.
# $1 = sandbox-Verzeichnis, Rest = Argumente fuer ninlight-station.
run_cli() {
local sandbox=$1; shift
PATH="$sandbox/bin:$PATH" \
NINLIGHT_STATION_CONF="$sandbox/station.conf" \
NINLIGHT_RUN_DIR="$sandbox/run" \
NINLIGHT_STATION_LIB="$STATION_DIR" \
"$CLI" "$@"
}
control_phase() {
# $1 = Pfad zu control.json
python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('phase','?'))" "$1" 2>/dev/null || echo "FEHLT"
}
control_target() {
python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('armed_target'))" "$1" 2>/dev/null || echo "FEHLT"
}
echo "=== Fall 1: 'on' faehrt ninlight-recovery hoch, startet den Dienst neu, meldet 'Station an' ==="
SBX="$TMPROOT/t1"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)"
echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
echo
echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ==="
SBX="$TMPROOT/t2"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" recovery on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch"
echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
echo
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
SBX="$TMPROOT/t3"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img"
run_cli "$SBX" on >/dev/null 2>&1 # Recovery vorher an
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" migrate on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
DOWN_LINE=$(grep -n "nmcli con down ninlight-recovery" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
UP_LINE=$(grep -n "nmcli con up qconnex-firmware" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
if [ -n "$DOWN_LINE" ] && [ -n "$UP_LINE" ] && [ "$DOWN_LINE" -lt "$UP_LINE" ]; then
pass "ninlight-recovery wird VOR qconnex-firmware heruntergefahren"
else
fail "Reihenfolge falsch oder Aufrufe fehlen (down=$DOWN_LINE, up=$UP_LINE): $(cat "$SBX/cmd.log")"
fi
[ "$(cat "$SBX/run/mode")" = "migration" ] && pass "Modusdatei enthaelt 'migration'" || fail "Modusdatei falsch"
[ "$(control_phase "$SBX/run/control.json")" = "auto" ] && pass "control.json: phase=auto" || fail "control.json phase ist nicht 'auto': $(cat "$SBX/run/control.json" 2>&1)"
echo
echo "=== Fall 4: 'migrate on --listen' wie Fall 3, aber phase=listen ==="
SBX="$TMPROOT/t4"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img"
run_cli "$SBX" on >/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, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "control.json phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)"
echo
echo "=== Fall 5: 'migrate on' ohne Migrationsimages -> Warnung auf stderr, Rueckgabewert 0 ==="
SBX="$TMPROOT/t5"
setup_sandbox "$SBX"
# images/migrate/ bleibt leer
set +e
OUT=$(run_cli "$SBX" migrate on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "Warnung" && pass "Warnung vorhanden" || fail "keine Warnung in der Ausgabe: $OUT"
echo
echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ==="
SBX="$TMPROOT/t6"
setup_sandbox "$SBX"
cat >"$SBX/run/status.json" <<'JSON'
{"mode": "migration", "phase": "listen", "last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}}
JSON
set +e
OUT=$(run_cli "$SBX" arm 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'"
[ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb (aus last_seen)" || fail "armed_target falsch: $(cat "$SBX/run/control.json")"
echo
echo "=== Fall 7: 'arm gu10_rgb' mit explizitem Ziel ==="
SBX="$TMPROOT/t7"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" arm gu10_rgb 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'"
[ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb" || fail "armed_target falsch: $(cat "$SBX/run/control.json")"
echo
echo "=== Fall 8: 'arm' ohne last_seen -> Fehler, Rueckgabewert != 0, control.json unveraendert ==="
SBX="$TMPROOT/t8"
setup_sandbox "$SBX"
# keine status.json vorhanden
set +e
OUT=$(run_cli "$SBX" arm 2>&1)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
echo "$OUT" | grep -qi "fehler" && pass "Fehlermeldung vorhanden" || fail "keine Fehlermeldung: $OUT"
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")"
echo
echo "=== Fall 9: 'arm unbekannt' -> Fehler, Rueckgabewert != 0 ==="
SBX="$TMPROOT/t9"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" arm unbekannt 2>&1)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
echo "$OUT" | grep -qi "fehler" && pass "Fehlermeldung vorhanden" || fail "keine Fehlermeldung: $OUT"
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt"
echo
echo "=== Fall 10: 'listen' setzt phase=listen ==="
SBX="$TMPROOT/t10"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" listen 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)"
echo
echo "=== Fall 11: 'off' faehrt beide Profile herunter, stoppt den Dienst, zurueck ins Heim-WLAN ==="
SBX="$TMPROOT/t11"
setup_sandbox "$SBX"
run_cli "$SBX" migrate on >/dev/null 2>&1
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" off 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "systemctl stop ninlight-station" "$SBX/cmd.log" && pass "Dienst gestoppt" || fail "kein systemctl stop im Log"
ACTIVE=$(cat "$SBX/nmstate/active")
echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv"
echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv"
echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE"
echo "$OUT" | grep -q "Heim-WLAN" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT"
echo
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ==="
SBX="$TMPROOT/t12"
setup_sandbox "$SBX"
cat >"$SBX/run/status.json" <<'JSON'
{"mode": "migration", "phase": "auto",
"last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}}
JSON
set +e
OUT=$(run_cli "$SBX" status 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "Modus.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT"
echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT"
echo "$OUT" | grep -q "Erkannt.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT"
echo
echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ==="
SBX="$TMPROOT/t13"
setup_sandbox "$SBX"
echo '{"mode": "recovery"}' >"$SBX/run/status.json"
chmod 000 "$SBX/run/status.json"
set +e
OUT=$(run_cli "$SBX" status 2>&1)
RC=$?
set -e
chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "existiert, ist aber nicht lesbar" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT"
echo
echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ==="
SBX="$TMPROOT/t14"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" frobnicate 2>&1)
RC=$?
set -e
[ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2"
echo "$OUT" | grep -qi "Aufruf" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT"
echo
echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ==="
SBX="$TMPROOT/t15"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img"
run_cli "$SBX" on >/dev/null 2>&1
ACTIVE_BEFORE=$(cat "$SBX/nmstate/active")
echo "$ACTIVE_BEFORE" | grep -qx "ninlight-recovery" && pass "Vorbedingung: ninlight-recovery laeuft" || fail "Vorbedingung nicht erfuellt: $ACTIVE_BEFORE"
set +e
OUT=$(run_cli "$SBX" migrate on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
COUNT=$(echo "$ACTIVE_AFTER" | grep -c . || true)
if [ "$COUNT" -eq 1 ] && [ "$ACTIVE_AFTER" = "qconnex-firmware" ]; then
pass "genau ein Profil aktiv: qconnex-firmware"
else
fail "erwartet genau 'qconnex-firmware', tatsaechlich aktiv: [$ACTIVE_AFTER]"
fi
DEVSHOW=$(PATH="$SBX/bin:$PATH" nmcli -t -f GENERAL.CONNECTION device show wlan0)
[ "$DEVSHOW" = "GENERAL.CONNECTION:qconnex-firmware" ] && pass "nmcli device show wlan0 bestaetigt qconnex-firmware" || fail "device show liefert: $DEVSHOW"
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
else
echo "Mindestens ein Test ist fehlgeschlagen." >&2
fi
exit "$FAILED"
+2
View File
@@ -11,3 +11,5 @@ if python3 -c 'import pytest' 2>/dev/null; then
else else
echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2 echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2
fi fi
"$ROOT/tools/station/tests/test_cli.sh"