feat(station): optionaler Zeitschalter schaltet die Station selbst ab

"on"/"recovery on"/"migrate on" nehmen jetzt "--for <minuten>" (1-240): die
Station schaltet sich danach selbsttaetig ab und kehrt ins Heim-WLAN zurueck.
Das hilft, wenn man ueber WLAN angemeldet ist - "on" macht wlan0 zum
Zugangspunkt und reisst jede SSH-Sitzung ab.

Ausdruecklich optional: ohne "--for" entsteht kein Zeitschalter und alles
verhaelt sich exakt wie bisher. Dazu "renew [<minuten>]" (Uhr zurueckstellen),
"hold" (abbestellen, Station bleibt an) und eine Restzeit in "status"; "off"
raeumt einen laufenden Zeitschalter mit ab.

Umgesetzt als transienter systemd-Timer unter festem Unit-Namen (systemd-run),
Zustand in RUN_DIR/autooff.json. Vor dem Abschalten wird per "ss" geprueft, ob
noch eine Verbindung auf Port 80 aus dem Stationsnetz offen ist - dann wird um
rund eine Minute verschoben, hoechstens zehnmal (Begruendung im Kommentar).
Das Panel zeigt den Zeitschalter als eigene Zeile, aber nur wenn einer laeuft.
This commit is contained in:
Michael Sauer
2026-09-14 19:39:42 +02:00
parent f105a0eb69
commit 1f01b29b8e
5 changed files with 841 additions and 17 deletions
+32
View File
@@ -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
+17
View File
@@ -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))
+372 -13
View File
@@ -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 <minuten>" 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 "<minuten> <deadline> <verschiebungen>" 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 <minuten>" 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 <minuten>", beide optional und in beliebiger
# Reihenfolge. Beides wird VOLLSTAENDIG geprueft, bevor irgendein Profil hochkommt.
_phase=auto
if [ "${1:-}" = "--listen" ]; then
_for_minutes=""
_for_seen=0
while [ $# -gt 0 ]; do
case "$1" in
--listen)
_phase=listen
elif [ -n "${1:-}" ]; then
;;
--for)
shift
_for_minutes=${1:-}
_for_seen=1
autooff_check_minutes "$_for_minutes" || exit 2
;;
*)
usage
exit 2
fi
;;
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 [<minuten>]" - 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 <minuten>'." >&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 [<target>]|listen|display {test|clear|off}|off|status" >&2
echo "Aufruf: ninlight-station on [--for <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2
echo "" >&2
echo " --for <minuten> Zeitschalter: die Station schaltet sich nach <minuten> 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 [<min>] 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
+301 -1
View File
@@ -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" <<EOF
#!/usr/bin/env bash
echo "STUB 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" <<EOF
#!/usr/bin/env bash
echo "STUB 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:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT"
echo "$ERR" | grep -qi "fehler" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR"
grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren"
grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet"
[ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")"
[ ! -e "$SBX/run/autooff.json" ] && pass "'--for ${BAD:-<leer>}': kein Zeitschalter angelegt" || fail "'--for ${BAD:-<leer>}': 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."
+116
View File
@@ -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