diff --git a/tools/station/display/display.py b/tools/station/display/display.py index 7c7351e..4ba661a 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -319,33 +319,54 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tupl return name, False -def local_ip() -> str: - """Die Adresse, unter der der Pi im Heimnetz erreichbar ist, oder "" wenn keine da ist. +def _route_lookup() -> str: + """Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt. - 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. + Eigene Funktion, damit die Tests sie ersetzen koennen, ohne einen Unterprozess zu + starten - und damit local_address() nur noch das Auswerten macht.""" + import subprocess - 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: + fertig = subprocess.run( + # Bewusst eine dokumentierte Testadresse (RFC 5737) statt eines echten Ziels + # wie 8.8.8.8: "route get" fragt nur den Kernel, es geht kein Paket hinaus, + # aber es soll auch beim Mitlesen nichts Fremdes anklingen. + ["ip", "-4", "route", "get", "192.0.2.1"], + capture_output=True, text=True, timeout=2, check=False) + except (OSError, subprocess.SubprocessError): return "" - finally: - if sock is not None: - sock.close() + return fertig.stdout if fertig.returncode == 0 else "" + + +def local_address() -> tuple[str, str]: + """(Geraet, Adresse), unter der der Pi im Heimnetz erreichbar ist - ("", "") ohne Netz. + + Ueber "ip route get", NICHT ueber einen verbundenen UDP-Socket: die Unit erlaubt dem + Anzeigedienst nur AF_UNIX und AF_NETLINK (ninlight-display.service: + RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort mit EAFNOSUPPORT, und + genau daran ist die Adresszeile am echten Panel gescheitert, waehrend derselbe + Aufruf von Hand - ohne Sandbox - die Adresse lieferte (Rueckmeldung 2026-09-23). + "ip" spricht Netlink und laeuft damit innerhalb der Erlaubnis. Der Unterprozess + kostet einen fork alle POLL_INTERVAL_S Sekunden und faellt nur im Aus-Zustand an, + dem einzigen Statusbild mit dieser Zeile. + + Das Geraet kommt mit, weil "LAN" am Panel eine Falschauskunft war: dieser Pi haengt + per WLAN im Netz, nicht am Kabel. Der Kernel nennt in derselben Auskunft beides - + "192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000". + + Wirft nie: ohne Route gibt es nichts auszuwerten, dann liefert der Aufruf ("", "") + und die Anzeige sagt das - eine Ausnahme aus der Adressermittlung darf das Panel + nicht aufhalten.""" + worte = _route_lookup().split() + geraet = adresse = "" + for i, w in enumerate(worte): + if w == "dev" and i + 1 < len(worte) and not geraet: + geraet = worte[i + 1] + elif w == "src" and i + 1 < len(worte) and not adresse: + adresse = worte[i + 1] + # Ohne Adresse ist auch der Geraetename wertlos - er wuerde ein Netz vortaeuschen, + # das nicht steht. + return (geraet, adresse) if adresse else ("", "") def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, @@ -394,8 +415,9 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, # 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. + geraet, adresse = local_address() return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok, - "ip": local_ip()} + "ip": adresse, "iface": geraet} status = _read_json(run_dir / "status.json") state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"), @@ -769,7 +791,7 @@ def run_daemon(revision: str, clock=time, panel: "Panel | None" = None, now = clock.monotonic() if now - last_state_poll >= POLL_INTERVAL_S: # Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler - # schon ab (siehe _read_json/_read_conf/_service_active/local_ip oben) - aber + # schon ab (siehe _read_json/_read_conf/_service_active/local_address oben) - aber # main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError) # ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim # Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese diff --git a/tools/station/display/render.py b/tools/station/display/render.py index 028b105..bda04e5 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -317,16 +317,23 @@ def blocks(state: dict) -> list[tuple[str, str]]: # die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz" # sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert. # - # Beschriftung "LAN" statt eines nackten "IP"/"no network": Rueckmeldung eines - # Bedieners (2026-09-23) las "no network" so, als sei damit der Migrations-/ - # Rettungs-Zugangspunkt gemeint (der ja tatsaechlich aus ist, die Station ist - # ausgeschaltet). Gemeint ist hier aber das GEGENTEIL - das gewoehnliche Heimnetz, - # in dem der Pi als normaler Rechner haengt, sobald die Station aus ist (siehe - # Kommentar oben). "LAN" grenzt das eindeutig vom Zugangspunkt-Netz ab, das auf - # jedem ANDEREN Statusbild als SSID steht. + # Beschriftet mit dem GERAET ("wlan0: 192.168.2.96"), nicht mit "LAN": derselbe + # Bediener las "LAN" als Falschauskunft, weil dieser Pi per WLAN im Netz haengt + # und gar kein Kabel hat (Rueckmeldung 2026-09-23). Der Geraetename grenzt + # ausserdem genauso eindeutig vom Zugangspunkt-Netz ab, das auf jedem ANDEREN + # Statusbild als SSID steht - und er sagt zusaetzlich, WORUEBER der Pi haengt. + # Ohne Netz "no home network" statt eines nackten "no network": letzteres wurde + # auf den Migrations-/Rettungs-Zugangspunkt bezogen (der im Aus-Zustand ja + # tatsaechlich aus ist). Gemeint ist das GEGENTEIL - das gewoehnliche Heimnetz. ip = state.get("ip") if isinstance(ip, str): - out.append((f"LAN: {ip}" if ip else "LAN: not connected", ROLE_DETAIL)) + geraet = state.get("iface") + if not ip: + out.append(("no home network", ROLE_DETAIL)) + elif isinstance(geraet, str) and geraet: + out.append((f"{geraet}: {ip}", ROLE_DETAIL)) + else: + out.append((f"IP: {ip}", ROLE_DETAIL)) return out + footer if mode not in ("recovery", "migration"): diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index c14c09e..d936d21 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -21,35 +21,50 @@ def test_off_mode_says_so_and_shows_nothing_else(): 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("LAN: 192.168.2.96" in l for l in lines) + lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}) + assert any("wlan0: 192.168.2.96" in l for l in lines) + + +def test_off_mode_names_the_interface_the_address_belongs_to(): + """Das Geraet gehoert dazu: Michael betreibt den Pi ohne Kabel, "LAN" las sich + deshalb als Falschauskunft (Rueckmeldung 2026-09-23). Am Kabel steht dort eth0.""" + lines = render.lines({"mode": "off", "ip": "192.168.1.92", "iface": "eth0"}) + assert any("eth0: 192.168.1.92" in l for l in lines) + assert not any("LAN" in l for l in lines) + + +def test_off_mode_shows_the_address_even_without_an_interface_name(): + """Notnagel: Adresse ohne Geraetenamen ist immer noch die Auskunft, die gebraucht + wird - dann ohne Beschriftung raten, sondern schlicht "IP".""" + lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": ""}) + 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. - Beschriftet mit "LAN", nicht mit einem nackten "no network": ein Bediener las die - frühere Formulierung so, als sei der Migrations-/Rettungs-Zugangspunkt gemeint - der - ist im Aus-Zustand ja tatsaechlich aus. Gemeint ist aber das GEGENTEIL, das - Heimnetz, in dem der Pi als gewoehnlicher Rechner haengt (siehe render.py).""" - lines = render.lines({"mode": "off", "ip": ""}) - assert any("LAN: not connected" in l for l in lines) - assert not any("LAN: " in l and "not connected" not in l for l in lines) + "no home network" statt eines nackten "no network": ein Bediener las die fruehere + Formulierung so, als sei der Migrations-/Rettungs-Zugangspunkt gemeint - der ist im + Aus-Zustand ja tatsaechlich aus. Gemeint ist aber das GEGENTEIL, das Heimnetz, in dem + der Pi als gewoehnlicher Rechner haengt (siehe render.py).""" + lines = render.lines({"mode": "off", "ip": "", "iface": ""}) + assert any("no home network" 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("LAN" in l for l in lines) + assert not any("home network" in l or ": 192." 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"}) + lines = render.lines({"mode": mode, "phase": "auto", "ip": "192.168.2.96", + "iface": "wlan0"}) assert not any("192.168.2.96" in l for l in lines) @@ -944,17 +959,18 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch): # "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll). assert state["mode"] == "off" - assert set(state) == {"mode", "imageset", "imageset_ok", "ip"} + assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface"} 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.""" + """Adresse und Geraet kommen aus local_address() und landen nur im Off-Zustand.""" monkeypatch.setattr(display, "_service_active", lambda service_name: False) - monkeypatch.setattr(display, "local_ip", lambda: "192.168.2.96") + monkeypatch.setattr(display, "local_address", lambda: ("wlan0", "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" + assert state["iface"] == "wlan0" def test_build_state_running_carries_no_address(tmp_path, monkeypatch): @@ -966,10 +982,45 @@ def test_build_state_running_carries_no_address(tmp_path, monkeypatch): 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 +def test_local_address_returns_strings_and_never_raises(): + """Ohne Netz gibt es keine Route - dann ("", "") statt einer Ausnahme, die das Panel aufhielte.""" - assert isinstance(display.local_ip(), str) + iface, ip = display.local_address() + assert isinstance(iface, str) and isinstance(ip, str) + + +def test_local_address_works_without_af_inet(monkeypatch): + """Der Anzeigedienst darf laut Unit NUR AF_UNIX und AF_NETLINK (siehe + ninlight-display.service:RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort + mit EAFNOSUPPORT - genau daran ist die Adresszeile am echten Panel gescheitert, + waehrend derselbe Aufruf von Hand (ohne Sandbox) die Adresse lieferte. Dieser Test + sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem.""" + import socket as _socket + + def kein_af_inet(family, *a, **kw): + if family == _socket.AF_INET: + raise OSError(97, "Address family not supported by protocol") + raise AssertionError("unerwartete Socketfamilie: %r" % (family,)) + + monkeypatch.setattr(_socket, "socket", kein_af_inet) + monkeypatch.setattr(display, "_route_lookup", + lambda: "192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000") + assert display.local_address() == ("wlan0", "192.168.2.96") + + +def test_local_address_reads_interface_and_source_from_the_route(monkeypatch): + """Das Geraet gehoert mit auf das Panel: Michael hat kein Kabel, "LAN" las sich + deshalb falsch (Rueckmeldung 2026-09-23). Beide Angaben stehen in derselben + Auskunft.""" + monkeypatch.setattr(display, "_route_lookup", + lambda: "192.0.2.1 dev eth0 src 192.168.1.92 uid 1000 \n cache") + assert display.local_address() == ("eth0", "192.168.1.92") + + +def test_local_address_without_a_route(monkeypatch): + """Kein Netz: "ip route get" meldet einen Fehler und liefert nichts Brauchbares.""" + monkeypatch.setattr(display, "_route_lookup", lambda: "") + assert display.local_address() == ("", "") # --- Lebenszeichen: ein kleiner Punkt, sobald eine Anfrage jeder Art hereinkommt ------- @@ -1616,7 +1667,7 @@ def _changing_every(period_s, mode="recovery"): # --- Lebenszeichen im Aus-Zustand: die IP muss auftauchen, sobald sie da ist ----------- # # Rueckmeldung eines Bedieners (2026-09-23): am laufenden Pi blieb "no network" stehen, -# obwohl der Pi laengst im Heimnetz haengt. local_ip() liefert die Adresse dabei +# obwohl der Pi laengst im Heimnetz haengt. local_address() liefert die Adresse dabei # nachweislich korrekt - der Verdacht war also die Anzeigeschleife: wird der Aus-Zustand # ueberhaupt regelmaessig neu erhoben, und fliesst die IP in den Vergleich ein, der ueber # ein Neuzeichnen entscheidet? Dieser Test treibt run_daemon() GENAU durch diesen Fall: @@ -1626,9 +1677,10 @@ def test_off_mode_redraws_once_the_address_appears(monkeypatch): """Haelt fest: run_daemon() erhebt den Zustand auch im Aus-Zustand unbedingt alle POLL_INTERVAL_S Sekunden neu (kein Ueberspringen mangels Moduswechsel), und die IP ist Teil des Vergleichs, der ueber ein Neuzeichnen entscheidet (render.lines() traegt - sie, siehe render.py). Schlaegt dieser Test fehl, bleibt "LAN: not connected" auf dem + sie, siehe render.py). Schlaegt dieser Test fehl, bleibt "no home network" auf dem Panel stehen, obwohl der Pi laengst erreichbar ist.""" - seq = [{"mode": "off", "ip": ""}, {"mode": "off", "ip": "192.168.2.96"}] + seq = [{"mode": "off", "ip": "", "iface": ""}, + {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}] def state_at(t): # Erste POLL_INTERVAL_S-Erhebung (bei t=0) sieht noch keine Adresse, danach schon - @@ -1647,7 +1699,7 @@ def test_off_mode_redraws_once_the_address_appears(monkeypatch): "kein weiterer Aufbau nach dem ersten - die neue Adresse waere nie gezeichnet worden", panel.calls, ) - assert any("LAN: 192.168.2.96" in line for line in render.lines(state_at(4 * display.POLL_INTERVAL_S))) + assert any("wlan0: 192.168.2.96" in line for line in render.lines(state_at(4 * display.POLL_INTERVAL_S))) def test_run_daemon_survives_a_single_failed_state_read(monkeypatch): @@ -1658,7 +1710,7 @@ def test_run_daemon_survives_a_single_failed_state_read(monkeypatch): jede Fehlermeldung am Geraet. Erhebung 1 wirft, Erhebung 2 liefert einen echten Zustand - danach muss dieser auch tatsaechlich gezeichnet sein.""" calls = {"n": 0} - good_state = {"mode": "off", "ip": "192.168.2.96"} + good_state = {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"} def flaky_state_at(t): calls["n"] += 1