Files
ninlight-w600/tools/station/ninlight-station
T
Michael Sauer bb39105ac4 feat(display): grosse Schrift, invertierter Kopf, GPIO-Uebergabe
Zwei Befunde der Hardware-Abnahme.

1) Die Schrift war zu klein: compose() zeichnete alles in
ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern
nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big),
compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem
Balken, die wichtigste Zeile gross (Migration: erkannte Lampe,
Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig.

Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit
DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden
Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst
ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt
load_default() ohne Argument, damit eine fehlende Schrift die Anzeige
(und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow
importierbar, die Breitenkuerzung misst weiter in Pixeln.

2) "display test" scheiterte bei laufendem Anzeigedienst mit einem
rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine
gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in
epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin
gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler -
und die Meldung nennt bei belegten Leitungen den Anzeigedienst.
"display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs
und startet ihn danach wieder, aber nur, wenn er vorher lief.
2026-09-14 02:24:14 +02:00

575 lines
25 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}
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"
}
# 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
}
recovery_on() {
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."
}
migrate_on() {
# $1 = "--listen" oder leer.
_phase=auto
if [ "${1:-}" = "--listen" ]; then
_phase=listen
elif [ -n "${1:-}" ]; then
usage
exit 2
fi
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."
}
# 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".
#
# Rueckgabewert ist der von display.py - der Handgriff muss weiterhin rot werden, wenn
# das Panel nicht antwortet; das Wiederanfahren des Dienstes darf ihn weder verdecken
# noch (bei eigenem Fehlschlag) einen Panel-Erfolg in einen Fehler verwandeln.
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
_rc=0
display_run "$1" || _rc=$?
if [ "$_was_active" -eq 1 ]; then
sudo systemctl start ninlight-display 2>/dev/null \
|| echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2
fi
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() {
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"
}
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)"
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|recovery {on|off}|migrate {on [--listen]|off}|arm [<target>]|listen|display {test|clear|off}|off|status" >&2
echo "" >&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
}
case "${1:-}" in
on)
recovery_on
;;
recovery)
case "${2:-}" in
on) recovery_on ;;
off) full_off ;;
*) usage; exit 2 ;;
esac
;;
migrate)
case "${2:-}" in
on) migrate_on "${3:-}" ;;
off) full_off ;;
*) usage; exit 2 ;;
esac
;;
arm)
arm_cmd "${2:-}"
;;
listen)
listen_cmd
;;
display)
display_cmd "${2:-}"
;;
off)
full_off
;;
status)
status_cmd
;;
*)
usage
exit 2
;;
esac