From cc398ef57ab1a97c1a8aa9add487ee15feadb2e1 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 23:18:59 +0200 Subject: [PATCH] feat(station): Anzeige ausliefern, Migrationsprofil, display-Befehle Schritt 8 des Plans: die fertige E-Paper-Anzeige wird endlich ausgeliefert und verdrahtet, dazu zwei liegengebliebene Dinge. - deploy.sh/setup_pi.sh uebertragen display/ (mit Panel-Treibern und HERKUNFT.md) und ninlight-display.service; setup_pi.sh installiert und aktiviert die Unit und zieht python3-pil nach - ohne Pillow beendet sich der Anzeigedienst still mit 0, das faellt sonst nirgends auf. - setup_pi.sh legt auch das Profil fuer den Migrations-Zugangspunkt an (qconnex-firmware, von der alten Klyqa-Firmware fest vorgegeben, gleiche Adresse wie das Rettungsprofil). Ohne das scheiterte "migrate on" auf einem frisch eingerichteten Pi an need_profile. Bestehende Profile bleiben unangetastet. - "ninlight-station display test|clear|off"; der Hilfetext erklaert die Revisionsbestimmung, denn EPD_REVISION laesst sich nur am Testbild bestimmen. - "off" nimmt die Anzeige mit: erst Dienst stoppen, dann Panel leeren (sonst schreibt der Daemon binnen 5 s sein altes Bild zurueck) und beides vor ap_switch, das bei haengendem NetworkManager abbricht. Ein stehengebliebenes "MIGRATION" wuerde einen Betriebszustand behaupten, den es nicht mehr gibt. - test_host.sh sammelt tools/mkimage als Ganzes ein: die 19 Tests in test_nl_mkimage.py liefen in keiner Suite mehr mit, darunter die Wache fuer die Python-3.8-Randbedingung des Build-Containers. Ein neuer Test haelt die Regel fest, dass keine Testdatei mehr herausfallen kann. - Nebenbei: die Temp-Datei mit dem WLAN-Passwort wird wieder zuverlaessig aufgeraeumt, und leere Konfigurationswerte legen kein kaputtes Profil an. --- tools/mkimage/tests/test_suite_coverage.py | 86 ++++++++++ tools/station/deploy.sh | 33 +++- tools/station/ninlight-station | 57 ++++++- tools/station/setup_pi.sh | 189 ++++++++++++++++----- tools/station/tests/test_cli.sh | 177 ++++++++++++++++++- tools/station/tests/test_deploy.sh | 108 ++++++++++++ tools/station/tests/test_setup_pi.sh | 185 +++++++++++++++++++- tools/test_host.sh | 14 +- 8 files changed, 795 insertions(+), 54 deletions(-) create mode 100644 tools/mkimage/tests/test_suite_coverage.py diff --git a/tools/mkimage/tests/test_suite_coverage.py b/tools/mkimage/tests/test_suite_coverage.py new file mode 100644 index 0000000..11580d5 --- /dev/null +++ b/tools/mkimage/tests/test_suite_coverage.py @@ -0,0 +1,86 @@ +"""Wache dagegen, dass eine Testdatei aus allen Suiten herausfaellt. + +Genau das war schon einmal passiert: tools/test_host.sh rief pytest auf +tools/mkimage/tests und tools/station/tests auf - tools/mkimage/test_nl_mkimage.py +liegt aber DIREKT in tools/mkimage/ und lief damit in keiner Suite mehr mit. 19 Tests +waren still verschwunden, darunter test_stays_python38_compatible, die Wache fuer die +bindende 3.8-Randbedingung des Docker-Build-Containers (`str | None` in nl_mkimage.py +hatte den Build schon einmal gebrochen). + +Eine stumm ausgefallene Testdatei ist schlimmer als eine fehlende: die Suite bleibt +gruen und behauptet damit eine Deckung, die es nicht gibt. Deshalb wird hier nicht ein +einzelner Pfad festgenagelt, sondern die Regel selbst - JEDE test_*.py unter tools/ +muss von einem der Pfade erfasst sein, die tools/test_host.sh an pytest uebergibt. +Eine neue Testdatei an einer neuen Stelle faellt damit sofort auf, statt erst dann, +wenn jemand sie vermisst. +""" +import re +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parents[2] +REPO_ROOT = TOOLS_DIR.parent +TEST_HOST = TOOLS_DIR / "test_host.sh" + +# Verzeichnisse, die keine Tests DIESES Projekts enthalten: virtuelle Umgebungen und +# installierte Fremdpakete bringen eigene test_*.py mit, die hier nichts zu suchen haben. +IGNORED_PARTS = (".venv", "__pycache__", "site-packages", "node_modules", ".git") + + +def pytest_paths(): + """Die Pfade, die tools/test_host.sh an pytest uebergibt, als aufgeloeste Pfade.""" + text = TEST_HOST.read_text() + match = re.search(r"^\s*python3 -m pytest (.+?)(?:\s+-\S+)*\s*$", text, re.M) + assert match, "in tools/test_host.sh ist kein 'python3 -m pytest'-Aufruf zu finden" + paths = [] + for raw in match.group(1).split(): + arg = raw.strip('"') + if arg.startswith("-"): + continue + paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve()) + assert paths, "der pytest-Aufruf in tools/test_host.sh uebergibt keine Pfade" + return paths + + +def own_test_files(): + return sorted( + p.resolve() + for p in TOOLS_DIR.rglob("test_*.py") + if not any(part in IGNORED_PARTS for part in p.parts) + ) + + +def test_every_test_file_is_reached_by_test_host(): + roots = pytest_paths() + orphans = [] + for test_file in own_test_files(): + if not any(test_file == r or r in test_file.parents for r in roots): + orphans.append(test_file.relative_to(REPO_ROOT)) + assert not orphans, ( + "diese Testdateien laufen in keiner Suite mit - tools/test_host.sh sammelt sie " + "nicht ein: {}. Entweder den Pfad in test_host.sh erweitern oder die Datei " + "dorthin verschieben, wo sie eingesammelt wird.".format( + ", ".join(str(o) for o in orphans) + ) + ) + + +def test_python38_guard_is_among_them(): + """Die konkrete Datei, deren Ausfall den Build-Bruch damals durchgelassen haette.""" + guard = (TOOLS_DIR / "mkimage" / "test_nl_mkimage.py").resolve() + assert guard.exists(), "tools/mkimage/test_nl_mkimage.py fehlt" + roots = pytest_paths() + assert any(guard == r or r in guard.parents for r in roots), ( + "tools/mkimage/test_nl_mkimage.py wird von tools/test_host.sh nicht eingesammelt " + "- darin steht test_stays_python38_compatible, die einzige Wache gegen erneute " + "3.10-Syntax in nl_mkimage.py (der Docker-Build-Container hat Python 3.8.10)" + ) + + +def test_vermin_is_installed(): + """vermin fehlt -> harter Fehlschlag, kein Skip. + + Eine Wache, die sich beim Fehlen ihres Werkzeugs stillschweigend abschaltet, haette + den Build-Bruch von damals genauso durchgelassen wie gar kein Test. Nachinstallieren: + pip install vermin (oder pip3 install --user vermin). + """ + import vermin # noqa: F401 diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh index 3c7b231..d5502ba 100755 --- a/tools/station/deploy.sh +++ b/tools/station/deploy.sh @@ -202,7 +202,14 @@ echo "== uebertragen ==" # ausgelieferte Verzeichnis. # -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen. rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/" -rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" +rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \ + "$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HOST:/tmp/" +# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden +# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf +# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter +# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere +# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen. +rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/" # Pfade als Variablen mit Default: so kann ein Test die Installation gegen # ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. "$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" <<'REMOTE' @@ -212,6 +219,7 @@ MIGRATE_COMPLETE=$2 ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} +DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service} sudo install -d -m 0755 "$ROOT_DIR/images" if [ "$FULL_SYNC" -eq 1 ]; then @@ -333,7 +341,14 @@ sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py" sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH" sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" -rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service +# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images): +# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy +# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden. +sudo install -d -m 0755 "$ROOT_DIR/display" +sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/" +sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH" +rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \ + /tmp/ninlight-display /tmp/ninlight-display.service sudo systemctl daemon-reload if systemctl is-active --quiet ninlight-station; then if sudo systemctl restart ninlight-station; then @@ -345,6 +360,20 @@ if systemctl is-active --quiet ninlight-station; then else echo " Dienst laeuft nicht (Station ist aus)" fi +# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird. +# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim +# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein +# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu +# uebersehen. +if systemctl is-active --quiet ninlight-display; then + if sudo systemctl restart ninlight-display; then + echo " Anzeigedienst neu gestartet" + else + echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2 + fi +else + echo " Anzeigedienst laeuft nicht" +fi REMOTE echo "fertig." diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 3e6628d..abf57ed 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -257,8 +257,41 @@ migrate_on() { 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" +} + +# 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 @@ -439,8 +472,27 @@ PY exit 0 } +display_cmd() { + case "${1:-}" in + test) display_run --test ;; + clear) display_run --clear ;; + off) display_off ;; + *) usage; exit 2 ;; + esac +} + usage() { - echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm []|listen|off|status" >&2 + echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm []|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 " display clear Panel leeren." >&2 + echo " display off Anzeigedienst stoppen und das Panel leeren (macht 'off' mit)." >&2 } case "${1:-}" in @@ -467,6 +519,9 @@ case "${1:-}" in listen) listen_cmd ;; + display) + display_cmd "${2:-}" + ;; off) full_off ;; diff --git a/tools/station/setup_pi.sh b/tools/station/setup_pi.sh index e9903dd..fbab5d4 100755 --- a/tools/station/setup_pi.sh +++ b/tools/station/setup_pi.sh @@ -77,8 +77,31 @@ fi rm -f "$TEMPLATE" REMOTE -echo "== Server, CLI und Unit installieren ==" -"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:$STAGE_DIR/" +echo "== Pakete ==" +# Pillow kommt auf dem Pi bewusst aus dem Distributionspaket (python3-pil), nicht per +# pip: tools/station/ laeuft dort unter dem System-Python 3.13, und Debian 13 laesst ein +# "pip install" ins System-Python gar nicht mehr zu (PEP 668). Ohne Pillow zeichnet die +# Anzeige nicht - display.py faengt den ImportError ab und beendet sich still mit 0 +# (Absicht, damit ein fehlendes Panel nie eine Migration verhindert). Genau deshalb muss +# das Paket HIER kommen: sonst faellt es nirgends auf. +"$SSH_BIN" "$HOST" bash -s <<'REMOTE' +set -e +if python3 -c 'import PIL' 2>/dev/null; then + echo " python3-pil ist bereits vorhanden" +else + sudo apt-get install -y python3-pil + echo " python3-pil installiert" +fi +REMOTE + +echo "== Server, CLI und Units installieren ==" +"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \ + "$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HOST:$STAGE_DIR/" +# Das Anzeigeverzeichnis als Ganzes: render.py, display.py, epdconfig.py, die beiden +# uebernommenen Panel-Treiber und HERKUNFT.md (der Herkunftsnachweis gehoert mit auf den +# Pi). "scp -r" statt rsync, weil setup_pi.sh - anders als deploy.sh - ausdruecklich nur +# ssh/scp voraussetzt; __pycache__ wird auf der Gegenseite weggeraeumt (unten). +"$SCP_BIN" -q -r "$HERE/display" "$HOST:$STAGE_DIR/ninlight-display" # Pfade als Variablen mit Default (wie in deploy.sh): so kann ein Test die # Installation gegen ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen # lassen. @@ -88,17 +111,40 @@ STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp} ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} -sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" +DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service} +sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" "$ROOT_DIR/display" sudo install -m 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py" sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py" sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH" sudo install -m 0644 "$STAGE_DIR/ninlight-station.service" "$UNIT_PATH" -rm -f "$STAGE_DIR/server.py" "$STAGE_DIR/products.py" "$STAGE_DIR/ninlight-station" "$STAGE_DIR/ninlight-station.service" +# Anzeige: display.py ist ausfuehrbar (die Unit ruft es zwar ueber /usr/bin/python3 auf, +# "ninlight-station display test" aber auch von Hand), der Rest ist importierter Code. +rm -rf "$STAGE_DIR/ninlight-display/__pycache__" +for f in "$STAGE_DIR"/ninlight-display/*; do + [ -f "$f" ] || continue + case "$(basename "$f")" in + display.py) sudo install -m 0755 "$f" "$ROOT_DIR/display/$(basename "$f")" ;; + *) sudo install -m 0644 "$f" "$ROOT_DIR/display/$(basename "$f")" ;; + esac +done +sudo install -m 0644 "$STAGE_DIR/ninlight-display.service" "$DISPLAY_UNIT_PATH" +rm -rf "$STAGE_DIR/server.py" "$STAGE_DIR/products.py" "$STAGE_DIR/ninlight-station" \ + "$STAGE_DIR/ninlight-station.service" "$STAGE_DIR/ninlight-display.service" \ + "$STAGE_DIR/ninlight-display" sudo systemctl daemon-reload -echo " installiert" +# Die Anzeige wird aktiviert (startet also beim Booten mit) UND jetzt gestartet - anders +# als die Station selbst, die absichtlich aus bleibt: die Anzeige sendet nichts, sie +# zeigt nur an, und "Station aus" ist genau das, was nach dem Einrichten auf dem Panel +# stehen soll. Fehlt Panel oder Pillow, beendet sich der Dienst still mit 0 (siehe +# display.py) - deshalb "|| true": ein fehlendes Panel darf setup_pi.sh nicht abbrechen. +sudo systemctl enable ninlight-display.service >/dev/null 2>&1 || \ + echo " Warnung: 'systemctl enable ninlight-display' fehlgeschlagen" >&2 +sudo systemctl restart ninlight-display.service >/dev/null 2>&1 || \ + echo " Warnung: 'systemctl restart ninlight-display' fehlgeschlagen" >&2 +echo " installiert (Anzeigedienst aktiviert)" REMOTE -echo "== NetworkManager-Profil ==" +echo "== NetworkManager-Profile ==" # Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf: # - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht # jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes @@ -116,47 +162,91 @@ set -e CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections} eval "$(sudo cat "$CONF")" -CONN_FILE="$NM_DIR/${AP_CONNECTION}.nmconnection" -if sudo test -f "$CONN_FILE"; then - UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1) - ACTION="aktualisiert" -else - # /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback - # auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt - # ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst. - UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]') - ACTION="angelegt" -fi -TMP=$(mktemp) -trap 'rm -f "$TMP"' EXIT -{ - printf '%s\n' '[connection]' - printf 'id=%s\n' "$AP_CONNECTION" - printf 'uuid=%s\n' "$UUID" - printf '%s\n' 'type=wifi' - printf '%s\n' 'interface-name=wlan0' - printf '%s\n' 'autoconnect=false' - printf '\n%s\n' '[wifi]' - printf '%s\n' 'mode=ap' - printf 'ssid=%s\n' "$SSID" - printf '%s\n' 'band=bg' - printf '%s\n' 'channel=6' - printf '\n%s\n' '[wifi-security]' - printf '%s\n' 'key-mgmt=wpa-psk' - printf '%s\n' 'proto=rsn' - printf '%s\n' 'pairwise=ccmp;' - printf '%s\n' 'group=ccmp;' - printf 'psk=%s\n' "$PSK" - printf '\n%s\n' '[ipv4]' - printf '%s\n' 'method=shared' - printf '%s\n' 'address1=192.168.8.1/24' - printf '\n%s\n' '[ipv6]' - printf '%s\n' 'method=disabled' -} >"$TMP" -sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE" -rm -f "$TMP" -sudo nmcli connection load "$CONN_FILE" >/dev/null -echo " Profil $AP_CONNECTION $ACTION" + +# Die Keyfiles werden erst in einer Temp-Datei aufgebaut und dann per "install" +# an ihren Platz gelegt - dazwischen liegt das WLAN-Passwort in einer Datei, die +# noch nicht 0600 root:root ist. Ein Abbruch (set -e) genau dort darf sie nicht +# liegen lassen. Ein EXIT-Trap in write_profile() selbst waere dafuer untauglich: +# die Funktion laeuft zweimal, der zweite trap ueberschriebe den ersten, und +# "$tmp" ist "local" - beim Auswerten des Traps am Skriptende gaebe es die +# Variable gar nicht mehr. Deshalb ein gemeinsames Verzeichnis mit genau einem +# Trap darum herum (0700, mktemp -d legt es so an). +PROFILE_TMPDIR=$(mktemp -d) +trap 'rm -rf "$PROFILE_TMPDIR"' EXIT + +# $1 = Profilname, $2 = SSID, $3 = PSK, $4 = "update" (bestehendes Profil neu schreiben, +# UUID behalten) oder "keep" (bestehendes Profil unangetastet lassen). +# +# Beide Profile bekommen dieselbe Adresse 192.168.8.1/24 auf wlan0 - genau deshalb +# schliessen sie sich gegenseitig aus, und genau deshalb prueft ninlight-station's +# ap_switch() nach jedem Umschalten nach, dass wirklich nur eines aktiv ist. +write_profile() { + local id=$1 ssid=$2 psk=$3 mode=$4 + # Ein leerer Profilname wuerde eine Datei ".nmconnection" anlegen, eine leere SSID + # oder ein leeres PSK ein Profil, das NetworkManager beim Hochfahren stumm ablehnt - + # in beiden Faellen faellt es erst auf, wenn jemand mit einer toten Lampe vor der + # Station steht. Der Konfigurationsschritt oben ergaenzt fehlende Schluessel aus der + # Vorlage; leer sein koennen sie trotzdem (von Hand geleert). + if [ -z "$id" ] || [ -z "$ssid" ] || [ -z "$psk" ]; then + echo "Fehler: Profilname, SSID oder PSK ist leer - $CONF pruefen (Profil '$id')" >&2 + return 1 + fi + local conn_file="$NM_DIR/${id}.nmconnection" + local uuid action tmp + if sudo test -f "$conn_file"; then + if [ "$mode" = "keep" ]; then + # Der Migrations-AP ist auf manchen Pis von Hand angelegt worden (SSID/PSK sind + # von der alten Klyqa-Firmware fest vorgegeben, es gibt also nichts zu variieren). + # Ein bestehendes Profil wird deshalb nicht ueberschrieben - es koennte + # handjustierte Felder (Kanal, Band, Regulierungsdomaene) tragen, die dieses + # Skript gar nicht kennt. + echo " Profil $id existiert bereits - unveraendert gelassen" + return 0 + fi + uuid=$(sudo sed -n 's/^uuid=//p' "$conn_file" | head -n1) + action="aktualisiert" + else + # /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback + # auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt + # ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst. + uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]') + action="angelegt" + fi + tmp=$(mktemp "$PROFILE_TMPDIR/profile.XXXXXX") + { + printf '%s\n' '[connection]' + printf 'id=%s\n' "$id" + printf 'uuid=%s\n' "$uuid" + printf '%s\n' 'type=wifi' + printf '%s\n' 'interface-name=wlan0' + printf '%s\n' 'autoconnect=false' + printf '\n%s\n' '[wifi]' + printf '%s\n' 'mode=ap' + printf 'ssid=%s\n' "$ssid" + printf '%s\n' 'band=bg' + printf '%s\n' 'channel=6' + printf '\n%s\n' '[wifi-security]' + printf '%s\n' 'key-mgmt=wpa-psk' + printf '%s\n' 'proto=rsn' + printf '%s\n' 'pairwise=ccmp;' + printf '%s\n' 'group=ccmp;' + printf 'psk=%s\n' "$psk" + printf '\n%s\n' '[ipv4]' + printf '%s\n' 'method=shared' + printf '%s\n' 'address1=192.168.8.1/24' + printf '\n%s\n' '[ipv6]' + printf '%s\n' 'method=disabled' + } >"$tmp" + sudo install -o root -g root -m 0600 "$tmp" "$conn_file" + rm -f "$tmp" + sudo nmcli connection load "$conn_file" >/dev/null + echo " Profil $id $action" +} + +write_profile "$AP_CONNECTION" "$SSID" "$PSK" update +# Ohne dieses zweite Profil scheitert "ninlight-station migrate on" auf einem frisch +# eingerichteten Pi an need_profile "$MIGRATE_AP_CONNECTION". +write_profile "$MIGRATE_AP_CONNECTION" "$MIGRATE_SSID" "$MIGRATE_PSK" keep REMOTE cat <<'EOF' @@ -164,6 +254,11 @@ cat <<'EOF' Fertig. Die Station ist absichtlich noch AUS. Naechste Schritte: + ssh ninlight-station display test + Testbild aufs E-Paper - bleibt es leer oder + verzerrt, steht EPD_REVISION in + /etc/ninlight-station.conf auf der falschen + Panel-Revision (V2 statt V3 oder umgekehrt) tools/station/deploy.sh Images ausliefern ssh ninlight-station on Station einschalten ssh ninlight-station off wieder ausschalten (nicht vergessen) diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index 873f764..fbd70e6 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -164,9 +164,31 @@ EOF setup_sandbox() { local sandbox=$1 unitfile=${2:-$REAL_UNIT} rm -rf "$sandbox" - mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate" + mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate" \ + "$sandbox/lib/display" echo "dummy" >"$sandbox/images/ninlight_foo_ota.img" + # NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst. + # products.py wird gebraucht (arm prueft das Ziel dagegen) und deshalb echt kopiert; + # display/display.py ist ein Rekorder, der seine Argumente in DIESELBE cmd.log + # schreibt wie die nmcli-/systemctl-Stubs. Nur so laesst sich die REIHENFOLGE + # "erst Dienst stoppen, dann Panel leeren" ueberhaupt pruefen - mit zwei getrennten + # Logs waere sie nicht rekonstruierbar. + # + # Der Rekorder ersetzt das echte display.py absichtlich: geprueft wird hier, WAS + # ninlight-station aufruft (Pfad, Schalter, Reihenfolge, Umgang mit dem Rueckgabe- + # wert), nicht was display.py daraus macht - das deckt tests/test_render.py ab. + cp "$STATION_DIR/products.py" "$sandbox/lib/products.py" + cat >"$sandbox/lib/display/display.py" <"))) +sys.exit(int(os.environ.get("FAKE_DISPLAY_EXIT", "0"))) +EOF + cat >"$sandbox/station.conf" <"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" display test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "STUB display.py --test" "$SBX/cmd.log" && pass "display.py wurde mit --test aufgerufen" || fail "kein '--test'-Aufruf im Log: $(cat "$SBX/cmd.log")" +# Der Testbild-Aufruf ist die einzige Moeglichkeit, die Panel-Revision zu bestimmen - +# er ist wertlos, wenn er nicht die Revision aus der Konfiguration durchreicht: +# EPD_REVISION wird per ". $CONF" nur als Shell-Variable gesetzt, nicht exportiert. +grep -Fq "STUB display.py --test (EPD_REVISION=V3)" "$SBX/cmd.log" && pass "EPD_REVISION aus der Konfiguration erreicht den Python-Prozess" || fail "EPD_REVISION kam nicht durch: $(cat "$SBX/cmd.log")" +# Nicht gleichzeitig den Anzeigedienst umbringen: 'display test' ist ein Handgriff bei +# der Abnahme, kein Ausschalten. +grep -Fq "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'display test' stoppt den Anzeigedienst - das soll nur 'display off'/'off'" || pass "'display test' laesst den Anzeigedienst in Ruhe" + +echo +echo "=== Fall D2: 'display clear' ruft display.py mit --clear auf ===" +SBX="$TMPROOT/tD2" +setup_sandbox "$SBX" +: >"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" display clear 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "display.py wurde mit --clear aufgerufen" || fail "kein '--clear'-Aufruf im Log: $(cat "$SBX/cmd.log")" +grep -Fq "STUB display.py --test" "$SBX/cmd.log" && fail "'display clear' hat --test aufgerufen" || pass "kein --test bei 'display clear'" + +echo +echo "=== Fall D3 (Task 6b, Punkt 4): 'display off' stoppt den Dienst VOR dem Leeren des Panels ===" +# Umgekehrte Reihenfolge waere wirkungslos: der Daemon fragt alle 5 s nach und +# schriebe sein altes Bild binnen dieser Zeit wieder aufs Panel. +SBX="$TMPROOT/tD3" +setup_sandbox "$SBX" +: >"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" display off 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +if [ -n "$STOP_LINE" ] && [ -n "$CLEAR_LINE" ] && [ "$STOP_LINE" -lt "$CLEAR_LINE" ]; then + pass "Anzeigedienst wird gestoppt, BEVOR das Panel geleert wird (stop=$STOP_LINE, clear=$CLEAR_LINE)" +else + fail "Reihenfolge falsch oder Aufruf fehlt (stop=$STOP_LINE, clear=$CLEAR_LINE): $(cat "$SBX/cmd.log")" +fi + +echo +echo "=== Fall D4 (Task 6b, Punkt 4): 'off' nimmt die Anzeige mit - Dienst gestoppt, Panel geleert ===" +# Ohne das behauptet das stehengebliebene Bild einen Betriebszustand ("MIGRATION"), +# den es nicht mehr gibt - bei einer Station, die Lampen ungefragt Firmware +# unterschieben kann, die gefaehrliche Richtung. +SBX="$TMPROOT/tD4" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +run_cli "$SBX" migrate on >/dev/null 2>&1 +: >"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" off 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && pass "'off' stoppt den Anzeigedienst" || fail "'off' stoppt den Anzeigedienst nicht: $(cat "$SBX/cmd.log")" +grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "'off' leert das Panel" || fail "'off' leert das Panel nicht: $(cat "$SBX/cmd.log")" +STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +if [ -n "$STOP_LINE" ] && [ -n "$CLEAR_LINE" ] && [ "$STOP_LINE" -lt "$CLEAR_LINE" ]; then + pass "auch bei 'off': erst stoppen, dann leeren" +else + fail "Reihenfolge bei 'off' falsch (stop=$STOP_LINE, clear=$CLEAR_LINE): $(cat "$SBX/cmd.log")" +fi +# Das Panel muss geleert sein, BEVOR ap_switch das Ergebnis von "off" verkuenden kann - +# und vor allem, bevor ap_switch bei haengendem NetworkManager abbricht (Fall D6). +UP_LINE=$(grep -n "nmcli con up home-wifi" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +if [ -n "$UP_LINE" ] && [ "$CLEAR_LINE" -lt "$UP_LINE" ]; then + pass "Panel ist geleert, bevor wlan0 ins Heim-WLAN zurueckgeschaltet wird" +else + fail "Panel wurde erst nach dem Zurueckschalten geleert (clear=$CLEAR_LINE, up=$UP_LINE)" +fi + +echo +echo "=== Fall D5: ein Fehler beim Leeren des Panels haelt 'off' nicht auf ===" +# Ein fehlendes/kaputtes Panel darf das Ausschalten der Station nie blockieren - +# sonst bliebe ausgerechnet der Zugangspunkt an, weil die Anzeige klemmt. +SBX="$TMPROOT/tD5" +setup_sandbox "$SBX" +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_DISPLAY_EXIT=1 run_cli "$SBX" off 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlgeschlagenem Leeren" || fail "Exit-Code $RC, stderr: $ERR" +echo "$ERR" | grep -q "Warnung" && pass "Warnung ueber das nicht geleerte Panel steht auf stderr" || fail "keine Warnung auf stderr: $ERR" +grep -Fq "nmcli con up home-wifi" "$SBX/cmd.log" && pass "'off' hat trotzdem bis zum Zurueckschalten ins Heim-WLAN durchgezogen" || fail "'off' ist am Anzeigefehler haengengeblieben: $(cat "$SBX/cmd.log")" + +echo +echo "=== Fall D6: haengender NetworkManager - das Panel wird trotzdem noch geleert ===" +# ap_switch "" bricht hier mit Fehler ab; unter "set -e" laeuft danach nichts mehr. +# Genau deshalb steht display_off VOR ap_switch: sonst bliebe ein "MIGRATION"-Bild +# stehen, waehrend der Zugangspunkt noch sendet und niemand mehr etwas abschaltet. +SBX="$TMPROOT/tD6" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +run_cli "$SBX" migrate on >/dev/null 2>&1 +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_NMCLI_STUCK=1 run_cli "$SBX" off 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -ne 0 ] && pass "Exit-Code != 0 (der Zugangspunkt liess sich nicht abschalten)" || fail "Exit-Code 0, obwohl der Zugangspunkt noch aktiv ist: $OUT" +grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "Panel wurde trotz haengendem NetworkManager geleert" || fail "Panel blieb stehen, waehrend der Zugangspunkt noch sendet: $(cat "$SBX/cmd.log")" + +echo +echo "=== Fall D7: 'display' ohne/mit unbekanntem Unterbefehl -> Hilfetext, Exit 2 ===" +SBX="$TMPROOT/tD7" +setup_sandbox "$SBX" +# Anlegen, bevor geprueft wird: eine fehlende Logdatei wuerde die "kein Aufruf"-Zusage +# unten sonst durch ein grep-Versagen erfuellen statt durch das Verhalten des Skripts. +: >"$SBX/cmd.log" +for ARG in "" "quatsch"; do + set +e + # shellcheck disable=SC2086 + OUT=$(run_cli "$SBX" display $ARG 2>&1) + RC=$? + set -e + [ "$RC" -eq 2 ] && pass "Exit-Code 2 fuer 'display ${ARG:-}'" || fail "Exit-Code $RC fuer 'display ${ARG:-}': $OUT" + echo "$OUT" | grep -q "display {test|clear|off}" && pass "Hilfetext nennt die Unterbefehle ('display ${ARG:-}')" || fail "Hilfetext fehlt: $OUT" +done +grep -Fq "STUB display.py" "$SBX/cmd.log" && fail "ein unbekannter Unterbefehl hat display.py trotzdem aufgerufen" || pass "kein display.py-Aufruf bei unbekanntem Unterbefehl" + +echo +echo "=== Fall D8 (Task 6b, Punkt 3): der Hilfetext erklaert die Revisionsbestimmung ===" +# Die Panel-Revision steht am Geraet nirgends; 'display test' ist die einzige +# Moeglichkeit, sie zu bestimmen, und das muss dort stehen, wo man beim ersten +# Handgriff der Hardware-Abnahme hinsieht. +SBX="$TMPROOT/tD8" +setup_sandbox "$SBX" +set +e +HELP=$(run_cli "$SBX" quatsch 2>&1) +set -e +echo "$HELP" | grep -q "EPD_REVISION" && pass "Hilfetext nennt EPD_REVISION" || fail "Hilfetext nennt EPD_REVISION nicht: $HELP" +echo "$HELP" | grep -q "ninlight-station.conf" && pass "Hilfetext nennt die Datei, in der die Revision steht" || fail "Hilfetext nennt /etc/ninlight-station.conf nicht: $HELP" +echo "$HELP" | grep -qE "V2|V3" && pass "Hilfetext nennt die beiden moeglichen Revisionen" || fail "Hilfetext nennt V2/V3 nicht: $HELP" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_deploy.sh b/tools/station/tests/test_deploy.sh index 6e80ae4..9a66534 100755 --- a/tools/station/tests/test_deploy.sh +++ b/tools/station/tests/test_deploy.sh @@ -51,6 +51,17 @@ setup_sandbox() { echo "# dummy" >"$sandbox/tools/station/products.py" echo "# dummy" >"$sandbox/tools/station/ninlight-station" echo "# dummy" >"$sandbox/tools/station/ninlight-station.service" + echo "# dummy" >"$sandbox/tools/station/ninlight-display.service" + # display/ wird ECHT kopiert, nicht durch Attrappen ersetzt: die Zusage aus Task 6b + # ist, dass die beiden uebernommenen Panel-Treiber UND der Herkunftsnachweis + # HERKUNFT.md mit auf den Pi gehen. Mit selbst erfundenen Dateinamen im Sandbox-Repo + # wuerde der Test genau das nicht mehr zeigen - er pruefte nur noch sich selbst. + cp -R "$STATION_DIR/display" "$sandbox/tools/station/display" + rm -rf "$sandbox/tools/station/display/__pycache__" + # Ein __pycache__ mit .pyc dieses Rechners (andere Python-Version als der Pi) - es + # darf nicht mit uebertragen werden. + mkdir -p "$sandbox/tools/station/display/__pycache__" + echo "pyc-muell" >"$sandbox/tools/station/display/__pycache__/render.cpython-999.pyc" # Fake nl_mkimage.py --verify: verhaelt sich wie das echte Tool (eine Zeile # " " auf stdout bei Erfolg, Exit 0; eine Zeile auf @@ -111,9 +122,22 @@ echo "STUB sudo \$*" >>"$log" exec "\$@" EOF + # Der Stub unterscheidet nach Unit: deploy.sh behandelt den Anzeigedienst + # ausdruecklich anders als den Hauptdienst (ein fehlgeschlagener Neustart der + # Anzeige ist KEIN Deploy-Fehler). Mit einem Schalter fuer beide Units zusammen + # liesse sich genau dieser Unterschied nicht pruefen. cat >"$bindir/systemctl" <>"$log" +case "\$*" in + *ninlight-display*) + case "\$1" in + is-active) exit "\${FAKE_DISPLAY_ACTIVE_EXIT:-1}" ;; + restart) exit "\${FAKE_DISPLAY_RESTART_EXIT:-0}" ;; + *) exit 0 ;; + esac + ;; +esac case "\$1" in is-active) exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-1}" ;; restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;; @@ -146,6 +170,7 @@ run_deploy() { NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \ NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \ NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \ + NINLIGHT_DISPLAY_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-display.service" \ NINLIGHT_SIGN_DIR="$sign_dir" \ "$sandbox/tools/station/deploy.sh" "$@" } @@ -948,6 +973,89 @@ for label in truncated empty array null string null-entry; do fi done +echo +echo "=== Szenario 22 (Task 6b, Punkt 1): deploy.sh liefert Anzeigeverzeichnis und Anzeige-Unit mit aus ===" +SANDBOX="$TMPROOT/s22"; STUBBIN="$TMPROOT/bin22"; LOG="$TMPROOT/s22.log"; SANDBOX_STATE="$TMPROOT/s22-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +# Bestand auf dem "Pi": eine Datei, die es im Repo nicht mehr gibt. display/ ist reiner +# Code aus dem Repo - was dort verschwindet, muss auch hier verschwinden, sonst laedt +# ein zurueckgebliebenes altes Modul weiter mit. +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/display" +echo "alt" >"$SANDBOX_STATE/opt/ninlight-station/display/veraltet.py" +: >"$LOG" +set +e +OUT=$(run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +DISP="$SANDBOX_STATE/opt/ninlight-station/display" +for F in display.py render.py epdconfig.py epd2in13_V2.py epd2in13_V3.py HERKUNFT.md; do + [ -f "$DISP/$F" ] && pass "display/$F ausgeliefert" || fail "display/$F fehlt unter $DISP" +done +[ -f "$SANDBOX_STATE/etc/systemd/system/ninlight-display.service" ] && pass "ninlight-display.service ausgeliefert" || fail "Anzeige-Unit fehlt - sie waere weiter toter Text im Repo" +[ ! -f "$DISP/veraltet.py" ] && pass "im Repo geloeschte Datei verschwindet auch auf dem Pi (--delete)" || fail "veraltet.py blieb im Zielverzeichnis liegen" +[ ! -d "$DISP/__pycache__" ] && pass "kein __pycache__ dieses Rechners auf dem Pi gelandet" || fail "__pycache__ wurde mit uebertragen: $(ls "$DISP/__pycache__")" +# Die Anzeige lief nicht (Stub-Default) - dann wird sie auch nicht neu gestartet, aber +# der Deploy sagt es. +echo "$OUT" | grep -q "Anzeigedienst laeuft nicht" && pass "Deploy meldet, dass der Anzeigedienst nicht laeuft" || fail "Ausgabe erwaehnt den Anzeigedienst nicht: $OUT" +# Die fluechtige Ablage wird wieder aufgeraeumt (sie liegt, wie bei server.py & Co., +# im echten /tmp - deploy.sh's REMOTE-Block kennt dafuer keine Sandbox-Variable). +[ ! -d /tmp/ninlight-display ] && pass "die fluechtige Ablage /tmp/ninlight-display wurde aufgeraeumt" || fail "/tmp/ninlight-display blieb liegen" + +echo +echo "=== Szenario 23: laeuft die Anzeige, wird sie nach dem Deploy neu gestartet ===" +# Ohne Neustart liefe sie mit dem gerade ersetzten Code weiter. +SANDBOX="$TMPROOT/s23"; STUBBIN="$TMPROOT/bin23"; LOG="$TMPROOT/s23.log"; SANDBOX_STATE="$TMPROOT/s23-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +: >"$LOG" +set +e +OUT=$(FAKE_DISPLAY_ACTIVE_EXIT=0 run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "systemctl restart ninlight-display" "$LOG" && pass "Anzeigedienst wurde neu gestartet" || fail "kein 'systemctl restart ninlight-display' im Log: $(cat "$LOG")" +echo "$OUT" | grep -q "Anzeigedienst neu gestartet" && pass "Ausgabe meldet den Neustart" || fail "Ausgabe meldet den Neustart nicht: $OUT" + +echo +echo "=== Szenario 24: ein fehlgeschlagener Neustart der ANZEIGE ist kein Deploy-Fehler ===" +# Die Anzeige ist nirgends Voraussetzung fuer eine Migration. Ein Deploy, der wegen des +# Panels rot wird, verleitet nur dazu, einen echten Fehler zu uebersehen. Beim +# HAUPTdienst ist es umgekehrt - das prueft das Szenario darunter zur Abgrenzung. +SANDBOX="$TMPROOT/s24"; STUBBIN="$TMPROOT/bin24"; LOG="$TMPROOT/s24.log"; SANDBOX_STATE="$TMPROOT/s24-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +: >"$LOG" +set +e +OUT=$(FAKE_DISPLAY_ACTIVE_EXIT=0 FAKE_DISPLAY_RESTART_EXIT=1 run_deploy "$SANDBOX" pi-test 2>"$TMPROOT/s24.err") +RC=$? +set -e +ERR=$(cat "$TMPROOT/s24.err") +[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlgeschlagenem Anzeige-Neustart" || fail "Exit-Code $RC - der Deploy wurde wegen der Anzeige rot: $ERR" +echo "$ERR" | grep -q "Warnung" && pass "Warnung (keine Fehlermeldung) auf stderr" || fail "keine Warnung auf stderr: $ERR" +[ -f "$SANDBOX_STATE/opt/ninlight-station/server.py" ] && pass "der Deploy selbst lief vollstaendig durch" || fail "server.py fehlt - der Deploy brach ab" + +echo +echo "=== Szenario 25 (Abgrenzung): ein fehlgeschlagener Neustart des HAUPTdienstes ist sehr wohl ein Deploy-Fehler ===" +SANDBOX="$TMPROOT/s25"; STUBBIN="$TMPROOT/bin25"; LOG="$TMPROOT/s25.log"; SANDBOX_STATE="$TMPROOT/s25-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +: >"$LOG" +set +e +OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +[ "$RC" -ne 0 ] && pass "Exit-Code != 0 beim Hauptdienst" || fail "Exit-Code 0 - ein toter Hauptdienst ginge unbemerkt durch: $OUT" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_setup_pi.sh b/tools/station/tests/test_setup_pi.sh index 1c18547..2d9ec1b 100755 --- a/tools/station/tests/test_setup_pi.sh +++ b/tools/station/tests/test_setup_pi.sh @@ -55,12 +55,41 @@ echo "STUB scp \$*" >>"$log" args=() for a in "\$@"; do case "\$a" in + # "-r" muss durchgereicht werden (als cp -R): setup_pi.sh uebertraegt das + # Anzeigeverzeichnis als Ganzes, und ein Stub, der das Flag verschluckt, laesst + # "cp" an einem Verzeichnis scheitern - der Test wuerde dann den Stub anzeigen, + # nicht das Skript. Alle anderen Optionen (-q) bleiben weiterhin draussen. + -r|-R) args+=("-R") ;; -*) continue ;; *:*) args+=("\${a#*:}") ;; *) args+=("\$a") ;; esac done exec cp "\${args[@]}" +EOF + + # apt-get gibt es auf dem Entwicklungsrechner nicht - und selbst auf einem Debian + # duerfte ein Test es nie wirklich aufrufen. Der Stub protokolliert nur. + cat >"$bindir/apt-get" <>"$log" +exit "\${FAKE_APT_EXIT:-0}" +EOF + + # Steht fuer das python3 DES PI, nicht fuer das des Testrechners: nur die + # Pillow-Probe wird beantwortet (steuerbar per FAKE_PIL_MISSING - auf diesem Rechner + # ist Pillow sonst je nach Installation zufaellig vorhanden oder nicht, und der + # Paketschritt waere damit gar nicht pruefbar). Jeder andere Aufruf laeuft durch das + # echte python3. + cat >"$bindir/python3" <>"$log" +for a in "\$@"; do + case "\$a" in + *"import PIL"*) exit "\${FAKE_PIL_MISSING:-0}" ;; + esac +done +exec "$(command -v python3)" "\$@" EOF # "sudo install -o root -g root ..." (im NetworkManager-Keyfile-Schritt) braucht @@ -104,7 +133,8 @@ fi exit 0 EOF - chmod +x "$bindir"/ssh "$bindir"/scp "$bindir"/sudo "$bindir"/systemctl "$bindir"/nmcli + chmod +x "$bindir"/ssh "$bindir"/scp "$bindir"/sudo "$bindir"/systemctl "$bindir"/nmcli \ + "$bindir"/apt-get "$bindir"/python3 } # $1 = Sandbox-Wurzel fuer die "Systempfade" @@ -133,6 +163,7 @@ run_setup() { NINLIGHT_STATION_ROOT="$state/opt/ninlight-station" \ NINLIGHT_STATION_BIN="$state/usr/local/bin/ninlight-station" \ NINLIGHT_STATION_UNIT="$state/etc/systemd/system/ninlight-station.service" \ + NINLIGHT_DISPLAY_UNIT="$state/etc/systemd/system/ninlight-display.service" \ NINLIGHT_NM_DIR="$state/etc/NetworkManager/system-connections" \ "$STATION_DIR/setup_pi.sh" pi-test } @@ -247,6 +278,158 @@ for KEY in MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do grep -Eq "^${KEY}=" "$CONF_OUT4" && pass "fehlender Schluessel $KEY wurde trotzdem ergaenzt" || fail "Schluessel $KEY fehlt weiterhin" done +echo +echo "=== Szenario 5 (Task 6b, Punkt 1): Anzeige wird ausgeliefert, installiert und aktiviert ===" +STATE="$TMPROOT/s5-state"; STUBBIN="$TMPROOT/bin5"; LOG="$TMPROOT/s5.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +DISP="$STATE/opt/ninlight-station/display" +# Die Panel-Treiber und der Herkunftsnachweis gehoeren mit auf den Pi, nicht nur ins +# Repo: HERKUNFT.md ist der Nachweis, woher der uebernommene Waveshare-Code stammt. +for F in display.py render.py epdconfig.py epd2in13_V2.py epd2in13_V3.py HERKUNFT.md; do + [ -f "$DISP/$F" ] && pass "display/$F ausgeliefert" || fail "display/$F fehlt unter $DISP" +done +[ -x "$DISP/display.py" ] && pass "display.py ist ausfuehrbar (Handbetrieb 'display test')" || fail "display.py ist nicht ausfuehrbar" +MODE=$(file_mode "$DISP/render.py") +[ "$MODE" = "644" ] && pass "importierter Code ist 0644" || fail "render.py hat Rechte $MODE statt 644" +[ ! -d "$DISP/__pycache__" ] && pass "kein __pycache__ auf dem Pi gelandet" || fail "__pycache__ wurde mitkopiert" +[ -f "$STATE/etc/systemd/system/ninlight-display.service" ] && pass "ninlight-display.service installiert" || fail "Anzeige-Unit fehlt - die Unit waere weiter toter Text im Repo" +# Ohne "enable" startet die Anzeige nach einem Stromausfall nicht wieder mit. +grep -Fq "systemctl enable ninlight-display.service" "$LOG" && pass "Anzeigedienst wurde aktiviert" || fail "kein 'systemctl enable ninlight-display' im Log" +grep -Fq "systemctl restart ninlight-display.service" "$LOG" && pass "Anzeigedienst wurde gestartet" || fail "kein 'systemctl restart ninlight-display' im Log" +# Die Station selbst bleibt nach dem Einrichten absichtlich AUS - nur die Anzeige +# laeuft. Ein versehentliches "enable ninlight-station" waere ein Sicherheitsfehler: +# der Zugangspunkt kaeme nach jedem Boot von allein hoch. +grep -Fq "systemctl enable ninlight-station" "$LOG" && fail "setup_pi.sh aktiviert den Stationsdienst - der Zugangspunkt kaeme nach jedem Boot von allein hoch" || pass "Stationsdienst bleibt deaktiviert (Station ist nach dem Einrichten aus)" + +echo +echo "=== Szenario 6 (Task 6b, Punkt 1): python3-pil wird nachgezogen, wenn Pillow fehlt ===" +# Ohne Pillow beendet sich der Anzeigedienst still mit 0 (Absicht) - das faellt +# nirgends auf, also muss das Paket hier kommen. +STATE="$TMPROOT/s6-state"; STUBBIN="$TMPROOT/bin6"; LOG="$TMPROOT/s6.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +: >"$LOG" +set +e +OUT=$(FAKE_PIL_MISSING=1 run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "apt-get install -y python3-pil" "$LOG" && pass "python3-pil wird per apt nachinstalliert" || fail "kein 'apt-get install -y python3-pil' im Log" + +echo +echo "=== Szenario 7: ist Pillow schon da, wird apt gar nicht erst bemueht ===" +STATE="$TMPROOT/s7-state"; STUBBIN="$TMPROOT/bin7"; LOG="$TMPROOT/s7.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "apt-get" "$LOG" && fail "apt-get wurde trotz vorhandenem Pillow aufgerufen" || pass "kein apt-Aufruf, wenn Pillow schon vorhanden ist" + +echo +echo "=== Szenario 8 (Task 6b, Punkt 2): das Profil fuer den Migrations-Zugangspunkt wird angelegt ===" +# Ohne dieses zweite Profil scheitert "ninlight-station migrate on" auf einem frisch +# eingerichteten Pi an need_profile "$MIGRATE_AP_CONNECTION". +# +# Geprueft wird der Zustand aus Szenario 7 (ein vollstaendiger Lauf auf einem frischen +# Pi, $STATE/$LOG zeigen noch dorthin) - deshalb hier kein eigener Lauf. +NMDIR="$STATE/etc/NetworkManager/system-connections" +MIGRATE_FILE="$NMDIR/qconnex-firmware.nmconnection" +[ -f "$MIGRATE_FILE" ] && pass "qconnex-firmware.nmconnection angelegt" || fail "Migrationsprofil fehlt in $NMDIR: $(ls "$NMDIR")" +[ -f "$NMDIR/ninlight-recovery.nmconnection" ] && pass "Rettungsprofil weiterhin angelegt" || fail "Rettungsprofil fehlt" +# SSID und PSK sind von der alten Klyqa-Firmware fest vorgegeben - weicht eines ab, +# findet keine alte Lampe die Migrationsstation. +grep -Fqx "ssid=qconnex-firmware" "$MIGRATE_FILE" && pass "SSID ist die von der Klyqa-Firmware vorgegebene" || fail "falsche SSID: $(grep '^ssid=' "$MIGRATE_FILE")" +grep -Fqx "psk=qconnex-firmware" "$MIGRATE_FILE" && pass "PSK ist der von der Klyqa-Firmware vorgegebene" || fail "falscher PSK" +grep -Fqx "mode=ap" "$MIGRATE_FILE" && pass "Profil ist ein Zugangspunkt" || fail "mode=ap fehlt" +grep -Fqx "method=shared" "$MIGRATE_FILE" && pass "ipv4.method ist 'shared'" || fail "method=shared fehlt" +# Dieselbe Adresse wie das Rettungsprofil - genau deshalb schliessen sich beide aus, +# und genau darauf verlaesst sich ap_switch() in ninlight-station. +grep -Fqx "address1=192.168.8.1/24" "$MIGRATE_FILE" && pass "Adresse 192.168.8.1/24 (wie das Rettungsprofil)" || fail "falsche Adresse: $(grep '^address1=' "$MIGRATE_FILE")" +grep -Fqx "autoconnect=false" "$MIGRATE_FILE" && pass "kein Selbststart beim Booten" || fail "autoconnect ist nicht false - der Migrations-AP kaeme nach jedem Boot von allein hoch" +MODE=$(file_mode "$MIGRATE_FILE") +[ "$MODE" = "600" ] && pass "Keyfile ist 0600 (das Passwort steht darin)" || fail "Keyfile hat Rechte $MODE statt 600" +# Das Passwort darf nie als Kommandozeilenargument an nmcli gehen (in "ps" sichtbar). +grep -Fq "nmcli connection load" "$LOG" && pass "Profil wird per 'connection load' eingelesen" || fail "kein 'nmcli connection load' im Log" +grep -Eq "STUB nmcli .*(psk|wifi-sec)" "$LOG" && fail "das Passwort wurde als nmcli-Argument uebergeben - in 'ps' fuer jeden lokalen Benutzer sichtbar" || pass "kein Passwort in einer nmcli-Kommandozeile" + +echo +echo "=== Szenario 9 (Task 6b, Punkt 2): ein bestehendes Migrationsprofil bleibt unangetastet ===" +STATE="$TMPROOT/s9-state"; STUBBIN="$TMPROOT/bin9"; LOG="$TMPROOT/s9.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +NMDIR9="$STATE/etc/NetworkManager/system-connections" +# Von Hand angelegt, mit handjustierten Feldern (anderer Kanal), die dieses Skript +# gar nicht kennt. +cat >"$NMDIR9/qconnex-firmware.nmconnection" <<'NM' +[connection] +id=qconnex-firmware +uuid=11111111-2222-3333-4444-555555555555 +type=wifi +[wifi] +mode=ap +ssid=qconnex-firmware +channel=11 +NM +chmod 0600 "$NMDIR9/qconnex-firmware.nmconnection" +BEFORE=$(cat "$NMDIR9/qconnex-firmware.nmconnection") +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +if [ "$BEFORE" = "$(cat "$NMDIR9/qconnex-firmware.nmconnection")" ]; then + pass "bestehendes Migrationsprofil wurde Byte fuer Byte unveraendert gelassen" +else + fail "bestehendes Migrationsprofil wurde ueberschrieben: $(cat "$NMDIR9/qconnex-firmware.nmconnection")" +fi +echo "$OUT" | grep -q "unveraendert gelassen" && pass "Ausgabe sagt, dass das Profil unveraendert blieb" || fail "Ausgabe erwaehnt das nicht: $OUT" +# Das Rettungsprofil dagegen wird bewusst neu geschrieben (SSID/PSK koennen sich +# geaendert haben) - aber unter Beibehaltung der UUID. +[ -f "$NMDIR9/ninlight-recovery.nmconnection" ] && pass "Rettungsprofil trotzdem angelegt" || fail "Rettungsprofil fehlt" + +echo +echo "=== Szenario 10: ein leerer Konfigurationswert legt kein kaputtes Profil an ===" +STATE="$TMPROOT/s10-state"; STUBBIN="$TMPROOT/bin10"; LOG="$TMPROOT/s10.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +cat >"$STATE/etc/ninlight-station.conf" <<'CONF' +SSID='eigene-ssid' +PSK='eigenes-passwort' +IMAGE_DIR='/opt/ninlight-station/images' +HOME_CONNECTION='preconfigured' +AP_CONNECTION='ninlight-recovery' +MIGRATE_SSID='qconnex-firmware' +MIGRATE_PSK='' +MIGRATE_AP_CONNECTION='qconnex-firmware' +EPD_REVISION='V3' +CONF +chmod 0600 "$STATE/etc/ninlight-station.conf" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -ne 0 ] && pass "Exit-Code != 0 bei leerem PSK" || fail "Exit-Code 0 trotz leerem Migrations-PSK: $OUT" +echo "$OUT" | grep -qi "leer" && pass "Fehlermeldung nennt den leeren Wert" || fail "Fehlermeldung fehlt: $OUT" +NMDIR10="$STATE/etc/NetworkManager/system-connections" +if [ -f "$NMDIR10/qconnex-firmware.nmconnection" ]; then + fail "trotz leerem PSK wurde ein Migrationsprofil geschrieben" +else + pass "kein Migrationsprofil mit leerem PSK angelegt" +fi + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/test_host.sh b/tools/test_host.sh index ae6e337..edefecc 100755 --- a/tools/test_host.sh +++ b/tools/test_host.sh @@ -7,9 +7,21 @@ cmake --build "$ROOT/build/host" ctest --test-dir "$ROOT/build/host" --output-on-failure "$@" if python3 -c 'import pytest' 2>/dev/null; then - python3 -m pytest "$ROOT/tools/mkimage/tests" "$ROOT/tools/station/tests" -q + # tools/mkimage als GANZES Verzeichnis, nicht nur tools/mkimage/tests: die 19 Tests in + # tools/mkimage/test_nl_mkimage.py liegen direkt daneben und liefen sonst in keiner + # Suite mit - darunter test_stays_python38_compatible, die Wache fuer die bindende + # 3.8-Randbedingung des Docker-Build-Containers. + # + # Diese Wache braucht das Paket "vermin" und schlaegt ohne es HART fehl (kein Skip): + # eine Wache, die sich beim Fehlen ihres Werkzeugs stillschweigend abschaltet, haette + # den Build-Bruch von damals genauso durchgelassen wie gar kein Test. Fehlt vermin, + # nachinstallieren: + # pip install vermin (oder: pip3 install --user vermin) + python3 -m pytest "$ROOT/tools/mkimage" "$ROOT/tools/station/tests" -q else echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2 + echo " Installation: pip install pytest vermin (vermin braucht" >&2 + echo " tools/mkimage/test_nl_mkimage.py::test_stays_python38_compatible)" >&2 fi "$ROOT/tools/station/tests/test_cli.sh"