#!/usr/bin/env bash # Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches # Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem # schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration" # unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet). # # Aufruf: tools/station/setup_pi.sh set -euo pipefail HOST=${1:?Aufruf: tools/station/setup_pi.sh } HERE=$(cd "$(dirname "$0")" && pwd) # /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen. # Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und in deploy.sh), damit ein Test gegen # Stubs statt einen echten Pi laufen kann. SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh} SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp} # Wo die per scp uebertragenen Dateien auf dem Zielsystem zwischenlanden, bevor # sie an ihren endgueltigen Platz installiert werden. Ueberschreibbar wie die # anderen NINLIGHT_*-Pfade: auf dem Pi bleibt es bei /tmp, ein Test kann hier in # eine Sandbox statt in das echte /tmp des Entwicklungsrechners umleiten # (Fixrunde 2, N4) - sowohl fuer die lokale scp-Zielangabe unten als auch fuer # die REMOTE-Bloecke, die dieselbe Variable (mit demselben Default) referenzieren. STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp} echo "== Voraussetzungen auf $HOST pruefen ==" "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; } nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; } python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; } echo " ok" REMOTE echo "== Konfiguration ==" "$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example" # CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es # beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte # Systempfade laufen. "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} TEMPLATE=${NINLIGHT_STAGE_DIR:-/tmp}/ninlight-station.conf.example if [ -f "$CONF" ]; then # Eine bestehende Konfiguration bleibt bei ihren Werten - nur Schluessel, die # eine neuere Version des CLI zusaetzlich verlangt (z.B. die Migrations- und # Anzeige-Schluessel aus Task 4), werden aus der Vorlage ergaenzt. Ohne das # wuerde das Ausrollen eines neuen ninlight-station auf einen bereits # eingerichteten Pi jeden Unterbefehl - "off" eingeschlossen - mit "Schluessel # ... fehlt" blockieren, und ein bereits laufender Zugangspunkt bliebe an, bis # jemand die 0600-Datei von Hand ergaenzt (Fixrunde 1, I4). ADDED="" for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do # Nicht nur "^KEY=": eine vorhandene Zuweisung mit fuehrendem Leerraum oder # einem "export" davor (beides gueltiges ". datei"-Shell) wuerde sonst als # fehlend gelten, die Vorlagenzeile wird angehaengt, und da das CLI die # Datei per ". $CONF" sourct, gewinnt die LETZTE Zuweisung - ein eigenes # Passwort wuerde so still durch den oeffentlichen Vorlagenwert ersetzt # (Fixrunde 2, N3: genau das "vorhandene Werte nicht ueberschreiben" aus I4, # nur eine Formatierungsvariante weiter). if ! sudo grep -Eq "^[[:space:]]*(export[[:space:]]+)?${KEY}=" "$CONF"; then LINE=$(grep -E "^${KEY}=" "$TEMPLATE" || true) if [ -n "$LINE" ]; then printf '%s\n' "$LINE" | sudo tee -a "$CONF" >/dev/null ADDED="$ADDED $KEY" fi fi done if [ -n "$ADDED" ]; then echo " $CONF: fehlende Schluessel ergaenzt:$ADDED (vorhandene Werte unveraendert)" else echo " $CONF existiert bereits, alle Schluessel vorhanden" fi else sudo install -m 0600 "$TEMPLATE" "$CONF" echo " $CONF aus der Vorlage angelegt" fi rm -f "$TEMPLATE" REMOTE 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. "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e 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} 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" # 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 # 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-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 # ". /etc/ninlight-station.conf" wuerde also mit "Permission denied" # abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der # Datei aufzuweichen. # - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen- # argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per # "ps"/"/proc//cmdline" sichtbar. Deshalb wird das Verbindungsprofil # stattdessen direkt als NetworkManager-Keyfile (0600 root:root) # geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer # Prozessliste. "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections} eval "$(sudo cat "$CONF")" # 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' 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) EOF