Fixrunde 1, Befund 1: imageset_dir()/migrate_images()/imageset_status_lines() pruefen bislang IMMER die Migrationsverzeichnisse, unabhaengig vom Modus - im Rettungsbetrieb meldete "status" so faelschlich "MISSING", waehrend rescue-nocreds voll war, oder umgekehrt "vorhanden", waehrend die Rettung nichts hatte. Ein falsches "MISSING" dort verleitet dazu, auf mit-wlan umzuschalten - und dann liefert die Rettung tatsaechlich Abbilder mit dem WLAN-Passwort aus. imageset_dir() nimmt jetzt den Modus als Parameter (Vorgabe: current_mode()), dieselbe Unterscheidung wie im Panel. Befund 2: images() griff weiter flach auf $IMAGE_DIR zu, wo seit dem Deploy-Umbau nichts mehr liegt - "recovery on" warnte deshalb bei JEDEM Start "answers everything with 404". Ersetzt durch imageset_images(), das dem Bildsatz-Umschalter UND dem Modus folgt. Befund 3 (CLI-Seite): current_mode() liest die neue dauerhafte LAST_MODE_FILE statt der ephemeren RUN_DIR/mode - write_mode() schreibt jetzt beide (siehe display.py-Commit fuer die Panel-Seite derselben Ursache). tests/test_cli.sh legte weiterhin die alte flache Rettungsablage an (images/ninlight_foo_ota.img) - die Sandbox hat jetzt eigene rescue-wlan/rescue-nocreds-Verzeichnisse wie ein echter Pi nach diesem Umschalter, und run_cli() setzt NINLIGHT_LAST_MODE_FILE auf einen Sandbox-Pfad. Mehrere bestehende Bildsatz-Szenarien liefen unbemerkt gegen den Migrationssatz, obwohl kein Modus gesetzt war (Vorgabe: recovery) - angepasst, damit sie den tatsaechlich geltenden Satz pruefen.
1266 lines
59 KiB
Bash
Executable File
1266 lines
59 KiB
Bash
Executable File
#!/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 <minuten>" 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 <pi-host> ninlight-station on" laeuft
|
|
# aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre
|
|
# Rechte aufzuweichen.
|
|
eval "$(sudo cat "$CONF")"
|
|
else
|
|
echo "Error: $CONF missing - run tools/station/setup_pi.sh first" >&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 "Error: $CONF: key $_key missing or empty - run tools/station/setup_pi.sh" >&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 --escape no -f NAME con show | grep -Fqx "$1" || {
|
|
echo "Error: NetworkManager profile '$1' missing - run tools/station/setup_pi.sh" >&2
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
# --- Modus lesen/schreiben (Fixrunde 1, Befund 3) -----------------------------------
|
|
#
|
|
# RUN_DIR/mode (write_mode() unten schreibt sie zusaetzlich) taugt NICHT als Quelle
|
|
# fuer "welcher Modus gilt gerade/als naechstes", solange die Station AUS ist: die Unit
|
|
# setzt RuntimeDirectoryPreserve=restart, das erhaelt /run/ninlight-station nur ueber
|
|
# einen "systemctl restart" (das, was "recovery on"/"migrate on" bei einem schon
|
|
# laufenden Dienst ausloesen), NICHT ueber einen echten "systemctl stop" - und genau
|
|
# den fuehrt "ninlight-station off" aus. Fehlt die Datei, gilt ueberall stillschweigend
|
|
# "recovery" (derselbe sichere Vorgabewert wie beim Bildsatz) - eine fruehere Fassung
|
|
# dieses Skripts UND von display.py hat das mit "die Datei bleibt bei jedem Zustand
|
|
# erhalten" verwechselt.
|
|
#
|
|
# LAST_MODE_FILE ist deshalb wie IMAGESET_FILE eine EIGENE, dauerhafte Ablage unter
|
|
# /var/lib - dieselbe Datei, die display.py:LAST_MODE_FILE liest (Panel und CLI muessen
|
|
# sich hier einig sein, sonst zeigt das eine "Rettung: MISSING" waehrend das andere
|
|
# "Migration: 3 image(s)" meldet).
|
|
LAST_MODE_FILE=${NINLIGHT_LAST_MODE_FILE:-/var/lib/ninlight-station/mode}
|
|
|
|
# Der zuletzt angeforderte Modus, oder "recovery" bei fehlender/unlesbarer/unbekannter
|
|
# Datei - dieselbe Vorgabe und dieselbe Begruendung wie imageset_get() unten. Scheitert
|
|
# nie: eine kaputte Datei darf weder "status" noch "bildsatz" aufhalten.
|
|
current_mode() {
|
|
_m=$(cat "$LAST_MODE_FILE" 2>/dev/null | tr -d ' \t\r\n') || true
|
|
case "$_m" in
|
|
recovery|migration) printf '%s' "$_m" ;;
|
|
*) printf '%s' "recovery" ;;
|
|
esac
|
|
}
|
|
|
|
# --- 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), fuer den Modus $2 (Vorgabe:
|
|
# current_mode()). Rettung und Migration liegen in getrennten Verzeichnispaaren
|
|
# (server.py:RESCUE_SET_DIRS/IMAGE_SET_DIRS begruendet das) - dieselbe Unterscheidung
|
|
# wie display.py:_read_imageset(). Fixrunde 1, Befund 1: vor dieser Aenderung pruefte
|
|
# dieses Skript hier IMMER die Migrationsverzeichnisse, auch im Rettungsbetrieb - ein
|
|
# falsches "MISSING" dort verleitet dazu, auf mit-wlan umzuschalten, und DANN liefert
|
|
# die Rettung tatsaechlich Abbilder mit dem WLAN-Passwort aus.
|
|
imageset_dir() {
|
|
_name=$1
|
|
_mode=${2:-$(current_mode)}
|
|
if [ "$_mode" = "recovery" ]; then
|
|
case "$_name" in
|
|
mit-wlan) printf '%s' "$IMAGE_DIR/rescue-wlan" ;;
|
|
*) printf '%s' "$IMAGE_DIR/rescue-nocreds" ;;
|
|
esac
|
|
else
|
|
case "$_name" in
|
|
mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;;
|
|
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
imageset_label() {
|
|
case "$1" in
|
|
mit-wlan) printf 'mit-wlan (lamp joins your Wi-Fi right away)' ;;
|
|
*) printf 'ohne-wlan (lamp starts its onboarding access point)' ;;
|
|
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"
|
|
}
|
|
|
|
# Die Abbilder des eingestellten Bildsatzes fuer den Modus $1 (Vorgabe: current_mode()).
|
|
# Ersetzt das fruehere migrate_images() (nur Migration) UND das fruehere images() (immer
|
|
# flach in $IMAGE_DIR, Fixrunde 1, Befund 2: nach dem Deploy-Umbau liegt dort nichts
|
|
# mehr - recovery_on() meldete seither bei JEDEM Start faelschlich "answers everything
|
|
# with 404", ununterscheidbar vom echten Fehlerfall).
|
|
imageset_images() {
|
|
_mode=${1:-$(current_mode)}
|
|
find "$(imageset_dir "$(imageset_get)" "$_mode")" -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 "Error: unknown image set '$_want' - allowed are 'ohne-wlan' and 'mit-wlan'" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
imageset_set "$_want" || {
|
|
echo "Error: could not write $IMAGESET_FILE" >&2
|
|
exit 1
|
|
}
|
|
echo "Image set: $(imageset_label "$_want")"
|
|
[ "$_now" = "$_want" ] && echo " (was already set)"
|
|
# Der Umschalter gilt fuer BEIDE Betriebsarten - hier wird deshalb der Satz gemeldet,
|
|
# der fuer den Modus zaehlt, in dem die Station gerade steht bzw. als naechstes
|
|
# anlaufen wuerde (current_mode()), nicht pauschal die Migration.
|
|
_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 "MISSING". Hier trotzdem sofort
|
|
# warnen, damit niemand erst an der Lampe merkt, dass nichts kommt.
|
|
echo "Warning: $_dir missing or empty - this image set serves NOTHING. Run tools/station/deploy.sh again." >&2
|
|
fi
|
|
if [ "$_want" = "mit-wlan" ]; then
|
|
echo "Caution: any lamp migrated OR rescued while this is set joins the Wi-Fi from config.local.cmake right away - including lamps that are not yours and got picked up in automatic mode." >&2
|
|
fi
|
|
}
|
|
|
|
# Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben - fuer
|
|
# den Modus, in dem die Station gerade steht bzw. als naechstes anlaufen wuerde.
|
|
imageset_status_lines() {
|
|
_mode=$(current_mode)
|
|
_set=$(imageset_get)
|
|
_dir=$(imageset_dir "$_set" "$_mode")
|
|
# "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie
|
|
# server.py:migrate_set_present()/rescue_set_present() und display.py:_read_imageset().
|
|
# Ein leeres Verzeichnis liefert genauso nichts aus wie ein fehlendes.
|
|
_n=$(imageset_images "$_mode" | wc -l | tr -d ' ')
|
|
if [ "$_n" -gt 0 ]; then
|
|
_state="$_n image(s)"
|
|
else
|
|
_state="MISSING - nothing will be served"
|
|
fi
|
|
printf 'Image set: %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 " (unknown setting '%s' in %s - default applies)\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.
|
|
# --escape no und "-f2-" gehoeren zusammen (Abschlussreview, M1): "nmcli -t"
|
|
# maskiert Doppelpunkte IN WERTEN als "\:", und "cut -f2" schneidet ohnehin am
|
|
# ersten Doppelpunkt ab. Ein Profilname mit Doppelpunkt ergaebe so einen
|
|
# abgeschnittenen Wert, der Vergleich unten schluege fehl - und zwar in die
|
|
# UNSICHERE Richtung: "Station aus" gemeldet, waehrend der Zugangspunkt
|
|
# weitersendet. --escape no liefert den Rohwert, -f2- nimmt den ganzen Rest der
|
|
# Zeile (die Ausgabe hat genau ein Feld, alles nach dem ersten Doppelpunkt
|
|
# gehoert zum Wert).
|
|
_active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)
|
|
if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
|
|
echo "Error: after switching off, wlan0 still shows '$_active' active - check NetworkManager (access point stays up)" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
fi
|
|
sudo nmcli con up "$_target" >/dev/null
|
|
# --escape no / -f2- aus demselben Grund wie oben.
|
|
_active=$(nmcli -t --escape no -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 "Error: after switching to '$_target', wlan0 unexpectedly shows '$_active' - both station profiles brought down again" >&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. ZUSAETZLICH
|
|
# dieselbe Zeichenkette nach $LAST_MODE_FILE (dauerhaft, siehe dort) - RUN_DIR/mode
|
|
# ueberlebt nur einen "systemctl restart", nicht das "systemctl stop" von
|
|
# "ninlight-station off" (Fixrunde 1, Befund 3). Beide Schreibvorgaenge muessen
|
|
# gelingen: schlaegt der dauerhafte fehl, wuerden Panel und CLI nach einem "off" auf
|
|
# den falschen (naemlich den vorherigen) Modus zurueckfallen, ohne dass das hier
|
|
# auffiele.
|
|
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" || return 1
|
|
sudo mkdir -p "$(dirname "$LAST_MODE_FILE")"
|
|
_tmp2="$(dirname "$LAST_MODE_FILE")/.mode-$$"
|
|
printf '%s' "$1" | sudo tee "$_tmp2" >/dev/null && sudo chmod 0644 "$_tmp2" || return 1
|
|
sudo mv -f "$_tmp2" "$LAST_MODE_FILE"
|
|
}
|
|
|
|
# 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 "Error: '--for' needs a value in minutes ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2
|
|
return 1
|
|
;;
|
|
*[!0-9]* )
|
|
echo "Error: '$1' is not a number of minutes - allowed are whole numbers from $AUTOOFF_MIN_MINUTES to $AUTOOFF_MAX_MINUTES" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then
|
|
echo "Error: $1 minutes is outside $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 "<minuten> <deadline> <verschiebungen>" 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 "Error: could not create the timer (systemd-run '$AUTOOFF_UNIT') - the station stays ON, do not forget 'ninlight-station off'." >&2
|
|
return 1
|
|
fi
|
|
autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || {
|
|
echo "Error: could not write $AUTOOFF_FILE - timer removed again" >&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 "Warning: 'ss' not found - could not check for downloads in progress" >&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 "Timer deferred ($_deferrals/$AUTOOFF_MAX_DEFERRALS): a download is still running"
|
|
exit 0
|
|
fi
|
|
echo "Warning: reached $AUTOOFF_MAX_DEFERRALS deferrals - a connection is still open and has probably stalled. Switching the station off anyway." >&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 "Error: could not (re)start ninlight-station - access point switched off again. 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 "Error: ninlight-station is not active after starting - access point switched off again. 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 "Error: after the restart $RUN_DIR/mode holds '$_actual_mode' instead of '$_expect_mode' - the RuntimeDirectory was probably wiped (check RuntimeDirectoryPreserve=restart in the unit). Access point switched off again." >&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 "Error: after the restart $RUN_DIR/control.json holds phase '$_actual_phase' instead of '$_expect_phase' - the RuntimeDirectory was probably wiped. Access point switched off again." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Liest "--for <minuten>" 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 "Timer: auto-off in $_for_minutes min ('ninlight-station renew' extends it, 'hold' cancels it)"
|
|
}
|
|
|
|
recovery_on() {
|
|
parse_for_option recovery "$@"
|
|
need_profile "$AP_CONNECTION"
|
|
# Explizit "recovery" statt current_mode(): dieser Aufruf ENTSCHEIDET gerade, dass
|
|
# der naechste Start Rettung ist - write_mode() darunter schreibt das erst gleich.
|
|
# current_mode() koennte hier noch "migration" von einem vorherigen Lauf zeigen.
|
|
_setdir=$(imageset_dir "$(imageset_get)" recovery)
|
|
n=$(imageset_images recovery | wc -l | tr -d ' ')
|
|
[ "$n" -gt 0 ] || echo "Warning: $_setdir holds no image - the station answers everything with 404" >&2
|
|
ap_switch "$AP_CONNECTION"
|
|
write_mode "recovery"
|
|
start_and_verify "$AP_CONNECTION" "recovery"
|
|
echo "Station on: SSID $SSID at 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 "Warning: PSK is still the public default 'ninlight-recovery' - set your own password in /etc/ninlight-station.conf." >&2
|
|
fi
|
|
echo "Do not forget: 'ninlight-station off' when you are done."
|
|
autooff_arm_if_requested
|
|
}
|
|
|
|
migrate_on() {
|
|
# Argumente: "--listen" und/oder "--for <minuten>", 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)
|
|
# Explizit "migration", aus demselben Grund wie in recovery_on(): dieser Aufruf legt
|
|
# den naechsten Modus gerade erst fest.
|
|
_setdir=$(imageset_dir "$_set" migration)
|
|
n=$(imageset_images migration | wc -l | tr -d ' ')
|
|
[ "$n" -gt 0 ] || echo "Warning: $_setdir holds no migration images - the station answers everything with 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 "Error: could not write $RUN_DIR/control.json" >&2
|
|
exit 1
|
|
}
|
|
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
|
|
echo "Migration on: SSID $MIGRATE_SSID at 192.168.8.1, phase $_phase, $n migration image(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 "Image set: $(imageset_label "$_set")"
|
|
echo "Caution: the password of this access point is publicly known from the old firmware - see README.md." >&2
|
|
echo "Do not forget: 'ninlight-station off' when you are done."
|
|
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() {
|
|
# "$@", nicht "$1": das Abschlussbild wird mit ZWEI Argumenten gerufen
|
|
# ("--final poweroff"), und ein verschlucktes zweites waere hier besonders teuer -
|
|
# argparse erzwingt bei --final eine Auswahl, der Aufruf scheiterte also, und auf dem
|
|
# Panel bliebe die letzte Rueckfrage stehen.
|
|
sudo env EPD_REVISION="$EPD_REVISION" python3 "$STATION_LIB/display/display.py" "$@"
|
|
}
|
|
|
|
# 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 "Warning: could not start the display service again - check 'systemctl status ninlight-display'" >&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 "Warning: could not clear the panel - check the display" >&2
|
|
}
|
|
|
|
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart ----------------------
|
|
#
|
|
# E-Paper behaelt sein Bild dauerhaft, auch stromlos. Faehrt der Pi herunter, wird der
|
|
# Anzeigedienst mit abgeraeumt und das ZULETZT gezeichnete Bild bleibt fuer immer stehen -
|
|
# bei der Abnahme stand dort die Rueckfrage, und der ausgeschaltete Pi sah aus, als warte
|
|
# er noch auf eine Eingabe.
|
|
#
|
|
# Dieser Handgriff gehoert deshalb ins System (ninlight-poweroff.service /
|
|
# ninlight-reboot.service), nicht in den Tasterdienst: so greift er bei JEDEM
|
|
# Herunterfahren - ueber das Menue, ueber "poweroff" per SSH, ueber "reboot" - und nicht
|
|
# nur auf einem von drei Wegen.
|
|
#
|
|
# Wie lange auf die Freigabe der GPIO-/SPI-Leitungen gewartet wird. Die Units ordnen sich
|
|
# per "After=ninlight-display.service" HINTER dessen Stop ein; systemd erledigt das also
|
|
# schon. Die Wartezeit ist das Netz darunter (und der Weg fuer einen Handaufruf zum
|
|
# Ausprobieren): zwei Prozesse auf denselben Leitungen enden in "lgpio.error: 'GPIO busy'",
|
|
# und das ist in diesem Projekt schon zweimal passiert.
|
|
DISPLAY_RELEASE_WAIT_S=${NINLIGHT_DISPLAY_RELEASE_WAIT_S:-10}
|
|
|
|
# $1 = poweroff|reboot. Beendet IMMER mit 0: ein Fehler an der Anzeige darf das
|
|
# Herunterfahren nie aufhalten (dieselbe Zusage wie ueberall sonst).
|
|
display_final() {
|
|
_waited=0
|
|
while systemctl is-active --quiet ninlight-display 2>/dev/null; do
|
|
if [ "$_waited" -ge "$DISPLAY_RELEASE_WAIT_S" ]; then
|
|
# Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der
|
|
# Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche
|
|
# Zeile im Journal als ein haengendes Herunterfahren.
|
|
echo "Warning: display service still running after ${DISPLAY_RELEASE_WAIT_S}s - no final image (GPIO busy)" >&2
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
_waited=$((_waited + 1))
|
|
done
|
|
display_run --final "$1" || echo "Warning: could not draw the final image - check the display" >&2
|
|
return 0
|
|
}
|
|
|
|
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 --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
|
|
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
|
|
echo "Station off, wlan0 back in the home Wi-Fi ($HOME_CONNECTION)"
|
|
else
|
|
echo "Station off. Switching back to '$HOME_CONNECTION' failed - check the wlan0 status." >&2
|
|
fi
|
|
else
|
|
echo "Station off. Profile '$HOME_CONNECTION' missing, wlan0 stays unused - access over the cable is unaffected." >&2
|
|
fi
|
|
}
|
|
|
|
listen_cmd() {
|
|
write_control "listen" "" || {
|
|
echo "Error: could not write $RUN_DIR/control.json" >&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 "Error: no recently detected model (last_seen) in $_status_file - name a target: ninlight-station arm <target>" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
if ! python3 - "$STATION_LIB" "$_target" <<'PY'
|
|
import sys
|
|
|
|
sys.path.insert(0, sys.argv[1])
|
|
import products
|
|
|
|
target = sys.argv[2]
|
|
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
|
|
PY
|
|
then
|
|
echo "Error: unknown target '$_target' - valid targets are listed in products.py" >&2
|
|
exit 1
|
|
fi
|
|
write_control "armed" "$_target" || {
|
|
echo "Error: could not write $RUN_DIR/control.json" >&2
|
|
exit 1
|
|
}
|
|
echo "Armed: $_target"
|
|
}
|
|
|
|
# "renew [<minuten>]" - 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 "Error: no timer active - 'renew' only resets a running one. A new one comes from 'ninlight-station on --for <minutes>'." >&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 "Timer reset: auto-off in $_minutes min"
|
|
}
|
|
|
|
# "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt.
|
|
hold_cmd() {
|
|
if ! autooff_read >/dev/null; then
|
|
echo "Error: no timer active - there is nothing to cancel." >&2
|
|
exit 1
|
|
fi
|
|
autooff_clear
|
|
echo "Timer cancelled - the station stays on until '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-off: in %s min (requested: %s min)\n' "$_rest" "$1"
|
|
else
|
|
printf 'Auto-off: pending (requested: %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="unknown (file not readable)"
|
|
_erkannt="unknown (file not readable)"
|
|
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 'Mode : %s\n' "$_modus"
|
|
printf 'Phase : %s\n' "$_phase"
|
|
imageset_status_lines
|
|
printf 'Detected: %s\n' "$_erkannt"
|
|
printf 'Service : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
|
|
autooff_status_line
|
|
printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)"
|
|
printf 'Address : %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
|
|
# Die Rettungsabbilder des eingestellten Bildsatzes - unabhaengig von $_modus (der
|
|
# Migrationsbildsatz hat kein passendes Trailer-Format). Fixrunde 1, Befund 2:
|
|
# frueher stand hier "images" (flach in $IMAGE_DIR) - dort liegt seit dem
|
|
# Deploy-Umbau nichts mehr.
|
|
echo "Images :"
|
|
imageset_images recovery | 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 "Last served:"
|
|
sed 's/^/ /' "$_status_file"
|
|
elif [ -e "$_status_file" ]; then
|
|
echo "Error: $_status_file exists but is not readable (check permissions)" >&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 ;;
|
|
final)
|
|
case "${2:-}" in
|
|
poweroff|reboot) display_final "$2" ;;
|
|
*) echo "Error: 'display final' needs 'poweroff' or 'reboot'" >&2; exit 2 ;;
|
|
esac
|
|
;;
|
|
*) usage; exit 2 ;;
|
|
esac
|
|
}
|
|
|
|
usage() {
|
|
echo "Usage: ninlight-station on [--for <minutes>]|recovery {on [--for <minutes>]|off}|migrate {on [--listen] [--for <minutes>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minutes>]|hold|display {test|clear|off}|off|status" >&2
|
|
echo "" >&2
|
|
echo " bildsatz Switch: which set of recovery/migration images is served." >&2
|
|
echo " Without an argument it only reports. 'ohne-wlan' (default) serves" >&2
|
|
echo " the builds from tools/build_nocreds.sh - the migrated lamp comes" >&2
|
|
echo " up in its own onboarding access point. 'mit-wlan' serves the" >&2
|
|
echo " builds from build/ - the lamp joins the Wi-Fi from" >&2
|
|
echo " config.local.cmake right away, including a lamp that is not" >&2
|
|
echo " yours and got picked up in automatic mode. The setting sticks" >&2
|
|
echo " until someone changes it, and is shown on the panel." >&2
|
|
echo " --for <minutes> Timer: the station switches itself off after <minutes> (back to" >&2
|
|
echo " the home Wi-Fi). OPTIONAL - without '--for' it stays on until" >&2
|
|
echo " someone says 'off'. Useful when you are logged in over Wi-Fi:" >&2
|
|
echo " 'on' turns wlan0 into an access point and drops your SSH" >&2
|
|
echo " session. Allowed: 1-240." >&2
|
|
echo " renew [<min>] Reset the timer (without an argument to the duration last" >&2
|
|
echo " requested). If none is running, that is an error." >&2
|
|
echo " hold Cancel the timer, station stays on." >&2
|
|
echo " display test Draw a test image on the panel. This is the first step of the" >&2
|
|
echo " hardware acceptance and the ONLY way to determine the panel" >&2
|
|
echo " revision: it is printed nowhere on the device. If the image" >&2
|
|
echo " stays blank, distorted or shifted, EPD_REVISION in" >&2
|
|
echo " /etc/ninlight-station.conf is wrong - switch it to the other" >&2
|
|
echo " revision (V2 instead of V3 or the other way round), power the" >&2
|
|
echo " panel down briefly and run 'display test' again." >&2
|
|
echo " A running display service is stopped for this and started" >&2
|
|
echo " again afterwards (it holds the GPIO lines otherwise)." >&2
|
|
echo " display clear Clear the panel (also with a short interruption of the service)." >&2
|
|
echo " display final {poweroff|reboot}" >&2
|
|
echo " Final image on the panel. Not a step for humans: this is what" >&2
|
|
echo " ninlight-poweroff.service/ninlight-reboot.service call during" >&2
|
|
echo " shutdown or reboot, once the display service has released the" >&2
|
|
echo " GPIO lines. E-paper keeps its image forever - without this step" >&2
|
|
echo " the last confirmation prompt would stay on the panel. The two" >&2
|
|
echo " texts differ: on a reboot you must NOT unplug." >&2
|
|
echo " display off Stop the display service for good and clear the panel. The" >&2
|
|
echo " station's 'on'/'off' do not need this - the display service" >&2
|
|
echo " keeps running and draws the state by itself." >&2
|
|
echo "" >&2
|
|
echo " Button (GPIO23): a short press opens a menu on the device covering exactly" >&2
|
|
echo " these subcommands, holding 3 s aborts a running migration or" >&2
|
|
echo " recovery." >&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:-}" "${2:-}"
|
|
;;
|
|
off)
|
|
full_off
|
|
;;
|
|
status)
|
|
status_cmd
|
|
;;
|
|
*)
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|