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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user