merge: Panel und CLI der Station auf Englisch, IP-Zeile im Off-Bild

* station-english:
  feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
This commit is contained in:
Michael Sauer
2026-09-20 02:33:49 +02:00
7 changed files with 373 additions and 274 deletions
+35 -35
View File
@@ -139,7 +139,7 @@ class Action:
diesem Moment gar nicht vor Augen hat.""" diesem Moment gar nicht vor Augen hat."""
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True, 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: confirm_details=None) -> None:
self.label = label self.label = label
self.argv = tuple(argv) self.argv = tuple(argv)
@@ -154,16 +154,16 @@ class Action:
def action_shutdown() -> Action: def action_shutdown() -> Action:
return Action("Herunterfahren", kind="shutdown", confirm=True, return Action("Shut down", kind="shutdown", confirm=True,
done_text="Faehrt herunter") done_text="Shutting down")
def action_migrate_listen() -> Action: def action_migrate_listen() -> Action:
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus, """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 bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
vor dem Automatikmodus und traegt die uebliche Rueckfrage.""" vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
return Action("Migration: horchen", ("migrate", "on", "--listen"), return Action("Migration: listen", ("migrate", "on", "--listen"),
done_text="Migration: horcht") done_text="Migration: listening")
def action_migrate_auto() -> Action: 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 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 Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
Rueckfrage selbst.""" Rueckfrage selbst."""
return Action("Migration: AUTOMATIK", ("migrate", "on"), return Action("Migration: AUTOMATIC", ("migrate", "on"),
done_text="Migration: automatisch", done_text="Migration: automatic",
confirm_title="ACHTUNG", confirm_title="WARNING",
confirm_details=["Flasht JEDE alte Klyqa-Lampe", confirm_details=["Flashes EVERY old Klyqa lamp",
"in Reichweite - auch fremde"]) "in range - other people's too"])
def action_imageset(current: str) -> Action: def action_imageset(current: str) -> Action:
@@ -196,19 +196,19 @@ def action_imageset(current: str) -> Action:
Lampe, die sie erwischt.""" Lampe, die sie erwischt."""
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan" target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
if target == "mit-wlan": if target == "mit-wlan":
return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"), return Action("Switch to: with Wi-Fi", ("bildsatz", "mit-wlan"),
done_text="Bildsatz: mit WLAN", done_text="Image set: with Wi-Fi",
confirm_title="ACHTUNG", confirm_title="WARNING",
confirm_details=["Jede migrierte Lampe bekommt", confirm_details=["Every migrated lamp receives",
"dein WLAN-Passwort"]) "your Wi-Fi password"])
return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"), return Action("Switch to: no Wi-Fi", ("bildsatz", "ohne-wlan"),
done_text="Bildsatz: ohne WLAN") done_text="Image set: no Wi-Fi")
def action_cancel() -> Action: def action_cancel() -> Action:
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die """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.""" 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]: def menu_actions(state: dict) -> list[Action]:
@@ -221,7 +221,7 @@ def menu_actions(state: dict) -> list[Action]:
if not running: if not running:
return [ 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 # Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo # naheliegende und der harmlose ist - wer nur blaettert und irgendwo
# loslaesst, landet nicht im Automatikmodus. # loslaesst, landet nicht im Automatikmodus.
@@ -237,13 +237,13 @@ def menu_actions(state: dict) -> list[Action]:
if mode == "migration" and target: if mode == "migration" and target:
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es # Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen. # nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)), items.append(Action("Arm: {}".format(target), ("arm", str(target)),
done_text="Freigegeben: {}".format(target))) done_text="Armed: {}".format(target)))
items.append(Action("Station aus", ("off",), done_text="Station aus")) items.append(Action("Station off", ("off",), done_text="Station off"))
items.append(action_imageset(imageset)) items.append(action_imageset(imageset))
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage. # Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False, items.append(Action("Refresh display", ("display", "test"), confirm=False,
done_text="Anzeige aufgefrischt")) done_text="Display refreshed"))
items.append(action_shutdown()) items.append(action_shutdown())
return items return items
@@ -561,32 +561,32 @@ class ButtonUI:
and self.screen in ("idle", "busy")): and self.screen in ("idle", "busy")):
held = now - self._press_t held = now - self._press_t
if self._hold_stage == 1: if self._hold_stage == 1:
return _view("hold", "STATION AUS", return _view("hold", "STATION OFF",
"Herunterfahren in {} s".format( "Shutting down in {} s".format(
_countdown(SHUTDOWN_HOLD_S, held)), _countdown(SHUTDOWN_HOLD_S, held)),
["Weiter halten - Loslassen bricht ab"]) ["Keep holding - release to cancel"])
if held >= SHORT_PRESS_MAX_S: if held >= SHORT_PRESS_MAX_S:
return _view("hold", "HALTEN", return _view("hold", "HOLD",
"Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)), "Cancel in {} s".format(_countdown(CANCEL_HOLD_S, held)),
["Weiter halten - Loslassen bricht ab"]) ["Keep holding - release to cancel"])
if self.screen == "menu" and self.items: if self.screen == "menu" and self.items:
selected = self.items[self.index] selected = self.items[self.index]
rest = self.items[self.index + 1:] + self.items[:self.index] rest = self.items[self.index + 1:] + self.items[:self.index]
return _view("menu", "MENUE", "> " + selected.label, return _view("menu", "MENU", "> " + selected.label,
[a.label for a in rest] + ["kurz: weiter, halten: waehlen"]) [a.label for a in rest] + ["tap: next, hold: select"])
if self.screen == "confirm" and self.pending is not None: if self.screen == "confirm" and self.pending is not None:
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei # Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des # der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird. # Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
details = list(self.pending.confirm_details or []) 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, return _view("confirm", self.pending.confirm_title, self.pending.label,
details) details)
if self.screen == "busy" and self.busy is not None: 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: if self.result is not None:
text, ok = self.result 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} return {"view": "idle", "active": False}
+36 -1
View File
@@ -237,6 +237,35 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool
return name, False 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, def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME, service_name: str = SERVICE_NAME,
imageset_path: Path = IMAGESET_FILE) -> dict: 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) imageset, imageset_ok = _read_imageset(imageset_path, conf)
if not _service_active(service_name): 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) mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json") status = _read_json(run_dir / "status.json")
+28 -18
View File
@@ -172,7 +172,7 @@ FONT_SEARCH_PATHS = (
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"), ("/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 # "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
# Zielnamen nach sich), deshalb hier nicht gelistet. # Zielnamen nach sich), deshalb hier nicht gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"} 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 # 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 # verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite. # Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
ARMED_LABEL = "FREIGEGEBEN" ARMED_LABEL = "ARMED"
ARMED_DETAIL_PREFIX = "Freigabe: " ARMED_DETAIL_PREFIX = "Armed: "
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert # 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, # 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 # 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. # test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
IMAGESET_LABELS = { IMAGESET_LABELS = {
"ohne-wlan": "ohne WLAN-Daten (Onboarding-AP)", "ohne-wlan": "no Wi-Fi data (onboarding AP)",
"mit-wlan": "MIT WLAN-Daten (eigenes Netz)", "mit-wlan": "WITH Wi-Fi data (own network)",
} }
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie # 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 # 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 # 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. # Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
IMAGESET_MISSING_LABELS = { IMAGESET_MISSING_LABELS = {
"ohne-wlan": "SATZ FEHLT: ohne WLAN-Daten", "ohne-wlan": "SET MISSING: no Wi-Fi data",
"mit-wlan": "SATZ FEHLT: MIT WLAN-Daten", "mit-wlan": "SET MISSING: WITH Wi-Fi data",
} }
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt # 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 # Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an # ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font. # test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
"poweroff": ("AUSGESCHALTET", "Strom trennen erlaubt", []), "poweroff": ("POWERED OFF", "Safe to unplug", []),
"reboot": ("NEUSTART", "Strom NICHT trennen!", "reboot": ("REBOOTING", "Do NOT unplug!",
["Der Pi kommt in rund 2 min wieder.", ["The Pi is back in about 2 min.",
"Solange bleibt dieses Bild stehen."]), "This screen stays until then."]),
} }
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant): # 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: if label is None:
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet" # Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
# zu sagen, wenn gleich wieder gebootet wird. # zu sagen, wenn gleich wieder gebootet wird.
return [("Faehrt herunter", ROLE_BIG)] return [("Shutting down", ROLE_BIG)]
title, hero, details = label title, hero, details = label
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)] return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
+ [(d, ROLE_DETAIL) for d in details]) + [(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 # diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt. # 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 return out + footer
if mode not in ("recovery", "migration"): if mode not in ("recovery", "migration"):
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
# unterstellen. # 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 return out + footer
out.append((MODE_LABELS[mode], ROLE_HEADER)) 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") autooff_minutes = state.get("autooff_minutes")
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool): if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
if autooff_minutes > 0: 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: else:
out.append(("Auto-Aus steht an", ROLE_DETAIL)) out.append(("Auto-off pending", ROLE_DETAIL))
served = state.get("served") 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") recent = state.get("recent")
if isinstance(recent, list) and 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 # Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als # (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
# ein leeres Panel zu zeigen, das nach "haengt" aussieht. # ein leeres Panel zu zeigen, das nach "haengt" aussieht.
out.append(("Bedienung", ROLE_BIG)) out.append(("Button menu", ROLE_BIG))
return out return out
+110 -110
View File
@@ -107,14 +107,14 @@ elif [ -e "$CONF" ]; then
# Rechte aufzuweichen. # Rechte aufzuweichen.
eval "$(sudo cat "$CONF")" eval "$(sudo cat "$CONF")"
else 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 exit 1
fi fi
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
eval "test -n \"\${$_key-}\"" || { 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 exit 1
} }
done done
@@ -125,7 +125,7 @@ need_profile() {
# -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit # -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
# "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen. # "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
nmcli -t --escape no -f NAME con show | grep -Fqx "$1" || { 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 exit 1
} }
} }
@@ -163,8 +163,8 @@ imageset_dir() {
imageset_label() { imageset_label() {
case "$1" in case "$1" in
mit-wlan) printf 'mit-wlan (Lampe haengt sofort im eigenen WLAN)' ;; mit-wlan) printf 'mit-wlan (lamp joins your Wi-Fi right away)' ;;
*) printf 'ohne-wlan (Lampe startet im Onboarding-Zugangspunkt)' ;; *) printf 'ohne-wlan (lamp starts its onboarding access point)' ;;
esac esac
} }
@@ -194,25 +194,25 @@ bildsatz_cmd() {
case "$_want" in case "$_want" in
ohne-wlan|mit-wlan) ;; 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 exit 2
;; ;;
esac esac
imageset_set "$_want" || { imageset_set "$_want" || {
echo "Fehler: $IMAGESET_FILE konnte nicht geschrieben werden" >&2 echo "Error: could not write $IMAGESET_FILE" >&2
exit 1 exit 1
} }
echo "Bildsatz: $(imageset_label "$_want")" echo "Image set: $(imageset_label "$_want")"
[ "$_now" = "$_want" ] && echo " (war schon eingestellt)" [ "$_now" = "$_want" ] && echo " (was already set)"
_dir=$(imageset_dir "$_want") _dir=$(imageset_dir "$_want")
if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then 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 # 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. # 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 fi
if [ "$_want" = "mit-wlan" ]; then 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 fi
} }
@@ -225,18 +225,18 @@ imageset_status_lines() {
# Verzeichnis liefert genauso nichts aus wie ein fehlendes. # Verzeichnis liefert genauso nichts aus wie ein fehlendes.
_n=$(migrate_images | wc -l | tr -d ' ') _n=$(migrate_images | wc -l | tr -d ' ')
if [ "$_n" -gt 0 ]; then if [ "$_n" -gt 0 ]; then
_state="$_n Image(s)" _state="$_n image(s)"
else else
_state="FEHLT - es wird nichts ausgeliefert" _state="MISSING - nothing will be served"
fi fi
printf 'Bildsatz: %s\n' "$(imageset_label "$_set")" printf 'Image set: %s\n' "$(imageset_label "$_set")"
printf ' %s (%s)\n' "$_dir" "$_state" printf ' %s (%s)\n' "$_dir" "$_state"
# Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die # 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 # Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst
# glaubt er, sein Eintrag von Hand haette gewirkt. # glaubt er, sein Eintrag von Hand haette gewirkt.
_raw=$(imageset_raw) _raw=$(imageset_raw)
if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then 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" "$_raw" "$IMAGESET_FILE"
fi fi
} }
@@ -276,7 +276,7 @@ ap_switch() {
# gehoert zum Wert). # gehoert zum Wert).
_active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-) _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 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 return 1
fi fi
return 0 return 0
@@ -287,7 +287,7 @@ ap_switch() {
if [ "$_active" != "$_target" ]; then if [ "$_active" != "$_target" ]; then
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
sudo nmcli con down "$MIGRATE_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 return 1
fi fi
} }
@@ -326,16 +326,16 @@ write_control() {
autooff_check_minutes() { autooff_check_minutes() {
case "${1:-}" in 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 return 1
;; ;;
*[!0-9]* ) *[!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 return 1
;; ;;
esac esac
if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then 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 return 1
fi fi
} }
@@ -424,11 +424,11 @@ autooff_arm() {
set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB" set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB"
fi fi
if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then 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 return 1
fi fi
autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || { 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 autooff_clear
return 1 return 1
} }
@@ -455,7 +455,7 @@ autooff_busy() {
# "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur # "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur
# Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der # Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der
# Nutzer sonst nicht mehr an den Pi kommt. # 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 return 1
fi fi
_conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true) _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 if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then
_deferrals=$((_deferrals + 1)) _deferrals=$((_deferrals + 1))
autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals" 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 exit 0
fi 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 fi
full_off full_off
} }
@@ -529,7 +529,7 @@ start_and_verify() {
# Lebendigkeitspruefung unten je erreicht wird, und der eben # Lebendigkeitspruefung unten je erreicht wird, und der eben
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen. # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true 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 exit 1
fi fi
# "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess # "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. # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$_ap" >/dev/null 2>&1 || 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 exit 1
fi fi
_actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true) _actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true)
if [ "$_actual_mode" != "$_expect_mode" ]; then if [ "$_actual_mode" != "$_expect_mode" ]; then
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$_ap" >/dev/null 2>&1 || 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 exit 1
fi fi
if [ -n "$_expect_phase" ]; then if [ -n "$_expect_phase" ]; then
@@ -568,7 +568,7 @@ PY
if [ "$_actual_phase" != "$_expect_phase" ]; then if [ "$_actual_phase" != "$_expect_phase" ]; then
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$_ap" >/dev/null 2>&1 || 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 exit 1
fi fi
fi fi
@@ -606,26 +606,26 @@ parse_for_option() {
autooff_arm_if_requested() { autooff_arm_if_requested() {
[ -n "$_for_minutes" ] || return 0 [ -n "$_for_minutes" ] || return 0
autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1 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() { recovery_on() {
parse_for_option recovery "$@" parse_for_option recovery "$@"
need_profile "$AP_CONNECTION" need_profile "$AP_CONNECTION"
n=$(images | wc -l | tr -d ' ') 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" ap_switch "$AP_CONNECTION"
write_mode "recovery" write_mode "recovery"
start_and_verify "$AP_CONNECTION" "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 # Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist # Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder # die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
# ausschalten) steht unten. # ausschalten) steht unten.
if [ "$PSK" = "ninlight-recovery" ]; then 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 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 autooff_arm_if_requested
} }
@@ -658,23 +658,23 @@ migrate_on() {
_set=$(imageset_get) _set=$(imageset_get)
_setdir=$(imageset_dir "$_set") _setdir=$(imageset_dir "$_set")
n=$(migrate_images | wc -l | tr -d ' ') 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 # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
ap_switch "$MIGRATE_AP_CONNECTION" ap_switch "$MIGRATE_AP_CONNECTION"
write_mode "migration" write_mode "migration"
write_control "$_phase" "" || { 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 exit 1
} }
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase" 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 # Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte
# Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche # Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche
# stehen. # stehen.
echo "Bildsatz: $(imageset_label "$_set")" echo "Image set: $(imageset_label "$_set")"
echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2 echo "Caution: the password of this access point is publicly known from the old firmware - see README.md." >&2
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 autooff_arm_if_requested
} }
@@ -722,7 +722,7 @@ display_run() {
display_service_resume() { display_service_resume() {
[ "${_was_active:-0}" -eq 1 ] || return 0 [ "${_was_active:-0}" -eq 1 ] || return 0
sudo systemctl start ninlight-display 2>/dev/null \ 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() { display_handover() {
@@ -789,7 +789,7 @@ display_off() {
sudo systemctl stop ninlight-display 2>/dev/null || true sudo systemctl stop ninlight-display 2>/dev/null || true
# Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und # 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. # 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 ---------------------- # --- Abschlussbildschirm beim Herunterfahren und beim Neustart ----------------------
@@ -820,13 +820,13 @@ display_final() {
# Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der # Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der
# Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche # Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche
# Zeile im Journal als ein haengendes Herunterfahren. # 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 return 0
fi fi
sleep 1 sleep 1
_waited=$((_waited + 1)) _waited=$((_waited + 1))
done 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 return 0
} }
@@ -846,18 +846,18 @@ full_off() {
ap_switch "" ap_switch ""
if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; 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 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 fi
else 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 fi
} }
listen_cmd() { listen_cmd() {
write_control "listen" "" || { 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 exit 1
} }
echo "Phase: listen" echo "Phase: listen"
@@ -882,7 +882,7 @@ if not t:
print(t) print(t)
PY PY
); then ); then
echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm <target>" >&2 echo "Error: no recently detected model (last_seen) in $_status_file - name a target: ninlight-station arm <target>" >&2
exit 1 exit 1
fi fi
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) sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
PY PY
then 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 exit 1
fi fi
write_control "armed" "$_target" || { 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 exit 1
} }
echo "Freigegeben: $_target" echo "Armed: $_target"
} }
# "renew [<minuten>]" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die # "renew [<minuten>]" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die
@@ -915,7 +915,7 @@ PY
renew_cmd() { renew_cmd() {
_arg=${1:-} _arg=${1:-}
if ! _info=$(autooff_read); then 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 <minuten>'." >&2 echo "Error: no timer active - 'renew' only resets a running one. A new one comes from 'ninlight-station on --for <minutes>'." >&2
exit 1 exit 1
fi fi
# Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt.
@@ -927,17 +927,17 @@ renew_cmd() {
_minutes=$_arg _minutes=$_arg
fi fi
autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1 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" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt.
hold_cmd() { hold_cmd() {
if ! autooff_read >/dev/null; then 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 exit 1
fi fi
autooff_clear 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 # Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der
@@ -949,9 +949,9 @@ autooff_status_line() {
set -- $_info set -- $_info
_rest=$(( ($2 - $(date +%s) + 59) / 60 )) _rest=$(( ($2 - $(date +%s) + 59) / 60 ))
if [ "$_rest" -gt 0 ]; then 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 else
printf 'Auto-Aus: steht an (angefordert: %s min)\n' "$1" printf 'Auto-off: pending (requested: %s min)\n' "$1"
fi fi
} }
@@ -994,8 +994,8 @@ PY
_modus=$(printf '%s\n' "$_info" | sed -n '1p') _modus=$(printf '%s\n' "$_info" | sed -n '1p')
_erkannt=$(printf '%s\n' "$_info" | sed -n '2p') _erkannt=$(printf '%s\n' "$_info" | sed -n '2p')
elif [ -e "$_status_file" ]; then elif [ -e "$_status_file" ]; then
_modus="unbekannt (Datei nicht lesbar)" _modus="unknown (file not readable)"
_erkannt="unbekannt (Datei nicht lesbar)" _erkannt="unknown (file not readable)"
else else
_modus="-" _modus="-"
_erkannt="-" _erkannt="-"
@@ -1044,15 +1044,15 @@ PY
else else
_phase="-" _phase="-"
fi fi
printf 'Modus : %s\n' "$_modus" printf 'Mode : %s\n' "$_modus"
printf 'Phase : %s\n' "$_phase" printf 'Phase : %s\n' "$_phase"
imageset_status_lines imageset_status_lines
printf 'Erkannt: %s\n' "$_erkannt" printf 'Detected: %s\n' "$_erkannt"
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" printf 'Service : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
autooff_status_line autooff_status_line
printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)" 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}')" printf 'Address : %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
echo "Images :" echo "Images :"
images | while read -r f; do images | while read -r f; do
printf ' %-34s %s\n' "$(basename "$f")" \ printf ' %-34s %s\n' "$(basename "$f")" \
"$(python3 - "$f" <<'PY' "$(python3 - "$f" <<'PY'
@@ -1063,10 +1063,10 @@ PY
)" )"
done done
if [ -r "$_status_file" ]; then if [ -r "$_status_file" ]; then
echo "Zuletzt ausgeliefert:" echo "Last served:"
sed 's/^/ /' "$_status_file" sed 's/^/ /' "$_status_file"
elif [ -e "$_status_file" ]; then 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 fi
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie # "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 # ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier
@@ -1082,7 +1082,7 @@ display_cmd() {
final) final)
case "${2:-}" in case "${2:-}" in
poweroff|reboot) display_final "$2" ;; 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 esac
;; ;;
*) usage; exit 2 ;; *) usage; exit 2 ;;
@@ -1090,48 +1090,48 @@ display_cmd() {
} }
usage() { usage() {
echo "Aufruf: ninlight-station on [--for <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2 echo "Usage: ninlight-station on [--for <minutes>]|recovery {on [--for <minutes>]|off}|migrate {on [--listen] [--for <minutes>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minutes>]|hold|display {test|clear|off}|off|status" >&2
echo "" >&2 echo "" >&2
echo " bildsatz Umschalter: welcher Satz Migrationsimages ausgeliefert wird." >&2 echo " bildsatz Switch: which set of migration images is served." >&2
echo " Ohne Argument nur berichten. 'ohne-wlan' (Vorgabe) liefert die" >&2 echo " Without an argument it only reports. 'ohne-wlan' (default) serves" >&2
echo " Abzuege aus tools/build_nocreds.sh - die migrierte Lampe startet" >&2 echo " the builds from tools/build_nocreds.sh - the migrated lamp comes" >&2
echo " im eigenen Onboarding-Zugangspunkt. 'mit-wlan' liefert die" >&2 echo " up in its own onboarding access point. 'mit-wlan' serves the" >&2
echo " Abzuege aus build/ - die Lampe haengt sofort im WLAN aus" >&2 echo " builds from build/ - the lamp joins the Wi-Fi from" >&2
echo " config.local.cmake, auch wenn es eine fremde Lampe ist, die im" >&2 echo " config.local.cmake right away, including a lamp that is not" >&2
echo " Automatikmodus mitgenommen wurde. Der Zustand bleibt, bis ihn" >&2 echo " yours and got picked up in automatic mode. The setting sticks" >&2
echo " jemand aendert, und steht dauerhaft auf dem Panel." >&2 echo " until someone changes it, and is shown on the panel." >&2
echo " --for <minuten> Zeitschalter: die Station schaltet sich nach <minuten> selbst" >&2 echo " --for <minutes> Timer: the station switches itself off after <minutes> (back to" >&2
echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&2 echo " the home Wi-Fi). OPTIONAL - without '--for' it stays on until" >&2
echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2 echo " someone says 'off'. Useful when you are logged in over Wi-Fi:" >&2
echo " WLAN angemeldet bist: 'on' macht wlan0 zum Zugangspunkt und" >&2 echo " 'on' turns wlan0 into an access point and drops your SSH" >&2
echo " reisst die SSH-Sitzung ab. Erlaubt: 1-240." >&2 echo " session. Allowed: 1-240." >&2
echo " renew [<min>] Uhr zurueckstellen (ohne Angabe auf die zuletzt angeforderte" >&2 echo " renew [<min>] Reset the timer (without an argument to the duration last" >&2
echo " Dauer). Laeuft keiner, ist das ein Fehler." >&2 echo " requested). If none is running, that is an error." >&2
echo " hold Zeitschalter abbestellen, Station bleibt an." >&2 echo " hold Cancel the timer, station stays on." >&2
echo " display test Testbild aufs Panel zeichnen. Das ist der erste Handgriff bei der" >&2 echo " display test Draw a test image on the panel. This is the first step of the" >&2
echo " Hardware-Abnahme und die EINZIGE Moeglichkeit, die Panel-Revision" >&2 echo " hardware acceptance and the ONLY way to determine the panel" >&2
echo " zu bestimmen: die steht am Geraet nirgends. Bleibt das Bild leer," >&2 echo " revision: it is printed nowhere on the device. If the image" >&2
echo " verzerrt oder verschoben, ist EPD_REVISION in" >&2 echo " stays blank, distorted or shifted, EPD_REVISION in" >&2
echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2 echo " /etc/ninlight-station.conf is wrong - switch it to the other" >&2
echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2 echo " revision (V2 instead of V3 or the other way round), power the" >&2
echo " stromlos machen und erneut 'display test'." >&2 echo " panel down briefly and run 'display test' again." >&2
echo " Ein laufender Anzeigedienst wird dafuer kurz gestoppt und" >&2 echo " A running display service is stopped for this and started" >&2
echo " danach wieder gestartet (er haelt sonst die GPIO-Leitungen)." >&2 echo " again afterwards (it holds the GPIO lines otherwise)." >&2
echo " display clear Panel leeren (ebenfalls mit kurzer Unterbrechung des Dienstes)." >&2 echo " display clear Clear the panel (also with a short interruption of the service)." >&2
echo " display final {poweroff|reboot}" >&2 echo " display final {poweroff|reboot}" >&2
echo " Abschlussbild aufs Panel. Kein Handgriff fuer Menschen: das" >&2 echo " Final image on the panel. Not a step for humans: this is what" >&2
echo " rufen ninlight-poweroff.service/ninlight-reboot.service beim" >&2 echo " ninlight-poweroff.service/ninlight-reboot.service call during" >&2
echo " Herunterfahren bzw. Neustart auf, nachdem der Anzeigedienst" >&2 echo " shutdown or reboot, once the display service has released the" >&2
echo " die GPIO-Leitungen freigegeben hat. E-Paper behaelt sein Bild" >&2 echo " GPIO lines. E-paper keeps its image forever - without this step" >&2
echo " dauerhaft - ohne diesen Schritt bliebe die letzte Rueckfrage" >&2 echo " the last confirmation prompt would stay on the panel. The two" >&2
echo " auf dem Panel stehen. Die Texte unterscheiden sich: beim" >&2 echo " texts differ: on a reboot you must NOT unplug." >&2
echo " Neustart darf man den Stecker gerade NICHT ziehen." >&2 echo " display off Stop the display service for good and clear the panel. The" >&2
echo " display off Anzeigedienst dauerhaft stoppen und das Panel leeren. 'on'/'off'" >&2 echo " station's 'on'/'off' do not need this - the display service" >&2
echo " der Station brauchen das nicht - der Anzeigedienst laeuft" >&2 echo " keeps running and draws the state by itself." >&2
echo " unabhaengig davon durch und zeichnet den Zustand selbst." >&2
echo "" >&2 echo "" >&2
echo " Taster (GPIO23): kurzer Druck oeffnet am Geraet ein Menue ueber genau diese" >&2 echo " Button (GPIO23): a short press opens a menu on the device covering exactly" >&2
echo " Unterbefehle, 3 s gehalten brechen eine laufende Migration/Rettung ab." >&2 echo " these subcommands, holding 3 s aborts a running migration or" >&2
echo " recovery." >&2
} }
_cmd=${1:-} _cmd=${1:-}
+37 -37
View File
@@ -167,43 +167,43 @@ def _labels(state):
def test_menu_off_offers_starting_and_shutdown(): def test_menu_off_offers_starting_and_shutdown():
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen", assert _labels(STATE_OFF) == ["Start recovery", "Migration: listen",
"Migration: AUTOMATIK", "Umschalten: mit WLAN", "Migration: AUTOMATIC", "Switch to: with Wi-Fi",
"Herunterfahren"] "Shut down"]
def test_the_listening_migration_comes_before_the_automatic_one(): def test_the_listening_migration_comes_before_the_automatic_one():
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und """Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
irgendwo loslaesst, soll nicht im Automatikmodus landen.""" irgendwo loslaesst, soll nicht im Automatikmodus landen."""
labels = _labels(STATE_OFF) 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(): def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN", assert _labels(STATE_RECOVERY) == ["Station off", "Switch to: with Wi-Fi",
"Anzeige auffrischen", "Herunterfahren"] "Refresh display", "Shut down"]
def test_menu_in_migration_offers_the_detected_target(): def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus", assert _labels(STATE_MIGRATION) == ["Arm: gu10_rgb", "Station off",
"Umschalten: mit WLAN", "Anzeige auffrischen", "Switch to: with Wi-Fi", "Refresh display",
"Herunterfahren"] "Shut down"]
def test_menu_in_migration_without_a_detected_target_offers_no_release(): 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 """Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde).""" eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND)) assert "Armed: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN", assert _labels(STATE_MIGRATION_BLIND) == ["Station off", "Switch to: with Wi-Fi",
"Anzeige auffrischen", "Herunterfahren"] "Refresh display", "Shut down"]
def test_only_the_refresh_entry_skips_the_confirmation(): 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.""" veraendert nichts und braucht keine."""
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION): for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
for action in button.menu_actions(state): 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(): 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) rec = _drive([_short(1.0)], 4.0)
assert rec.argvs() == [] assert rec.argvs() == []
t, view = rec.first_view("menu") t, view = rec.first_view("menu")
assert view["hero"] == "> Rettung starten" assert view["hero"] == "> Start recovery"
assert view["active"] is True assert view["active"] is True
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem # Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe # 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(): def test_a_short_press_steps_to_the_next_entry():
rec = _drive([_short(1.0), _short(2.0)], 4.0) rec = _drive([_short(1.0), _short(2.0)], 4.0)
assert rec.argvs() == [] 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(): 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), rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
_short(6.0)], 8.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(): def test_holding_one_and_a_half_seconds_selects_the_entry():
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0) rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
t, view = rec.first_view("confirm") t, view = rec.first_view("confirm")
assert view["hero"] == "Rettung starten" assert view["hero"] == "Start recovery"
assert 3.5 <= t <= 3.6 assert 3.5 <= t <= 3.6
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da. # Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
assert rec.argvs() == [] 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(): 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 Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
freigibt.""" freigibt."""
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0) 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(): 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 ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
fremde Lampen ungefragt migriert werden.""" 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) 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.""" 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) rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
_t, view = rec.first_view("confirm") _t, view = rec.first_view("confirm")
assert view["title"] == "ACHTUNG" assert view["title"] == "WARNING"
assert view["hero"] == "Migration: AUTOMATIK" assert view["hero"] == "Migration: AUTOMATIC"
text = " ".join(view["details"]).lower() 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. # 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. # Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0) rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
_t, plain = rec.first_view("confirm") _t, plain = rec.first_view("confirm")
assert plain["title"] == "RUECKFRAGE" assert plain["title"] == "CONFIRM"
assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"] assert plain["details"] == ["hold (1.5 s): yes", "tap: no"]
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held(): 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 assert 4.0 <= t <= 4.1
# ... und danach laeuft am Panel der Countdown der zweiten Stufe. # ... und danach laeuft am Panel der Countdown der zweiten Stufe.
later = [v for vt, v in rec.views() if vt > t] 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(): 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.""" erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
rec = _drive([(1.0, 12.0)], 14.0) rec = _drive([(1.0, 12.0)], 14.0)
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0] heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
assert "Herunterfahren in 6 s" in heroes assert "Shutting down in 6 s" in heroes
assert "Herunterfahren in 1 s" in heroes assert "Shutting down in 1 s" in heroes
def test_a_blocking_action_does_not_break_the_hold_gesture(): 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(): 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) 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()) for _t, v in rec.views())
@@ -411,7 +411,7 @@ def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
geschrieben.""" geschrieben."""
path = tmp_path / "run" / "ui.json" path = tmp_path / "run" / "ui.json"
writer = button.UiWriter(path) writer = button.UiWriter(path)
view = {"view": "menu", "title": "MENUE", "hero": "> Station aus", "details": [], view = {"view": "menu", "title": "MENU", "hero": "> Station off", "details": [],
"active": True} "active": True}
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt # Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage # 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(): def test_the_image_set_entry_names_where_it_switches_to():
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der """Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand.""" 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") ohne = dict(STATE_OFF, imageset="mit-wlan")
assert "Umschalten: ohne WLAN" in _labels(ohne) assert "Switch to: no Wi-Fi" in _labels(ohne)
assert "Umschalten: mit WLAN" not in _labels(ohne) assert "Switch to: with Wi-Fi" not in _labels(ohne)
def test_the_image_set_entry_switches_through_the_cli(): 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") loud = button.action_imageset("ohne-wlan")
back = button.action_imageset("mit-wlan") back = button.action_imageset("mit-wlan")
assert loud.confirm and back.confirm assert loud.confirm and back.confirm
assert loud.confirm_title == "ACHTUNG" assert loud.confirm_title == "WARNING"
assert back.confirm_title == "RUECKFRAGE" assert back.confirm_title == "CONFIRM"
assert "WLAN-Passwort" in " ".join(loud.confirm_details) assert "Wi-Fi password" in " ".join(loud.confirm_details)
assert back.confirm_details is None assert back.confirm_details is None
+38 -38
View File
@@ -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 "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" 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)" [ "$(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
echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ===" 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 "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" 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")" = "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
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ===" echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
@@ -427,7 +427,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR" [ "$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
echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ===" echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ==="
@@ -469,7 +469,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" [ "$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")" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")"
echo echo
@@ -482,7 +482,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" [ "$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" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt"
echo 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 "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 "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 "$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
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" 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=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$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 "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
echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ===" echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ==="
@@ -547,7 +547,7 @@ RC=$?
set -e set -e
chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt 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" [ "$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
echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ===" echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ==="
@@ -558,7 +558,7 @@ OUT=$(run_cli "$SBX" frobnicate 2>&1)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2" [ "$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
echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ===" 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") 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" [ "$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 "$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") ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER" [ -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 - # Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei -
@@ -646,8 +646,8 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") 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" [ "$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 "$OUT" | grep -q "Station off" && 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 "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active") 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" [ "$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 ] \ [ "$RC" -ne 0 ] \
&& pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \ && 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" || 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" || pass "keine falsche 'Station aus'-Meldung"
echo "$ERR" | grep -q "ninlight:recovery" \ echo "$ERR" | grep -q "ninlight:recovery" \
&& pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \ && pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \
@@ -695,7 +695,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") 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" [ "$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") 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" [ -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"} {"mode": "recovery"}
JSON JSON
OUT=$(run_cli "$SBX" status 2>&1) 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 -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" 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 run_cli "$SBX" on >/dev/null 2>&1
rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie
OUT=$(run_cli "$SBX" status 2>&1) 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 -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" 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" [ "$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")" [ ! -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" 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) 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
echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ===" 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")" 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" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch"
OUT=$(run_cli "$SBX" status 2>&1) 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
echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ===" 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 set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT" [ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT"
echo "$ERR" | grep -qi "fehler" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR" echo "$ERR" | grep -qi "error" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR"
grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren" grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren"
grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet" grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet"
[ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")" [ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' 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" 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" 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")" [ "$(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. # Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab.
SBX="$TMPROOT/tZ5b" SBX="$TMPROOT/tZ5b"
setup_sandbox "$SBX" setup_sandbox "$SBX"
@@ -1149,7 +1149,7 @@ set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR" [ "$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")" 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" [ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen"
# Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die # Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die
# Grenzpruefung auch einfach immer abschalten). # Grenzpruefung auch einfach immer abschalten).
@@ -1195,7 +1195,7 @@ for CMD in renew hold; do
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT" [ "$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")" [ ! -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" grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run"
done 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" 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")" [ "$(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) 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")" 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. # Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt.
set +e set +e
@@ -1262,7 +1262,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT" [ "$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")" [ ! -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" 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=$(file_mode "$SBX/var/imageset")
[ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644" [ "$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" [ -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. # ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn.
OUT2=$(run_cli "$SBX" bildsatz 2>&1) 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" 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 set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz" [ "$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")" [ "$(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
echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ===" 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" 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 # Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die
# Station fuer umgeschaltet. # 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" \ && pass "die unbekannte Einstellung wird beim Namen genannt" \
|| fail "die unbekannte Einstellung wird verschwiegen: $OUT" || fail "die unbekannte Einstellung wird verschwiegen: $OUT"
# Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen. # Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen.
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
OUT=$(run_cli "$SBX" bildsatz 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" \ && fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \
|| pass "kein Fehlalarm bei gueltigem Eintrag" || pass "kein Fehlalarm bei gueltigem Eintrag"
@@ -1346,9 +1346,9 @@ ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC" [ "$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) 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
echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ===" echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ==="
@@ -1356,7 +1356,7 @@ SBX="$TMPROOT/bs6"
setup_sandbox "$SBX" setup_sandbox "$SBX"
rm -f "$SBX/images/migrate-nocreds"/*.img rm -f "$SBX/images/migrate-nocreds"/*.img
OUT=$(run_cli "$SBX" status 2>&1) 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
echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ===" 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=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" [ "$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
echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ===" 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 set +e
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e 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 run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
set +e set +e
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e 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
echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ===" echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ==="
@@ -1466,7 +1466,7 @@ set -e
unset FAKE_DISPLAY_ACTIVE_FOR unset FAKE_DISPLAY_ACTIVE_FOR
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR" [ "$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" 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 # Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in
# der Abschalt-Transaktion. # 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" 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 set -e
unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT 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" [ "$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 echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
+89 -35
View File
@@ -14,10 +14,40 @@ import render # noqa: E402
def test_off_mode_says_so_and_shows_nothing_else(): def test_off_mode_says_so_and_shows_nothing_else():
lines = render.lines({"mode": "off"}) 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) 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(): def test_migration_shows_detected_lamp():
lines = render.lines({ lines = render.lines({
"mode": "migration", "phase": "auto", "served": 3, "mode": "migration", "phase": "auto", "served": 3,
@@ -57,7 +87,7 @@ def test_recovery_mode_without_traffic():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
"served": 0, "recent": []}) "served": 0, "recent": []})
text = "\n".join(lines) text = "\n".join(lines)
assert "RETTUNG" in text assert "RECOVERY" in text
assert "ninlight-recovery" in text assert "ninlight-recovery" in text
@@ -151,7 +181,7 @@ def _migration_state():
def test_off_screen_is_drawn_big_and_centered(): 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 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 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): 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).""" nicht erst gebaut werden (faules Laden, Befund M4)."""
real_load_font = render._load_font real_load_font = render._load_font
requested = [] requested = []
@@ -663,7 +693,7 @@ def test_missing_fields_do_not_raise():
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) ----- # --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
# #
# Frueher stand "FREIGEGEBEN: <ziel>" als Phase im Kopfbalken. Neben dem Modus bleiben # Frueher stand "ARMED: <target>" als Phase im Kopfbalken. Neben dem Modus bleiben
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein # 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 # Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war # (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(): def test_autooff_line_appears_only_with_a_running_timer():
without = render.lines(_migration_state()) 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) state = dict(_migration_state(), autooff_minutes=7)
with_timer = render.lines(state) 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(): 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.""" die SSH-Sitzung genauso ab."""
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0, lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0,
"recent": [], "autooff_minutes": 42}) "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(): 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 unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete
Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten.""" Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten."""
state = dict(_armed_state(), autooff_minutes=7) 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 # Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile
# sind eigene Rollen und belegen keine Beiwerkzeile.) # sind eigene Rollen und belegen keine Beiwerkzeile.)
details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL] 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(): def test_autooff_line_says_so_when_the_deadline_has_arrived():
lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [], lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [],
"autooff_minutes": 0}) "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): 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") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert state["autooff_minutes"] == 7 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): 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") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert "autooff_minutes" not in state 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): 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): def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
""""off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz """"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) monkeypatch.setattr(display, "_service_active", lambda service_name: False)
(tmp_path / "autooff.json").write_text(json.dumps( (tmp_path / "autooff.json").write_text(json.dumps(
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0})) {"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 state["mode"] == "off"
assert "autooff_minutes" not in state 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) --------------------------------------- # --- 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 # 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 # 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 # 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): def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True) 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 state["mode"] == "recovery"
assert "phase" not in state assert "phase" not in state
assert "armed_target" 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): 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 # "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll). # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
assert state["mode"] == "off" 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): 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(): def test_planner_draws_fully_on_mode_change():
planner = display.RefreshPlanner(0.0) 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" 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 # wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein. # dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus", _MENU_UI = {"view": "menu", "title": "MENU", "hero": "> Station off",
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True} "details": ["Refresh display", "Shut down"], "active": True}
def test_the_ui_file_lives_outside_the_stations_runtime_directory(): def test_the_ui_file_lives_outside_the_stations_runtime_directory():
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim """Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station off", in dem
das Menue "Rettung starten"/"Migration starten" anbietet.""" das Menue "Start recovery"/"Migration starten" anbietet."""
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json" assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
assert not str(display.UI_FILE).startswith("/run/ninlight-station/") 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(): def test_a_running_operation_replaces_the_status_screen():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7, lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
"recent": [], "ui": _MENU_UI}) "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) 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.""" kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17, lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
"hero": None, "details": "nope"}}) "hero": None, "details": "nope"}})
assert lines == ["Bedienung"] assert lines == ["Button menu"]
render.compose({"mode": "recovery", "ui": {"view": "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})) path.write_text(json.dumps({"view": "menu", "active": False}))
assert display.read_ui(path) == {} assert display.read_ui(path) == {}
path.write_text(json.dumps(_MENU_UI)) 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(): 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 """Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos.""" Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"] 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(): def test_a_missing_image_set_says_so_on_the_panel():
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False) state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
line = _imageset_line(state) 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(): 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", state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch) imageset_path=switch)
assert state["imageset_ok"] is False 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. # Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
(images / "migrate-wlan").mkdir() (images / "migrate-wlan").mkdir()
@@ -1884,14 +1938,14 @@ def test_the_shutdown_and_the_reboot_screen_differ():
gekostet).""" gekostet)."""
off, reboot = _final_text("poweroff"), _final_text("reboot") off, reboot = _final_text("poweroff"), _final_text("reboot")
assert off != 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 # 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 # 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. # 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. # Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
assert "NICHT trennen" in reboot assert "Do NOT unplug" in reboot
assert "trennen erlaubt" not in reboot assert "Safe to unplug" not in reboot
def test_the_reboot_screen_says_how_long_it_stays(): 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} "imageset_ok": True, "served": 7}
blocks = render.blocks(state) blocks = render.blocks(state)
assert not any(role == render.ROLE_FOOTER for _, role in blocks) 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(): def test_an_unknown_final_kind_claims_neither():
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder """Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
gebootet wird.""" gebootet wird."""
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"})) 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 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 """Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste 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 ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
"Strom kann getrennt werden".""" "Safe to unplug"."""
drawn = [] drawn = []
class _Panel: class _Panel: