feat(station): Rettung folgt dem Bildsatz-Umschalter, Server meldet Lebenszeichen

server.py: image_path() liefert jetzt aus rescue_dir() (nach dem Umschalter
ohne-wlan/mit-wlan), genau wie migrate_dir() es fuer die Migration schon tut.
Bewusst kein Fallback auf den alten flachen Bestand in images/ - ein Pi mit
altem Bestand muss einmal per deploy.sh neu bestueckt werden, sonst wuerde
"ohne-wlan" den alten Zustand (immer mit Zugangsdaten) stillschweigend
fortsetzen.

Station.last_activity/"last_request_ts" in status.json: Zeitpunkt jeder
Anfrage (auch erfolgloser), damit die Anzeige ein Lebenszeichen zeigen kann,
statt nur am Servierzaehler abzulesen, ob ueberhaupt etwas ankommt.
This commit is contained in:
Michael Sauer
2026-09-23 21:22:22 +02:00
parent eec9fa5a21
commit ac8dfb1077
2 changed files with 166 additions and 13 deletions
+64 -3
View File
@@ -86,6 +86,21 @@ def trailer_version(path: Path) -> str:
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
DEFAULT_IMAGE_SET = "ohne-wlan"
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (nicht nur die Migration): der
# Eigentuemer hat das ausdruecklich verlangt, denn bisher bekam eine Rettung IMMER ein
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ stammten aus build/). Die
# Rettungsabzuege OHNE Zugangsdaten kommen aus tools/build_nocreds.sh, genau wie ihr
# Migrations-Gegenstueck - deshalb dasselbe Namensschema, nur mit "rescue-" statt
# "migrate-" (deploy.sh legt sie entsprechend ab, siehe dort).
#
# Zwei getrennte Verzeichnispaare (migrate-*/rescue-*) statt einem gemeinsamen: die
# Migrationsabzuege sind signiert und tragen ein eigenes manifest.json, die
# Rettungsabzuege sind die gewoehnlichen _ota.img mit ninlight-Trailer. Sie
# unterscheiden sich in Form und Herstellung; ein gemeinsames Verzeichnis wuerde beide
# Herstellungswege ununterscheidbar ineinander mischen (siehe auch: "migrate-wlan/ und
# migrate-nocreds/ werden bei der Rettung nie angefasst" im Auftrag).
RESCUE_SET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
def read_image_set(path: Path | None) -> str:
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
@@ -117,6 +132,13 @@ class Station:
self.since = now_iso()
self.served = 0
self.recent: list[dict] = []
# Zeitpunkt der letzten Anfrage JEDER Art (erfolgreich oder nicht) - das
# Lebenszeichen fuer die Anzeige (display.py:_recent_request()). Anders als
# "served" zaehlt hier auch eine erfolglose Anfrage: der Eigentuemer wollte
# gerade nicht erst am Servierzaehler raten muessen, ob ueberhaupt etwas
# anklopft (Bedienerwunsch 2026-09-23). None, solange seit dem Start der
# Station noch keine einzige Anfrage einging.
self.last_activity: str | None = None
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
self.last_seen: dict | None = None
@@ -148,8 +170,36 @@ class Station:
self.lock = threading.RLock()
self.write_state()
def rescue_dir(self) -> Path:
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes fuer die RETTUNG. Dieselbe Regel
wie migrate_dir() (dort steht die ausfuehrliche Begruendung): der Umschalter
entscheidet, nicht eine Abfrage je Rettung, und es wird nie stillschweigend auf
den jeweils anderen Satz ausgewichen.
Bestandskompatibilitaet bewusst NICHT gegeben: vor diesem Umschalter lagen
Rettungsabzuege FLACH in image_dir (immer MIT Zugangsdaten, aus build/). Ein
Pi mit diesem alten Bestand wuerde, faende image_path() dort noch etwas, bei der
Vorgabe "ohne-wlan" weiterhin klaglos ein Abbild MIT Zugangsdaten ausliefern -
also genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend
fortsetzen, die dieser Umschalter gerade abstellen soll. Ein Pi mit altem
Bestand muss deshalb einmal per deploy.sh neu bestueckt werden (das raeumt den
flachen Altbestand auch auf); bis dahin liefert die Rettung in der Vorgabe
"ohne-wlan" sichtbar nichts aus (Status "notfound"), statt einen Fehlgriff zu
verstecken."""
return self.image_dir / RESCUE_SET_DIRS[self.image_set()]
def rescue_set_present(self) -> bool:
"""Liegt der eingestellte Rettungs-Bildsatz ueberhaupt auf dem Pi? Dieselbe Regel
wie migrate_set_present() (Verzeichnis vorhanden UND mindestens ein Image
darin) - Panel und CLI sollen fuer die Rettung nach demselben Massstab
urteilen wie fuer die Migration."""
try:
return any(p.name.endswith(".img") for p in self.rescue_dir().iterdir())
except OSError:
return False
def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img"
return self.rescue_dir() / f"ninlight_{target}_ota.img"
def image_set(self) -> str:
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
@@ -326,7 +376,11 @@ class Station:
with self.lock:
if status == "ok":
self.served += 1
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
ts = now_iso()
# Jede Anfrage zaehlt als Lebenszeichen, nicht nur eine erfolgreiche - siehe
# self.last_activity oben.
self.last_activity = ts
self.recent.append({"ts": ts, "ip": ip, "target": target,
"version": version, "bytes": nbytes, "status": status})
del self.recent[:-RECENT_MAX]
self.write_state()
@@ -347,6 +401,9 @@ class Station:
"variant_source": variant_source,
"product_id": product_id, "lamp_version": lamp_version,
"version": version, "bytes": nbytes, "status": status}
# Jede Anfrage zaehlt als Lebenszeichen, auch "listen"/"unknown"/"notfound"/
# "noimageset" - siehe self.last_activity oben und record()'s Gegenstueck.
self.last_activity = entry["ts"]
self.recent.append(entry)
del self.recent[:-RECENT_MAX]
if target is not None:
@@ -359,7 +416,11 @@ class Station:
def write_state(self) -> None:
with self.lock:
payload = {"since": self.since, "mode": self.mode, "served": self.served,
"recent": list(self.recent)}
"recent": list(self.recent),
# In BEIDEN Modi (nicht nur migration, siehe unten) - das
# Lebenszeichen auf dem Panel gilt fuer die Rettung genauso wie
# fuer die Migration (display.py:_recent_request()).
"last_request_ts": self.last_activity}
if self.mode == "migration":
phase, armed_target = self.read_control()
payload["phase"] = phase
+102 -10
View File
@@ -43,6 +43,14 @@ class Station:
def __init__(self, tmp_path, *extra):
self.image_dir = tmp_path / "images"
self.image_dir.mkdir()
# image_path() folgt jetzt dem Bildsatz-Umschalter, genauso wie migrate_dir() es
# tut - ohne --imageset-file gilt die Vorgabe "ohne-wlan" (server.py,
# DEFAULT_IMAGE_SET), also landen Rettungsabbilder in image_dir/rescue-nocreds.
# Die meisten Tests hier pruefen nur die Ausliefermechanik, nicht den
# Bildsatz-Umschalter selbst - die Bildsatz-spezifischen Tests weiter unten legen
# ihre Abbilder gezielt in andere Verzeichnisse.
self.rescue_dir = self.image_dir / "rescue-nocreds"
self.rescue_dir.mkdir()
self.state = tmp_path / "status.json"
self.port = free_port()
self.proc = subprocess.Popen(
@@ -140,7 +148,7 @@ def station(tmp_path):
def test_serves_a_known_image(station):
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
data = make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
r = c.getresponse()
@@ -157,7 +165,7 @@ def test_missing_image_is_404(station):
def test_unknown_target_is_404(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
c = station.conn()
c.request("GET", path)
@@ -216,7 +224,7 @@ def test_log_error_is_not_silenced_by_log_message(capsys):
def test_range_request_returns_206_and_the_right_slice(station):
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
data = make_image(station.rescue_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
c = station.conn()
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
r = c.getresponse()
@@ -227,7 +235,7 @@ def test_range_request_returns_206_and_the_right_slice(station):
def test_state_file_records_a_delivery(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
@@ -241,6 +249,21 @@ def test_state_file_records_a_delivery(station):
assert entry["bytes"] == 1000 + TRAILER_SIZE
def test_state_file_carries_a_last_request_timestamp_for_the_display(station):
"""display.py zeigt einen kleinen Lebenszeichen-Punkt, sobald eine Anfrage jeder Art
hereinkommt (auch ein 404) - dafuer muss status.json den Zeitpunkt tragen, nicht nur
den Erfolgszaehler. Vor der ersten Anfrage ist das Feld da, aber None."""
initial = station.read_state()
assert "last_request_ts" in initial
assert initial["last_request_ts"] is None
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img") # existiert nicht - zaehlt trotzdem
c.getresponse().read()
state = station.read_state(lambda s: s["last_request_ts"] is not None)
assert isinstance(state["last_request_ts"], str) and state["last_request_ts"]
def test_state_file_records_a_missing_image(station):
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img")
@@ -266,7 +289,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
finally:
os.umask(old_umask)
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
def world_readable() -> bool:
mode = stat.S_IMODE(s.state.stat().st_mode)
@@ -289,7 +312,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
def test_recent_is_capped_at_eight(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
for _ in range(12):
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
@@ -302,7 +325,7 @@ def test_recent_is_capped_at_eight(station):
def test_aborted_transfer_is_recorded(tmp_path):
s = Station(tmp_path, "--stall-after", "256")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
sock.recv(4096)
@@ -326,7 +349,7 @@ def test_aborted_transfer_is_recorded(tmp_path):
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
s = Station(tmp_path, "--stall-after", "512")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
got = b""
@@ -369,7 +392,7 @@ def test_stall_after_budget_survives_a_resumed_request(tmp_path):
"""
s = Station(tmp_path, "--stall-after", "1000")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
# First request: burns through the whole --stall-after budget, then the server
# holds the connection open and sends nothing further.
@@ -419,7 +442,7 @@ def test_concurrent_downloads_are_all_recorded(station):
concurrent requests can lose increments (two threads read the same old value) or have
one thread's status.json write clobber another's.
"""
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
expected_len = 5000 + TRAILER_SIZE
n_workers = 20
errors = []
@@ -1092,6 +1115,75 @@ def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_serve
)["recent"][-1]["version"] == "0.4.0-wlan"
# --- Derselbe Umschalter gilt jetzt auch fuer die RETTUNG ---------------------------
#
# Der Eigentuemer hat das ausdruecklich verlangt: bisher bekam eine Rettung IMMER ein
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ kamen aus build/). image_path()
# folgt jetzt demselben Umschalter wie migrate_dir() - dieselben Tests wie oben fuer die
# Migration, nur gegen den Rettungspfad "/ninlight_<target>_ota.img".
def _both_rescue_sets(imgdir):
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"OHNE" * 100)
(imgdir / "rescue-wlan").mkdir(exist_ok=True)
make_image(imgdir / "rescue-wlan" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"MITW" * 100)
def test_rescue_default_image_set_is_the_one_without_wifi_credentials(server):
"""Wie bei der Migration: ohne Umschalterdatei gilt die sichere Vorgabe - ein
Fehlgriff darf niemandem das eigene WLAN-Passwort in ein fremdes Geraet schreiben.
Vor diesem Umschalter bekam eine Rettung IMMER das Abbild MIT Zugangsdaten; das war
genau die Nebenwirkung, die der Eigentuemer sichtbar gemacht haben wollte."""
srv, imgdir = server
_both_rescue_sets(imgdir)
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 200
assert r.body.startswith(b"OHNE")
def test_rescue_switch_decides_which_file_is_actually_delivered(server):
srv, imgdir = server
_both_rescue_sets(imgdir)
srv.set_image_set("mit-wlan")
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"MITW")
# ... und wieder zurueck, ohne Dienstneustart.
srv.set_image_set("ohne-wlan")
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"OHNE")
def test_rescue_missing_selected_set_delivers_nothing(server):
"""Der gewaehlte Rettungs-Bildsatz liegt nicht auf dem Pi: NICHTS geht raus -
insbesondere nicht das gleichnamige Abbild des anderen, danebenliegenden Satzes.
Auf den anderen Satz auszuweichen waere genau der Fehlgriff, gegen den der
Umschalter da ist (siehe rescue_dir() in server.py)."""
srv, imgdir = server
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"OHNE" * 100)
srv.set_image_set("mit-wlan") # rescue-wlan/ existiert nicht
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 404
assert b"OHNE" not in r.body
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["served"] == 0
assert st["recent"][-1]["status"] == "notfound"
def test_rescue_old_flat_layout_is_no_longer_served(server):
"""Rueckwaertskompatibilitaet bewusst NICHT gegeben (server.py, rescue_dir()): ein Pi
mit dem alten, flachen Bestand (vor diesem Umschalter, immer MIT Zugangsdaten aus
build/) muss einmal per deploy.sh neu bestueckt werden. Wuerde image_path() den
flachen Altbestand noch lesen, wuerde die Vorgabe "ohne-wlan" NICHTS daran aendern -
genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend fortgesetzt."""
srv, imgdir = server
make_image(imgdir / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", b"ALTBESTAND" * 50)
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 404
def test_read_image_set_defaults_without_a_file(tmp_path):
module = _load_server_module()
assert module.read_image_set(None) == "ohne-wlan"