diff --git a/tools/station/display/display.py b/tools/station/display/display.py index 4ba661a..4c9b280 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -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") diff --git a/tools/station/display/render.py b/tools/station/display/render.py index bda04e5..9f8c022 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -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: " 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 diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index d936d21..666b6b7 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -957,9 +957,11 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch): last_mode_path=tmp_path / "last-mode") # "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im - # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll). + # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll), die Adresse + # fuer SSH und die Version, die die Station ausliefern WUERDE. assert state["mode"] == "off" - assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface"} + assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface", + "image_version"} assert isinstance(state["ip"], str) @@ -2462,3 +2464,59 @@ def test_fixed_labels_fit_the_panel_with_room_for_a_wider_font(source, text, rol "{}: {!r} misst {:.1f} px; mit {:.0f} % Reserve fuer eine breitere Schrift " "bleiben nur {:.1f} px Platz".format( source, text, width_px, (FOOTER_WIDTH_SAFETY - 1) * 100, max_width_px)) + + +# --- Ausgelieferte Firmware-Version --------------------------------------------------- +# +# "Welche Version liefert die Station gerade aus?" war am Geraet nur zu beantworten, +# indem man sich per SSH die Abzuege ansah. Die Anzeige sagt es jetzt selbst. + + +def test_off_screen_shows_delivered_version_next_to_the_address(): + bloecke = render.blocks({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0", + "image_version": "0.4.0"}) + assert ("wlan0: 192.168.2.96", render.ROLE_DETAIL) in bloecke + assert ("0.4.0", render.ROLE_DETAIL_RIGHT) in bloecke + # Rechts NEBEN der Adresse, also unmittelbar dahinter - sonst landete sie neben + # einer anderen Zeile. + assert bloecke.index(("0.4.0", render.ROLE_DETAIL_RIGHT)) == ( + bloecke.index(("wlan0: 192.168.2.96", render.ROLE_DETAIL)) + 1 + ) + + +def test_rescue_shows_delivered_version_in_the_header(): + bloecke = render.blocks({"mode": "recovery", "ssid": "ninlight-rescue", + "image_version": "0.4.0"}) + assert ("0.4.0", render.ROLE_HEADER_PHASE) in bloecke + + +def test_migration_labels_the_delivered_version(): + """Im Migrationsmodus beschriftet, weil daneben schon die Lampenversion steht.""" + bloecke = render.blocks({ + "mode": "migration", "phase": "auto", "image_version": "0.4.0", + "last_seen": {"product_name": "Klyqa E27 RGB", "lamp_version": "1.1.1.2358"}, + }) + texte = [text for text, _ in bloecke] + assert "FW: 1.1.1.2358" in texte + assert "liefert: 0.4.0" in texte + # NICHT in die Kopfzeile: dort steht die Phase. + assert ("0.4.0", render.ROLE_HEADER_PHASE) not in bloecke + assert ("AUTO", render.ROLE_HEADER_PHASE) in bloecke + + +def test_unknown_version_shows_nothing(): + """Lieber keine Angabe als eine erfundene - sie beantwortet sonst genau die Frage + falsch, fuer die sie da ist.""" + for zustand in ({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}, + {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0", + "image_version": ""}, + {"mode": "recovery", "ssid": "x", "image_version": None}): + bloecke = render.blocks(zustand) + assert not [b for b in bloecke if b[1] == render.ROLE_DETAIL_RIGHT] + assert not [b for b in bloecke if b[1] == render.ROLE_HEADER_PHASE] + + +def test_mixed_sets_are_called_out(): + bloecke = render.blocks({"mode": "off", "ip": "10.0.0.5", "iface": "eth0", + "image_version": "gemischt"}) + assert ("gemischt", render.ROLE_DETAIL_RIGHT) in bloecke