feat(station): Panel zeigt Rettungs-Bildsatz, Lebenszeichen und klarere LAN-Zeile

display.py/_read_imageset() prueft jetzt das zum Modus passende Verzeichnispaar
(rescue-* im Rettungsmodus, migrate-* in der Migration) - vorher pruefte die
Fusszeile immer nur den Migrationssatz, auch im Rettungsmodus.

render.py haengt einen kleinen Punkt an die Servierzeile, solange
display.py:_recent_request() eine Anfrage der letzten ACTIVITY_WINDOW_S
Sekunden meldet - faehrt ueber den vorhandenen Textvergleich mit, kein
zusaetzlicher Zeichentakt fuer das E-Paper.

Aus-Zustand: "no network" hiess fuer einen Bediener wie "der Migrations-/
Rettungs-Zugangspunkt ist aus" - gemeint ist das Gegenteil, das Heimnetz.
Jetzt "LAN: <ip>" bzw. "LAN: not connected".

run_daemon() faengt jetzt jeden Fehler beim Erheben des Zustands ab
(_safe_read_state) statt die Schleife zu beenden - sonst friert das Panel
nach einem einzigen unerwarteten Lesefehler fuer immer auf dem letzten Bild
ein, ohne dass das am Geraet zu erkennen waere.
This commit is contained in:
Michael Sauer
2026-09-23 21:22:42 +02:00
parent ac8dfb1077
commit 6d0f79a6ac
3 changed files with 303 additions and 23 deletions
+173 -6
View File
@@ -22,22 +22,27 @@ 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)
assert any("LAN: 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."""
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("no network" in l for l in lines)
assert not any("IP " in l for l in lines)
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)
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)
assert not any("LAN" in l for l in lines)
def test_the_address_line_belongs_to_the_off_screen_only():
@@ -962,6 +967,70 @@ def test_local_ip_returns_a_string_and_never_raises():
assert isinstance(display.local_ip(), str)
# --- Lebenszeichen: ein kleiner Punkt, sobald eine Anfrage jeder Art hereinkommt -------
#
# Bedienerwunsch (2026-09-23): "so ist es immer raetselraten und warten das der zaehler
# hochgeht um zu wissen ob es geklappt hat" - der Punkt soll VOR dem Erfolg schon zeigen,
# dass ueberhaupt jemand anklopft.
def test_recent_request_is_true_right_after_a_request():
status = {"last_request_ts": "2026-09-23T12:00:00Z"}
now = display.datetime(2026, 9, 23, 12, 0, 5, tzinfo=display.timezone.utc).timestamp()
assert display._recent_request(status, now=now) is True
def test_recent_request_expires_after_the_window():
status = {"last_request_ts": "2026-09-23T12:00:00Z"}
now = (display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp()
+ display.ACTIVITY_WINDOW_S + 1)
assert display._recent_request(status, now=now) is False
def test_recent_request_without_a_timestamp_is_false():
"""Aeltere server.py-Fassung, oder noch nie eine Anfrage seit dem Start - kein
erfundener Punkt."""
assert display._recent_request({}) is False
assert display._recent_request({"last_request_ts": None}) is False
assert display._recent_request({"last_request_ts": "kaputt"}) is False
def test_recent_request_from_the_future_is_false():
"""Eine verstellte Uhr darf den Punkt nicht auf ewig scharf halten."""
status = {"last_request_ts": "2026-09-23T12:00:10Z"}
now = display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp()
assert display._recent_request(status, now=now) is False
def test_served_line_shows_a_dot_while_activity_is_recent():
lines_active = render.lines({"mode": "recovery", "served": 3, "recent": [],
"activity": True})
lines_idle = render.lines({"mode": "recovery", "served": 3, "recent": [],
"activity": False})
active_line = next(l for l in lines_active if l.startswith("Served:"))
idle_line = next(l for l in lines_idle if l.startswith("Served:"))
assert active_line != idle_line
assert "3" in active_line and "3" in idle_line
def test_served_line_without_an_activity_field_claims_nothing():
"""Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand im Test): ohne das
Feld bleibt die Zeile wie zuvor, kein erfundener Punkt."""
lines = render.lines({"mode": "recovery", "served": 3, "recent": []})
served_line = next(l for l in lines if l.startswith("Served:"))
assert served_line == "Served: 3"
def test_build_state_carries_activity_in_recovery_and_migration(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
(tmp_path / "status.json").write_text(json.dumps({"last_request_ts": None}))
conf = tmp_path / "station.conf"
conf.write_text("")
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=tmp_path / "imageset")
assert state["activity"] is False
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
"""Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld -
status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst
@@ -1511,6 +1580,67 @@ def _changing_every(period_s, mode="recovery"):
return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []}
# --- 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
# 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:
# beim ersten Erheben keine Adresse, beim zweiten eine.
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
Panel stehen, obwohl der Pi laengst erreichbar ist."""
seq = [{"mode": "off", "ip": ""}, {"mode": "off", "ip": "192.168.2.96"}]
def state_at(t):
# Erste POLL_INTERVAL_S-Erhebung (bei t=0) sieht noch keine Adresse, danach schon -
# weit genug auseinander, dass zwei unterschiedliche Polls sicher dazwischenliegen.
return seq[0] if t < 2 * display.POLL_INTERVAL_S else seq[1]
panel = _drive_daemon(state_at, duration_s=4 * display.POLL_INTERVAL_S)
# Erster Aufbau zeigt "keine Adresse" (Vollaufbau beim Start). Danach MUSS
# spaetestens einer der Aufrufe die neue Adresse tatsaechlich zeichnen - das
# Panel-Doppel protokolliert keinen Bildinhalt, deshalb wird direkt geprueft, dass
# render.lines() zum Zeitpunkt des naechsten Aufbaus schon die neue Adresse traegt
# (dieselbe Quelle, die run_daemon() intern fuer den Vergleich benutzt).
assert len(panel.calls) >= 2, panel.calls
draws_after_first = [name for _, name in panel.calls[1:]]
assert any(name in ("full", "partial", "wake-full") for name in draws_after_first), (
"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)))
def test_run_daemon_survives_a_single_failed_state_read(monkeypatch):
"""Ein einzelner unerwarteter Fehler beim Erheben (ausserhalb dessen, was
build_state()s eigene Bausteine schon abfangen) darf die Endlosschleife nicht
beenden - sonst friert das Panel beim naechsten Fehlschlag fuer immer auf dem
zuletzt gezeichneten Bild ein (E-Paper vergisst nichts von selbst), und zwar OHNE
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"}
def flaky_state_at(t):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("simulierter Lesefehler (z.B. RUN_DIR mitten im Neustart)")
return good_state
panel = _drive_daemon(flaky_state_at, duration_s=3 * display.POLL_INTERVAL_S)
# Kein Absturz (sonst haette _drive_daemon die Ausnahme durchgereicht statt sie
# abzufangen) und die Schleife lief tatsaechlich weiter: mindestens der initiale
# Vollaufbau (mit dem neutralen Platzhalter) und ein weiterer, sobald der zweite
# Versuch den echten Zustand liefert.
assert len(panel.calls) >= 2, panel.calls
def test_static_content_never_triggers_another_full_refresh():
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen.
@@ -1885,8 +2015,13 @@ def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts."""
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts.
Ausdruecklich im Migrationsmodus (RUN_DIR/mode) - das ist es, was die migrate-*-
Verzeichnisse betrifft. Der Rettungsmodus prueft ein eigenes Paar (rescue-*), siehe
test_build_state_reports_a_missing_rescue_image_set unten."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("migration")
images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
@@ -1909,6 +2044,37 @@ def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
imageset_path=switch)["imageset_ok"] is False
def test_build_state_reports_a_missing_rescue_image_set(tmp_path, monkeypatch):
"""Derselbe Umschalter gilt jetzt auch fuer die Rettung, mit einem EIGENEN
Verzeichnispaar (rescue-*, nicht migrate-*) - server.py begruendet die Trennung.
Ohne Modusdatei gilt "recovery" (dieselbe Vorgabe wie resolve_mode() in server.py),
das Panel muss also hier die rescue-*-Verzeichnisse pruefen, nicht die migrate-*."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
images = tmp_path / "images"
(images / "rescue-nocreds").mkdir(parents=True)
(images / "rescue-nocreds" / "ninlight_g95_cw_ota.img").write_bytes(b"IMG")
# Ein voll bestueckter Migrations-Bildsatz daneben - er darf das Ergebnis nicht
# beeinflussen (falsche Zuordnung waere der Fehler, den dieser Test faengt).
(images / "migrate-wlan").mkdir(parents=True)
conf = tmp_path / "station.conf"
conf.write_text(f"IMAGE_DIR='{images}'\n")
switch = tmp_path / "imageset"
switch.write_text("ohne-wlan")
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is True
switch.write_text("mit-wlan") # rescue-wlan/ gibt es nicht
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)
assert state["imageset_ok"] is False
assert "MISSING" in _imageset_line(state)
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
(images / "rescue-wlan").mkdir()
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is False
def test_the_display_and_the_server_agree_on_the_set_names():
"""Zwei Prozesse, eine Wahrheit: waeren die Namen oder die Vorgabe verschieden,
behauptete das Panel etwas anderes, als der Server tut."""
@@ -1919,6 +2085,7 @@ def test_the_display_and_the_server_agree_on_the_set_names():
spec.loader.exec_module(server)
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
assert display.RESCUE_IMAGESET_DIRS == server.RESCUE_SET_DIRS
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------