Sechs Review-Befunde an e2d836a/2aab1b0. C1: die Pixelschwellen in test_render.py waren gegen Pillow 12/Arial kalibriert und auf dem Pi wie auf Debian 13 (DejaVu) rot - "> 150 schwarze Pixel in Zeile 11" sind dort nur 143. Die Zusagen ueber den Kopfbalken sind jetzt Anteile und Symmetrien statt Pixelzahlen (mindestens 55 % des Balkens schwarz, weisse Schrift links UND rechts der Mitte, Balken ueber die volle Breite). Zwei weitere schriftabhaengige Schwellen ebenso: die grosse Zeile hebt sich ueber das VERHAELTNIS der Tintenhoehen vom Beiwerk ab, und die Randgrenze zaehlt Zeilenbaender statt Tinten-Cluster. I2: im Zustand "armed" stand "FREIGEGEBEN: <ziel>" in der Kopfzeile, wo 112 px Platz sind und der Text 182 px misst - gezeichnet wurde "FREIGEGEBE…", bei jedem Ziel gleich. Im Kopf steht jetzt nur noch das Label, der Zielname als eigene Beiwerkzeile ueber die volle Breite; die SSID-Zeile gibt dafuer in "armed" ihren Platz ab. Geprueft wird das gezeichnete Bild (Pixelvergleich gegen eine Referenzzeile), nicht lines(). I3: der Kommentar an display_handover() begruendete die Rueckgabewert-Durchreichung damit, dass der Handgriff bei stummem Panel rot werde. Das stimmt nicht - display.py beendet sich bei fehlendem Panel/SPI/Pillow mit 0. Der Kommentar sagt jetzt, dass man die Revision am Panel beurteilt und nicht am Exit-Code. I4: zwischen "systemctl stop" und "systemctl start" gab es keinen Signalhandler - ein Strg-C waehrend "display test" (der dokumentierte Weg, die Revision zu bestimmen, bei falscher Revision haengt das Panel) liess den Anzeigedienst gestoppt zurueck. trap auf INT/TERM/HUP, das Wiederanfahren in display_service_resume(), Handler beenden selbst mit 128 + Signalnummer. M2: HEADER_PHASE_MIN_WIDTH_PX bleibt als Sicherheitsnetz, ist jetzt aber getestet statt toter Code. M4: compose() baute je Aufruf fuenf Schriften neu. Prozess-Cache (mit ImageFont-Modul und FONT_SEARCH_PATHS im Schluessel, damit die Tests nicht verfaelscht werden) und faules Laden je Rolle: 25,0 ms -> 7,0 ms. Die Pixeltests laufen zusaetzlich unter Pillow 11.1 mit echtem DejaVu und unter fuenf weiteren Schriften. Sieben Mutationsproben angewandt, angesehen und zurueckgenommen. tools/test_host.sh gruen.
603 lines
27 KiB
Bash
Executable File
603 lines
27 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".
|
|
#
|
|
# 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() {
|
|
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
|