feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
Die deutschen Texte sahen auf dem 2,13"-Panel merkwuerdig aus - jetzt sind alle Texte, die der Bediener am Geraet oder auf der Konsole sieht, englisch: Modus-, Phasen-, Bildsatz- und Abschlussbeschriftungen in render.py, die Bedientexte des Tasterdienstes und saemtliche Ausgaben von ninlight-station inklusive des Hilfetexts. Die Testbeschreibungen der Suiten bleiben deutsch - die liest nur, wer die Tests laufen laesst. Neu im Off-Bild: die Adresse, unter der der Pi gerade erreichbar ist. Ist die Station aus, ist der Pi ein gewoehnlicher Rechner im Heimnetz, und die einzige Auskunft, die man dann am Panel braucht, ist die fuer SSH. Die Adresse kommt aus einem UDP-Socket, der nur verbunden und nie benutzt wird (kein Datenverkehr, kein DNS, kein Unterprozess in einer 5-s-Schleife) und liefert genau die Adresse der Standardroute. Ohne Netz sagt die Zeile ausdruecklich "no network": eine fehlende Zeile wuerde offen lassen, warum SSH gerade scheitert. Die Zeile gibt es NUR im Off-Bild - im Betrieb traegt wlan0 die Adresse des Zugangspunkts, die ohnehin auf jedem Statusbild steht.
This commit is contained in:
@@ -14,10 +14,40 @@ import render # noqa: E402
|
||||
|
||||
def test_off_mode_says_so_and_shows_nothing_else():
|
||||
lines = render.lines({"mode": "off"})
|
||||
assert any("Station aus" in l for l in lines)
|
||||
assert any("Station off" in l for l in lines)
|
||||
assert not any("SSID" in l for l in lines)
|
||||
|
||||
|
||||
def test_off_mode_shows_the_address_the_pi_is_reachable_at():
|
||||
"""Bei ausgeschalteter Station ist der Pi ein gewoehnlicher Rechner im Heimnetz - die
|
||||
Adresse am Panel erspart die Suche im Router, bevor man per SSH drangeht."""
|
||||
lines = render.lines({"mode": "off", "ip": "192.168.2.96"})
|
||||
assert any("IP 192.168.2.96" in l for l in lines)
|
||||
|
||||
|
||||
def test_off_mode_says_when_there_is_no_network():
|
||||
"""Leeres Feld heisst "kein Netz" und wird ausdruecklich gesagt: das erklaert, warum
|
||||
SSH gerade scheitert. Eine fehlende Zeile wuerde die Frage offen lassen."""
|
||||
lines = render.lines({"mode": "off", "ip": ""})
|
||||
assert any("no network" in l for l in lines)
|
||||
assert not any("IP " in l for l in lines)
|
||||
|
||||
|
||||
def test_off_mode_without_an_address_field_claims_nothing():
|
||||
"""Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand): ohne das Feld
|
||||
bleibt die Zeile weg, statt eine Adresse zu erfinden."""
|
||||
lines = render.lines({"mode": "off"})
|
||||
assert not any("IP " in l or "no network" in l for l in lines)
|
||||
|
||||
|
||||
def test_the_address_line_belongs_to_the_off_screen_only():
|
||||
"""Im Betrieb traegt wlan0 die Zugangspunkt-Adresse, die ohnehin auf jedem Statusbild
|
||||
steht - eine zweite Adresse waere irrefuehrend und kostet die knappe Zeile."""
|
||||
for mode in ("migration", "recovery"):
|
||||
lines = render.lines({"mode": mode, "phase": "auto", "ip": "192.168.2.96"})
|
||||
assert not any("192.168.2.96" in l for l in lines)
|
||||
|
||||
|
||||
def test_migration_shows_detected_lamp():
|
||||
lines = render.lines({
|
||||
"mode": "migration", "phase": "auto", "served": 3,
|
||||
@@ -57,7 +87,7 @@ def test_recovery_mode_without_traffic():
|
||||
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
|
||||
"served": 0, "recent": []})
|
||||
text = "\n".join(lines)
|
||||
assert "RETTUNG" in text
|
||||
assert "RECOVERY" in text
|
||||
assert "ninlight-recovery" in text
|
||||
|
||||
|
||||
@@ -151,7 +181,7 @@ def _migration_state():
|
||||
|
||||
|
||||
def test_off_screen_is_drawn_big_and_centered():
|
||||
""""Station aus" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
|
||||
""""Station off" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
|
||||
|
||||
Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links
|
||||
wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind
|
||||
@@ -519,7 +549,7 @@ def test_font_cache_keeps_different_modules_and_search_paths_apart(monkeypatch):
|
||||
|
||||
|
||||
def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch):
|
||||
""""Station aus" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
|
||||
""""Station off" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
|
||||
nicht erst gebaut werden (faules Laden, Befund M4)."""
|
||||
real_load_font = render._load_font
|
||||
requested = []
|
||||
@@ -663,7 +693,7 @@ def test_missing_fields_do_not_raise():
|
||||
|
||||
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
|
||||
#
|
||||
# Frueher stand "FREIGEGEBEN: <ziel>" als Phase im Kopfbalken. Neben dem Modus bleiben
|
||||
# Frueher stand "ARMED: <target>" als Phase im Kopfbalken. Neben dem Modus bleiben
|
||||
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein
|
||||
# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
|
||||
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war
|
||||
@@ -760,11 +790,11 @@ def test_armed_phase_label_is_visible():
|
||||
|
||||
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
|
||||
assert not any("Auto-off" 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
|
||||
assert "Auto-off in 7 min" in with_timer, with_timer
|
||||
|
||||
|
||||
def test_autooff_line_also_appears_in_recovery_mode():
|
||||
@@ -772,7 +802,7 @@ def test_autooff_line_also_appears_in_recovery_mode():
|
||||
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
|
||||
assert "Auto-off in 42 min" in lines, lines
|
||||
|
||||
|
||||
def test_autooff_line_is_actually_drawn_on_the_panel():
|
||||
@@ -781,7 +811,7 @@ def test_autooff_line_is_actually_drawn_on_the_panel():
|
||||
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"
|
||||
expected = "Auto-off 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]
|
||||
@@ -800,7 +830,7 @@ def test_autooff_line_is_actually_drawn_on_the_panel():
|
||||
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
|
||||
assert "Auto-off pending" in lines, lines
|
||||
|
||||
|
||||
def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
|
||||
@@ -815,7 +845,7 @@ def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
|
||||
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)
|
||||
assert "Auto-off in 7 min" in render.lines(state)
|
||||
|
||||
|
||||
def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch):
|
||||
@@ -827,7 +857,7 @@ def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypat
|
||||
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))
|
||||
assert not any("Auto-off" in line for line in render.lines(state))
|
||||
|
||||
|
||||
def test_broken_autooff_file_is_treated_as_no_timer(tmp_path):
|
||||
@@ -855,7 +885,7 @@ def test_autooff_remaining_time_rounds_up(tmp_path):
|
||||
|
||||
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."""
|
||||
liegen bliebe, darf neben "Station off" 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}))
|
||||
@@ -866,7 +896,7 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
||||
|
||||
assert state["mode"] == "off"
|
||||
assert "autooff_minutes" not in state
|
||||
assert not any("Auto-Aus" in line for line in render.lines(state))
|
||||
assert not any("Auto-off" in line for line in render.lines(state))
|
||||
|
||||
|
||||
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
|
||||
@@ -874,7 +904,7 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
||||
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
|
||||
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
|
||||
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
|
||||
# Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen.
|
||||
# Rettungsmodus nicht als "ARMED" durchschlagen.
|
||||
|
||||
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||
@@ -888,7 +918,7 @@ def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
|
||||
assert state["mode"] == "recovery"
|
||||
assert "phase" not in state
|
||||
assert "armed_target" not in state
|
||||
assert "FREIGEGEBEN" not in "\n".join(render.lines(state))
|
||||
assert "ARMED" not in "\n".join(render.lines(state))
|
||||
|
||||
|
||||
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
|
||||
@@ -905,7 +935,31 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
|
||||
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
|
||||
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
|
||||
assert state["mode"] == "off"
|
||||
assert set(state) == {"mode", "imageset", "imageset_ok"}
|
||||
assert set(state) == {"mode", "imageset", "imageset_ok", "ip"}
|
||||
assert isinstance(state["ip"], str)
|
||||
|
||||
|
||||
def test_build_state_off_carries_the_local_address(tmp_path, monkeypatch):
|
||||
"""Die Adresse kommt aus local_ip() und landet nur im Off-Zustand."""
|
||||
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
||||
monkeypatch.setattr(display, "local_ip", lambda: "192.168.2.96")
|
||||
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||
service_name="x", imageset_path=tmp_path / "imageset")
|
||||
assert state["ip"] == "192.168.2.96"
|
||||
|
||||
|
||||
def test_build_state_running_carries_no_address(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||
(tmp_path / "mode").write_text("migration")
|
||||
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||
service_name="x", imageset_path=tmp_path / "imageset")
|
||||
assert "ip" not in state
|
||||
|
||||
|
||||
def test_local_ip_returns_a_string_and_never_raises():
|
||||
"""Ohne Netz gibt es keine Route - dann "" statt einer Ausnahme, die das Panel
|
||||
aufhielte."""
|
||||
assert isinstance(display.local_ip(), str)
|
||||
|
||||
|
||||
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
|
||||
@@ -1599,7 +1653,7 @@ def test_planner_counts_an_upgraded_partial_as_a_full_refresh():
|
||||
|
||||
def test_planner_draws_fully_on_mode_change():
|
||||
planner = display.RefreshPlanner(0.0)
|
||||
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True)
|
||||
planner.record_draw(0.0, ["RECOVERY"], "recovery", was_full=True)
|
||||
|
||||
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"
|
||||
|
||||
@@ -1612,14 +1666,14 @@ def test_planner_draws_fully_on_mode_change():
|
||||
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
|
||||
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
|
||||
|
||||
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus",
|
||||
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True}
|
||||
_MENU_UI = {"view": "menu", "title": "MENU", "hero": "> Station off",
|
||||
"details": ["Refresh display", "Shut down"], "active": True}
|
||||
|
||||
|
||||
def test_the_ui_file_lives_outside_the_stations_runtime_directory():
|
||||
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
|
||||
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem
|
||||
das Menue "Rettung starten"/"Migration starten" anbietet."""
|
||||
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station off", in dem
|
||||
das Menue "Start recovery"/"Migration starten" anbietet."""
|
||||
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
|
||||
assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
|
||||
|
||||
@@ -1627,7 +1681,7 @@ def test_the_ui_file_lives_outside_the_stations_runtime_directory():
|
||||
def test_a_running_operation_replaces_the_status_screen():
|
||||
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
|
||||
"recent": [], "ui": _MENU_UI})
|
||||
assert lines == ["MENUE", "> Station aus", "Anzeige auffrischen", "Herunterfahren"]
|
||||
assert lines == ["MENU", "> Station off", "Refresh display", "Shut down"]
|
||||
assert not any("ninlight-recovery" in l for l in lines)
|
||||
|
||||
|
||||
@@ -1652,7 +1706,7 @@ def test_a_broken_ui_file_does_not_break_the_display():
|
||||
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
|
||||
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
|
||||
"hero": None, "details": "nope"}})
|
||||
assert lines == ["Bedienung"]
|
||||
assert lines == ["Button menu"]
|
||||
render.compose({"mode": "recovery", "ui": {"view": "menu"}})
|
||||
|
||||
|
||||
@@ -1668,7 +1722,7 @@ def test_read_ui_ignores_a_missing_broken_or_idle_file(tmp_path):
|
||||
path.write_text(json.dumps({"view": "menu", "active": False}))
|
||||
assert display.read_ui(path) == {}
|
||||
path.write_text(json.dumps(_MENU_UI))
|
||||
assert display.read_ui(path)["hero"] == "> Station aus"
|
||||
assert display.read_ui(path)["hero"] == "> Station off"
|
||||
|
||||
|
||||
def test_the_daemon_shows_an_operation_within_half_a_second():
|
||||
@@ -1748,13 +1802,13 @@ def test_the_two_image_sets_read_differently():
|
||||
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
|
||||
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
|
||||
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
|
||||
assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"]
|
||||
assert "Wi-Fi" in render.IMAGESET_LABELS["mit-wlan"]
|
||||
|
||||
|
||||
def test_a_missing_image_set_says_so_on_the_panel():
|
||||
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
|
||||
line = _imageset_line(state)
|
||||
assert line is not None and "FEHLT" in line
|
||||
assert line is not None and "MISSING" in line
|
||||
|
||||
|
||||
def test_without_an_image_set_field_nothing_is_claimed():
|
||||
@@ -1847,7 +1901,7 @@ def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
|
||||
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||
imageset_path=switch)
|
||||
assert state["imageset_ok"] is False
|
||||
assert "FEHLT" in _imageset_line(state)
|
||||
assert "MISSING" in _imageset_line(state)
|
||||
|
||||
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
|
||||
(images / "migrate-wlan").mkdir()
|
||||
@@ -1884,14 +1938,14 @@ def test_the_shutdown_and_the_reboot_screen_differ():
|
||||
gekostet)."""
|
||||
off, reboot = _final_text("poweroff"), _final_text("reboot")
|
||||
assert off != reboot
|
||||
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot
|
||||
assert "POWERED OFF" in off and "REBOOTING" in reboot
|
||||
# Ausschalten: erlaubt das Trennen ausdruecklich. Geprueft wird die ZUSAGE, nicht
|
||||
# der genaue Satz - der Wortlaut musste am 2026-09-15 kuerzer werden, weil die alte
|
||||
# Fassung ("Strom kann getrennt werden", 262,1 px) auf dem Panel abgeschnitten war.
|
||||
assert "trennen erlaubt" in off
|
||||
assert "Safe to unplug" in off
|
||||
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
|
||||
assert "NICHT trennen" in reboot
|
||||
assert "trennen erlaubt" not in reboot
|
||||
assert "Do NOT unplug" in reboot
|
||||
assert "Safe to unplug" not in reboot
|
||||
|
||||
|
||||
def test_the_reboot_screen_says_how_long_it_stays():
|
||||
@@ -1907,14 +1961,14 @@ def test_the_final_screen_carries_nothing_else():
|
||||
"imageset_ok": True, "served": 7}
|
||||
blocks = render.blocks(state)
|
||||
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
|
||||
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks)
|
||||
assert not any("Bildsatz" in text or "Served" in text for text, _ in blocks)
|
||||
|
||||
|
||||
def test_an_unknown_final_kind_claims_neither():
|
||||
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
|
||||
gebootet wird."""
|
||||
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
|
||||
assert "AUSGESCHALTET" not in text
|
||||
assert "POWERED OFF" not in text
|
||||
assert "getrennt" not in text
|
||||
|
||||
|
||||
@@ -1965,7 +2019,7 @@ def test_display_main_passes_the_final_kind_through(monkeypatch):
|
||||
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
|
||||
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
|
||||
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
|
||||
"Strom kann getrennt werden"."""
|
||||
"Safe to unplug"."""
|
||||
drawn = []
|
||||
|
||||
class _Panel:
|
||||
|
||||
Reference in New Issue
Block a user