Files
ninlight-w600/tools/station/ninlight-station
T
Michael Sauer d1535433ce fix(station): Anzeigedienst bleibt ueber on/off hinweg durchgehend an
'off' stoppte bisher den Anzeigedienst und leerte das Panel, aber kein
'on'-Pfad hat ihn je wieder gestartet - ab dem zweiten Ein/Aus blieb das
Panel bis zum naechsten Pi-Neustart dunkel. Die Begruendung dafuer trug
nicht: der Anzeigedienst erkennt den ausgeschalteten Zustand selbst
(systemctl is-active ninlight-station) und zeichnet von sich aus
"Station aus".

'off' fasst den Anzeigedienst jetzt nicht mehr an; er laeuft durchgehend
(wie seit dem Booten ohnehin) und zeigt den jeweils aktuellen Zustand.
display_off() bleibt fuer den expliziten Diagnosebefehl
'ninlight-station display off' erhalten. Enthaelt zusaetzlich die schon
im Arbeitsbaum liegende PHASE_LABELS-Korrektur in render.py
(HORCHT -> LISTEN).
2026-09-14 21:09:50 +02:00

981 lines
44 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}
# --- 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 "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2
exit 1
fi
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
eval "test -n \"\${$_key-}\"" || {
echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2
exit 1
}
done
unset _key
need_profile() {
# $1 = NetworkManager-Profilname, der existieren muss.
# -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
# "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
nmcli -t -f NAME con show | grep -Fqx "$1" || {
echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
exit 1
}
}
images() {
find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort
}
migrate_images() {
find "$IMAGE_DIR/migrate" -maxdepth 1 -name '*.img' 2>/dev/null | sort
}
# Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist)
# das gewuenschte hoch, und prueft danach per nmcli nach, dass wirklich genau
# dieses Profil auf wlan0 aktiv ist. Weicht es ab, geht alles wieder herunter und
# die Funktion bricht mit Fehler ab - das ist die Absicherung fuer Fall 15 der
# Testliste: zwei gleichzeitig aktive Stationsprofile auf demselben Interface
# duerfen nie vorkommen.
#
# $1 leer bedeutet "nur alles herunterfahren" (Grundlage fuer "off").
ap_switch() {
_target=${1:-}
for _p in "$AP_CONNECTION" "$MIGRATE_AP_CONNECTION"; do
if [ -n "$_target" ] && [ "$_p" = "$_target" ]; then
continue
fi
sudo nmcli con down "$_p" >/dev/null 2>&1 || true
done
if [ -z "$_target" ]; then
# "off": jedes "con down" oben ist mit "|| true" abgesichert, weil ein
# haengender NetworkManager den Aufruf sonst zum Abbruch braechte, bevor
# ueberhaupt versucht wurde, beide Profile herunterzufahren. Das darf aber
# nicht heissen, dass ein haengengebliebenes Profil stillschweigend als
# "aus" durchgeht: nachsehen, ob wlan0 wirklich kein Stationsprofil mehr
# zeigt, und erst dann Erfolg melden (Fixrunde 1, I2) - sonst kann z.B. der
# Migrations-AP mit oeffentlich bekanntem Passwort weitersenden, waehrend
# das CLI "Station aus" behauptet.
_active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2
return 1
fi
return 0
fi
sudo nmcli con up "$_target" >/dev/null
_active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
if [ "$_active" != "$_target" ]; then
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true
echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2
return 1
fi
}
# Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann
# umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file.
write_mode() {
sudo mkdir -p "$RUN_DIR"
_tmp="$RUN_DIR/.mode-$$"
printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
sudo mv -f "$_tmp" "$RUN_DIR/mode"
}
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im
# selben Verzeichnis anlegen, Rechte setzen, dann umbenennen) und dieselben Rechte
# wie server.py's eigenes write_control(), sonst kann der Dienst die Datei
# anschliessend nicht mehr ueberschreiben oder das CLI sie nicht mehr lesen.
#
# $1 = phase (auto|listen|armed), $2 = armed_target oder leer (-> null im JSON).
write_control() {
sudo mkdir -p "$RUN_DIR"
_tmp="$RUN_DIR/.control-$$.json"
if [ -n "${2:-}" ]; then
_json=$(printf '{"phase": "%s", "armed_target": "%s"}' "$1" "$2")
else
_json=$(printf '{"phase": "%s", "armed_target": null}' "$1")
fi
printf '%s' "$_json" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
sudo mv -f "$_tmp" "$RUN_DIR/control.json"
}
# --- 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 "<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 "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 <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 "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 <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"
n=$(migrate_images | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR/migrate enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2
# Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
ap_switch "$MIGRATE_AP_CONNECTION"
write_mode "migration"
write_control "$_phase" "" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
exit 1
}
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)"
echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
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 <target>" >&2
exit 1
fi
fi
if ! python3 - "$STATION_LIB" "$_target" <<'PY'
import sys
sys.path.insert(0, sys.argv[1])
import products
target = sys.argv[2]
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
PY
then
echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2
exit 1
fi
write_control "armed" "$_target" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
exit 1
}
echo "Freigegeben: $_target"
}
# "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 "Fehler: kein Zeitschalter aktiv - 'renew' stellt nur einen laufenden zurueck. Einen neuen gibt es nur mit 'ninlight-station on --for <minuten>'." >&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"
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 <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2
echo "" >&2
echo " --for <minuten> Zeitschalter: die Station schaltet sich nach <minuten> 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 [<min>] 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
}
_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
;;
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