Files
ninlight-w600/tools/station/ninlight-station
T
Michael Sauer 021089a68b feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
Die deutschen Texte sahen auf dem 2,13"-Panel merkwuerdig aus - jetzt sind
alle Texte, die der Bediener am Geraet oder auf der Konsole sieht, englisch:
Modus-, Phasen-, Bildsatz- und Abschlussbeschriftungen in render.py, die
Bedientexte des Tasterdienstes und saemtliche Ausgaben von ninlight-station
inklusive des Hilfetexts. Die Testbeschreibungen der Suiten bleiben deutsch -
die liest nur, wer die Tests laufen laesst.

Neu im Off-Bild: die Adresse, unter der der Pi gerade erreichbar ist. Ist die
Station aus, ist der Pi ein gewoehnlicher Rechner im Heimnetz, und die
einzige Auskunft, die man dann am Panel braucht, ist die fuer SSH. Die
Adresse kommt aus einem UDP-Socket, der nur verbunden und nie benutzt wird
(kein Datenverkehr, kein DNS, kein Unterprozess in einer 5-s-Schleife) und
liefert genau die Adresse der Standardroute. Ohne Netz sagt die Zeile
ausdruecklich "no network": eine fehlende Zeile wuerde offen lassen, warum
SSH gerade scheitert.

Die Zeile gibt es NUR im Off-Bild - im Betrieb traegt wlan0 die Adresse des
Zugangspunkts, die ohnehin auf jedem Statusbild steht.
2026-09-20 00:28:33 +02:00

1195 lines
54 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
}
}
images() {
find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort
}
# --- Bildsatz lesen, schreiben, benennen --------------------------------------------
# Der rohe Inhalt der Umschalterdatei (ohne Leerraum), oder leer. Nur fuer die Meldung
# unten gedacht - entschieden wird ausschliesslich mit imageset_get().
imageset_raw() {
cat "$IMAGESET_FILE" 2>/dev/null | tr -d ' \t\r\n' || true
}
# Der eingestellte Bildsatz, oder $IMAGESET_DEFAULT bei fehlender/unlesbarer Datei bzw.
# unbekanntem Inhalt. Gibt IMMER einen gueltigen Namen aus und scheitert nie - ein
# kaputter Umschalter darf weder "off" noch das Panel aufhalten.
imageset_get() {
_value=$(imageset_raw)
case "$_value" in
ohne-wlan|mit-wlan) printf '%s' "$_value" ;;
*) printf '%s' "$IMAGESET_DEFAULT" ;;
esac
}
# Das Verzeichnis zum Namen des Bildsatzes ($1).
imageset_dir() {
case "$1" in
mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;;
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;;
esac
}
imageset_label() {
case "$1" in
mit-wlan) printf 'mit-wlan (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"
}
migrate_images() {
find "$(imageset_dir "$(imageset_get)")" -maxdepth 1 -name '*.img' 2>/dev/null | sort
}
# "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten.
bildsatz_cmd() {
_want=${1:-}
_now=$(imageset_get)
if [ -z "$_want" ]; then
imageset_status_lines
return 0
fi
case "$_want" in
ohne-wlan|mit-wlan) ;;
*)
echo "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)"
_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: migrated lamps join 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.
imageset_status_lines() {
_set=$(imageset_get)
_dir=$(imageset_dir "$_set")
# "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie
# server.py:migrate_set_present() und display.py:_read_imageset(). Ein leeres
# Verzeichnis liefert genauso nichts aus wie ein fehlendes.
_n=$(migrate_images | wc -l | tr -d ' ')
if [ "$_n" -gt 0 ]; then
_state="$_n image(s)"
else
_state="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.
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 "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"
n=$(images | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warning: $IMAGE_DIR 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)
_setdir=$(imageset_dir "$_set")
n=$(migrate_images | 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}')"
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 "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 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