#!/bin/sh # 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). 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 # 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" elif [ -e "$CONF" ]; then # $CONF ist 0600 root:root, damit das Passwort nicht fuer jeden lesbar # ist. Der dokumentierte Aufruf "ssh ninlight-station on" laeuft # aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre # Rechte aufzuweichen. eval "$(sudo cat "$CONF")" else echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2 exit 1 fi 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 } done unset _key need_profile() { # $1 = NetworkManager-Profilname, der existieren muss. # -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit # "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen. nmcli -t -f NAME con show | grep -Fqx "$1" || { echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 exit 1 } } images() { find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort } 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 if [ -z "$_target" ]; then # "off": jedes "con down" oben ist mit "|| true" abgesichert, weil ein # haengender NetworkManager den Aufruf sonst zum Abbruch braechte, bevor # ueberhaupt versucht wurde, beide Profile herunterzufahren. Das darf aber # nicht heissen, dass ein haengengebliebenes Profil stillschweigend als # "aus" durchgeht: nachsehen, ob wlan0 wirklich kein Stationsprofil mehr # zeigt, und erst dann Erfolg melden (Fixrunde 1, I2) - sonst kann z.B. der # Migrations-AP mit oeffentlich bekanntem Passwort weitersenden, waehrend # das CLI "Station aus" behauptet. _active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2) if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2 return 1 fi return 0 fi 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 tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 sudo mv -f "$_tmp" "$RUN_DIR/mode" } # Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im # selben Verzeichnis anlegen, Rechte setzen, 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 tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 sudo mv -f "$_tmp" "$RUN_DIR/control.json" } # Startet (neu) und wartet kurz auf Lebenszeichen, dann prueft es nach, dass der # eben gestartete Prozess wirklich den beabsichtigten Modus/die beabsichtigte # Phase sieht. # # $1 = das gerade hochgefahrene AP-Profil (fuer Rollback bei Fehlschlag) # $2 = erwarteter Inhalt von $RUN_DIR/mode nach dem Neustart # $3 = erwartete Phase in control.json (nur im Migrationsmodus angeben, sonst leer) # # Der Grund fuer $2/$3: die systemd-Unit traegt RuntimeDirectory=ninlight-station, # und systemd leert dieses Verzeichnis bei jedem Stop/Restart, sofern die Unit # nicht RuntimeDirectoryPreserve=restart setzt. write_mode()/write_control() oben # laufen VOR diesem "systemctl restart" - ein Neustart, der das Verzeichnis doch # leert (weil die Unit die Zeile verliert, oder auf einem Pi mit alter Unit-Datei # installiert wird), wischt also genau die Dateien wieder weg, die gerade erst # geschrieben wurden. Der frisch gestartete Serverprozess faende dann keine # Modusdatei und faellt laut server.py:resolve_mode() still auf "recovery" # zurueck (bzw. read_control() auf "auto" bei fehlender Steuerdatei) - ein # ausdrueckliches "migrate on --listen" wuerde so unbemerkt zu "Rettungsmodus, # antwortet 404" bzw. zu "automatische Auslieferung" (Fixrunde 1, C1). Die Unit # traegt deshalb RuntimeDirectoryPreserve=restart; diese Nachpruefung faengt es # zusaetzlich ab, falls das je auseinanderlaeuft, statt sich allein auf die # Unit-Datei zu verlassen. start_and_verify() { _ap=$1 _expect_mode=$2 _expect_phase=${3:-} # Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht # blockieren (systemd verweigert "start"/"restart" sonst sofort mit # "start-limit-hit"). sudo systemctl reset-failed ninlight-station 2>/dev/null || true 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" >/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 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 # 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" >/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 _actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true) if [ "$_actual_mode" != "$_expect_mode" ]; then sudo systemctl stop ninlight-station 2>/dev/null || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2 exit 1 fi if [ -n "$_expect_phase" ]; then _actual_phase=$(python3 - "$RUN_DIR/control.json" <<'PY' import json import sys try: with open(sys.argv[1]) as f: d = json.load(f) except (OSError, ValueError): d = {} print(d.get("phase", "")) PY ) if [ "$_actual_phase" != "$_expect_phase" ]; then sudo systemctl stop ninlight-station 2>/dev/null || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2 exit 1 fi fi } 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" "recovery" 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" "migration" "$_phase" 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." } # Ruft display.py mit einem Schalter auf. EPD_REVISION steht in $CONF und wird von # ". $CONF" nur als Shell-Variable gesetzt, nicht exportiert - "env VAR=..." reicht sie # an den Python-Prozess durch. "sudo", weil /dev/spidev0.0 und die GPIO-Leitungen root # gehoeren (der Anzeigedienst laeuft aus demselben Grund als root). display_run() { sudo env EPD_REVISION="$EPD_REVISION" python3 "$STATION_LIB/display/display.py" "$1" } # Handbetrieb am Panel ("display test"/"display clear"), waehrend der Anzeigedienst # laufen kann. Beide wollen dieselben GPIO-Leitungen, und die kann nur einer haben: bei # laufendem Dienst scheiterte der Handgriff mit "lgpio.error: 'GPIO busy'", rc=1. # # Warum stoppen-und-wieder-starten statt "brich mit einer Anweisung ab": 'display test' # ist laut Hilfetext der ERSTE Handgriff der Hardware-Abnahme und die einzige # Moeglichkeit, die Panel-Revision zu bestimmen. Ein Abbruch wuerde also ausgerechnet dem # Abnehmer, der die Station gerade zum ersten Mal in der Hand hat, eine Hausaufgabe # stellen ("erst den Dienst stoppen, dann nochmal") - und zwar bei jedem einzelnen # Versuch, denn die Revision bestimmt man durch Ausprobieren beider Werte. Das Stoppen # ist ausserdem gefahrlos: der Dienst zeichnet nur, er ist an keiner Migration beteiligt. # # Der Dienst wird NUR wieder gestartet, wenn er vorher lief - sonst haette ein # Abnahme-Handgriff den Dienst nebenbei eingeschaltet und den Zustand der Station # veraendert, was 'off' zuvor bewusst hergestellt hatte. Deshalb "is-active" vorher # fragen statt blind "stop" und "start". # # Der Rueckgabewert von display.py wird unveraendert durchgereicht, und das # Wiederanfahren des Dienstes darf ihn weder verdecken noch (bei eigenem Fehlschlag) in # einen Fehler verwandeln. # # Was dieser Rueckgabewert NICHT ist: eine Aussage ueber das Panel. display.py's main() # faengt DisplayUnavailable/ImportError ab, protokolliert eine Zeile und beendet sich mit # sys.exit(0) - die ueber mehrere Fixrunden erkaempfte Zusage "die Anzeige ist nirgends # Voraussetzung". Fehlendes Panel, fehlendes SPI, fehlendes Pillow, lgpio 'GPIO busy': # alles rc=0. Rot wird der Handgriff nur bei einem echten Absturz (ein Traceback kommt # unveraendert durch). Ob die Revision stimmt, beurteilst du deshalb AM PANEL - zeigt es # ein sauberes, seitenrichtiges Testbild? - und nie am Exit-Code; "Exit 0" heisst hier # ausdruecklich nicht "Panel in Ordnung". display_service_resume() { [ "${_was_active:-0}" -eq 1 ] || return 0 sudo systemctl start ninlight-display 2>/dev/null \ || echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2 } display_handover() { _was_active=0 if systemctl is-active --quiet ninlight-display 2>/dev/null; then _was_active=1 sudo systemctl stop ninlight-display 2>/dev/null || true fi # Zwischen "stop" und "start" darf uns kein Signal aus der Hand nehmen, den Dienst # wieder anzufahren. Der Fall ist nicht theoretisch: die Abnahmeprozedur in # display.py's Modul-Docstring sagt ausdruecklich, dass bei falscher Revision # ReadBusy() NIE zurueckkehrt - der Abnehmer probiert EPD_REVISION=V3, das Panel # haengt, er drueckt Strg-C. Ohne Handler stirbt display_handover mit, der # Anzeigedienst bleibt aus, das Panel bleibt schwarz, und mitten in der Lampenabnahme # sieht das aus wie "Panel kaputt". # # Die Handler beenden SELBST (130/143/129 = 128 + Signalnummer): ein Handler ohne # "exit" laesst die Shell hinter dem unterbrochenen Kommando weiterlaufen - in diesem # Projekt schon einmal ein Befund (setup_pi.sh, Profilschreiben). trap 'display_service_resume; exit 130' INT trap 'display_service_resume; exit 143' TERM trap 'display_service_resume; exit 129' HUP _rc=0 display_run "$1" || _rc=$? trap - INT TERM HUP display_service_resume return "$_rc" } # Nimmt die Anzeige mit aus. Reihenfolge: erst den Dienst stoppen, dann das Panel leeren - # umgekehrt zeichnet der noch laufende Daemon binnen 5 s sein altes Bild wieder darauf. # # Warum leeren und nicht "Station aus" stehen lassen: der Anzeigedienst erkennt den # ausgeschalteten Zustand zwar selbst (systemctl is-active), aber ein laufender # Anzeigedienst nach "off" bedeutet, dass "off" eben NICHT alles abgeschaltet hat. Ein # leeres Panel behauptet dagegen gar nichts - und das ist bei einer Station, die Lampen # ungefragt Firmware unterschieben kann, die einzig ungefaehrliche Richtung: ein # stehengebliebenes "MIGRATION"-Bild wuerde einen Betriebszustand behaupten, den es nicht # mehr gibt, waehrend ein leeres Panel hoechstens nach "aus" aussieht - was es ist. display_off() { sudo systemctl stop ninlight-display 2>/dev/null || true # Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und # ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten. display_run --clear || echo "Warnung: Panel konnte nicht geleert werden - Anzeige pruefen" >&2 } full_off() { sudo systemctl stop ninlight-station 2>/dev/null || true # VOR ap_switch, nicht danach: ap_switch "" bricht bei einem haengenden # NetworkManager mit Fehler ab, und unter "set -e" kaeme danach nichts mehr. Genau # dann - Zugangspunkt laesst sich nicht abschalten - waere die Anzeige sonst der # letzte Rest von "off", der nie ausgefuehrt wird: Daemon laeuft weiter, Panel zeigt # unveraendert "MIGRATION". Ein leeres Panel behauptet dagegen nichts; die Wahrheit # ueber den noch offenen Zugangspunkt steht in ap_switch's Fehlermeldung, wo man sie # liest, statt auf einem Panel, das man auch fuer aktuell halten koennte. display_off ap_switch "" if nmcli -t -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)" else echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2 fi else echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2 fi } 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. _status_file="$RUN_DIR/status.json" _control_file="$RUN_DIR/control.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/Erkannt unten und fuer # Phase/control.json weiter 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(seen) PY ) _modus=$(printf '%s\n' "$_info" | sed -n '1p') _erkannt=$(printf '%s\n' "$_info" | sed -n '2p') elif [ -e "$_status_file" ]; then _modus="unbekannt (Datei nicht lesbar)" _erkannt="unbekannt (Datei nicht lesbar)" else _modus="-" _erkannt="-" fi # Die Phase kommt bewusst aus control.json, nicht aus status.json: "arm" und # "listen" starten den Dienst absichtlich nicht neu, also schreibt server.py's # write_state() status.json in der Zwischenzeit nicht neu (Fixrunde 1, I1) - # wer die Phase dort ablesen wuerde, saehe nach einer erfolgreichen Freigabe # weiterhin die Phase von vor dem letzten Dienststart und koennte "arm" fuer # gescheitert halten oder faelschlich glauben, nichts sei mehr scharf. # # ABER: Phase ist ein Konzept, das nur im Migrationsmodus existiert - control.json # kann von einem frueheren "arm"/"migrate on" liegen bleiben (RuntimeDirectoryPreserve= # restart aus Fixrunde 1, C1 laesst es Neustarts jetzt ausdruecklich ueberleben), # auch nachdem laengst wieder in den Rettungsmodus zurueckgeschaltet wurde - # "recovery on" raeumt die Datei bewusst nicht auf, weil der Dienst sie im # Rettungsmodus ohnehin ignoriert (server.py liest sie nur im Migrationsmodus). # Ohne diese Bedingung wuerde status nach "migrate on --listen; arm X; on" ein # irrefuehrendes "Phase: armed" im Rettungsmodus zeigen (Fixrunde 2, N2) - genau # in der sicherheitsrelevanten Dimension "scharf/nicht scharf", die I1 eigentlich # klarstellen sollte. Deshalb wird control.json nur gelesen, wenn Modus wirklich # "migration" ist; sonst ist die Phase per Definition "-", unabhaengig davon, was # noch in control.json steht. if [ "$_modus" != "migration" ]; then _phase="-" elif [ -r "$_control_file" ]; then _phase=$(python3 - "$_control_file" <<'PY' import json import sys try: with open(sys.argv[1]) as f: d = json.load(f) except (OSError, ValueError): d = {} phase = d.get("phase", "-") target = d.get("armed_target") if phase == "armed" and target: print("{} (Ziel: {})".format(phase, target)) else: print(phase) PY ) elif [ -e "$_control_file" ]; then _phase="unbekannt (Datei nicht lesbar)" else _phase="-" 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}')" echo "Images :" images | while read -r f; do printf ' %-34s %s\n' "$(basename "$f")" \ "$(python3 - "$f" <<'PY' import pathlib, sys t = pathlib.Path(sys.argv[1]).read_bytes()[-512:] print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" else "kein Trailer") PY )" done 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 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 } display_cmd() { case "${1:-}" in test) display_handover --test ;; clear) display_handover --clear ;; off) display_off ;; *) usage; exit 2 ;; esac } usage() { echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm []|listen|display {test|clear|off}|off|status" >&2 echo "" >&2 echo " display test Testbild aufs Panel zeichnen. Das ist der erste Handgriff bei der" >&2 echo " Hardware-Abnahme und die EINZIGE Moeglichkeit, die Panel-Revision" >&2 echo " zu bestimmen: die steht am Geraet nirgends. Bleibt das Bild leer," >&2 echo " verzerrt oder verschoben, ist EPD_REVISION in" >&2 echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2 echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2 echo " stromlos machen und erneut 'display test'." >&2 echo " Ein laufender Anzeigedienst wird dafuer kurz gestoppt und" >&2 echo " danach wieder gestartet (er haelt sonst die GPIO-Leitungen)." >&2 echo " display clear Panel leeren (ebenfalls mit kurzer Unterbrechung des Dienstes)." >&2 echo " display off Anzeigedienst stoppen und das Panel leeren (macht 'off' mit)." >&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 ;; display) display_cmd "${2:-}" ;; off) full_off ;; status) status_cmd ;; *) usage exit 2 ;; esac