feat(station): Umschalter fuer Migrationsimages mit/ohne WLAN-Daten
Die WLAN-Zugangsdaten stecken zur Bauzeit in der Firmware, es gibt also zwei Abzuege je Ziel. Die Station haelt jetzt beide vor - images/ migrate-nocreds (aus tools/build_nocreds.sh, Lampe startet im Onboarding-AP) und images/migrate-wlan (aus build/, Lampe haengt sofort im eigenen WLAN) - und der Bediener waehlt per Umschalter, welcher ausgeliefert wird: "ninlight-station bildsatz" oder der Menueeintrag am Taster. Der Zustand liegt in /var/lib/ninlight-station/imageset, ueberlebt Neustarts und steht dauerhaft in der Fusszeile des Panels - in jedem Modus, auch bei ausgeschalteter Station: man soll vor dem Einschalten sehen, was gilt. Vorgabe und Rueckfallwert in JEDEM Zweifelsfall ist "ohne-wlan". Im Automatikmodus wird jede Klyqa-Lampe in Reichweite migriert, auch fremde; ein Abzug ohne Zugangsdaten ist dann ein harmloser Fehlgriff, einer mit Zugangsdaten traegt das eigene WLAN-Passwort in ein fremdes Geraet. Fehlt der gewaehlte Satz (Verzeichnis weg oder leer), wird NICHTS ausgeliefert - kein stilles Ausweichen auf den anderen Satz. Der Server antwortet 404 mit Status "noimageset", CLI und Panel sagen "FEHLT". server.py und die Stations-Unit muessen mit: die Auswahl der ausgelieferten Datei ist genau die Aufgabe des Servers, und ein Symlink-Tausch haette "gewaehlt, aber nicht vorhanden" von "dieses Modell fehlt" nicht mehr unterscheidbar gemacht.
This commit is contained in:
@@ -64,17 +64,29 @@ TEXT_MARGIN_PX = 2
|
||||
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"):
|
||||
# gross und mittig, damit man quer durch den Raum sieht, dass nichts
|
||||
# laeuft.
|
||||
# footer - eine Zeile am UNTEREN Panelrand, ueber einen Trennstrich abgesetzt,
|
||||
# unabhaengig vom Zeilenfluss darueber. Genau ein Block benutzt sie: der
|
||||
# Bildsatz-Umschalter (mit oder ohne einkompilierte WLAN-Zugangsdaten).
|
||||
# Er gehoert dorthin, weil er ein DAUERZUSTAND ist und nicht zum
|
||||
# wechselnden Inhalt: er gilt in jedem Modus, auch bei ausgeschalteter
|
||||
# Station, und man soll ihn vor dem Einschalten sehen - nicht erst,
|
||||
# wenn zwanzig Lampen mit den eigenen Zugangsdaten geflasht sind.
|
||||
# Waere er eine gewoehnliche Beiwerkzeile, fiele er im vollsten Zustand
|
||||
# als letzte unten heraus, also ausgerechnet waehrend einer laufenden
|
||||
# Migration.
|
||||
ROLE_HEADER = "header"
|
||||
ROLE_HEADER_PHASE = "header_phase"
|
||||
ROLE_HERO = "hero"
|
||||
ROLE_DETAIL = "detail"
|
||||
ROLE_BIG = "big"
|
||||
ROLE_FOOTER = "footer"
|
||||
|
||||
HEADER_FONT_PX = 20
|
||||
HEADER_PHASE_FONT_PX = 13
|
||||
HERO_FONT_PX = 16
|
||||
DETAIL_FONT_PX = 11
|
||||
BIG_FONT_PX = 26
|
||||
FOOTER_FONT_PX = 11
|
||||
|
||||
# Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe:
|
||||
# Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem
|
||||
@@ -94,6 +106,16 @@ DETAIL_LINE_HEIGHT_PX = 13
|
||||
# bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten.
|
||||
BOTTOM_MARGIN_PX = 10
|
||||
|
||||
# Das Band am unteren Rand fuer ROLE_FOOTER (Trennstrich + eine 11-px-Zeile). Es liegt
|
||||
# UEBER BOTTOM_MARGIN_PX, der Rand bleibt also weiterhin tintenfrei; der Zeilenfluss
|
||||
# darueber bricht entsprechend frueher ab. Das kostet im vollsten Zustand (Migration mit
|
||||
# erkannter Lampe) eine Beiwerkzeile - die unterste, also die unwichtigste nach der
|
||||
# Sortierung in blocks(). Der Preis ist gewollt: welcher Bildsatz gilt, ist die einzige
|
||||
# Auskunft auf diesem Panel, die man BRAUCHT, bevor etwas passiert.
|
||||
FOOTER_BAND_HEIGHT_PX = 14
|
||||
# Abstand des Trennstrichs zur Fusszeile.
|
||||
FOOTER_RULE_GAP_PX = 2
|
||||
|
||||
# Mindestabstand zwischen dem Ende des Modus-Texts und dem Anfang der Phase in der
|
||||
# Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten
|
||||
# beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: <langer Zielname>")
|
||||
@@ -167,6 +189,17 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
||||
ARMED_LABEL = "FREIGEGEBEN"
|
||||
ARMED_DETAIL_PREFIX = "Freigabe: "
|
||||
|
||||
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
|
||||
# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts,
|
||||
# "Lampe: sofort ins WLAN" sagt ihm genau das, was er wissen muss, bevor er einschaltet.
|
||||
# Der gefaehrliche Zustand steht in Grossbuchstaben - er ist der, den man uebersehen soll
|
||||
# und darf.
|
||||
IMAGESET_LABELS = {
|
||||
"ohne-wlan": "Lampe -> Onboarding-AP (ohne WLAN-Daten)",
|
||||
"mit-wlan": "Lampe -> SOFORT INS EIGENE WLAN",
|
||||
}
|
||||
IMAGESET_MISSING_SUFFIX = " - SATZ FEHLT"
|
||||
|
||||
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
|
||||
# "-dev" kann in der Produkt-ID stehen, in der Version, oder in beiden. Die Quelle steht
|
||||
# mit auf dem Panel, weil genau hier am 2026-09-15 ein falsch signiertes Image
|
||||
@@ -217,6 +250,10 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
|
||||
mode = state.get("mode")
|
||||
out: list[tuple[str, str]] = []
|
||||
# Die Fusszeile haengt an JEDEM Statusbild - auch an "Station aus" und an "Status
|
||||
# unbekannt", die beide sofort zurueckkehren. Sie wird deshalb hier gebaut und unten
|
||||
# bei jedem Rueckgabepunkt angehaengt.
|
||||
footer = _imageset_blocks(state)
|
||||
|
||||
if mode == "off":
|
||||
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
|
||||
@@ -224,14 +261,14 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
|
||||
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
|
||||
out.append(("Station aus", ROLE_BIG))
|
||||
return out
|
||||
return out + footer
|
||||
|
||||
if mode not in ("recovery", "migration"):
|
||||
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
|
||||
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
|
||||
# unterstellen.
|
||||
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG))
|
||||
return out
|
||||
return out + footer
|
||||
|
||||
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
||||
|
||||
@@ -338,7 +375,22 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
|
||||
f"{last.get('status') or '?'}", ROLE_DETAIL))
|
||||
|
||||
return out
|
||||
return out + footer
|
||||
|
||||
|
||||
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).
|
||||
Fehlt das Verzeichnis des GEWAEHLTEN Satzes, sagt die Zeile das - dann liefert
|
||||
server.py naemlich nichts aus, und ein Panel, das trotzdem nur den Namen zeigt,
|
||||
behauptet einen Betrieb, den es nicht gibt."""
|
||||
name = state.get("imageset")
|
||||
label = IMAGESET_LABELS.get(name)
|
||||
if not label:
|
||||
return []
|
||||
if state.get("imageset_ok") is False:
|
||||
label += IMAGESET_MISSING_SUFFIX
|
||||
return [(label, ROLE_FOOTER)]
|
||||
|
||||
|
||||
def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
|
||||
@@ -501,6 +553,7 @@ def compose(state: dict):
|
||||
ROLE_HERO: (HERO_FONT_PX, True),
|
||||
ROLE_DETAIL: (DETAIL_FONT_PX, False),
|
||||
ROLE_BIG: (BIG_FONT_PX, True),
|
||||
ROLE_FOOTER: (FOOTER_FONT_PX, True),
|
||||
}
|
||||
loaded: dict = {}
|
||||
|
||||
@@ -532,6 +585,36 @@ def compose(state: dict):
|
||||
has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list)
|
||||
header_mode_right_edge_px = 0.0
|
||||
|
||||
# Die Fusszeile sitzt an einem FESTEN Platz am unteren Rand, nicht im Zeilenfluss -
|
||||
# der Zeilenfluss darueber muss deshalb frueher abbrechen. Ohne Fusszeile bleibt die
|
||||
# Grenze exakt die alte (BOTTOM_MARGIN_PX), damit sich an allen bisherigen Bildern
|
||||
# nichts verschiebt.
|
||||
footer_texts = [text for text, role in block_list if role == ROLE_FOOTER]
|
||||
block_list = [(text, role) for text, role in block_list if role != ROLE_FOOTER]
|
||||
has_footer = bool(footer_texts)
|
||||
footer_top_px = PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX - FOOTER_BAND_HEIGHT_PX
|
||||
flow_limit_px = footer_top_px if has_footer else PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX
|
||||
# ROLE_BIG bekommt das ganze Panel (bisheriges Verhalten) bzw. alles oberhalb der
|
||||
# Fusszeile - der untere Rand ist dort kein Abbruchkriterium, sondern nur eine
|
||||
# Reserve fuer den Zeilenfluss.
|
||||
big_height_px = footer_top_px if has_footer else PANEL_HEIGHT_PX
|
||||
|
||||
# Die Fusszeile wird VOR dem Zeilenfluss gezeichnet und ist aus block_list bereits
|
||||
# herausgenommen - sonst haette sie der "break" der Abbruchbedingung weiter unten
|
||||
# verschluckt, sobald der Zeilenfluss vorher voll ist (also ausgerechnet im vollsten
|
||||
# Zustand, waehrend einer laufenden Migration). Sie sitzt an festem Platz; ihre
|
||||
# Reihenfolge innerhalb von blocks() spielt fuer das Bild keine Rolle.
|
||||
if has_footer:
|
||||
footer_font = font_for(ROLE_FOOTER)
|
||||
# Trennstrich ueber die volle Breite, darunter die Zeile - so ist auf einen
|
||||
# Blick klar, dass das keine weitere Beiwerkzeile ist, sondern ein Zustand,
|
||||
# der unabhaengig vom Rest gilt.
|
||||
draw.line((0, footer_top_px, PANEL_WIDTH_PX - 1, footer_top_px), fill=0)
|
||||
_draw_text(draw, fit(footer_texts[0], footer_font), footer_font,
|
||||
top=footer_top_px + FOOTER_RULE_GAP_PX,
|
||||
height=FOOTER_BAND_HEIGHT_PX - FOOTER_RULE_GAP_PX,
|
||||
fill=0, align="center")
|
||||
|
||||
y = TEXT_MARGIN_PX
|
||||
for raw_text, role in block_list:
|
||||
font = font_for(role)
|
||||
@@ -540,7 +623,9 @@ def compose(state: dict):
|
||||
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
|
||||
# Panel.
|
||||
text = fit(raw_text, font)
|
||||
_draw_text(draw, text, font, top=0, height=PANEL_HEIGHT_PX, fill=0,
|
||||
# Mittig im Bereich UEBER der Fusszeile (ohne Fusszeile: im ganzen Panel) -
|
||||
# sonst sitzt "Station aus" sichtbar zu tief und beruehrt den Trennstrich.
|
||||
_draw_text(draw, text, font, top=0, height=big_height_px, fill=0,
|
||||
align="center")
|
||||
continue
|
||||
|
||||
@@ -573,7 +658,7 @@ def compose(state: dict):
|
||||
|
||||
text = fit(raw_text, font)
|
||||
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
|
||||
if y + line_height > PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX:
|
||||
if y + line_height > flow_limit_px:
|
||||
# Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX
|
||||
# reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die
|
||||
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
|
||||
|
||||
Reference in New Issue
Block a user