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
+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