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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+375
-16
@@ -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
|
||||
_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 [<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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user