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:
@@ -58,6 +58,18 @@ SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
|
||||
# RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also
|
||||
# ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet.
|
||||
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
|
||||
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" (siehe
|
||||
# ninlight-station, bildsatz_cmd, und server.py:read_image_set). NUR GELESEN, und
|
||||
# absichtlich aus derselben Datei wie der Server - nicht aus status.json: der Zustand
|
||||
# muss auch dann auf dem Panel stehen, wenn die Station AUS ist. Genau davor soll er
|
||||
# schuetzen ("man soll vor jedem Einschalten sehen, was gilt"), und ausgeschaltet
|
||||
# schreibt server.py nichts mehr.
|
||||
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
||||
"/var/lib/ninlight-station/imageset"))
|
||||
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
|
||||
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
|
||||
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||
IMAGESET_DEFAULT = "ohne-wlan"
|
||||
|
||||
POLL_INTERVAL_S = 5
|
||||
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
|
||||
@@ -197,8 +209,37 @@ def _service_active(service_name: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool]:
|
||||
"""(Name des eingestellten Bildsatzes, liegt er ueberhaupt auf dem Pi?).
|
||||
|
||||
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
|
||||
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
|
||||
Panel etwas anderes behaupten als der Server tut.
|
||||
|
||||
`ok` ist False, wenn der GEWAEHLTE Satz nicht auf dem Pi liegt - nach derselben
|
||||
Regel wie server.py:migrate_set_present(): Verzeichnis vorhanden UND mindestens ein
|
||||
Image darin. Dann liefert server.py nichts aus (Status "noimageset"), und genau das
|
||||
muss am Panel stehen, statt dass der Bediener vor einer Lampe steht, die nichts
|
||||
bekommt. Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts
|
||||
behauptet: dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
|
||||
try:
|
||||
value = imageset_path.read_text().strip()
|
||||
except OSError:
|
||||
value = ""
|
||||
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
|
||||
image_dir = conf.get("IMAGE_DIR")
|
||||
if not image_dir:
|
||||
return name, True
|
||||
set_dir = Path(image_dir) / IMAGESET_DIRS[name]
|
||||
try:
|
||||
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
|
||||
except OSError:
|
||||
return name, False
|
||||
|
||||
|
||||
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||
service_name: str = SERVICE_NAME) -> dict:
|
||||
service_name: str = SERVICE_NAME,
|
||||
imageset_path: Path = IMAGESET_FILE) -> dict:
|
||||
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
||||
|
||||
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
|
||||
@@ -211,14 +252,22 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||
|
||||
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
|
||||
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
|
||||
alles, wenn der Hauptdienst gar nicht laeuft."""
|
||||
alles, wenn der Hauptdienst gar nicht laeuft.
|
||||
|
||||
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
|
||||
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
|
||||
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
|
||||
Menue auffaellt, hat genau den Zweck verfehlt."""
|
||||
conf = _read_conf(conf_path)
|
||||
imageset, imageset_ok = _read_imageset(imageset_path, conf)
|
||||
|
||||
if not _service_active(service_name):
|
||||
return {"mode": "off"}
|
||||
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok}
|
||||
|
||||
mode = _read_mode_file(run_dir)
|
||||
status = _read_json(run_dir / "status.json")
|
||||
conf = _read_conf(conf_path)
|
||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")}
|
||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
||||
"imageset": imageset, "imageset_ok": imageset_ok}
|
||||
|
||||
if mode == "recovery":
|
||||
state["ssid"] = conf.get("SSID")
|
||||
|
||||
@@ -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