fix(station): Panel ermittelt die Adresse per Netlink und nennt das Geraet

Am echten Pi stand im Aus-Zustand "LAN: not connected", obwohl er unter
192.168.2.96 erreichbar war - derselbe build_state()-Aufruf von Hand lieferte die
Adresse dagegen richtig. Ursache ist die Absicherung des Anzeigedienstes:
ninlight-display.service setzt RestrictAddressFamilies=AF_UNIX AF_NETLINK, der
UDP-Kniff in local_ip() legt aber ein AF_INET-Socket an. Das scheitert dort mit
EAFNOSUPPORT, und die Zeile fiel auf "not connected" zurueck. Kein Test hat das
gefunden, weil alle ohne diese Beschraenkung liefen.

Statt die Absicherung zu lockern ermittelt local_address() die Adresse jetzt mit
"ip -4 route get" - das spricht Netlink und bleibt damit innerhalb der Erlaubnis.
Der Unterprozess faellt nur im Aus-Zustand an, dem einzigen Statusbild mit dieser
Zeile.

Dazu die Beschriftung: "LAN" war eine Falschauskunft, dieser Pi haengt per WLAN
im Netz und hat gar kein Kabel. Der Kernel nennt in derselben Auskunft beides,
also steht dort jetzt "wlan0: 192.168.2.96" (bzw. "eth0: ..." am Kabel) und ohne
Netz "no home network" statt eines nackten "no network", das schon einmal auf den
Rettungs-Zugangspunkt bezogen wurde.

Der neue Test sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem -
genau die Luecke, die den Befund ueberhaupt durchgelassen hat.
This commit is contained in:
Michael Sauer
2026-09-23 22:35:50 +02:00
parent f31ef879ee
commit bd467b5e52
3 changed files with 136 additions and 55 deletions
+74 -22
View File
@@ -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