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:
Michael Sauer
2026-09-20 00:28:33 +02:00
parent 71a579c0f9
commit 021089a68b
7 changed files with 373 additions and 274 deletions
+37 -37
View File
@@ -167,43 +167,43 @@ def _labels(state):
def test_menu_off_offers_starting_and_shutdown():
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen",
"Migration: AUTOMATIK", "Umschalten: mit WLAN",
"Herunterfahren"]
assert _labels(STATE_OFF) == ["Start recovery", "Migration: listen",
"Migration: AUTOMATIC", "Switch to: with Wi-Fi",
"Shut down"]
def test_the_listening_migration_comes_before_the_automatic_one():
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
irgendwo loslaesst, soll nicht im Automatikmodus landen."""
labels = _labels(STATE_OFF)
assert labels.index("Migration: horchen") < labels.index("Migration: AUTOMATIK")
assert labels.index("Migration: listen") < labels.index("Migration: AUTOMATIC")
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN",
"Anzeige auffrischen", "Herunterfahren"]
assert _labels(STATE_RECOVERY) == ["Station off", "Switch to: with Wi-Fi",
"Refresh display", "Shut down"]
def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus",
"Umschalten: mit WLAN", "Anzeige auffrischen",
"Herunterfahren"]
assert _labels(STATE_MIGRATION) == ["Arm: gu10_rgb", "Station off",
"Switch to: with Wi-Fi", "Refresh display",
"Shut down"]
def test_menu_in_migration_without_a_detected_target_offers_no_release():
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN",
"Anzeige auffrischen", "Herunterfahren"]
assert "Armed: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station off", "Switch to: with Wi-Fi",
"Refresh display", "Shut down"]
def test_only_the_refresh_entry_skips_the_confirmation():
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Anzeige auffrischen"
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Refresh display"
veraendert nichts und braucht keine."""
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
for action in button.menu_actions(state):
assert action.confirm is (action.label != "Anzeige auffrischen"), action.label
assert action.confirm is (action.label != "Refresh display"), action.label
def test_actions_run_through_the_station_cli():
@@ -222,7 +222,7 @@ def test_a_short_press_opens_the_menu():
rec = _drive([_short(1.0)], 4.0)
assert rec.argvs() == []
t, view = rec.first_view("menu")
assert view["hero"] == "> Rettung starten"
assert view["hero"] == "> Start recovery"
assert view["active"] is True
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
@@ -233,19 +233,19 @@ def test_a_short_press_opens_the_menu():
def test_a_short_press_steps_to_the_next_entry():
rec = _drive([_short(1.0), _short(2.0)], 4.0)
assert rec.argvs() == []
assert rec.views()[-1][1]["hero"] == "> Migration: horchen"
assert rec.views()[-1][1]["hero"] == "> Migration: listen"
def test_a_short_press_wraps_around_the_last_entry():
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
_short(6.0)], 8.0)
assert rec.views()[-1][1]["hero"] == "> Rettung starten"
assert rec.views()[-1][1]["hero"] == "> Start recovery"
def test_holding_one_and_a_half_seconds_selects_the_entry():
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
t, view = rec.first_view("confirm")
assert view["hero"] == "Rettung starten"
assert view["hero"] == "Start recovery"
assert 3.5 <= t <= 3.6
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
assert rec.argvs() == []
@@ -271,7 +271,7 @@ def test_the_refresh_entry_runs_without_a_confirmation():
def test_the_listening_entry_calls_the_cli_with_listen():
"""Der zweite Eintrag ("Migration: horchen") ruft das CLI MIT --listen: der
"""Der zweite Eintrag ("Migration: listen") ruft das CLI MIT --listen: der
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
freigibt."""
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
@@ -280,7 +280,7 @@ def test_the_listening_entry_calls_the_cli_with_listen():
def test_the_automatic_entry_calls_the_cli_without_listen():
"""Der dritte Eintrag ("Migration: AUTOMATIK") ruft dasselbe CLI OHNE --listen - das
"""Der dritte Eintrag ("Migration: AUTOMATIC") ruft dasselbe CLI OHNE --listen - das
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
fremde Lampen ungefragt migriert werden."""
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
@@ -293,18 +293,18 @@ def test_the_automatic_entry_asks_a_louder_question_than_the_others():
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege."""
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
_t, view = rec.first_view("confirm")
assert view["title"] == "ACHTUNG"
assert view["hero"] == "Migration: AUTOMATIK"
assert view["title"] == "WARNING"
assert view["hero"] == "Migration: AUTOMATIC"
text = " ".join(view["details"]).lower()
assert "jede" in text and "fremde" in text
assert "every" in text and "other people" in text
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
assert view["details"][-2:] == ["halten (1,5 s): ja", "kurz: nein"]
assert view["details"][-2:] == ["hold (1.5 s): yes", "tap: no"]
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
_t, plain = rec.first_view("confirm")
assert plain["title"] == "RUECKFRAGE"
assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"]
assert plain["title"] == "CONFIRM"
assert plain["details"] == ["hold (1.5 s): yes", "tap: no"]
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
@@ -316,7 +316,7 @@ def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
assert 4.0 <= t <= 4.1
# ... und danach laeuft am Panel der Countdown der zweiten Stufe.
later = [v for vt, v in rec.views() if vt > t]
assert any("Herunterfahren in" in v.get("hero", "") for v in later)
assert any("Shutting down in" in v.get("hero", "") for v in later)
def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
@@ -337,8 +337,8 @@ def test_the_shutdown_countdown_is_visible_before_it_happens():
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
rec = _drive([(1.0, 12.0)], 14.0)
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
assert "Herunterfahren in 6 s" in heroes
assert "Herunterfahren in 1 s" in heroes
assert "Shutting down in 6 s" in heroes
assert "Shutting down in 1 s" in heroes
def test_a_blocking_action_does_not_break_the_hold_gesture():
@@ -392,7 +392,7 @@ def test_an_expired_confirmation_leaves_nothing_pending():
def test_a_failed_action_says_so_on_the_panel():
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
assert any(v.get("view") == "result" and v.get("title") == "FEHLER"
assert any(v.get("view") == "result" and v.get("title") == "ERROR"
for _t, v in rec.views())
@@ -411,7 +411,7 @@ def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
geschrieben."""
path = tmp_path / "run" / "ui.json"
writer = button.UiWriter(path)
view = {"view": "menu", "title": "MENUE", "hero": "> Station aus", "details": [],
view = {"view": "menu", "title": "MENU", "hero": "> Station off", "details": [],
"active": True}
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
@@ -509,10 +509,10 @@ def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
def test_the_image_set_entry_names_where_it_switches_to():
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
assert "Umschalten: mit WLAN" in _labels(STATE_OFF)
assert "Switch to: with Wi-Fi" in _labels(STATE_OFF)
ohne = dict(STATE_OFF, imageset="mit-wlan")
assert "Umschalten: ohne WLAN" in _labels(ohne)
assert "Umschalten: mit WLAN" not in _labels(ohne)
assert "Switch to: no Wi-Fi" in _labels(ohne)
assert "Switch to: with Wi-Fi" not in _labels(ohne)
def test_the_image_set_entry_switches_through_the_cli():
@@ -527,9 +527,9 @@ def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
loud = button.action_imageset("ohne-wlan")
back = button.action_imageset("mit-wlan")
assert loud.confirm and back.confirm
assert loud.confirm_title == "ACHTUNG"
assert back.confirm_title == "RUECKFRAGE"
assert "WLAN-Passwort" in " ".join(loud.confirm_details)
assert loud.confirm_title == "WARNING"
assert back.confirm_title == "CONFIRM"
assert "Wi-Fi password" in " ".join(loud.confirm_details)
assert back.confirm_details is None