From 1d3627bacde713f1949afe8d5a666879840d1459 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 18:39:48 +0200 Subject: [PATCH] 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. --- tools/station/ninlight-station | 341 ++++++++++++++++++---- tools/station/ninlight-station.service | 2 +- tools/station/station.conf.example | 12 + tools/station/tests/test_cli.sh | 388 +++++++++++++++++++++++++ tools/test_host.sh | 2 + 5 files changed, 688 insertions(+), 57 deletions(-) create mode 100755 tools/station/tests/test_cli.sh diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 6a3386c..49f36fc 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -1,14 +1,27 @@ #!/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 # in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige # Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert # 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 -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 # shellcheck disable=SC1090 . "$CONF" @@ -23,7 +36,8 @@ else exit 1 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-}\"" || { echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2 exit 1 @@ -32,8 +46,9 @@ done unset _key need_profile() { - nmcli -t -f NAME con show | grep -qx "$AP_CONNECTION" || { - echo "Fehler: NetworkManager-Profil '$AP_CONNECTION' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 + # $1 = NetworkManager-Profilname, der existieren muss. + nmcli -t -f NAME con show | grep -qx "$1" || { + echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 exit 1 } } @@ -42,51 +57,142 @@ images() { find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort } -case "${1:-}" in -on) - 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 - sudo nmcli con up "$AP_CONNECTION" >/dev/null +migrate_images() { + find "$IMAGE_DIR/migrate" -maxdepth 1 -name '*.img' 2>/dev/null | sort +} + +# Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist) +# 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 - # 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 - if ! sudo systemctl start ninlight-station; then - # Der Start-Aufruf selbst ist gescheitert (z.B. start-limit-hit) - unter + if ! sudo systemctl restart ninlight-station; then + # Der Neustart selbst ist gescheitert (z.B. start-limit-hit) - unter # set -eu wuerde das Skript hier sonst abbrechen, bevor die # Lebendigkeitspruefung unten je erreicht wird, und der eben # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen. - sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true - echo "Fehler: ninlight-station liess sich nicht starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 + sudo nmcli con down "$1" >/dev/null 2>&1 || true + echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 exit 1 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 # nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen. sleep 1 - 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 + if ! systemctl is-active --quiet ninlight-station; then # Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund # weiter in seiner Neustartschleife - erst stoppen, dann den AP runter, # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist. 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 exit 1 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 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 sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)" @@ -96,14 +202,109 @@ off) else echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2 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 " >&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 # liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist - # 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 - # erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand - deshalb - # am Ende explizit exit 0. + # erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand. + _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 '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}')" @@ -117,26 +318,54 @@ print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" el PY )" done - # "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie - # 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 + if [ -r "$_status_file" ]; then echo "Zuletzt ausgeliefert:" - sed 's/^/ /' "$STATUS_FILE" - elif [ -e "$STATUS_FILE" ]; then - echo "Fehler: $STATUS_FILE existiert, ist aber nicht lesbar (Rechte pruefen)" >&2 + sed 's/^/ /' "$_status_file" + elif [ -e "$_status_file" ]; then + echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2 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 - ;; -*) - echo "Aufruf: ninlight-station on|off|status" >&2 - exit 2 - ;; +} + +usage() { + echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm []|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 diff --git a/tools/station/ninlight-station.service b/tools/station/ninlight-station.service index 04cff98..e464ee9 100644 --- a/tools/station/ninlight-station.service +++ b/tools/station/ninlight-station.service @@ -20,7 +20,7 @@ StartLimitBurst=5 [Service] Type=simple EnvironmentFile=/etc/ninlight-station.conf -ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} +ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode Restart=always RestartSec=2 diff --git a/tools/station/station.conf.example b/tools/station/station.conf.example index 15bcaa8..73e6e1e 100644 --- a/tools/station/station.conf.example +++ b/tools/station/station.conf.example @@ -24,3 +24,15 @@ HOME_CONNECTION='preconfigured' # NetworkManager-Profil der Station selbst. 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' diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh new file mode 100755 index 0000000..deba20f --- /dev/null +++ b/tools/station/tests/test_cli.sh @@ -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" <>"$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" <>"$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" <>"$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 ), 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" <"$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" diff --git a/tools/test_host.sh b/tools/test_host.sh index bd09054..e2c93e2 100755 --- a/tools/test_host.sh +++ b/tools/test_host.sh @@ -11,3 +11,5 @@ if python3 -c 'import pytest' 2>/dev/null; then else echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2 fi + +"$ROOT/tools/station/tests/test_cli.sh"