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