#!/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} # --- Umschalter: Migrationsimages mit oder ohne WLAN-Zugangsdaten -------------------- # # Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware. Es liegen deshalb zwei # Saetze Migrationsimages auf dem Pi: # # ohne-wlan -> $IMAGE_DIR/migrate-nocreds (tools/build_nocreds.sh): die migrierte # Lampe startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN # mit-wlan -> $IMAGE_DIR/migrate-wlan (build/): die Lampe haengt sofort im WLAN # des Bedieners # # Der Umschalter ist ein ZUSTAND, keine Abfrage je Migration: er bleibt, bis jemand ihn # aendert, und er steht dauerhaft auf dem Panel (display.py liest dieselbe Datei). # Deshalb liegt sie NICHT in $RUN_DIR (tmpfs, weg nach jedem Neustart) und nicht in # $CONF (0600 root:root, und setup_pi.sh traegt dort Schluessel nach) - sondern in einer # eigenen, dauerhaften Datei mit 0644, die auch der Anzeigedienst lesen kann. IMAGESET_FILE=${NINLIGHT_IMAGESET_FILE:-/var/lib/ninlight-station/imageset} # Die sichere Vorgabe, und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar, unbekannter # Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite # migriert, auch fremde. Mit dem Abzug OHNE Zugangsdaten ist ein Fehlgriff aergerlich, # aber harmlos - die Lampe steht im Onboarding-AP und wartet. Mit dem Abzug MIT # Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden Geraet. # Der teurere Fehler darf nie der sein, der ohne Zutun passiert. server.py:read_image_set() # haelt dieselbe Vorgabe unabhaengig ein. IMAGESET_DEFAULT=ohne-wlan # --- Zeitschalter ("Auto-Aus") ----------------------------------------------------- # # Rein OPTIONAL: nur "--for " legt einen an. Ohne die Angabe passiert hier # nichts, und die Station verhaelt sich exakt wie vorher - der Regelfall ist der Pi am # Kabel, dem niemand den Zugangspunkt unter der laufenden Arbeit wegschalten soll. # # Wozu er dann gut ist: "on" macht wlan0 zum Zugangspunkt und reisst damit JEDE # SSH-Sitzung ueber WLAN ab. Wer den Pi nicht am Kabel hat, sperrt sich damit aus und # kommt nur noch ueber Tastatur und Monitor zurueck. Der Zeitschalter schaltet die # Station nach der angeforderten Zeit selbsttaetig ab (= zurueck ins Heim-WLAN). # # Umgesetzt mit einem transienten systemd-Timer unter FESTEM Unit-Namen (systemd-run # --unit=...): so sind "renew"/"hold"/"off" schlichte systemctl-Aufrufe auf einen # bekannten Namen, und es braucht keine zusaetzliche Unit-Datei im Repo. AUTOOFF_UNIT=${NINLIGHT_AUTOOFF_UNIT:-ninlight-station-autooff} # Eigene Zustandsdatei neben mode/control.json, im selben RUN_DIR und mit denselben # Rechten (0644): sie ist die Quelle fuer "status", fuer "renew" ohne Argument (welche # Dauer war zuletzt angefordert?) und fuer die Panelzeile - display.py liest sie. Sie ist # zugleich die Antwort auf "laeuft ueberhaupt einer?": jeder Weg, der den Timer anlegt # oder abraeumt, schreibt bzw. loescht sie in derselben Funktion. AUTOOFF_FILE="$RUN_DIR/autooff.json" # Abstand zwischen zwei Anlaeufen, wenn gerade ausgeliefert wird ("in etwa einer Minute # erneut versuchen"). Der Timer bringt ihn selbst mit (--on-unit-inactive), es wird also # nichts nachgelegt - siehe autooff_arm(). AUTOOFF_RETRY_S=${NINLIGHT_AUTOOFF_RETRY_S:-60} # Obergrenze fuers Verschieben. 10 Anlaeufe x 1 min = rund 10 Minuten Aufschub. # # Begruendung der Zahl: eine Auslieferung ist ein einzelner HTTP-GET ueber ein halbes # Megabyte im eigenen WLAN - Sekunden, keine Minuten. Eine Verbindung, die nach zehn # Minuten noch offen steht, liefert nicht mehr aus, sie haengt (Lampe abgestuerzt, # Funkloch, halboffener Socket, den der Kernel noch als ESTABLISHED fuehrt). Ohne # Obergrenze wuerde genau so eine haengende Verbindung den Zeitschalter dauerhaft # aushebeln - und damit ausgerechnet den Fall herbeifuehren, gegen den er angefordert # wurde: der Nutzer sitzt ohne Kabel da und kommt nicht mehr an den Pi. Zehn Minuten # sind lang genug, dass keine echte Auslieferung abgeschnitten wird, und kurz genug, # dass der Zeitschalter seine Zusage noch einloest. AUTOOFF_MAX_DEFERRALS=${NINLIGHT_AUTOOFF_MAX_DEFERRALS:-10} # Das Netz des Zugangspunkts (server.py haengt auf 192.168.8.1:80) - daran wird erkannt, # ob gerade eine Lampe laedt, ohne dafuer etwas am Server zu aendern. AUTOOFF_STATION_NET=192.168.8.0/24 # Sinnvolle Grenzen fuer --for/renew. AUTOOFF_MIN_MINUTES=1 AUTOOFF_MAX_MINUTES=240 # Absoluter Pfad auf dieses Skript - der Timer ruft es spaeter ohne unser $PWD auf. case "$0" in /*) SELF=$0 ;; *) SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") ;; esac 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 } # --- Bildsatz lesen, schreiben, benennen -------------------------------------------- # Der rohe Inhalt der Umschalterdatei (ohne Leerraum), oder leer. Nur fuer die Meldung # unten gedacht - entschieden wird ausschliesslich mit imageset_get(). imageset_raw() { cat "$IMAGESET_FILE" 2>/dev/null | tr -d ' \t\r\n' || true } # Der eingestellte Bildsatz, oder $IMAGESET_DEFAULT bei fehlender/unlesbarer Datei bzw. # unbekanntem Inhalt. Gibt IMMER einen gueltigen Namen aus und scheitert nie - ein # kaputter Umschalter darf weder "off" noch das Panel aufhalten. imageset_get() { _value=$(imageset_raw) case "$_value" in ohne-wlan|mit-wlan) printf '%s' "$_value" ;; *) printf '%s' "$IMAGESET_DEFAULT" ;; esac } # Das Verzeichnis zum Namen des Bildsatzes ($1). imageset_dir() { case "$1" in mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;; *) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;; esac } imageset_label() { case "$1" in mit-wlan) printf 'mit-wlan (Lampe haengt sofort im eigenen WLAN)' ;; *) printf 'ohne-wlan (Lampe startet im Onboarding-Zugangspunkt)' ;; esac } # Schreibt den Umschalter atomar mit 0644 - dasselbe Muster wie write_mode()/ # write_control(), aber in ein DAUERHAFTES Verzeichnis: der Zustand soll einen Neustart # ueberleben (das ist der Sinn eines Umschalters), und der Anzeigedienst muss ihn lesen # koennen. imageset_set() { sudo mkdir -p "$(dirname "$IMAGESET_FILE")" _tmp="$(dirname "$IMAGESET_FILE")/.imageset-$$" printf '%s\n' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 sudo mv -f "$_tmp" "$IMAGESET_FILE" } migrate_images() { find "$(imageset_dir "$(imageset_get)")" -maxdepth 1 -name '*.img' 2>/dev/null | sort } # "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten. bildsatz_cmd() { _want=${1:-} _now=$(imageset_get) if [ -z "$_want" ]; then imageset_status_lines return 0 fi case "$_want" in ohne-wlan|mit-wlan) ;; *) echo "Fehler: unbekannter Bildsatz '$_want' - erlaubt sind 'ohne-wlan' und 'mit-wlan'" >&2 exit 2 ;; esac imageset_set "$_want" || { echo "Fehler: $IMAGESET_FILE konnte nicht geschrieben werden" >&2 exit 1 } echo "Bildsatz: $(imageset_label "$_want")" [ "$_now" = "$_want" ] && echo " (war schon eingestellt)" _dir=$(imageset_dir "$_want") if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then # Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts # aus (Status "noimageset"), und das Panel sagt "FEHLT". Hier trotzdem sofort # warnen, damit niemand erst an der Lampe merkt, dass nichts kommt. echo "Warnung: $_dir fehlt oder ist leer - in diesem Bildsatz wird NICHTS ausgeliefert. tools/station/deploy.sh erneut laufen lassen." >&2 fi if [ "$_want" = "mit-wlan" ]; then echo "Achtung: migrierte Lampen haengen damit sofort im WLAN aus config.local.cmake - auch fremde Lampen, die im Automatikmodus mitgenommen werden." >&2 fi } # Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben. imageset_status_lines() { _set=$(imageset_get) _dir=$(imageset_dir "$_set") # "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie # server.py:migrate_set_present() und display.py:_read_imageset(). Ein leeres # Verzeichnis liefert genauso nichts aus wie ein fehlendes. _n=$(migrate_images | wc -l | tr -d ' ') if [ "$_n" -gt 0 ]; then _state="$_n Image(s)" else _state="FEHLT - es wird nichts ausgeliefert" fi printf 'Bildsatz: %s\n' "$(imageset_label "$_set")" printf ' %s (%s)\n' "$_dir" "$_state" # Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die # Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst # glaubt er, sein Eintrag von Hand haette gewirkt. _raw=$(imageset_raw) if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then printf " (unbekannte Einstellung '%s' in %s - Vorgabe gilt)\n" \ "$_raw" "$IMAGESET_FILE" fi } # Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist) # 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" } # --- Zeitschalter: Zustandsdatei, Anlegen, Abraeumen, Pruefung ----------------------- # Prueft eine Minutenangabe, BEVOR irgendetwas eingeschaltet wird (Zusage: eine # ungueltige Angabe schaltet gar nichts an). Gibt 0 zurueck, wenn sie taugt. autooff_check_minutes() { case "${1:-}" in '' ) echo "Fehler: '--for' braucht eine Angabe in Minuten ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2 return 1 ;; *[!0-9]* ) echo "Fehler: '$1' ist keine Minutenangabe - erlaubt sind ganze Zahlen von $AUTOOFF_MIN_MINUTES bis $AUTOOFF_MAX_MINUTES" >&2 return 1 ;; esac if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then echo "Fehler: $1 Minuten liegen ausserhalb von $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2 return 1 fi } # Schreibt $AUTOOFF_FILE atomar mit 0644 - dasselbe Muster und dieselben Rechte wie # write_control(), damit display.py (laeuft als root) und das CLI (laeuft als normaler # Benutzer) sie beide lesen koennen. # # $1 = angeforderte Dauer in Minuten (bleibt ueber Verschiebungen hinweg unveraendert - # "renew" ohne Argument stellt genau darauf zurueck), $2 = Zeitpunkt des naechsten # Anlaufs als Unix-Zeit, $3 = Zahl der bisherigen Verschiebungen. autooff_write() { sudo mkdir -p "$RUN_DIR" _tmp="$RUN_DIR/.autooff-$$.json" _json=$(printf '{"minutes": %s, "deadline": %s, "deferrals": %s}' "$1" "$2" "$3") printf '%s' "$_json" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 sudo mv -f "$_tmp" "$RUN_DIR/autooff.json" } # Gibt " " aus, oder scheitert (Rueckgabewert 1), # wenn kein Zeitschalter vorgemerkt ist bzw. die Datei unbrauchbar ist. autooff_read() { [ -e "$AUTOOFF_FILE" ] || return 1 python3 - "$AUTOOFF_FILE" <<'PY' import json import sys try: with open(sys.argv[1]) as f: d = json.load(f) except (OSError, ValueError): sys.exit(1) try: print(int(d["minutes"]), int(d["deadline"]), int(d.get("deferrals", 0))) except (KeyError, TypeError, ValueError): sys.exit(1) PY } # Haelt NUR den Timer an, nie die gleichnamige .service-Einheit: wenn diese Funktion aus # dem gefeuerten Timer heraus laeuft (autooff_fire -> full_off), ist genau diese Einheit # der eigene Prozess - ein "systemctl stop $AUTOOFF_UNIT" ohne Endung loeste sich zur # .service auf und wuerde sich mitten im Abschalten selbst umbringen. autooff_stop_unit() { sudo systemctl stop "$AUTOOFF_UNIT.timer" >/dev/null 2>&1 || true # Damit ein spaeteres systemd-run denselben Namen wieder vergeben kann, auch wenn ein # frueherer Lauf im Zustand "failed" haengengeblieben ist. sudo systemctl reset-failed "$AUTOOFF_UNIT.timer" "$AUTOOFF_UNIT.service" >/dev/null 2>&1 || true } # Timer anhalten UND die Vormerkung loeschen - "es laeuft keiner mehr". autooff_clear() { autooff_stop_unit sudo rm -f "$AUTOOFF_FILE" } # Legt den Zeitschalter an (oder erneuert ihn). # $1 = angeforderte Dauer in Minuten, $2 = Verzoegerung des naechsten Anlaufs in # Sekunden, $3 = Zahl der bisherigen Verschiebungen. # # --on-unit-inactive sorgt dafuer, dass der Timer nach JEDEM Lauf der Aktion erneut # feuert. Das ist die Verschiebung "in etwa einer Minute" (siehe autooff_fire): der # gefeuerte Lauf kann sie nicht selbst nachlegen, weil er unter demselben Unit-Namen # laeuft und systemd-run einen belegten Namen ablehnt. Wird nicht verschoben, endet der # Lauf in full_off -> autooff_clear, und der Timer ist weg, bevor er ein zweites Mal # feuern koennte. autooff_arm() { _req=$1 _delay=$2 _deferrals=$3 autooff_stop_unit set -- --unit="$AUTOOFF_UNIT" \ --description="ninlight-station: Zeitschalter (Auto-Aus)" \ --on-active="${_delay}s" \ --on-unit-inactive="${AUTOOFF_RETRY_S}s" # Die Testumgebung (tests/test_cli.sh) laeuft gegen eine Sandbox statt gegen # /etc und /run; der spaeter gefeuerte Lauf haette davon sonst nichts. Im Betrieb # sind diese Variablen nicht gesetzt und es wird nichts durchgereicht. if [ -n "${NINLIGHT_STATION_CONF:-}" ]; then set -- "$@" "--setenv=NINLIGHT_STATION_CONF=$NINLIGHT_STATION_CONF" fi if [ -n "${NINLIGHT_RUN_DIR:-}" ]; then set -- "$@" "--setenv=NINLIGHT_RUN_DIR=$NINLIGHT_RUN_DIR" fi if [ -n "${NINLIGHT_STATION_LIB:-}" ]; then set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB" fi if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then echo "Fehler: Zeitschalter liess sich nicht anlegen (systemd-run '$AUTOOFF_UNIT') - die Station bleibt AN, 'ninlight-station off' nicht vergessen." >&2 return 1 fi autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || { echo "Fehler: $AUTOOFF_FILE konnte nicht geschrieben werden - Zeitschalter wieder abgeraeumt" >&2 autooff_clear return 1 } } # Laeuft gerade eine Auslieferung? Geprueft an den offenen TCP-Verbindungen auf Port 80 # aus dem Stationsnetz - ohne jede Aenderung an server.py. # # "ss" liegt auf Debian in /usr/sbin; der gefeuerte Timer-Lauf bringt nicht zwingend # denselben $PATH mit wie eine Anmeldesitzung, deshalb die beiden festen Pfade als # Rueckfall. autooff_busy() { _ss=$(command -v ss 2>/dev/null || true) if [ -z "$_ss" ]; then for _cand in /usr/sbin/ss /sbin/ss; do if [ -x "$_cand" ]; then _ss=$_cand break fi done fi if [ -z "$_ss" ]; then # Nicht pruefbar ist nicht dasselbe wie "es laeuft etwas": waere das hier ein # "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur # Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der # Nutzer sonst nicht mehr an den Pi kommt. echo "Warnung: 'ss' nicht gefunden - laufende Auslieferungen konnten nicht geprueft werden" >&2 return 1 fi _conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true) [ "${_conns:-0}" -gt 0 ] } # Die Aktion des Timers. Absichtlich ein eigener (im Hilfetext nicht gefuehrter) # Unterbefehl statt schlicht "off": das Verschieben bei laufender Auslieferung gehoert # NUR hierher. Wer von Hand "off" tippt, will sofort aus - und nicht, dass ihm das CLI # das Abschalten zehn Minuten lang verweigert. Ausgeschaltet wird am Ende trotzdem # genau das, was "off" tut (full_off), nicht ein zweiter, aehnlicher Weg. autooff_fire() { if ! _info=$(autooff_read); then # Niemand hat mehr einen Zeitschalter vorgemerkt (z.B. "hold" oder ein # zwischenzeitliches "off") - dann hier nichts abschalten, nur den eigenen Timer # abraeumen. Sonst faellt dieser Lauf in eine Station hinein, die laengst aus # oder aus einem anderen Grund wieder an ist. autooff_stop_unit exit 0 fi # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. # shellcheck disable=SC2086 set -- $_info _req=$1 _deferrals=$3 if autooff_busy; then if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then _deferrals=$((_deferrals + 1)) autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals" echo "Zeitschalter verschoben ($_deferrals/$AUTOOFF_MAX_DEFERRALS): eine Auslieferung laeuft noch" exit 0 fi echo "Warnung: $AUTOOFF_MAX_DEFERRALS Verschiebungen erreicht - es steht immer noch eine Verbindung offen, sie haengt vermutlich. Station wird jetzt trotzdem abgeschaltet." >&2 fi full_off } # 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 } # Liest "--for " aus den Argumenten und legt die Dauer in $_for_minutes ab # (leer = kein Zeitschalter, die Vorgabe). Wird VOR jedem Einschalten aufgerufen, damit # eine ungueltige Angabe nichts anschaltet. # $1 = das Kommando fuer die Fehlermeldung, Rest = die Argumente. parse_for_option() { _for_minutes="" _for_seen=0 shift while [ $# -gt 0 ]; do case "$1" in --for) shift _for_minutes=${1:-} _for_seen=1 autooff_check_minutes "$_for_minutes" || exit 2 ;; *) usage exit 2 ;; esac [ $# -gt 0 ] && shift || true done [ "$_for_seen" -eq 1 ] || _for_minutes="" } # Legt den angeforderten Zeitschalter an - NUR wenn "--for" dabei war. Ohne die Angabe # passiert hier nichts: das ist die Vorgabe und der Grund, warum der Pi am Kabel von # dieser Funktion nie etwas merkt. autooff_arm_if_requested() { [ -n "$_for_minutes" ] || return 0 autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1 echo "Zeitschalter: Auto-Aus in $_for_minutes min ('ninlight-station renew' verlaengert, 'hold' bestellt ab)" } recovery_on() { parse_for_option recovery "$@" 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." autooff_arm_if_requested } migrate_on() { # Argumente: "--listen" und/oder "--for ", beide optional und in beliebiger # Reihenfolge. Beides wird VOLLSTAENDIG geprueft, bevor irgendein Profil hochkommt. _phase=auto _for_minutes="" _for_seen=0 while [ $# -gt 0 ]; do case "$1" in --listen) _phase=listen ;; --for) shift _for_minutes=${1:-} _for_seen=1 autooff_check_minutes "$_for_minutes" || exit 2 ;; *) usage exit 2 ;; esac [ $# -gt 0 ] && shift || true done [ "$_for_seen" -eq 1 ] || _for_minutes="" need_profile "$MIGRATE_AP_CONNECTION" _set=$(imageset_get) _setdir=$(imageset_dir "$_set") n=$(migrate_images | wc -l | tr -d ' ') [ "$n" -gt 0 ] || echo "Warnung: $_setdir enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. ap_switch "$MIGRATE_AP_CONNECTION" 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)" # Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte # Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche # stehen. echo "Bildsatz: $(imageset_label "$_set")" echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2 echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." autooff_arm_if_requested } # 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" } # Stoppt den Anzeigedienst dauerhaft und leert danach das Panel. Reihenfolge: erst der # Dienst, dann das Leeren - umgekehrt zeichnet der noch laufende Daemon binnen 5 s sein # altes Bild wieder darauf. # # Frueher hing diese Funktion auch in full_off() (also in jedem "off") und stuetzte das # auf die Annahme, ein weiterlaufender Anzeigedienst wuerde nach "off" ein stehen- # gebliebenes "MIGRATION"-Bild zeigen, das einen Betriebszustand behauptet, den es nicht # mehr gibt - ein leeres Panel sei da die einzig ungefaehrliche Richtung. Die Praemisse # stimmt nicht: der Anzeigedienst erkennt den ausgeschalteten Zustand selbst # (_service_active() in display.py fragt "systemctl is-active ninlight-station") und # zeichnet dann von sich aus "Station aus" - am Geraet so bestaetigt. Es gab also nie ein # stehenbleibendes Bild, gegen das zu schuetzen waere. Der tatsaechliche Effekt war ein # anderer, ungewollter: der Dienst blieb nach dem ersten "off" gestoppt, und KEIN "on"-Pfad # (recovery_on/migrate_on/die Kurzform "on") hat ihn je wieder gestartet - ab dem zweiten # Durchgang blieb das Panel bis zum naechsten Neustart des Pi dunkel, obwohl die Station # wieder lief. # # Jetzt laesst full_off() den Anzeigedienst laufen (er startet ohnehin beim Booten und # zeichnet den jeweils aktuellen Zustand selbst) - symmetrisch, ohne dass "on" ihn eigens # wiederanfahren muesste, und ohne dass "off" ihn extra stoppen muesste. Diese Funktion # bleibt nur fuer den EXPLIZITEN Diagnosebefehl 'ninlight-station display off' erhalten, # mit dem man den Dienst bewusst und dauerhaft stilllegen kann (z.B. um das Panel zu # schonen) - dafuer braucht es weiterhin Stop+Leeren in dieser Reihenfolge. # # Preis der Entscheidung, den Dienst durchlaufen zu lassen: er ruft alle # POLL_INTERVAL_S=5s (display.py) 'systemctl is-active' auf - dauerhaft, auch waehrend die # Station aus ist, nicht nur waehrend sie an ist wie bisher. Das ist ein einzelner lokaler # D-Bus-Aufruf ohne Netzwerk; auf einem Pi Zero W (ARMv6, ein Kern) kostet ein Fork+Exec # davon alle 5 s spuerbar, aber nicht ernsthaft etwas - derselbe Takt laeuft schon waehrend # jedes "an"-Zustands, und ein ansonsten untaetiger Kern hat davon reichlich Luft. Vertretbar. 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() { # ZUERST: ein noch vorgemerkter Zeitschalter muss mit weg, sonst feuert er spaeter in # eine Station hinein, die laengst aus ist - oder, schlimmer, in eine, die inzwischen # jemand anders wieder eingeschaltet hat. Ganz vorne, damit auch ein spaeter # scheiterndes "off" (haengender NetworkManager, siehe ap_switch) ihn nicht # stehenlaesst. autooff_clear sudo systemctl stop ninlight-station 2>/dev/null || true # Am Anzeigedienst wird hier bewusst NICHTS getan (siehe display_off()'s Kommentar): # er laeuft weiter und zeichnet "Station aus" von selbst, sobald sein naechster Poll # (spaetestens 5 s) "systemctl is-active ninlight-station" als inaktiv sieht. Das # macht "off" unabhaengig vom Anzeigedienst - ob der installiert, aktiv oder gerade # gescheitert ist, hat auf das Abschalten der Station keinen Einfluss. 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" } # "renew []" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die # zuletzt angefordert wurde (nicht auf die Restzeit und auch nicht auf eine Vorgabe). # # Laeuft keiner, ist das ein Fehler und AUSDRUECKLICH kein stillschweigendes Neuanlegen: # "renew" auf einem Pi am Kabel wuerde sonst genau den Zeitschalter herbeifuehren, den # der Nutzer nie wollte. renew_cmd() { _arg=${1:-} if ! _info=$(autooff_read); then echo "Fehler: kein Zeitschalter aktiv - 'renew' stellt nur einen laufenden zurueck. Einen neuen gibt es nur mit 'ninlight-station on --for '." >&2 exit 1 fi # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. # shellcheck disable=SC2086 set -- $_info _minutes=$1 if [ -n "$_arg" ]; then autooff_check_minutes "$_arg" || exit 2 _minutes=$_arg fi autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1 echo "Zeitschalter zurueckgestellt: Auto-Aus in $_minutes min" } # "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt. hold_cmd() { if ! autooff_read >/dev/null; then echo "Fehler: kein Zeitschalter aktiv - es gibt nichts abzubestellen." >&2 exit 1 fi autooff_clear echo "Zeitschalter abbestellt - die Station bleibt an, bis 'ninlight-station off'." } # Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der # Ausgabe gar nichts davon - eine Zeile "kein Zeitschalter aktiv" kostet nur Platz). autooff_status_line() { _info=$(autooff_read) || return 0 # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. # shellcheck disable=SC2086 set -- $_info _rest=$(( ($2 - $(date +%s) + 59) / 60 )) if [ "$_rest" -gt 0 ]; then printf 'Auto-Aus: in %s min (angefordert: %s min)\n' "$_rest" "$1" else printf 'Auto-Aus: steht an (angefordert: %s min)\n' "$1" fi } 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" imageset_status_lines printf 'Erkannt: %s\n' "$_erkannt" printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" autooff_status_line 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 [--for ]|recovery {on [--for ]|off}|migrate {on [--listen] [--for ]|off}|arm []|listen|bildsatz [ohne-wlan|mit-wlan]|renew []|hold|display {test|clear|off}|off|status" >&2 echo "" >&2 echo " bildsatz Umschalter: welcher Satz Migrationsimages ausgeliefert wird." >&2 echo " Ohne Argument nur berichten. 'ohne-wlan' (Vorgabe) liefert die" >&2 echo " Abzuege aus tools/build_nocreds.sh - die migrierte Lampe startet" >&2 echo " im eigenen Onboarding-Zugangspunkt. 'mit-wlan' liefert die" >&2 echo " Abzuege aus build/ - die Lampe haengt sofort im WLAN aus" >&2 echo " config.local.cmake, auch wenn es eine fremde Lampe ist, die im" >&2 echo " Automatikmodus mitgenommen wurde. Der Zustand bleibt, bis ihn" >&2 echo " jemand aendert, und steht dauerhaft auf dem Panel." >&2 echo " --for Zeitschalter: die Station schaltet sich nach selbst" >&2 echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&2 echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2 echo " WLAN angemeldet bist: 'on' macht wlan0 zum Zugangspunkt und" >&2 echo " reisst die SSH-Sitzung ab. Erlaubt: 1-240." >&2 echo " renew [] Uhr zurueckstellen (ohne Angabe auf die zuletzt angeforderte" >&2 echo " Dauer). Laeuft keiner, ist das ein Fehler." >&2 echo " hold Zeitschalter abbestellen, Station bleibt an." >&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 dauerhaft stoppen und das Panel leeren. 'on'/'off'" >&2 echo " der Station brauchen das nicht - der Anzeigedienst laeuft" >&2 echo " unabhaengig davon durch und zeichnet den Zustand selbst." >&2 echo "" >&2 echo " Taster (GPIO23): kurzer Druck oeffnet am Geraet ein Menue ueber genau diese" >&2 echo " Unterbefehle, 3 s gehalten brechen eine laufende Migration/Rettung ab." >&2 } _cmd=${1:-} [ $# -gt 0 ] && shift || true case "$_cmd" in on) recovery_on "$@" ;; recovery) _sub=${1:-} [ $# -gt 0 ] && shift || true case "$_sub" in on) recovery_on "$@" ;; off) full_off ;; *) usage; exit 2 ;; esac ;; migrate) _sub=${1:-} [ $# -gt 0 ] && shift || true case "$_sub" in on) migrate_on "$@" ;; off) full_off ;; *) usage; exit 2 ;; esac ;; arm) arm_cmd "${1:-}" ;; listen) listen_cmd ;; bildsatz) bildsatz_cmd "${1:-}" ;; renew) renew_cmd "${1:-}" ;; hold) hold_cmd ;; __autooff) # Nicht im Hilfetext: das ist die Aktion des Zeitschalters, kein Handgriff fuer # Menschen (siehe autooff_fire). autooff_fire ;; display) display_cmd "${1:-}" ;; off) full_off ;; status) status_cmd ;; *) usage exit 2 ;; esac