"on"/"recovery on"/"migrate on" nehmen jetzt "--for <minuten>" (1-240): die Station schaltet sich danach selbsttaetig ab und kehrt ins Heim-WLAN zurueck. Das hilft, wenn man ueber WLAN angemeldet ist - "on" macht wlan0 zum Zugangspunkt und reisst jede SSH-Sitzung ab. Ausdruecklich optional: ohne "--for" entsteht kein Zeitschalter und alles verhaelt sich exakt wie bisher. Dazu "renew [<minuten>]" (Uhr zurueckstellen), "hold" (abbestellen, Station bleibt an) und eine Restzeit in "status"; "off" raeumt einen laufenden Zeitschalter mit ab. Umgesetzt als transienter systemd-Timer unter festem Unit-Namen (systemd-run), Zustand in RUN_DIR/autooff.json. Vor dem Abschalten wird per "ss" geprueft, ob noch eine Verbindung auf Port 80 aus dem Stationsnetz offen ist - dann wird um rund eine Minute verschoben, hoechstens zehnmal (Begruendung im Kommentar). Das Panel zeigt den Zeitschalter als eigene Zeile, aber nur wenn einer laeuft.
962 lines
42 KiB
Bash
Executable File
962 lines
42 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"
|
|
}
|
|
|
|
# Nimmt die Anzeige mit aus. Reihenfolge: erst den Dienst stoppen, dann das Panel leeren -
|
|
# umgekehrt zeichnet der noch laufende Daemon binnen 5 s sein altes Bild wieder darauf.
|
|
#
|
|
# Warum leeren und nicht "Station aus" stehen lassen: der Anzeigedienst erkennt den
|
|
# ausgeschalteten Zustand zwar selbst (systemctl is-active), aber ein laufender
|
|
# Anzeigedienst nach "off" bedeutet, dass "off" eben NICHT alles abgeschaltet hat. Ein
|
|
# leeres Panel behauptet dagegen gar nichts - und das ist bei einer Station, die Lampen
|
|
# ungefragt Firmware unterschieben kann, die einzig ungefaehrliche Richtung: ein
|
|
# stehengebliebenes "MIGRATION"-Bild wuerde einen Betriebszustand behaupten, den es nicht
|
|
# mehr gibt, waehrend ein leeres Panel hoechstens nach "aus" aussieht - was es ist.
|
|
display_off() {
|
|
sudo systemctl stop ninlight-display 2>/dev/null || true
|
|
# Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und
|
|
# ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten.
|
|
display_run --clear || echo "Warnung: Panel konnte nicht geleert werden - Anzeige pruefen" >&2
|
|
}
|
|
|
|
full_off() {
|
|
# 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
|
|
# VOR ap_switch, nicht danach: ap_switch "" bricht bei einem haengenden
|
|
# NetworkManager mit Fehler ab, und unter "set -e" kaeme danach nichts mehr. Genau
|
|
# dann - Zugangspunkt laesst sich nicht abschalten - waere die Anzeige sonst der
|
|
# letzte Rest von "off", der nie ausgefuehrt wird: Daemon laeuft weiter, Panel zeigt
|
|
# unveraendert "MIGRATION". Ein leeres Panel behauptet dagegen nichts; die Wahrheit
|
|
# ueber den noch offenen Zugangspunkt steht in ap_switch's Fehlermeldung, wo man sie
|
|
# liest, statt auf einem Panel, das man auch fuer aktuell halten koennte.
|
|
display_off
|
|
ap_switch ""
|
|
if nmcli -t -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
|
|
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
|
|
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
|
|
else
|
|
echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2
|
|
fi
|
|
else
|
|
echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2
|
|
fi
|
|
}
|
|
|
|
listen_cmd() {
|
|
write_control "listen" "" || {
|
|
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
|
|
exit 1
|
|
}
|
|
echo "Phase: listen"
|
|
}
|
|
|
|
arm_cmd() {
|
|
_target=${1:-}
|
|
if [ -z "$_target" ]; then
|
|
_status_file="$RUN_DIR/status.json"
|
|
if ! _target=$(python3 - "$_status_file" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1]) as f:
|
|
d = json.load(f)
|
|
except (OSError, ValueError):
|
|
sys.exit(1)
|
|
t = (d.get("last_seen") or {}).get("target")
|
|
if not t:
|
|
sys.exit(1)
|
|
print(t)
|
|
PY
|
|
); then
|
|
echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm <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 stoppen und das Panel leeren (macht 'off' mit)." >&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
|