diff --git a/tools/station/button/button.py b/tools/station/button/button.py index 6c67957..67ecb38 100644 --- a/tools/station/button/button.py +++ b/tools/station/button/button.py @@ -139,7 +139,7 @@ class Action: diesem Moment gar nicht vor Augen hat.""" def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True, - done_text: str | None = None, confirm_title: str = "RUECKFRAGE", + done_text: str | None = None, confirm_title: str = "CONFIRM", confirm_details=None) -> None: self.label = label self.argv = tuple(argv) @@ -154,16 +154,16 @@ class Action: def action_shutdown() -> Action: - return Action("Herunterfahren", kind="shutdown", confirm=True, - done_text="Faehrt herunter") + return Action("Shut down", kind="shutdown", confirm=True, + done_text="Shutting down") def action_migrate_listen() -> Action: """Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus, bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag vor dem Automatikmodus und traegt die uebliche Rueckfrage.""" - return Action("Migration: horchen", ("migrate", "on", "--listen"), - done_text="Migration: horcht") + return Action("Migration: listen", ("migrate", "on", "--listen"), + done_text="Migration: listening") def action_migrate_auto() -> Action: @@ -179,11 +179,11 @@ def action_migrate_auto() -> Action: danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der Rueckfrage selbst.""" - return Action("Migration: AUTOMATIK", ("migrate", "on"), - done_text="Migration: automatisch", - confirm_title="ACHTUNG", - confirm_details=["Flasht JEDE alte Klyqa-Lampe", - "in Reichweite - auch fremde"]) + return Action("Migration: AUTOMATIC", ("migrate", "on"), + done_text="Migration: automatic", + confirm_title="WARNING", + confirm_details=["Flashes EVERY old Klyqa lamp", + "in range - other people's too"]) def action_imageset(current: str) -> Action: @@ -196,19 +196,19 @@ def action_imageset(current: str) -> Action: Lampe, die sie erwischt.""" target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan" if target == "mit-wlan": - return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"), - done_text="Bildsatz: mit WLAN", - confirm_title="ACHTUNG", - confirm_details=["Jede migrierte Lampe bekommt", - "dein WLAN-Passwort"]) - return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"), - done_text="Bildsatz: ohne WLAN") + return Action("Switch to: with Wi-Fi", ("bildsatz", "mit-wlan"), + done_text="Image set: with Wi-Fi", + confirm_title="WARNING", + confirm_details=["Every migrated lamp receives", + "your Wi-Fi password"]) + return Action("Switch to: no Wi-Fi", ("bildsatz", "ohne-wlan"), + done_text="Image set: no Wi-Fi") def action_cancel() -> Action: """Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss.""" - return Action("Station aus", ("off",), confirm=False, done_text="Station aus") + return Action("Station off", ("off",), confirm=False, done_text="Station off") def menu_actions(state: dict) -> list[Action]: @@ -221,7 +221,7 @@ def menu_actions(state: dict) -> list[Action]: if not running: return [ - Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"), + Action("Start recovery", ("recovery", "on"), done_text="Recovery running"), # Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der # naheliegende und der harmlose ist - wer nur blaettert und irgendwo # loslaesst, landet nicht im Automatikmodus. @@ -237,13 +237,13 @@ def menu_actions(state: dict) -> list[Action]: if mode == "migration" and target: # Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es # nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen. - items.append(Action("Freigabe: {}".format(target), ("arm", str(target)), - done_text="Freigegeben: {}".format(target))) - items.append(Action("Station aus", ("off",), done_text="Station aus")) + items.append(Action("Arm: {}".format(target), ("arm", str(target)), + done_text="Armed: {}".format(target))) + items.append(Action("Station off", ("off",), done_text="Station off")) items.append(action_imageset(imageset)) # Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage. - items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False, - done_text="Anzeige aufgefrischt")) + items.append(Action("Refresh display", ("display", "test"), confirm=False, + done_text="Display refreshed")) items.append(action_shutdown()) return items @@ -561,32 +561,32 @@ class ButtonUI: and self.screen in ("idle", "busy")): held = now - self._press_t if self._hold_stage == 1: - return _view("hold", "STATION AUS", - "Herunterfahren in {} s".format( + return _view("hold", "STATION OFF", + "Shutting down in {} s".format( _countdown(SHUTDOWN_HOLD_S, held)), - ["Weiter halten - Loslassen bricht ab"]) + ["Keep holding - release to cancel"]) if held >= SHORT_PRESS_MAX_S: - return _view("hold", "HALTEN", - "Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)), - ["Weiter halten - Loslassen bricht ab"]) + return _view("hold", "HOLD", + "Cancel in {} s".format(_countdown(CANCEL_HOLD_S, held)), + ["Keep holding - release to cancel"]) if self.screen == "menu" and self.items: selected = self.items[self.index] rest = self.items[self.index + 1:] + self.items[:self.index] - return _view("menu", "MENUE", "> " + selected.label, - [a.label for a in rest] + ["kurz: weiter, halten: waehlen"]) + return _view("menu", "MENU", "> " + selected.label, + [a.label for a in rest] + ["tap: next, hold: select"]) if self.screen == "confirm" and self.pending is not None: # Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei # der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des # Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird. details = list(self.pending.confirm_details or []) - details += ["halten (1,5 s): ja", "kurz: nein"] + details += ["hold (1.5 s): yes", "tap: no"] return _view("confirm", self.pending.confirm_title, self.pending.label, details) if self.screen == "busy" and self.busy is not None: - return _view("busy", "BITTE WARTEN", self.busy.label, []) + return _view("busy", "PLEASE WAIT", self.busy.label, []) if self.result is not None: text, ok = self.result - return _view("result", "FERTIG" if ok else "FEHLER", text, []) + return _view("result", "DONE" if ok else "ERROR", text, []) return {"view": "idle", "active": False} diff --git a/tools/station/display/display.py b/tools/station/display/display.py index dd4f66c..1b502a0 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -237,6 +237,35 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool return name, False +def local_ip() -> str: + """Die Adresse, unter der der Pi im Heimnetz erreichbar ist, oder "" wenn keine da ist. + + Ueber einen UDP-Socket, der nur verbunden und nie benutzt wird: das erzeugt keinen + Datenverkehr und fragt kein DNS, liefert aber genau die Adresse, die der Kernel fuer + die Standardroute waehlen wuerde - also die, ueber die der Bediener tatsaechlich per + SSH hereinkommt. Ein Aufruf von "hostname -I" waere die naheliegende Alternative, + listet aber ALLE Adressen (inklusive der des Zugangspunkts und IPv6) und braucht einen + Unterprozess in einer Schleife, die alle 5 s laeuft. + + Wirft nie: ohne Netz gibt es keine Route, dann liefert der Aufruf "" und die Anzeige + sagt das - eine Ausnahme aus der Adressermittlung darf das Panel nicht aufhalten.""" + import socket + + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Die Adresse wird nie angesprochen; sie waehlt nur die Route aus. Bewusst eine + # dokumentierte Testadresse (RFC 5737) statt eines echten Ziels wie 8.8.8.8 - + # es soll auch dann nichts Fremdes anklingen, wenn jemand mitliest. + sock.connect(("192.0.2.1", 9)) + return str(sock.getsockname()[0]) + except OSError: + return "" + finally: + if sock is not None: + sock.close() + + def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, service_name: str = SERVICE_NAME, imageset_path: Path = IMAGESET_FILE) -> dict: @@ -262,7 +291,13 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, imageset, imageset_ok = _read_imageset(imageset_path, conf) if not _service_active(service_name): - return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok} + # Die IP-Zeile gibt es NUR hier: ist die Station aus, ist der Pi ein gewoehnlicher + # Rechner im Heimnetz, und die einzige Auskunft, die man dann am Panel braucht, + # ist die Adresse, unter der man per SSH drankommt. Im Betrieb waere sie sinnlos + # (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und + # wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen. + return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok, + "ip": local_ip()} mode = _read_mode_file(run_dir) status = _read_json(run_dir / "status.json") diff --git a/tools/station/display/render.py b/tools/station/display/render.py index 6c1bdac..d2225d3 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -172,7 +172,7 @@ FONT_SEARCH_PATHS = ( ("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"), ) -MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"} +MODE_LABELS = {"recovery": "RECOVERY", "migration": "MIGRATION"} # "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem # Zielnamen nach sich), deshalb hier nicht gelistet. PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"} @@ -186,8 +186,8 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"} # breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft # verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der # Zielname als eigene Beiwerkzeile ueber die volle Panelbreite. -ARMED_LABEL = "FREIGEGEBEN" -ARMED_DETAIL_PREFIX = "Freigabe: " +ARMED_LABEL = "ARMED" +ARMED_DETAIL_PREFIX = "Armed: " # Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert # und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts, @@ -201,16 +201,16 @@ ARMED_DETAIL_PREFIX = "Freigabe: " # mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe # test_imageset_footer_fits_the_panel_with_room_for_a_wider_font. IMAGESET_LABELS = { - "ohne-wlan": "ohne WLAN-Daten (Onboarding-AP)", - "mit-wlan": "MIT WLAN-Daten (eigenes Netz)", + "ohne-wlan": "no Wi-Fi data (onboarding AP)", + "mit-wlan": "WITH Wi-Fi data (own network)", } # Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie # zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in # BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige # Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen. IMAGESET_MISSING_LABELS = { - "ohne-wlan": "SATZ FEHLT: ohne WLAN-Daten", - "mit-wlan": "SATZ FEHLT: MIT WLAN-Daten", + "ohne-wlan": "SET MISSING: no Wi-Fi data", + "mit-wlan": "SET MISSING: WITH Wi-Fi data", } # Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt @@ -228,10 +228,10 @@ FINAL_LABELS = { # Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es # ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an # test_fixed_labels_fit_the_panel_with_room_for_a_wider_font. - "poweroff": ("AUSGESCHALTET", "Strom trennen erlaubt", []), - "reboot": ("NEUSTART", "Strom NICHT trennen!", - ["Der Pi kommt in rund 2 min wieder.", - "Solange bleibt dieses Bild stehen."]), + "poweroff": ("POWERED OFF", "Safe to unplug", []), + "reboot": ("REBOOTING", "Do NOT unplug!", + ["The Pi is back in about 2 min.", + "This screen stays until then."]), } # Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant): @@ -293,7 +293,7 @@ def blocks(state: dict) -> list[tuple[str, str]]: if label is None: # Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet" # zu sagen, wenn gleich wieder gebootet wird. - return [("Faehrt herunter", ROLE_BIG)] + return [("Shutting down", ROLE_BIG)] title, hero, details = label return ([(title, ROLE_HEADER), (hero, ROLE_HERO)] + [(d, ROLE_DETAIL) for d in details]) @@ -308,14 +308,24 @@ def blocks(state: dict) -> list[tuple[str, str]]: # diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und # gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt. - out.append(("Station aus", ROLE_BIG)) + out.append(("Station off", ROLE_BIG)) + # Die Adresse, unter der der Pi gerade erreichbar ist - die einzige Auskunft, die + # man bei ausgeschalteter Station am Geraet wirklich braucht (per SSH draufgehen, + # ohne den Router zu befragen). display.py legt sie nur in DIESEN Zustand; fehlt + # das Feld (aeltere display.py-Fassung, von Hand gebauter Zustand im Test), bleibt + # die Zeile weg statt eine Adresse zu erfinden. Ist das Feld da, aber leer, sagt + # die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz" + # sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert. + ip = state.get("ip") + if isinstance(ip, str): + out.append((f"IP {ip}" if ip else "no network", ROLE_DETAIL)) return out + footer if mode not in ("recovery", "migration"): # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # unterstellen. - out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG)) + out.append((f"Status unknown ({mode})" if mode else "Status unknown", ROLE_BIG)) return out + footer out.append((MODE_LABELS[mode], ROLE_HEADER)) @@ -409,12 +419,12 @@ def blocks(state: dict) -> list[tuple[str, str]]: 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)) + out.append(("Auto-off in {} min".format(autooff_minutes), ROLE_DETAIL)) else: - out.append(("Auto-Aus steht an", ROLE_DETAIL)) + out.append(("Auto-off pending", ROLE_DETAIL)) served = state.get("served") - out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL)) + out.append((f"Served: {served if isinstance(served, int) else 0}", ROLE_DETAIL)) recent = state.get("recent") if isinstance(recent, list) and recent: @@ -467,7 +477,7 @@ def _ui_blocks(ui: dict) -> list[tuple[str, str]]: # Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text # (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als # ein leeres Panel zu zeigen, das nach "haengt" aussieht. - out.append(("Bedienung", ROLE_BIG)) + out.append(("Button menu", ROLE_BIG)) return out diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 1273c02..4329ffa 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -107,14 +107,14 @@ elif [ -e "$CONF" ]; then # Rechte aufzuweichen. eval "$(sudo cat "$CONF")" else - echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2 + echo "Error: $CONF missing - run tools/station/setup_pi.sh first" >&2 exit 1 fi for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do eval "test -n \"\${$_key-}\"" || { - echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2 + echo "Error: $CONF: key $_key missing or empty - run tools/station/setup_pi.sh" >&2 exit 1 } done @@ -125,7 +125,7 @@ need_profile() { # -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit # "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen. nmcli -t --escape no -f NAME con show | grep -Fqx "$1" || { - echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 + echo "Error: NetworkManager profile '$1' missing - run tools/station/setup_pi.sh" >&2 exit 1 } } @@ -163,8 +163,8 @@ imageset_dir() { imageset_label() { case "$1" in - mit-wlan) printf 'mit-wlan (Lampe haengt sofort im eigenen WLAN)' ;; - *) printf 'ohne-wlan (Lampe startet im Onboarding-Zugangspunkt)' ;; + mit-wlan) printf 'mit-wlan (lamp joins your Wi-Fi right away)' ;; + *) printf 'ohne-wlan (lamp starts its onboarding access point)' ;; esac } @@ -194,25 +194,25 @@ bildsatz_cmd() { case "$_want" in ohne-wlan|mit-wlan) ;; *) - echo "Fehler: unbekannter Bildsatz '$_want' - erlaubt sind 'ohne-wlan' und 'mit-wlan'" >&2 + echo "Error: unknown image set '$_want' - allowed are 'ohne-wlan' and 'mit-wlan'" >&2 exit 2 ;; esac imageset_set "$_want" || { - echo "Fehler: $IMAGESET_FILE konnte nicht geschrieben werden" >&2 + echo "Error: could not write $IMAGESET_FILE" >&2 exit 1 } - echo "Bildsatz: $(imageset_label "$_want")" - [ "$_now" = "$_want" ] && echo " (war schon eingestellt)" + echo "Image set: $(imageset_label "$_want")" + [ "$_now" = "$_want" ] && echo " (was already set)" _dir=$(imageset_dir "$_want") if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then # Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts - # aus (Status "noimageset"), und das Panel sagt "FEHLT". Hier trotzdem sofort + # aus (Status "noimageset"), und das Panel sagt "MISSING". Hier trotzdem sofort # warnen, damit niemand erst an der Lampe merkt, dass nichts kommt. - echo "Warnung: $_dir fehlt oder ist leer - in diesem Bildsatz wird NICHTS ausgeliefert. tools/station/deploy.sh erneut laufen lassen." >&2 + echo "Warning: $_dir missing or empty - this image set serves NOTHING. Run tools/station/deploy.sh again." >&2 fi if [ "$_want" = "mit-wlan" ]; then - echo "Achtung: migrierte Lampen haengen damit sofort im WLAN aus config.local.cmake - auch fremde Lampen, die im Automatikmodus mitgenommen werden." >&2 + echo "Caution: migrated lamps join the Wi-Fi from config.local.cmake right away - including lamps that are not yours and got picked up in automatic mode." >&2 fi } @@ -225,18 +225,18 @@ imageset_status_lines() { # Verzeichnis liefert genauso nichts aus wie ein fehlendes. _n=$(migrate_images | wc -l | tr -d ' ') if [ "$_n" -gt 0 ]; then - _state="$_n Image(s)" + _state="$_n image(s)" else - _state="FEHLT - es wird nichts ausgeliefert" + _state="MISSING - nothing will be served" fi - printf 'Bildsatz: %s\n' "$(imageset_label "$_set")" + printf 'Image set: %s\n' "$(imageset_label "$_set")" printf ' %s (%s)\n' "$_dir" "$_state" # Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die # Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst # glaubt er, sein Eintrag von Hand haette gewirkt. _raw=$(imageset_raw) if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then - printf " (unbekannte Einstellung '%s' in %s - Vorgabe gilt)\n" \ + printf " (unknown setting '%s' in %s - default applies)\n" \ "$_raw" "$IMAGESET_FILE" fi } @@ -276,7 +276,7 @@ ap_switch() { # gehoert zum Wert). _active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-) if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then - echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2 + echo "Error: after switching off, wlan0 still shows '$_active' active - check NetworkManager (access point stays up)" >&2 return 1 fi return 0 @@ -287,7 +287,7 @@ ap_switch() { if [ "$_active" != "$_target" ]; then sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true - echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2 + echo "Error: after switching to '$_target', wlan0 unexpectedly shows '$_active' - both station profiles brought down again" >&2 return 1 fi } @@ -326,16 +326,16 @@ write_control() { autooff_check_minutes() { case "${1:-}" in '' ) - echo "Fehler: '--for' braucht eine Angabe in Minuten ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2 + echo "Error: '--for' needs a value in minutes ($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 + echo "Error: '$1' is not a number of minutes - allowed are whole numbers from $AUTOOFF_MIN_MINUTES to $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 + echo "Error: $1 minutes is outside $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2 return 1 fi } @@ -424,11 +424,11 @@ autooff_arm() { 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 + echo "Error: could not create the timer (systemd-run '$AUTOOFF_UNIT') - the station stays ON, do not forget 'ninlight-station off'." >&2 return 1 fi autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || { - echo "Fehler: $AUTOOFF_FILE konnte nicht geschrieben werden - Zeitschalter wieder abgeraeumt" >&2 + echo "Error: could not write $AUTOOFF_FILE - timer removed again" >&2 autooff_clear return 1 } @@ -455,7 +455,7 @@ autooff_busy() { # "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 + echo "Warning: 'ss' not found - could not check for downloads in progress" >&2 return 1 fi _conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true) @@ -485,10 +485,10 @@ autooff_fire() { 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" + echo "Timer deferred ($_deferrals/$AUTOOFF_MAX_DEFERRALS): a download is still running" 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 + echo "Warning: reached $AUTOOFF_MAX_DEFERRALS deferrals - a connection is still open and has probably stalled. Switching the station off anyway." >&2 fi full_off } @@ -529,7 +529,7 @@ start_and_verify() { # Lebendigkeitspruefung unten je erreicht wird, und der eben # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen. sudo nmcli con down "$_ap" >/dev/null 2>&1 || true - echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 + echo "Error: could not (re)start ninlight-station - access point switched off again. Details: journalctl -u ninlight-station" >&2 exit 1 fi # "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess @@ -542,14 +542,14 @@ start_and_verify() { # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist. sudo systemctl stop ninlight-station 2>/dev/null || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true - echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 + echo "Error: ninlight-station is not active after starting - access point switched off again. Details: journalctl -u ninlight-station" >&2 exit 1 fi _actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true) if [ "$_actual_mode" != "$_expect_mode" ]; then sudo systemctl stop ninlight-station 2>/dev/null || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true - echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2 + echo "Error: after the restart $RUN_DIR/mode holds '$_actual_mode' instead of '$_expect_mode' - the RuntimeDirectory was probably wiped (check RuntimeDirectoryPreserve=restart in the unit). Access point switched off again." >&2 exit 1 fi if [ -n "$_expect_phase" ]; then @@ -568,7 +568,7 @@ PY if [ "$_actual_phase" != "$_expect_phase" ]; then sudo systemctl stop ninlight-station 2>/dev/null || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true - echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2 + echo "Error: after the restart $RUN_DIR/control.json holds phase '$_actual_phase' instead of '$_expect_phase' - the RuntimeDirectory was probably wiped. Access point switched off again." >&2 exit 1 fi fi @@ -606,26 +606,26 @@ parse_for_option() { 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)" + echo "Timer: auto-off in $_for_minutes min ('ninlight-station renew' extends it, 'hold' cancels it)" } 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 + [ "$n" -gt 0 ] || echo "Warning: $IMAGE_DIR holds no image - the station answers everything with 404" >&2 ap_switch "$AP_CONNECTION" write_mode "recovery" start_and_verify "$AP_CONNECTION" "recovery" - echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)" + echo "Station on: SSID $SSID at 192.168.8.1, $n image(s)" # Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in # Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist # die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder # ausschalten) steht unten. if [ "$PSK" = "ninlight-recovery" ]; then - echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2 + echo "Warning: PSK is still the public default 'ninlight-recovery' - set your own password in /etc/ninlight-station.conf." >&2 fi - echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." + echo "Do not forget: 'ninlight-station off' when you are done." autooff_arm_if_requested } @@ -658,23 +658,23 @@ migrate_on() { _set=$(imageset_get) _setdir=$(imageset_dir "$_set") n=$(migrate_images | wc -l | tr -d ' ') - [ "$n" -gt 0 ] || echo "Warnung: $_setdir enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 + [ "$n" -gt 0 ] || echo "Warning: $_setdir holds no migration images - the station answers everything with 404" >&2 # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. ap_switch "$MIGRATE_AP_CONNECTION" write_mode "migration" write_control "$_phase" "" || { - echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 + echo "Error: could not write $RUN_DIR/control.json" >&2 exit 1 } start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase" - echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)" + echo "Migration on: SSID $MIGRATE_SSID at 192.168.8.1, phase $_phase, $n migration image(s)" # Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte # Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche # stehen. - echo "Bildsatz: $(imageset_label "$_set")" - 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." + echo "Image set: $(imageset_label "$_set")" + echo "Caution: the password of this access point is publicly known from the old firmware - see README.md." >&2 + echo "Do not forget: 'ninlight-station off' when you are done." autooff_arm_if_requested } @@ -722,7 +722,7 @@ display_run() { display_service_resume() { [ "${_was_active:-0}" -eq 1 ] || return 0 sudo systemctl start ninlight-display 2>/dev/null \ - || echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2 + || echo "Warning: could not start the display service again - check 'systemctl status ninlight-display'" >&2 } display_handover() { @@ -789,7 +789,7 @@ display_off() { sudo systemctl stop ninlight-display 2>/dev/null || true # Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und # ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten. - display_run --clear || echo "Warnung: Panel konnte nicht geleert werden - Anzeige pruefen" >&2 + display_run --clear || echo "Warning: could not clear the panel - check the display" >&2 } # --- Abschlussbildschirm beim Herunterfahren und beim Neustart ---------------------- @@ -820,13 +820,13 @@ display_final() { # Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der # Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche # Zeile im Journal als ein haengendes Herunterfahren. - echo "Warnung: Anzeigedienst laeuft nach ${DISPLAY_RELEASE_WAIT_S}s noch - kein Abschlussbild (GPIO belegt)" >&2 + echo "Warning: display service still running after ${DISPLAY_RELEASE_WAIT_S}s - no final image (GPIO busy)" >&2 return 0 fi sleep 1 _waited=$((_waited + 1)) done - display_run --final "$1" || echo "Warnung: Abschlussbild konnte nicht gezeichnet werden - Anzeige pruefen" >&2 + display_run --final "$1" || echo "Warning: could not draw the final image - check the display" >&2 return 0 } @@ -846,18 +846,18 @@ full_off() { ap_switch "" if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then - echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)" + echo "Station off, wlan0 back in the home Wi-Fi ($HOME_CONNECTION)" else - echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2 + echo "Station off. Switching back to '$HOME_CONNECTION' failed - check the wlan0 status." >&2 fi else - echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2 + echo "Station off. Profile '$HOME_CONNECTION' missing, wlan0 stays unused - access over the cable is unaffected." >&2 fi } listen_cmd() { write_control "listen" "" || { - echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 + echo "Error: could not write $RUN_DIR/control.json" >&2 exit 1 } echo "Phase: listen" @@ -882,7 +882,7 @@ if not t: print(t) PY ); then - echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm " >&2 + echo "Error: no recently detected model (last_seen) in $_status_file - name a target: ninlight-station arm " >&2 exit 1 fi fi @@ -896,14 +896,14 @@ target = sys.argv[2] sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1) PY then - echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2 + echo "Error: unknown target '$_target' - valid targets are listed in products.py" >&2 exit 1 fi write_control "armed" "$_target" || { - echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 + echo "Error: could not write $RUN_DIR/control.json" >&2 exit 1 } - echo "Freigegeben: $_target" + echo "Armed: $_target" } # "renew []" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die @@ -915,7 +915,7 @@ PY 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 '." >&2 + echo "Error: no timer active - 'renew' only resets a running one. A new one comes from 'ninlight-station on --for '." >&2 exit 1 fi # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. @@ -927,17 +927,17 @@ renew_cmd() { _minutes=$_arg fi autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1 - echo "Zeitschalter zurueckgestellt: Auto-Aus in $_minutes min" + echo "Timer reset: auto-off 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 + echo "Error: no timer active - there is nothing to cancel." >&2 exit 1 fi autooff_clear - echo "Zeitschalter abbestellt - die Station bleibt an, bis 'ninlight-station off'." + echo "Timer cancelled - the station stays on until 'ninlight-station off'." } # Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der @@ -949,9 +949,9 @@ autooff_status_line() { 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" + printf 'Auto-off: in %s min (requested: %s min)\n' "$_rest" "$1" else - printf 'Auto-Aus: steht an (angefordert: %s min)\n' "$1" + printf 'Auto-off: pending (requested: %s min)\n' "$1" fi } @@ -994,8 +994,8 @@ PY _modus=$(printf '%s\n' "$_info" | sed -n '1p') _erkannt=$(printf '%s\n' "$_info" | sed -n '2p') elif [ -e "$_status_file" ]; then - _modus="unbekannt (Datei nicht lesbar)" - _erkannt="unbekannt (Datei nicht lesbar)" + _modus="unknown (file not readable)" + _erkannt="unknown (file not readable)" else _modus="-" _erkannt="-" @@ -1044,15 +1044,15 @@ PY else _phase="-" fi - printf 'Modus : %s\n' "$_modus" - printf 'Phase : %s\n' "$_phase" + printf 'Mode : %s\n' "$_modus" + printf 'Phase : %s\n' "$_phase" imageset_status_lines - printf 'Erkannt: %s\n' "$_erkannt" - printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" + printf 'Detected: %s\n' "$_erkannt" + printf 'Service : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" autooff_status_line - printf 'wlan0 : %s\n' "$(nmcli -t --escape no -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 :" + printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)" + printf 'Address : %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')" + echo "Images :" images | while read -r f; do printf ' %-34s %s\n' "$(basename "$f")" \ "$(python3 - "$f" <<'PY' @@ -1063,10 +1063,10 @@ PY )" done if [ -r "$_status_file" ]; then - echo "Zuletzt ausgeliefert:" + echo "Last served:" sed 's/^/ /' "$_status_file" elif [ -e "$_status_file" ]; then - echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2 + echo "Error: $_status_file exists but is not readable (check permissions)" >&2 fi # "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie # ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier @@ -1082,7 +1082,7 @@ display_cmd() { final) case "${2:-}" in poweroff|reboot) display_final "$2" ;; - *) echo "Fehler: 'display final' braucht 'poweroff' oder 'reboot'" >&2; exit 2 ;; + *) echo "Error: 'display final' needs 'poweroff' or 'reboot'" >&2; exit 2 ;; esac ;; *) usage; exit 2 ;; @@ -1090,48 +1090,48 @@ display_cmd() { } usage() { - echo "Aufruf: ninlight-station on [--for ]|recovery {on [--for ]|off}|migrate {on [--listen] [--for ]|off}|arm []|listen|bildsatz [ohne-wlan|mit-wlan]|renew []|hold|display {test|clear|off}|off|status" >&2 + echo "Usage: ninlight-station on [--for ]|recovery {on [--for ]|off}|migrate {on [--listen] [--for ]|off}|arm []|listen|bildsatz [ohne-wlan|mit-wlan]|renew []|hold|display {test|clear|off}|off|status" >&2 echo "" >&2 - echo " bildsatz Umschalter: welcher Satz Migrationsimages ausgeliefert wird." >&2 - echo " Ohne Argument nur berichten. 'ohne-wlan' (Vorgabe) liefert die" >&2 - echo " Abzuege aus tools/build_nocreds.sh - die migrierte Lampe startet" >&2 - echo " im eigenen Onboarding-Zugangspunkt. 'mit-wlan' liefert die" >&2 - echo " Abzuege aus build/ - die Lampe haengt sofort im WLAN aus" >&2 - echo " config.local.cmake, auch wenn es eine fremde Lampe ist, die im" >&2 - echo " Automatikmodus mitgenommen wurde. Der Zustand bleibt, bis ihn" >&2 - echo " jemand aendert, und steht dauerhaft auf dem Panel." >&2 - echo " --for Zeitschalter: die Station schaltet sich nach 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 [] 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 - echo " verzerrt oder verschoben, ist EPD_REVISION in" >&2 - echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2 - echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2 - echo " stromlos machen und erneut 'display test'." >&2 - echo " Ein laufender Anzeigedienst wird dafuer kurz gestoppt und" >&2 - echo " danach wieder gestartet (er haelt sonst die GPIO-Leitungen)." >&2 - echo " display clear Panel leeren (ebenfalls mit kurzer Unterbrechung des Dienstes)." >&2 + echo " bildsatz Switch: which set of migration images is served." >&2 + echo " Without an argument it only reports. 'ohne-wlan' (default) serves" >&2 + echo " the builds from tools/build_nocreds.sh - the migrated lamp comes" >&2 + echo " up in its own onboarding access point. 'mit-wlan' serves the" >&2 + echo " builds from build/ - the lamp joins the Wi-Fi from" >&2 + echo " config.local.cmake right away, including a lamp that is not" >&2 + echo " yours and got picked up in automatic mode. The setting sticks" >&2 + echo " until someone changes it, and is shown on the panel." >&2 + echo " --for Timer: the station switches itself off after (back to" >&2 + echo " the home Wi-Fi). OPTIONAL - without '--for' it stays on until" >&2 + echo " someone says 'off'. Useful when you are logged in over Wi-Fi:" >&2 + echo " 'on' turns wlan0 into an access point and drops your SSH" >&2 + echo " session. Allowed: 1-240." >&2 + echo " renew [] Reset the timer (without an argument to the duration last" >&2 + echo " requested). If none is running, that is an error." >&2 + echo " hold Cancel the timer, station stays on." >&2 + echo " display test Draw a test image on the panel. This is the first step of the" >&2 + echo " hardware acceptance and the ONLY way to determine the panel" >&2 + echo " revision: it is printed nowhere on the device. If the image" >&2 + echo " stays blank, distorted or shifted, EPD_REVISION in" >&2 + echo " /etc/ninlight-station.conf is wrong - switch it to the other" >&2 + echo " revision (V2 instead of V3 or the other way round), power the" >&2 + echo " panel down briefly and run 'display test' again." >&2 + echo " A running display service is stopped for this and started" >&2 + echo " again afterwards (it holds the GPIO lines otherwise)." >&2 + echo " display clear Clear the panel (also with a short interruption of the service)." >&2 echo " display final {poweroff|reboot}" >&2 - echo " Abschlussbild aufs Panel. Kein Handgriff fuer Menschen: das" >&2 - echo " rufen ninlight-poweroff.service/ninlight-reboot.service beim" >&2 - echo " Herunterfahren bzw. Neustart auf, nachdem der Anzeigedienst" >&2 - echo " die GPIO-Leitungen freigegeben hat. E-Paper behaelt sein Bild" >&2 - echo " dauerhaft - ohne diesen Schritt bliebe die letzte Rueckfrage" >&2 - echo " auf dem Panel stehen. Die Texte unterscheiden sich: beim" >&2 - echo " Neustart darf man den Stecker gerade NICHT ziehen." >&2 - echo " display off Anzeigedienst dauerhaft stoppen und das Panel leeren. 'on'/'off'" >&2 - echo " der Station brauchen das nicht - der Anzeigedienst laeuft" >&2 - echo " unabhaengig davon durch und zeichnet den Zustand selbst." >&2 + echo " Final image on the panel. Not a step for humans: this is what" >&2 + echo " ninlight-poweroff.service/ninlight-reboot.service call during" >&2 + echo " shutdown or reboot, once the display service has released the" >&2 + echo " GPIO lines. E-paper keeps its image forever - without this step" >&2 + echo " the last confirmation prompt would stay on the panel. The two" >&2 + echo " texts differ: on a reboot you must NOT unplug." >&2 + echo " display off Stop the display service for good and clear the panel. The" >&2 + echo " station's 'on'/'off' do not need this - the display service" >&2 + echo " keeps running and draws the state by itself." >&2 echo "" >&2 - echo " Taster (GPIO23): kurzer Druck oeffnet am Geraet ein Menue ueber genau diese" >&2 - echo " Unterbefehle, 3 s gehalten brechen eine laufende Migration/Rettung ab." >&2 + echo " Button (GPIO23): a short press opens a menu on the device covering exactly" >&2 + echo " these subcommands, holding 3 s aborts a running migration or" >&2 + echo " recovery." >&2 } _cmd=${1:-} diff --git a/tools/station/tests/test_button.py b/tools/station/tests/test_button.py index 98949fe..13278ac 100644 --- a/tools/station/tests/test_button.py +++ b/tools/station/tests/test_button.py @@ -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 diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index 437f14e..13db2d3 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -364,7 +364,7 @@ set -e grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log" grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)" -echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" +echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" echo echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ===" @@ -378,7 +378,7 @@ set -e grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log" grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch" -echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" +echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" echo echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ===" @@ -427,7 +427,7 @@ RC=$? set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR" -echo "$ERR" | grep -q "Warnung" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR" +echo "$ERR" | grep -q "Warning" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR" echo echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ===" @@ -469,7 +469,7 @@ RC=$? set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" -echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" +echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")" echo @@ -482,7 +482,7 @@ RC=$? set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" -echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" +echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt" echo @@ -513,7 +513,7 @@ ACTIVE=$(cat "$SBX/nmstate/active") echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv" echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv" echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE" -echo "$OUT" | grep -q "Heim-WLAN" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT" +echo "$OUT" | grep -q "home Wi-Fi" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT" echo echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" @@ -531,9 +531,9 @@ OUT=$(run_cli "$SBX" status 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" -echo "$OUT" | grep -q "Modus.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT" +echo "$OUT" | grep -q "Mode.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT" echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT" -echo "$OUT" | grep -q "Erkannt.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT" +echo "$OUT" | grep -q "Detected.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT" echo echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ===" @@ -547,7 +547,7 @@ RC=$? set -e chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt [ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT" -echo "$OUT" | grep -q "existiert, ist aber nicht lesbar" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT" +echo "$OUT" | grep -q "not readable" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT" echo echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ===" @@ -558,7 +558,7 @@ OUT=$(run_cli "$SBX" frobnicate 2>&1) RC=$? set -e [ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2" -echo "$OUT" | grep -qi "Aufruf" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT" +echo "$OUT" | grep -qi "usage" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT" echo echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ===" @@ -597,7 +597,7 @@ set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT" echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR" -echo "$OUT" | grep -q "Migration an" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'" +echo "$OUT" | grep -q "Migration on" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'" ACTIVE_AFTER=$(cat "$SBX/nmstate/active") [ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER" # Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei - @@ -646,8 +646,8 @@ RC=$? set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT" -echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung" -echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR" +echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung" +echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR" ACTIVE_AFTER=$(cat "$SBX/nmstate/active") [ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER" @@ -679,7 +679,7 @@ ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] \ && pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \ || fail "Exit-Code war 0, obwohl 'ninlight:recovery' weiterhin aktiv war: $OUT" -echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" \ +echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" \ || pass "keine falsche 'Station aus'-Meldung" echo "$ERR" | grep -q "ninlight:recovery" \ && pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \ @@ -695,7 +695,7 @@ RC=$? set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT" -echo "$ERR" | grep -q "unerwartet" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR" +echo "$ERR" | grep -q "unexpectedly" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR" ACTIVE_AFTER=$(cat "$SBX/nmstate/active") [ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER" @@ -714,7 +714,7 @@ cat >"$SBX/run/status.json" <<'JSON' {"mode": "recovery"} JSON OUT=$(run_cli "$SBX" status 2>&1) -echo "$OUT" | grep -qE "Modus[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT" +echo "$OUT" | grep -qE "Mode[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT" echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus" echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT" @@ -728,7 +728,7 @@ run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 run_cli "$SBX" on >/dev/null 2>&1 rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie OUT=$(run_cli "$SBX" status 2>&1) -echo "$OUT" | grep -qE "Modus[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT" +echo "$OUT" | grep -qE "Mode[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT" echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus" echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT" @@ -1024,9 +1024,9 @@ 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" +echo "$OUT" | grep -qi "Auto-off" && 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 "$OUT" | grep -qi "Auto-off" && 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 ===" @@ -1046,7 +1046,7 @@ grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "Timer feuert nach 30 min" 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 "$OUT" | grep -qE "Auto-off: 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 ===" @@ -1070,7 +1070,7 @@ for BAD in "0" "241" "abc" "-5" "1.5" ""; do set -e ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "'--for ${BAD:-}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-}' wurde angenommen: $OUT" - echo "$ERR" | grep -qi "fehler" && pass "'--for ${BAD:-}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-}': keine Fehlermeldung: $ERR" + echo "$ERR" | grep -qi "error" && pass "'--for ${BAD:-}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-}': keine Fehlermeldung: $ERR" grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-}': kein Zugangspunkt hochgefahren" grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-}' hat den Dienst gestartet" || pass "'--for ${BAD:-}': Dienst nicht gestartet" [ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")" @@ -1115,7 +1115,7 @@ grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "die Station w 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" +echo "$OUT" | grep -qi "deferred" && 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" @@ -1149,7 +1149,7 @@ 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" +echo "$ERR" | grep -qi "warning" && 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). @@ -1195,7 +1195,7 @@ for CMD in renew hold; do 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" + echo "$ERR" | grep -qi "no timer active" && 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 @@ -1215,7 +1215,7 @@ set -e [ "$(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" +echo "$STATUS" | grep -qE "Auto-off: 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 @@ -1262,7 +1262,7 @@ 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" +echo "$ERR" | grep -qi "error" && 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" @@ -1294,7 +1294,7 @@ set -e MODE=$(file_mode "$SBX/var/imageset") [ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644" [ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da" -echo "$OUT" | grep -qi "achtung" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT" +echo "$OUT" | grep -qi "caution" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT" # ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn. OUT2=$(run_cli "$SBX" bildsatz 2>&1) echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2" @@ -1313,7 +1313,7 @@ RC=$? set -e [ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz" [ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")" -echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR" +echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR" echo echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ===" @@ -1326,13 +1326,13 @@ echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fa echo "$OUT" | grep -q "migrate-nocreds" && pass "und das Verzeichnis des sicheren Satzes" || fail "falsches Verzeichnis: $OUT" # Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die # Station fuer umgeschaltet. -echo "$OUT" | grep -q "unbekannte Einstellung 'voellig-anderes'" \ +echo "$OUT" | grep -q "unknown setting 'voellig-anderes'" \ && pass "die unbekannte Einstellung wird beim Namen genannt" \ || fail "die unbekannte Einstellung wird verschwiegen: $OUT" # Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen. run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 OUT=$(run_cli "$SBX" bildsatz 2>&1) -echo "$OUT" | grep -q "unbekannte Einstellung" \ +echo "$OUT" | grep -q "unknown setting" \ && fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \ || pass "kein Fehlalarm bei gueltigem Eintrag" @@ -1346,9 +1346,9 @@ ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null) RC=$? set -e [ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC" -echo "$ERR" | grep -qi "warnung" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR" +echo "$ERR" | grep -qi "warning" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR" OUT=$(run_cli "$SBX" status 2>&1) -echo "$OUT" | grep -q "FEHLT" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT" +echo "$OUT" | grep -q "MISSING" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT" echo echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ===" @@ -1356,7 +1356,7 @@ SBX="$TMPROOT/bs6" setup_sandbox "$SBX" rm -f "$SBX/images/migrate-nocreds"/*.img OUT=$(run_cli "$SBX" status 2>&1) -echo "$OUT" | grep -q "FEHLT" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT" +echo "$OUT" | grep -q "MISSING" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT" echo echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ===" @@ -1369,7 +1369,7 @@ OUT=$(run_cli "$SBX" migrate on --listen 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" -echo "$OUT" | grep -q "Bildsatz: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT" +echo "$OUT" | grep -q "Image set: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT" echo echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ===" @@ -1379,12 +1379,12 @@ rm -f "$SBX/images/migrate-wlan"/*.img # nur der andere Satz ist leer set +e ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) set -e -echo "$ERR" | grep -q "keine Migrationsimages" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz" +echo "$ERR" | grep -q "no migration images" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz" run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 set +e ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) set -e -echo "$ERR" | grep -q "keine Migrationsimages" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR" +echo "$ERR" | grep -q "no migration images" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR" echo echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ===" @@ -1466,7 +1466,7 @@ set -e unset FAKE_DISPLAY_ACTIVE_FOR [ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR" grep -q "STUB display.py" "$SBX/cmd.log" && fail "es wurde auf belegte Leitungen gezeichnet - 'GPIO busy'" || pass "nicht gezeichnet, solange der Dienst haelt" -echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR" +echo "$ERR" | grep -qi "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR" # Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in # der Abschalt-Transaktion. grep -q "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'systemctl stop' mitten im Herunterfahren" || pass "kein 'systemctl stop' aus dem Abschaltschritt heraus" @@ -1482,7 +1482,7 @@ RC=$? set -e unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - ein Anzeigefehler wuerde das Herunterfahren rot machen: $ERR" -echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR" +echo "$ERR" | grep -qi "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR" echo if [ "$FAILED" -eq 0 ]; then diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 2fad452..b1aa632 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -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: " als Phase im Kopfbalken. Neben dem Modus bleiben +# Frueher stand "ARMED: " 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: