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:
@@ -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")
|
||||
|
||||
@@ -76,6 +76,11 @@ TEXT_MARGIN_PX = 2
|
||||
# Migration.
|
||||
ROLE_HEADER = "header"
|
||||
ROLE_HEADER_PHASE = "header_phase"
|
||||
# Rechtsbuendig in DERSELBEN Zeile wie die zuletzt gezeichnete Detailzeile.
|
||||
# Genau ein Block benutzt sie: die ausgelieferte Firmware-Version neben der
|
||||
# Adresse im Aus-Zustand. Eigene Rolle statt einer zweiten Zeile, weil der
|
||||
# Platz auf 250x122 knapp ist und beide Angaben zusammengehoeren.
|
||||
ROLE_DETAIL_RIGHT = "detail_right"
|
||||
ROLE_HERO = "hero"
|
||||
ROLE_DETAIL = "detail"
|
||||
ROLE_BIG = "big"
|
||||
@@ -187,6 +192,9 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
||||
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
|
||||
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
|
||||
ARMED_LABEL = "ARMED"
|
||||
# Praefix der Zeile mit der ausgelieferten Version - grenzt sie von "FW:" ab, das die
|
||||
# Version der erkannten Lampe traegt.
|
||||
DELIVERS_PREFIX = "liefert: "
|
||||
ARMED_DETAIL_PREFIX = "Armed: "
|
||||
|
||||
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
|
||||
@@ -334,6 +342,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
out.append((f"{geraet}: {ip}", ROLE_DETAIL))
|
||||
else:
|
||||
out.append((f"IP: {ip}", ROLE_DETAIL))
|
||||
out += _delivered_version_blocks(state, ROLE_DETAIL_RIGHT)
|
||||
return out + footer
|
||||
|
||||
if mode not in ("recovery", "migration"):
|
||||
@@ -348,6 +357,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
ssid = state.get("ssid")
|
||||
|
||||
if mode == "recovery":
|
||||
out += _delivered_version_blocks(state, ROLE_HEADER_PHASE)
|
||||
# Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
|
||||
# Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
|
||||
# wie vor der zweiten Hardware-Abnahme, siehe dort).
|
||||
@@ -409,6 +419,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
lamp_version = last_seen.get("lamp_version")
|
||||
if lamp_version:
|
||||
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
|
||||
# Beschriftet und unmittelbar hinter "FW:", weil beides Versionsnummern sind
|
||||
# und nebeneinander sonst niemand weiss, welche wem gehoert: "FW:" ist die
|
||||
# der erkannten LAMPE, "liefert:" die des Bildsatzes. Nicht in die Kopfzeile:
|
||||
# dort steht im Migrationsmodus die Phase, und neben dem Modus bleiben nur
|
||||
# rund 112 px - daran ist schon "FREIGEGEBEN: <ziel>" gescheitert.
|
||||
for text, _ in _delivered_version_blocks(state, ROLE_DETAIL):
|
||||
out.append((f"{DELIVERS_PREFIX}{text}", ROLE_DETAIL))
|
||||
if ssid and armed_target is None:
|
||||
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
|
||||
# SSID zweimal auf dem Panel.
|
||||
@@ -462,6 +479,19 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
return out + footer
|
||||
|
||||
|
||||
def _delivered_version_blocks(state: dict, role: str) -> list[tuple[str, str]]:
|
||||
"""Die Version, die die Station ausliefert - oder [], wenn sie unbekannt ist.
|
||||
|
||||
Bewusst keine Ersatzanzeige: eine erfundene oder veraltete Versionsnummer ist
|
||||
schlimmer als keine, denn sie beantwortet genau die Frage falsch, fuer die sie da
|
||||
ist. display.py liefert "" , wenn manifest.json bzw. die Abzuege fehlen.
|
||||
"""
|
||||
version = state.get("image_version")
|
||||
if not isinstance(version, str) or not version:
|
||||
return []
|
||||
return [(version, role)]
|
||||
|
||||
|
||||
def _imageset_blocks(state: dict) -> list[tuple[str, str]]:
|
||||
"""Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt
|
||||
(aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test).
|
||||
@@ -636,6 +666,7 @@ def compose(state: dict):
|
||||
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
|
||||
ROLE_HERO: (HERO_FONT_PX, True),
|
||||
ROLE_DETAIL: (DETAIL_FONT_PX, False),
|
||||
ROLE_DETAIL_RIGHT: (DETAIL_FONT_PX, False),
|
||||
ROLE_BIG: (BIG_FONT_PX, True),
|
||||
ROLE_FOOTER: (FOOTER_FONT_PX, True),
|
||||
}
|
||||
@@ -700,6 +731,10 @@ def compose(state: dict):
|
||||
fill=0, align="center")
|
||||
|
||||
y = TEXT_MARGIN_PX
|
||||
# Oberkante und rechter Rand der zuletzt gezeichneten Flusszeile - ROLE_DETAIL_RIGHT
|
||||
# setzt seinen Text daneben.
|
||||
letzte_zeile_oben: int | None = None
|
||||
letzte_zeile_rechts = 0.0
|
||||
for raw_text, role in block_list:
|
||||
font = font_for(role)
|
||||
|
||||
@@ -740,6 +775,22 @@ def compose(state: dict):
|
||||
# Ueberlappen mit dem Modus ist nicht erlaubt.
|
||||
continue
|
||||
|
||||
if role == ROLE_DETAIL_RIGHT:
|
||||
# Rechtsbuendig neben der zuletzt gezeichneten Zeile, gemessen ab deren
|
||||
# tatsaechlichem rechten Rand - wie die Phase neben dem Modus. Ohne
|
||||
# vorangehende Zeile (von Hand zusammengesetzter Zustand im Test) oder bei
|
||||
# zu wenig Platz entfaellt die Angabe: ein Ueberlappen waere schlimmer als
|
||||
# eine fehlende Zusatzauskunft.
|
||||
if letzte_zeile_oben is None:
|
||||
continue
|
||||
frei_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - letzte_zeile_rechts
|
||||
- HEADER_PHASE_GAP_PX)
|
||||
if frei_px >= HEADER_PHASE_MIN_WIDTH_PX:
|
||||
text = fit(raw_text, font, width_px=frei_px)
|
||||
_draw_text(draw, text, font, top=letzte_zeile_oben,
|
||||
height=DETAIL_LINE_HEIGHT_PX, fill=0, align="right")
|
||||
continue
|
||||
|
||||
text = fit(raw_text, font)
|
||||
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
|
||||
if y + line_height > flow_limit_px:
|
||||
@@ -748,6 +799,8 @@ def compose(state: dict):
|
||||
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
|
||||
break
|
||||
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
|
||||
letzte_zeile_oben = y
|
||||
letzte_zeile_rechts = TEXT_MARGIN_PX + draw.textlength(text, font=font)
|
||||
y += line_height
|
||||
|
||||
return image
|
||||
|
||||
Reference in New Issue
Block a user