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:
@@ -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