diff --git a/tools/station/display/display.py b/tools/station/display/display.py index 23ee305..d32775b 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -113,6 +113,32 @@ def _read_conf(path: Path) -> dict[str, str]: return values +def _read_autooff_minutes(run_dir: Path, now: float | None = None): + """Restzeit des optionalen Zeitschalters in ganzen Minuten (aufgerundet), oder None, + wenn gerade keiner laeuft. + + Quelle ist RUN_DIR/autooff.json, die "ninlight-station --for/renew" schreibt und + "hold"/"off" wieder loescht (siehe dort). Ein Zeitschalter, den man am Panel nicht + sieht, waere schlimmer als keiner: man glaubt, die Station laeuft, und sie ist + laengst aus. + + "deadline" ist eine Unix-Zeit (der Zeitschalter selbst haengt an systemds monotoner + Uhr, die Anzeige rechnet in Wanduhrzeit) - eine Zeitverstellung verschiebt also die + angezeigte Restzeit, nicht das tatsaechliche Abschalten. Rundet auf: solange noch + eine Sekunde uebrig ist, steht dort "1 min" und nicht "0 min".""" + data = _read_json(run_dir / "autooff.json") + deadline = data.get("deadline") + if isinstance(deadline, bool) or not isinstance(deadline, (int, float)): + return None + remaining_s = deadline - (time.time() if now is None else now) + if remaining_s <= 0: + # Der Termin ist da (oder der Lauf verschiebt gerade) - "0 min" ist hier die + # ehrliche Auskunft, nicht das Verschwinden der Zeile. + return 0 + whole, rest = divmod(remaining_s, 60) + return int(whole) + (1 if rest else 0) + + def _read_mode_file(run_dir: Path) -> str: """Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert @@ -185,6 +211,12 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto" state["armed_target"] = control.get("armed_target") + # Der Zeitschalter ist optional und gilt in beiden Modi gleichermassen - ohne ihn + # bleibt das Feld weg und render.blocks() zeichnet keine Zeile dafuer. + autooff_minutes = _read_autooff_minutes(run_dir) + if autooff_minutes is not None: + state["autooff_minutes"] = autooff_minutes + return state diff --git a/tools/station/display/render.py b/tools/station/display/render.py index 239fbca..c3c4489 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -281,6 +281,23 @@ def blocks(state: dict) -> list[tuple[str, str]]: # ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da. out.append((f"SSID: {ssid}", ROLE_DETAIL)) + # Der optionale Zeitschalter, in BEIDEN Modi und vor den reinen Zaehlerzeilen: ein + # Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner - man glaubt, + # die Station laeuft, und sie ist laengst aus. Fehlt er (der Regelfall, denn er + # entsteht nur mit "--for"), fehlt die Zeile ersatzlos. + # + # Er steht damit VOR "Ausgeliefert"/"zuletzt ausgeliefert": im vollsten Zustand + # passen fuenf Beiwerkzeilen aufs Panel (siehe BOTTOM_MARGIN_PX), die sechste faellt + # unten heraus. Von diesen beiden Auskuenften ist "wann geht die Station aus" + # diejenige, nach der man ueberhaupt aufs Panel sieht - der Zaehler der letzten + # Auslieferung steht auch noch in "ninlight-station status". + autooff_minutes = state.get("autooff_minutes") + if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool): + if autooff_minutes > 0: + out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL)) + else: + out.append(("Auto-Aus steht an", ROLE_DETAIL)) + served = state.get("served") out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL)) diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 9dcbdd9..a10304b 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -22,6 +22,56 @@ CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station} STATION_LIB=${NINLIGHT_STATION_LIB:-/opt/ninlight-station} +# --- Zeitschalter ("Auto-Aus") ----------------------------------------------------- +# +# Rein OPTIONAL: nur "--for " legt einen an. Ohne die Angabe passiert hier +# nichts, und die Station verhaelt sich exakt wie vorher - der Regelfall ist der Pi am +# Kabel, dem niemand den Zugangspunkt unter der laufenden Arbeit wegschalten soll. +# +# Wozu er dann gut ist: "on" macht wlan0 zum Zugangspunkt und reisst damit JEDE +# SSH-Sitzung ueber WLAN ab. Wer den Pi nicht am Kabel hat, sperrt sich damit aus und +# kommt nur noch ueber Tastatur und Monitor zurueck. Der Zeitschalter schaltet die +# Station nach der angeforderten Zeit selbsttaetig ab (= zurueck ins Heim-WLAN). +# +# Umgesetzt mit einem transienten systemd-Timer unter FESTEM Unit-Namen (systemd-run +# --unit=...): so sind "renew"/"hold"/"off" schlichte systemctl-Aufrufe auf einen +# bekannten Namen, und es braucht keine zusaetzliche Unit-Datei im Repo. +AUTOOFF_UNIT=${NINLIGHT_AUTOOFF_UNIT:-ninlight-station-autooff} +# Eigene Zustandsdatei neben mode/control.json, im selben RUN_DIR und mit denselben +# Rechten (0644): sie ist die Quelle fuer "status", fuer "renew" ohne Argument (welche +# Dauer war zuletzt angefordert?) und fuer die Panelzeile - display.py liest sie. Sie ist +# zugleich die Antwort auf "laeuft ueberhaupt einer?": jeder Weg, der den Timer anlegt +# oder abraeumt, schreibt bzw. loescht sie in derselben Funktion. +AUTOOFF_FILE="$RUN_DIR/autooff.json" +# Abstand zwischen zwei Anlaeufen, wenn gerade ausgeliefert wird ("in etwa einer Minute +# erneut versuchen"). Der Timer bringt ihn selbst mit (--on-unit-inactive), es wird also +# nichts nachgelegt - siehe autooff_arm(). +AUTOOFF_RETRY_S=${NINLIGHT_AUTOOFF_RETRY_S:-60} +# Obergrenze fuers Verschieben. 10 Anlaeufe x 1 min = rund 10 Minuten Aufschub. +# +# Begruendung der Zahl: eine Auslieferung ist ein einzelner HTTP-GET ueber ein halbes +# Megabyte im eigenen WLAN - Sekunden, keine Minuten. Eine Verbindung, die nach zehn +# Minuten noch offen steht, liefert nicht mehr aus, sie haengt (Lampe abgestuerzt, +# Funkloch, halboffener Socket, den der Kernel noch als ESTABLISHED fuehrt). Ohne +# Obergrenze wuerde genau so eine haengende Verbindung den Zeitschalter dauerhaft +# aushebeln - und damit ausgerechnet den Fall herbeifuehren, gegen den er angefordert +# wurde: der Nutzer sitzt ohne Kabel da und kommt nicht mehr an den Pi. Zehn Minuten +# sind lang genug, dass keine echte Auslieferung abgeschnitten wird, und kurz genug, +# dass der Zeitschalter seine Zusage noch einloest. +AUTOOFF_MAX_DEFERRALS=${NINLIGHT_AUTOOFF_MAX_DEFERRALS:-10} +# Das Netz des Zugangspunkts (server.py haengt auf 192.168.8.1:80) - daran wird erkannt, +# ob gerade eine Lampe laedt, ohne dafuer etwas am Server zu aendern. +AUTOOFF_STATION_NET=192.168.8.0/24 +# Sinnvolle Grenzen fuer --for/renew. +AUTOOFF_MIN_MINUTES=1 +AUTOOFF_MAX_MINUTES=240 + +# Absoluter Pfad auf dieses Skript - der Timer ruft es spaeter ohne unser $PWD auf. +case "$0" in + /*) SELF=$0 ;; + *) SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") ;; +esac + if [ -r "$CONF" ]; then # shellcheck disable=SC1090 . "$CONF" @@ -132,6 +182,180 @@ write_control() { sudo mv -f "$_tmp" "$RUN_DIR/control.json" } +# --- Zeitschalter: Zustandsdatei, Anlegen, Abraeumen, Pruefung ----------------------- + +# Prueft eine Minutenangabe, BEVOR irgendetwas eingeschaltet wird (Zusage: eine +# ungueltige Angabe schaltet gar nichts an). Gibt 0 zurueck, wenn sie taugt. +autooff_check_minutes() { + case "${1:-}" in + '' ) + echo "Fehler: '--for' braucht eine Angabe in Minuten ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2 + return 1 + ;; + *[!0-9]* ) + echo "Fehler: '$1' ist keine Minutenangabe - erlaubt sind ganze Zahlen von $AUTOOFF_MIN_MINUTES bis $AUTOOFF_MAX_MINUTES" >&2 + return 1 + ;; + esac + if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then + echo "Fehler: $1 Minuten liegen ausserhalb von $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2 + return 1 + fi +} + +# Schreibt $AUTOOFF_FILE atomar mit 0644 - dasselbe Muster und dieselben Rechte wie +# write_control(), damit display.py (laeuft als root) und das CLI (laeuft als normaler +# Benutzer) sie beide lesen koennen. +# +# $1 = angeforderte Dauer in Minuten (bleibt ueber Verschiebungen hinweg unveraendert - +# "renew" ohne Argument stellt genau darauf zurueck), $2 = Zeitpunkt des naechsten +# Anlaufs als Unix-Zeit, $3 = Zahl der bisherigen Verschiebungen. +autooff_write() { + sudo mkdir -p "$RUN_DIR" + _tmp="$RUN_DIR/.autooff-$$.json" + _json=$(printf '{"minutes": %s, "deadline": %s, "deferrals": %s}' "$1" "$2" "$3") + printf '%s' "$_json" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 + sudo mv -f "$_tmp" "$RUN_DIR/autooff.json" +} + +# Gibt " " aus, oder scheitert (Rueckgabewert 1), +# wenn kein Zeitschalter vorgemerkt ist bzw. die Datei unbrauchbar ist. +autooff_read() { + [ -e "$AUTOOFF_FILE" ] || return 1 + python3 - "$AUTOOFF_FILE" <<'PY' +import json +import sys + +try: + with open(sys.argv[1]) as f: + d = json.load(f) +except (OSError, ValueError): + sys.exit(1) +try: + print(int(d["minutes"]), int(d["deadline"]), int(d.get("deferrals", 0))) +except (KeyError, TypeError, ValueError): + sys.exit(1) +PY +} + +# Haelt NUR den Timer an, nie die gleichnamige .service-Einheit: wenn diese Funktion aus +# dem gefeuerten Timer heraus laeuft (autooff_fire -> full_off), ist genau diese Einheit +# der eigene Prozess - ein "systemctl stop $AUTOOFF_UNIT" ohne Endung loeste sich zur +# .service auf und wuerde sich mitten im Abschalten selbst umbringen. +autooff_stop_unit() { + sudo systemctl stop "$AUTOOFF_UNIT.timer" >/dev/null 2>&1 || true + # Damit ein spaeteres systemd-run denselben Namen wieder vergeben kann, auch wenn ein + # frueherer Lauf im Zustand "failed" haengengeblieben ist. + sudo systemctl reset-failed "$AUTOOFF_UNIT.timer" "$AUTOOFF_UNIT.service" >/dev/null 2>&1 || true +} + +# Timer anhalten UND die Vormerkung loeschen - "es laeuft keiner mehr". +autooff_clear() { + autooff_stop_unit + sudo rm -f "$AUTOOFF_FILE" +} + +# Legt den Zeitschalter an (oder erneuert ihn). +# $1 = angeforderte Dauer in Minuten, $2 = Verzoegerung des naechsten Anlaufs in +# Sekunden, $3 = Zahl der bisherigen Verschiebungen. +# +# --on-unit-inactive sorgt dafuer, dass der Timer nach JEDEM Lauf der Aktion erneut +# feuert. Das ist die Verschiebung "in etwa einer Minute" (siehe autooff_fire): der +# gefeuerte Lauf kann sie nicht selbst nachlegen, weil er unter demselben Unit-Namen +# laeuft und systemd-run einen belegten Namen ablehnt. Wird nicht verschoben, endet der +# Lauf in full_off -> autooff_clear, und der Timer ist weg, bevor er ein zweites Mal +# feuern koennte. +autooff_arm() { + _req=$1 + _delay=$2 + _deferrals=$3 + autooff_stop_unit + set -- --unit="$AUTOOFF_UNIT" \ + --description="ninlight-station: Zeitschalter (Auto-Aus)" \ + --on-active="${_delay}s" \ + --on-unit-inactive="${AUTOOFF_RETRY_S}s" + # Die Testumgebung (tests/test_cli.sh) laeuft gegen eine Sandbox statt gegen + # /etc und /run; der spaeter gefeuerte Lauf haette davon sonst nichts. Im Betrieb + # sind diese Variablen nicht gesetzt und es wird nichts durchgereicht. + if [ -n "${NINLIGHT_STATION_CONF:-}" ]; then + set -- "$@" "--setenv=NINLIGHT_STATION_CONF=$NINLIGHT_STATION_CONF" + fi + if [ -n "${NINLIGHT_RUN_DIR:-}" ]; then + set -- "$@" "--setenv=NINLIGHT_RUN_DIR=$NINLIGHT_RUN_DIR" + fi + if [ -n "${NINLIGHT_STATION_LIB:-}" ]; then + set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB" + fi + if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then + echo "Fehler: Zeitschalter liess sich nicht anlegen (systemd-run '$AUTOOFF_UNIT') - die Station bleibt AN, 'ninlight-station off' nicht vergessen." >&2 + return 1 + fi + autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || { + echo "Fehler: $AUTOOFF_FILE konnte nicht geschrieben werden - Zeitschalter wieder abgeraeumt" >&2 + autooff_clear + return 1 + } +} + +# Laeuft gerade eine Auslieferung? Geprueft an den offenen TCP-Verbindungen auf Port 80 +# aus dem Stationsnetz - ohne jede Aenderung an server.py. +# +# "ss" liegt auf Debian in /usr/sbin; der gefeuerte Timer-Lauf bringt nicht zwingend +# denselben $PATH mit wie eine Anmeldesitzung, deshalb die beiden festen Pfade als +# Rueckfall. +autooff_busy() { + _ss=$(command -v ss 2>/dev/null || true) + if [ -z "$_ss" ]; then + for _cand in /usr/sbin/ss /sbin/ss; do + if [ -x "$_cand" ]; then + _ss=$_cand + break + fi + done + fi + if [ -z "$_ss" ]; then + # Nicht pruefbar ist nicht dasselbe wie "es laeuft etwas": waere das hier ein + # "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur + # Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der + # Nutzer sonst nicht mehr an den Pi kommt. + echo "Warnung: 'ss' nicht gefunden - laufende Auslieferungen konnten nicht geprueft werden" >&2 + return 1 + fi + _conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true) + [ "${_conns:-0}" -gt 0 ] +} + +# Die Aktion des Timers. Absichtlich ein eigener (im Hilfetext nicht gefuehrter) +# Unterbefehl statt schlicht "off": das Verschieben bei laufender Auslieferung gehoert +# NUR hierher. Wer von Hand "off" tippt, will sofort aus - und nicht, dass ihm das CLI +# das Abschalten zehn Minuten lang verweigert. Ausgeschaltet wird am Ende trotzdem +# genau das, was "off" tut (full_off), nicht ein zweiter, aehnlicher Weg. +autooff_fire() { + if ! _info=$(autooff_read); then + # Niemand hat mehr einen Zeitschalter vorgemerkt (z.B. "hold" oder ein + # zwischenzeitliches "off") - dann hier nichts abschalten, nur den eigenen Timer + # abraeumen. Sonst faellt dieser Lauf in eine Station hinein, die laengst aus + # oder aus einem anderen Grund wieder an ist. + autooff_stop_unit + exit 0 + fi + # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. + # shellcheck disable=SC2086 + set -- $_info + _req=$1 + _deferrals=$3 + if autooff_busy; then + if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then + _deferrals=$((_deferrals + 1)) + autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals" + echo "Zeitschalter verschoben ($_deferrals/$AUTOOFF_MAX_DEFERRALS): eine Auslieferung laeuft noch" + exit 0 + fi + echo "Warnung: $AUTOOFF_MAX_DEFERRALS Verschiebungen erreicht - es steht immer noch eine Verbindung offen, sie haengt vermutlich. Station wird jetzt trotzdem abgeschaltet." >&2 + fi + full_off +} + # Startet (neu) und wartet kurz auf Lebenszeichen, dann prueft es nach, dass der # eben gestartete Prozess wirklich den beabsichtigten Modus/die beabsichtigte # Phase sieht. @@ -213,7 +437,43 @@ PY fi } +# Liest "--for " aus den Argumenten und legt die Dauer in $_for_minutes ab +# (leer = kein Zeitschalter, die Vorgabe). Wird VOR jedem Einschalten aufgerufen, damit +# eine ungueltige Angabe nichts anschaltet. +# $1 = das Kommando fuer die Fehlermeldung, Rest = die Argumente. +parse_for_option() { + _for_minutes="" + _for_seen=0 + shift + while [ $# -gt 0 ]; do + case "$1" in + --for) + shift + _for_minutes=${1:-} + _for_seen=1 + autooff_check_minutes "$_for_minutes" || exit 2 + ;; + *) + usage + exit 2 + ;; + esac + [ $# -gt 0 ] && shift || true + done + [ "$_for_seen" -eq 1 ] || _for_minutes="" +} + +# Legt den angeforderten Zeitschalter an - NUR wenn "--for" dabei war. Ohne die Angabe +# passiert hier nichts: das ist die Vorgabe und der Grund, warum der Pi am Kabel von +# dieser Funktion nie etwas merkt. +autooff_arm_if_requested() { + [ -n "$_for_minutes" ] || return 0 + autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1 + echo "Zeitschalter: Auto-Aus in $_for_minutes min ('ninlight-station renew' verlaengert, 'hold' bestellt ab)" +} + recovery_on() { + parse_for_option recovery "$@" need_profile "$AP_CONNECTION" n=$(images | wc -l | tr -d ' ') [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2 @@ -229,17 +489,34 @@ recovery_on() { echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2 fi echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." + autooff_arm_if_requested } migrate_on() { - # $1 = "--listen" oder leer. + # Argumente: "--listen" und/oder "--for ", beide optional und in beliebiger + # Reihenfolge. Beides wird VOLLSTAENDIG geprueft, bevor irgendein Profil hochkommt. _phase=auto - if [ "${1:-}" = "--listen" ]; then - _phase=listen - elif [ -n "${1:-}" ]; then - usage - exit 2 - fi + _for_minutes="" + _for_seen=0 + while [ $# -gt 0 ]; do + case "$1" in + --listen) + _phase=listen + ;; + --for) + shift + _for_minutes=${1:-} + _for_seen=1 + autooff_check_minutes "$_for_minutes" || exit 2 + ;; + *) + usage + exit 2 + ;; + esac + [ $# -gt 0 ] && shift || true + done + [ "$_for_seen" -eq 1 ] || _for_minutes="" need_profile "$MIGRATE_AP_CONNECTION" n=$(migrate_images | wc -l | tr -d ' ') [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR/migrate enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 @@ -255,6 +532,7 @@ migrate_on() { echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)" echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2 echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." + autooff_arm_if_requested } # Ruft display.py mit einem Schalter auf. EPD_REVISION steht in $CONF und wird von @@ -348,6 +626,12 @@ display_off() { } full_off() { + # ZUERST: ein noch vorgemerkter Zeitschalter muss mit weg, sonst feuert er spaeter in + # eine Station hinein, die laengst aus ist - oder, schlimmer, in eine, die inzwischen + # jemand anders wieder eingeschaltet hat. Ganz vorne, damit auch ein spaeter + # scheiterndes "off" (haengender NetworkManager, siehe ap_switch) ihn nicht + # stehenlaesst. + autooff_clear sudo systemctl stop ninlight-station 2>/dev/null || true # VOR ap_switch, nicht danach: ap_switch "" bricht bei einem haengenden # NetworkManager mit Fehler ab, und unter "set -e" kaeme danach nichts mehr. Genau @@ -420,6 +704,55 @@ PY echo "Freigegeben: $_target" } +# "renew []" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die +# zuletzt angefordert wurde (nicht auf die Restzeit und auch nicht auf eine Vorgabe). +# +# Laeuft keiner, ist das ein Fehler und AUSDRUECKLICH kein stillschweigendes Neuanlegen: +# "renew" auf einem Pi am Kabel wuerde sonst genau den Zeitschalter herbeifuehren, den +# der Nutzer nie wollte. +renew_cmd() { + _arg=${1:-} + if ! _info=$(autooff_read); then + echo "Fehler: kein Zeitschalter aktiv - 'renew' stellt nur einen laufenden zurueck. Einen neuen gibt es nur mit 'ninlight-station on --for '." >&2 + exit 1 + fi + # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. + # shellcheck disable=SC2086 + set -- $_info + _minutes=$1 + if [ -n "$_arg" ]; then + autooff_check_minutes "$_arg" || exit 2 + _minutes=$_arg + fi + autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1 + echo "Zeitschalter zurueckgestellt: Auto-Aus in $_minutes min" +} + +# "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt. +hold_cmd() { + if ! autooff_read >/dev/null; then + echo "Fehler: kein Zeitschalter aktiv - es gibt nichts abzubestellen." >&2 + exit 1 + fi + autooff_clear + echo "Zeitschalter abbestellt - die Station bleibt an, bis 'ninlight-station off'." +} + +# Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der +# Ausgabe gar nichts davon - eine Zeile "kein Zeitschalter aktiv" kostet nur Platz). +autooff_status_line() { + _info=$(autooff_read) || return 0 + # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. + # shellcheck disable=SC2086 + set -- $_info + _rest=$(( ($2 - $(date +%s) + 59) / 60 )) + if [ "$_rest" -gt 0 ]; then + printf 'Auto-Aus: in %s min (angefordert: %s min)\n' "$_rest" "$1" + else + printf 'Auto-Aus: steht an (angefordert: %s min)\n' "$1" + fi +} + status_cmd() { # "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und # liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist - @@ -513,6 +846,7 @@ PY printf 'Phase : %s\n' "$_phase" printf 'Erkannt: %s\n' "$_erkannt" printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" + autooff_status_line printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)" printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')" echo "Images :" @@ -547,8 +881,16 @@ display_cmd() { } usage() { - echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm []|listen|display {test|clear|off}|off|status" >&2 + echo "Aufruf: ninlight-station on [--for ]|recovery {on [--for ]|off}|migrate {on [--listen] [--for ]|off}|arm []|listen|renew []|hold|display {test|clear|off}|off|status" >&2 echo "" >&2 + echo " --for Zeitschalter: die Station schaltet sich nach selbst" >&2 + echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&2 + echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2 + echo " WLAN angemeldet bist: 'on' macht wlan0 zum Zugangspunkt und" >&2 + echo " reisst die SSH-Sitzung ab. Erlaubt: 1-240." >&2 + echo " renew [] Uhr zurueckstellen (ohne Angabe auf die zuletzt angeforderte" >&2 + echo " Dauer). Laeuft keiner, ist das ein Fehler." >&2 + echo " hold Zeitschalter abbestellen, Station bleibt an." >&2 echo " display test Testbild aufs Panel zeichnen. Das ist der erste Handgriff bei der" >&2 echo " Hardware-Abnahme und die EINZIGE Moeglichkeit, die Panel-Revision" >&2 echo " zu bestimmen: die steht am Geraet nirgends. Bleibt das Bild leer," >&2 @@ -562,32 +904,49 @@ usage() { echo " display off Anzeigedienst stoppen und das Panel leeren (macht 'off' mit)." >&2 } -case "${1:-}" in +_cmd=${1:-} +[ $# -gt 0 ] && shift || true +case "$_cmd" in on) - recovery_on + recovery_on "$@" ;; recovery) - case "${2:-}" in - on) recovery_on ;; + _sub=${1:-} + [ $# -gt 0 ] && shift || true + case "$_sub" in + on) recovery_on "$@" ;; off) full_off ;; *) usage; exit 2 ;; esac ;; migrate) - case "${2:-}" in - on) migrate_on "${3:-}" ;; + _sub=${1:-} + [ $# -gt 0 ] && shift || true + case "$_sub" in + on) migrate_on "$@" ;; off) full_off ;; *) usage; exit 2 ;; esac ;; arm) - arm_cmd "${2:-}" + arm_cmd "${1:-}" ;; listen) listen_cmd ;; + renew) + renew_cmd "${1:-}" + ;; + hold) + hold_cmd + ;; + __autooff) + # Nicht im Hilfetext: das ist die Aktion des Zeitschalters, kein Handgriff fuer + # Menschen (siehe autooff_fire). + autooff_fire + ;; display) - display_cmd "${2:-}" + display_cmd "${1:-}" ;; off) full_off diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index fbd298d..f504772 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -172,7 +172,29 @@ echo "STUB sudo \$*" >>"$log" exec "\$@" EOF - chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo + # Der Zeitschalter legt seinen Timer per "systemd-run --unit=..." an; der Stub + # protokolliert nur, was angefordert wurde (der ECHTE Beleg dafuer, dass ein + # Zeitschalter existiert bzw. nicht existiert, ist unten immer die Zustandsdatei + # $RUN_DIR/autooff.json und das Verhalten des Skripts, nicht diese Zeile). + cat >"$bindir/systemd-run" <>"$log" +exit "\${FAKE_SYSTEMD_RUN_EXIT:-0}" +EOF + + # "ss" beantwortet die Frage "laeuft gerade eine Auslieferung?" - mit + # FAKE_SS_BUSY=1 gibt es eine offene Verbindung aus dem Stationsnetz auf Port 80. + cat >"$bindir/ss" <>"$log" +if [ -n "\${FAKE_SS_BUSY:-}" ]; then + echo "ESTAB 0 0 192.168.8.1:80 192.168.8.42:52344" +fi +exit 0 +EOF + + chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo \ + "$bindir"/systemd-run "$bindir"/ss } # --- Sandbox fuer einen Testfall: eigene conf/RUN_DIR/IMAGE_DIR/nmcli-Zustand --- @@ -871,6 +893,284 @@ echo "$HELP" | grep -q "EPD_REVISION" && pass "Hilfetext nennt EPD_REVISION" || 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 +echo "########## Zeitschalter (Auto-Aus) ##########" +# Geprueft wird durchgehend das ERGEBNIS: die Zustandsdatei $RUN_DIR/autooff.json (sie +# ist im Skript die einzige Quelle dafuer, ob ein Zeitschalter laeuft), der +# Rueckgabewert, die Ausgabe und der nmcli-/systemd-Zustand - nicht die Interna der +# Stubs. + +autooff_field() { + # $1 = Pfad zu autooff.json, $2 = Feldname + python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2]))" "$1" "$2" 2>/dev/null \ + || echo "FEHLT" +} +write_autooff() { + # $1 = sandbox, $2 = minutes, $3 = deadline-Offset in Sekunden ab jetzt, $4 = deferrals + python3 - "$1/run/autooff.json" "$2" "$3" "$4" <<'PY' +import json, sys, time +path, minutes, offset, deferrals = sys.argv[1:5] +with open(path, "w") as fh: + json.dump({"minutes": int(minutes), + "deadline": int(time.time()) + int(offset), + "deferrals": int(deferrals)}, fh) +PY +} + +echo "=== Fall Z1 (die wichtigste Zusage): 'on' OHNE --for legt keinen Zeitschalter an ===" +# Der Regelfall ist der Pi am Kabel - dem darf niemand den Zugangspunkt unter der +# laufenden Arbeit wegschalten, nur weil jemand vorsichtig sein wollte. +SBX="$TMPROOT/tZ1" +setup_sandbox "$SBX" +set +e +OUT=$(run_cli "$SBX" on 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zeitschalter-Zustandsdatei angelegt" || fail "ohne --for entstand trotzdem ein Zeitschalter: $(cat "$SBX/run/autooff.json")" +grep -q "systemd-run" "$SBX/cmd.log" && fail "ohne --for wurde trotzdem ein Timer angelegt: $(grep systemd-run "$SBX/cmd.log")" || pass "kein systemd-run-Aufruf" +echo "$OUT" | grep -qi "Auto-Aus" && fail "Ausgabe verspricht einen Zeitschalter, den es nicht gibt: $OUT" || pass "Ausgabe erwaehnt keinen Zeitschalter" +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -qi "Auto-Aus" && fail "'status' zeigt eine Zeitschalter-Zeile, obwohl keiner laeuft: $OUT" || pass "'status' schweigt ueber den Zeitschalter" + +echo +echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ===" +SBX="$TMPROOT/tZ2" +setup_sandbox "$SBX" +set +e +OUT=$(run_cli "$SBX" on --for 30 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +[ -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei angelegt" || fail "keine autooff.json angelegt: $(cat "$SBX/cmd.log")" +[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "angeforderte Dauer 30 min gemerkt" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")" +[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "noch keine Verschiebung" || fail "deferrals falsch: $(cat "$SBX/run/autooff.json")" +[ "$(file_mode "$SBX/run/autooff.json")" = "644" ] && pass "autooff.json ist 0644 (display.py muss sie lesen)" || fail "falsche Rechte: $(file_mode "$SBX/run/autooff.json")" +grep -q "systemd-run .*--unit=ninlight-station-autooff" "$SBX/cmd.log" && pass "Timer unter festem Unit-Namen angelegt" || fail "kein systemd-run mit festem Unit-Namen: $(cat "$SBX/cmd.log")" +grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "Timer feuert nach 30 min" || fail "falsche Laufzeit: $(grep systemd-run "$SBX/cmd.log")" +grep -q "__autooff" "$SBX/cmd.log" && pass "Timer ruft das CLI mit seiner Abschaltaktion" || fail "Timer ruft nichts Abschaltendes: $(grep systemd-run "$SBX/cmd.log")" +[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch" +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -qE "Auto-Aus: in (29|30) min" && pass "'status' zeigt die Restzeit" || fail "'status' zeigt keine brauchbare Restzeit: $OUT" + +echo +echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ===" +# Geprueft wird nicht nur der Rueckgabewert: nach einer abgelehnten Angabe darf weder ein +# Zugangspunkt hochgefahren noch der Dienst neu gestartet noch eine Modusdatei +# geschrieben worden sein. +for BAD in "0" "241" "abc" "-5" "1.5" ""; do + SBX="$TMPROOT/tZ3-${BAD:-leer}" + setup_sandbox "$SBX" + # Logdatei anlegen, BEVOR geprueft wird (wie in Fall D7): eine fehlende Datei wuerde + # die "hat nichts eingeschaltet"-Zusagen unten sonst durch ein grep-Versagen erfuellen + # statt durch das Verhalten des Skripts. + : >"$SBX/cmd.log" + set +e + if [ -z "$BAD" ]; then + OUT=$(run_cli "$SBX" on --for 2>"$SBX/stderr.log") # --for ohne Wert + else + OUT=$(run_cli "$SBX" on --for "$BAD" 2>"$SBX/stderr.log") + fi + RC=$? + set -e + ERR=$(cat "$SBX/stderr.log") + [ "$RC" -ne 0 ] && pass "'--for ${BAD:-}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-}' wurde angenommen: $OUT" + echo "$ERR" | grep -qi "fehler" && pass "'--for ${BAD:-}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-}': keine Fehlermeldung: $ERR" + grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-}': kein Zugangspunkt hochgefahren" + grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-}' hat den Dienst gestartet" || pass "'--for ${BAD:-}': Dienst nicht gestartet" + [ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")" + [ ! -e "$SBX/run/autooff.json" ] && pass "'--for ${BAD:-}': kein Zeitschalter angelegt" || fail "'--for ${BAD:-}': Zeitschalter trotz ungueltiger Angabe" +done + +echo +echo "=== Fall Z4: 'migrate on --listen --for 5' - beides zusammen ===" +SBX="$TMPROOT/tZ4" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +set +e +OUT=$(run_cli "$SBX" migrate on --listen --for 5 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +[ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "phase=listen bleibt erhalten" || fail "phase falsch: $(cat "$SBX/run/control.json" 2>&1)" +[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "5" ] && pass "Zeitschalter ueber 5 min angelegt" || fail "Zeitschalter falsch/fehlt: $(cat "$SBX/run/autooff.json" 2>&1)" +# Umgekehrte Reihenfolge der Schalter muss genauso gehen. +SBX="$TMPROOT/tZ4b" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +set +e +OUT=$(run_cli "$SBX" migrate on --for 5 --listen 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && [ "$(control_phase "$SBX/run/control.json")" = "listen" ] \ + && pass "Schalterreihenfolge ist egal" || fail "'--for 5 --listen' scheiterte (Exit $RC): $OUT" + +echo +echo "=== Fall Z5 (nicht verhandelbar): der Zeitschalter schaltet NICHT in eine laufende Auslieferung ===" +SBX="$TMPROOT/tZ5" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_SS_BUSY=1 run_cli "$SBX" __autooff 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "die Station wurde mitten in der Auslieferung abgeschaltet: $(cat "$SBX/cmd.log")" || pass "die Station bleibt an" +grep -q "nmcli con down" "$SBX/cmd.log" && fail "der Zugangspunkt wurde mitten in der Auslieferung heruntergefahren" || pass "der Zugangspunkt bleibt oben" +[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "1" ] && pass "Verschiebung gezaehlt (1)" || fail "deferrals nicht hochgezaehlt: $(cat "$SBX/run/autooff.json" 2>&1)" +[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "die angeforderte Dauer bleibt fuer 'renew' erhalten" || fail "minutes wurde ueberschrieben: $(cat "$SBX/run/autooff.json")" +echo "$OUT" | grep -qi "verschoben" && pass "die Verschiebung wird gemeldet" || fail "keine Meldung ueber die Verschiebung: $OUT" +# Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab. +SBX="$TMPROOT/tZ5b" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +: >"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" __autooff 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Gegenprobe: Exit-Code 0" || fail "Gegenprobe: Exit-Code $RC: $OUT" +grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "ohne laufende Auslieferung schaltet der Zeitschalter ab" || fail "der Zeitschalter schaltet gar nicht ab: $(cat "$SBX/cmd.log")" +ACTIVE_AFTER=$(cat "$SBX/nmstate/active") +echo "$ACTIVE_AFTER" | grep -qx "home-wifi" && pass "wlan0 ist zurueck im Heim-WLAN" || fail "nicht zurueck im Heim-WLAN: $ACTIVE_AFTER" +[ ! -e "$SBX/run/autooff.json" ] && pass "der abgelaufene Zeitschalter ist abgeraeumt" || fail "Zustandsdatei blieb liegen" +# Die Pruefung muss die richtige Frage stellen: offene Verbindungen auf Port 80 aus dem +# Stationsnetz. +grep -q "STUB ss .*:80" "$SBX/cmd.log" && pass "geprueft wird Port 80" || fail "die Verbindungspruefung fragt nicht nach Port 80: $(grep 'STUB ss' "$SBX/cmd.log")" +grep -q "STUB ss .*192.168.8.0/24" "$SBX/cmd.log" && pass "geprueft wird das Stationsnetz 192.168.8.0/24" || fail "die Verbindungspruefung fragt nicht nach dem Stationsnetz: $(grep 'STUB ss' "$SBX/cmd.log")" + +echo +echo "=== Fall Z6: die Obergrenze fuers Verschieben greift - eine haengende Verbindung haelt den Zeitschalter nicht ewig auf ===" +SBX="$TMPROOT/tZ6" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +write_autooff "$SBX" 30 60 10 # 10 Verschiebungen sind die Obergrenze +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_SS_BUSY=1 run_cli "$SBX" __autooff 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR" +grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "an der Obergrenze wird trotz offener Verbindung abgeschaltet" || fail "die haengende Verbindung hebelt den Zeitschalter dauerhaft aus: $(cat "$SBX/cmd.log")" +echo "$ERR" | grep -qi "warnung" && pass "das Abschalten trotz offener Verbindung wird gemeldet" || fail "keine Warnung: $ERR" +[ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen" +# Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die +# Grenzpruefung auch einfach immer abschalten). +SBX="$TMPROOT/tZ6b" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +write_autooff "$SBX" 30 60 9 +: >"$SBX/cmd.log" +set +e +FAKE_SS_BUSY=1 run_cli "$SBX" __autooff >/dev/null 2>&1 +set -e +grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "eine Verschiebung unter der Grenze hat schon abgeschaltet" || pass "unter der Grenze wird weiter verschoben" +[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "10" ] && pass "Zaehler steht jetzt auf der Grenze (10)" || fail "deferrals falsch: $(cat "$SBX/run/autooff.json" 2>&1)" + +echo +echo "=== Fall Z7: 'off' raeumt einen laufenden Zeitschalter mit ab ===" +# Sonst feuert er spaeter in eine Station hinein, die laengst aus ist - oder inzwischen +# wieder an. +SBX="$TMPROOT/tZ7" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +[ -e "$SBX/run/autooff.json" ] || fail "Vorbedingung: kein Zeitschalter angelegt" +: >"$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: $OUT" +[ ! -e "$SBX/run/autooff.json" ] && pass "Zeitschalter-Zustandsdatei ist weg" || fail "'off' liess den Zeitschalter stehen: $(cat "$SBX/run/autooff.json")" +grep -q "systemctl stop ninlight-station-autooff.timer" "$SBX/cmd.log" && pass "der Timer selbst wurde angehalten" || fail "der systemd-Timer wurde nicht angehalten: $(cat "$SBX/cmd.log")" +grep -q "systemctl stop ninlight-station-autooff$" "$SBX/cmd.log" && fail "es wurde die .service statt der .timer gestoppt - das brächte den Zeitschalter-Lauf um sich selbst" || pass "die gleichnamige .service wird nicht gestoppt" + +echo +echo "=== Fall Z8: 'renew'/'hold' ohne laufenden Zeitschalter sind klare Fehler, kein stillschweigendes Neuanlegen ===" +for CMD in renew hold; do + SBX="$TMPROOT/tZ8-$CMD" + setup_sandbox "$SBX" + run_cli "$SBX" on >/dev/null 2>&1 # Station an, aber OHNE --for + : >"$SBX/cmd.log" + set +e + OUT=$(run_cli "$SBX" "$CMD" 2>"$SBX/stderr.log") + RC=$? + set -e + ERR=$(cat "$SBX/stderr.log") + [ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT" + echo "$ERR" | grep -qi "kein Zeitschalter" && pass "'$CMD': klare Meldung auf stderr" || fail "'$CMD': unklare Meldung: $ERR" + [ ! -e "$SBX/run/autooff.json" ] && pass "'$CMD' hat keinen Zeitschalter angelegt" || fail "'$CMD' hat stillschweigend einen angelegt: $(cat "$SBX/run/autooff.json")" + grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run" +done + +echo +echo "=== Fall Z9: 'renew' stellt die Uhr zurueck - ohne Argument auf die zuletzt angeforderte Dauer ===" +SBX="$TMPROOT/tZ9" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +write_autooff "$SBX" 30 120 4 # Restzeit fast abgelaufen, schon viermal verschoben +: >"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" renew 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "dieselbe Dauer wie zuletzt angefordert (30)" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")" +[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "Verschiebungszaehler zurueckgesetzt" || fail "deferrals nicht zurueckgesetzt: $(cat "$SBX/run/autooff.json")" +STATUS=$(run_cli "$SBX" status 2>&1) +echo "$STATUS" | grep -qE "Auto-Aus: in (29|30) min" && pass "die Restzeit steht wieder bei 30 min" || fail "Restzeit nicht zurueckgestellt: $STATUS" +grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "der Timer wurde neu gestellt" || fail "kein neuer Timer: $(cat "$SBX/cmd.log")" +# Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt. +set +e +OUT=$(run_cli "$SBX" renew 90 2>&1); RC=$? +set -e +[ "$RC" -eq 0 ] && [ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "90" ] && pass "'renew 90' stellt auf 90 min" || fail "'renew 90' scheiterte (Exit $RC): $OUT" +set +e +OUT=$(run_cli "$SBX" renew 999 2>"$SBX/stderr.log"); RC=$? +set -e +[ "$RC" -ne 0 ] && [ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "90" ] && pass "'renew 999' wird abgelehnt, der laufende Zeitschalter bleibt unveraendert" || fail "'renew 999' wurde angenommen (Exit $RC): $(cat "$SBX/run/autooff.json")" + +echo +echo "=== Fall Z10: 'hold' bestellt ab - die Station bleibt an ===" +SBX="$TMPROOT/tZ10" +setup_sandbox "$SBX" +run_cli "$SBX" on --for 30 >/dev/null 2>&1 +: >"$SBX/cmd.log" +set +e +OUT=$(run_cli "$SBX" hold 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" +[ ! -e "$SBX/run/autooff.json" ] && pass "Zeitschalter abbestellt" || fail "Zeitschalter laeuft weiter: $(cat "$SBX/run/autooff.json")" +grep -q "systemctl stop ninlight-station-autooff.timer" "$SBX/cmd.log" && pass "der Timer wurde angehalten" || fail "Timer nicht angehalten: $(cat "$SBX/cmd.log")" +grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "'hold' hat die Station abgeschaltet" || pass "die Station bleibt an" +ACTIVE_AFTER=$(cat "$SBX/nmstate/active") +[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "der Zugangspunkt laeuft unveraendert weiter" || fail "Zugangspunkt-Zustand veraendert: $ACTIVE_AFTER" +# Und ein danach doch noch feuernder Timer-Lauf schaltet nichts ab. +: >"$SBX/cmd.log" +set +e +run_cli "$SBX" __autooff >/dev/null 2>&1 +set -e +grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "ein abbestellter Zeitschalter hat trotzdem abgeschaltet: $(cat "$SBX/cmd.log")" || pass "ein abbestellter Zeitschalter schaltet nichts mehr ab" + +echo +echo "=== Fall Z11: scheitert systemd-run, bleibt das nicht unbemerkt ===" +# Sonst glaubte der Nutzer an einen Zeitschalter, den es nie gab - und genau darauf +# hat er sich verlassen, als er die SSH-Sitzung ueber WLAN aufgab. +SBX="$TMPROOT/tZ11" +setup_sandbox "$SBX" +set +e +OUT=$(FAKE_SYSTEMD_RUN_EXIT=1 run_cli "$SBX" on --for 30 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT" +echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR" +[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")" +echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 425d49c..57951da 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -1,5 +1,6 @@ import json import sys +import time from pathlib import Path import pytest @@ -731,6 +732,121 @@ def test_armed_phase_label_is_visible(): assert "g95_cw" in text +# --- Zeitschalter am Panel (Task 7e) ------------------------------------------------ +# +# Ein Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner: man glaubt, +# die Station laeuft, und sie ist laengst aus. Umgekehrt darf ohne Zeitschalter (der +# Regelfall - er entsteht nur mit "--for") keine Zeile dafuer erscheinen. + +def test_autooff_line_appears_only_with_a_running_timer(): + without = render.lines(_migration_state()) + assert not any("Auto-Aus" in line for line in without), without + + state = dict(_migration_state(), autooff_minutes=7) + with_timer = render.lines(state) + assert "Auto-Aus in 7 min" in with_timer, with_timer + + +def test_autooff_line_also_appears_in_recovery_mode(): + """Der Zeitschalter haengt nicht am Migrationsmodus - im Rettungsmodus reisst "on" + die SSH-Sitzung genauso ab.""" + lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0, + "recent": [], "autooff_minutes": 42}) + assert "Auto-Aus in 42 min" in lines, lines + + +def test_autooff_line_is_actually_drawn_on_the_panel(): + """Nicht nur in lines(): die Zeile muss im vollsten Zustand auch tatsaechlich aufs + Panel kommen und nicht unten herausfallen (compose() bricht Beiwerkzeilen vor dem + unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete + Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten.""" + state = dict(_armed_state(), autooff_minutes=7) + expected = "Auto-Aus in 7 min" + # Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile + # sind eigene Rollen und belegen keine Beiwerkzeile.) + details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL] + assert expected in details, details + index = details.index(expected) + + top, bottom = _detail_band(index) + drawn = _band_pixels(render.compose(state), top, bottom) + reference = _band_pixels(_reference_detail_line(expected, top), top, bottom) + + assert drawn == reference, ( + "die Zeitschalter-Zeile steht nicht (ungekuerzt) auf dem Panel - der Bediener " + "sieht nicht, dass sich die Station gleich abschaltet") + + +def test_autooff_line_says_so_when_the_deadline_has_arrived(): + lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [], + "autooff_minutes": 0}) + assert "Auto-Aus steht an" in lines, lines + + +def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch): + monkeypatch.setattr(display, "_service_active", lambda service_name: True) + (tmp_path / "mode").write_text("recovery") + conf_path = tmp_path / "station.conf" + conf_path.write_text("SSID='ninlight-recovery'\n") + # Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet. + (tmp_path / "autooff.json").write_text(json.dumps( + {"minutes": 30, "deadline": time.time() + 361, "deferrals": 0})) + + state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") + + assert state["autooff_minutes"] == 7 + assert "Auto-Aus in 7 min" in render.lines(state) + + +def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch): + monkeypatch.setattr(display, "_service_active", lambda service_name: True) + (tmp_path / "mode").write_text("recovery") + conf_path = tmp_path / "station.conf" + conf_path.write_text("SSID='ninlight-recovery'\n") + + state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") + + assert "autooff_minutes" not in state + assert not any("Auto-Aus" in line for line in render.lines(state)) + + +def test_broken_autooff_file_is_treated_as_no_timer(tmp_path): + """Dieselbe Zusage wie fuer status.json/control.json: halb geschrieben, kaputt oder + aus einer aelteren Fassung darf keine Ausnahme werfen - und vor allem keine + erfundene Restzeit zeigen.""" + (tmp_path / "autooff.json").write_text("{kein json") + assert display._read_autooff_minutes(tmp_path) is None + + (tmp_path / "autooff.json").write_text(json.dumps({"minutes": 30})) + assert display._read_autooff_minutes(tmp_path) is None + + (tmp_path / "autooff.json").write_text(json.dumps({"deadline": "gleich"})) + assert display._read_autooff_minutes(tmp_path) is None + + +def test_autooff_remaining_time_rounds_up(tmp_path): + (tmp_path / "autooff.json").write_text(json.dumps({"deadline": 1000.0})) + assert display._read_autooff_minutes(tmp_path, now=1000.0 - 600) == 10 + assert display._read_autooff_minutes(tmp_path, now=1000.0 - 601) == 11 + assert display._read_autooff_minutes(tmp_path, now=1000.0 - 1) == 1 + assert display._read_autooff_minutes(tmp_path, now=1000.0) == 0 + assert display._read_autooff_minutes(tmp_path, now=1000.0 + 90) == 0 + + +def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch): + """"off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz + liegen bliebe, darf neben "Station aus" keine Restzeit stehen.""" + monkeypatch.setattr(display, "_service_active", lambda service_name: False) + (tmp_path / "autooff.json").write_text(json.dumps( + {"minutes": 30, "deadline": time.time() + 600, "deferrals": 0})) + + state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", + service_name="x") + + assert state == {"mode": "off"} + assert not any("Auto-Aus" in line for line in render.lines(state)) + + # --- display.build_state() (Fixrunde 1, I3) --------------------------------------- # # Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal