feat(station): ausgelieferte Firmware-Version auf dem Panel

Welche Version die Station gerade ausliefert, war am Geraet nur per SSH zu
beantworten. Jetzt sagt es die Anzeige selbst, je nach Bild an der Stelle, wo
Platz ist:

- Aus-Zustand: rechtsbuendig neben der Adresse (neue Rolle ROLE_DETAIL_RIGHT,
  nach dem Muster der Phase neben dem Modus)
- Rettung: rechtsbuendig in der Kopfzeile, dort steht keine Phase im Weg
- Migration: als beschriftete Zeile "liefert: <version>" direkt hinter "FW:",
  NICHT in der Kopfzeile - daneben bleiben nur rund 112 px, daran ist schon
  "FREIGEGEBEN: <ziel>" gescheitert, und zwei nackte Versionsnummern
  nebeneinander waeren eine Verwechslungsfalle (FW: ist die der Lampe)

Quelle ist der GEWAEHLTE Bildsatz: im Migrationsmodus manifest.json (die
Migrationsabzuege tragen keinen ninlight-Anhaenger), im Rettungsmodus der
Anhaenger der _ota.img. Weichen die Eintraege voneinander ab, steht da
"gemischt" - ein zusammengefuehrter Deploy kann Alt-Eintraege mit anderer
Version enthalten, und eine davon herauszugreifen waere geraten. Fehlt die
Quelle, bleibt die Angabe weg statt eine Version zu erfinden.
This commit is contained in:
Michael Sauer
2026-10-02 23:44:37 +02:00
parent c53a23daba
commit 58cdc73afc
3 changed files with 190 additions and 4 deletions
+77 -2
View File
@@ -319,6 +319,77 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tupl
return name, False
def _trailer_version(path: Path) -> str:
"""Firmware-Version aus dem NINL-Anhaenger eines ninlight-Abzugs, sonst "".
Nur die letzten 512 Byte werden gelesen: die Rettungsabzuege liegen als
_ota.img vor und tragen ihre Version im Anhaenger (Geraetetyp bei Offset 5,
Version bei 21) - das Entpacken des gzip-Rumpfs waere dafuer unnoetige Arbeit auf
einem Pi, der alle paar Sekunden neu zeichnet.
"""
try:
with path.open("rb") as f:
f.seek(-512, 2)
anhaenger = f.read(512)
except OSError:
return ""
if len(anhaenger) != 512 or anhaenger[0:4] != b"NINL":
return ""
return anhaenger[21:37].split(b"\x00", 1)[0].decode("ascii", errors="replace")
def delivered_version(conf: dict, imageset: str, mode: str) -> str:
"""Die Firmware-Version, die die Station gerade ausliefern wuerde, sonst "".
Quelle ist der GEWAEHLTE Bildsatz, nicht irgendein Verzeichnis: wer am Taster
zwischen "mit WLAN" und "ohne WLAN" umschaltet, bekommt sonst eine Zahl angezeigt,
die zum anderen Satz gehoert.
Im Migrationsmodus steht die Version in `manifest.json` - die Migrationsabzuege
selbst tragen keinen ninlight-Anhaenger (siehe server.py:image_version). Weichen
die Eintraege voneinander ab, ist das Ergebnis "gemischt": ein zusammengefuehrter
Deploy kann Alt-Eintraege mit anderer Version enthalten, und eine davon
herauszugreifen waere geraten. Im Rettungsmodus liegen gewoehnliche _ota.img, deren
Anhaenger die Version tragen.
Fehlt die Quelle oder ist sie unlesbar, bleibt das Ergebnis leer - dann zeigt die
Anzeige gar nichts, statt eine Version zu erfinden.
"""
image_dir = conf.get("IMAGE_DIR")
if not image_dir:
return ""
if mode == "recovery":
verzeichnis = Path(image_dir) / RESCUE_IMAGESET_DIRS.get(imageset, "")
try:
abzuege = sorted(p for p in verzeichnis.iterdir() if p.name.endswith(".img"))
except OSError:
return ""
versionen = {v for v in (_trailer_version(p) for p in abzuege) if v}
else:
pfad = Path(image_dir) / IMAGESET_DIRS.get(imageset, "") / "manifest.json"
try:
daten = json.loads(pfad.read_text())
except (OSError, ValueError):
return ""
if not isinstance(daten, dict):
return ""
versionen = set()
eintraege = daten.get("images")
if isinstance(eintraege, list):
versionen = {
e["ninlight_version"]
for e in eintraege
if isinstance(e, dict) and isinstance(e.get("ninlight_version"), str)
and e["ninlight_version"]
}
if not versionen:
oben = daten.get("ninlight_version")
versionen = {oben} if isinstance(oben, str) and oben else set()
if not versionen:
return ""
return versionen.pop() if len(versionen) == 1 else "gemischt"
def _route_lookup() -> str:
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt.
@@ -416,13 +487,17 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
geraet, adresse = local_address()
# Auch im Aus-Zustand: welche Version die Station ausliefern WUERDE, ist genau
# dann interessant, wenn man sie noch nicht eingeschaltet hat.
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
"ip": adresse, "iface": geraet}
"ip": adresse, "iface": geraet,
"image_version": delivered_version(conf, imageset, "migration")}
status = _read_json(run_dir / "status.json")
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
"imageset": imageset, "imageset_ok": imageset_ok,
"activity": _recent_request(status)}
"activity": _recent_request(status),
"image_version": delivered_version(conf, imageset, mode)}
if mode == "recovery":
state["ssid"] = conf.get("SSID")