2 Commits
Author SHA1 Message Date
Michael Sauer e0379445e8 docs(bruno): Standalone-Anfragen und die neuen Felder in system/info 2026-10-02 23:50:15 +02:00
Michael Sauer 58cdc73afc 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.
2026-10-02 23:44:37 +02:00
4 changed files with 274 additions and 5 deletions
+84 -1
View File
@@ -234,6 +234,16 @@ items:
Enthält `last_ota_error`: `null`, solange kein Update fehlgeschlagen ist, sonst einer von Enthält `last_ota_error`: `null`, solange kein Update fehlgeschlagen ist, sonst einer von
`download`, `crc`, `trailer`, `device_mismatch`, `hash`, `busy` (`busy` heißt: es läuft bereits `download`, `crc`, `trailer`, `device_mismatch`, `hash`, `busy` (`busy` heißt: es läuft bereits
ein Update). Kein `reset_reason` mehr im Feld-Satz. ein Update). Kein `reset_reason` mehr im Feld-Satz.
`wifi.standalone` unterscheidet den dauerhaften eigenen Zugangspunkt vom
Onboarding-AP - `wifi.mode` ist bei beiden `"ap"`. Im AP-Betrieb steht unter
`wifi.ssid` der **laufende** AP-Name, nicht die gespeicherte Stations-SSID; der
Name des Heimnetzes steht damit nicht in dieser tokenfrei lesbaren Antwort.
`sacn.running` sagt, ob der Empfangs-Task lebt und einen Socket haelt - zusammen
mit `sacn.active` (es kommt gerade ein gueltiger Strom an) und `sacn.multicast`
(der letzte Gruppenbeitritt hat geklappt) ist damit unterscheidbar, ob niemand
sendet oder ob der Empfang tot ist.
- info: - info:
name: Get System Settings name: Get System Settings
type: http type: http
@@ -251,6 +261,12 @@ items:
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
eine verbundene SSID zeigen. eine verbundene SSID zeigen.
Der `standalone`-Block zeigt `enabled`, den **gewuenschten** AP-Namen (`ssid`: der
gespeicherte, ohne eigenen der Hostname), `channel` und `pulse`. Das AP-Passwort
steht nie darin. Der gewuenschte Name kann vom laufenden abweichen, den
`GET system/info` meldet - genau das ist das Erkennungsmerkmal fuer einen Rueckfall
auf die Vorgaben nach mehrfach gescheitertem AP-Aufbau.
- info: - info:
name: Set WiFi name: Set WiFi
type: http type: http
@@ -350,10 +366,77 @@ items:
timeout: 0 timeout: 0
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
- info:
name: Standalone einschalten
type: http
seq: 8
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "standalone",
"enabled": true,
"password": "geheim123",
"ssid": "Buehne",
"channel": 6,
"pulse": true
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Die Lampe spannt dauerhaft einen eigenen Zugangspunkt auf, statt sich in ein WLAN
einzuwaehlen - danach ist sie unter `192.168.4.1` erreichbar, per mDNS als
`<hostname>.local`. Antwort **202** `{"status":"standalone"}`, nicht 200: die
Antwort muss das Geraet verlassen haben, bevor das Netz unter der Verbindung
weggezogen wird.
`password` ist beim Einschalten Pflicht, solange keines gespeichert ist (8..63
Zeichen). `ssid` (1..32), `channel` (1..13, Vorgabe 6) und `pulse` sind optional
und behalten sonst ihren gespeicherten Wert; ein leerer `ssid`-String wird mit 400
`invalid ssid` abgelehnt.
**Achtung beim Ausprobieren:** Aenderst du Name oder Passwort, waehrend du ueber
genau diesen AP verbunden bist, baut die Lampe ihn neu auf und wirft dich hinaus -
die 202 kommt noch an, danach musst du dem neuen Netz beitreten.
Anders als der unkonfigurierte Onboarding-AP ist der Standalone-AP **nicht**
token-frei: ab da gilt der Tokenschutz wie im Normalbetrieb.
- info:
name: Standalone ausschalten
type: http
seq: 9
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "standalone",
"enabled": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Braucht kein Passwort. Hat die Lampe gespeicherte WLAN-Daten, waehlt sie sich damit
wieder ein; ohne solche oeffnet sie den unkonfigurierten Onboarding-AP
`ninlight-<mac4>` - der ist dann wieder token-frei.
Der gespeicherte AP-Name bleibt dabei in den Einstellungen stehen und greift beim
naechsten Einschalten des Modus erneut.
- info: - info:
name: Commands name: Commands
type: folder type: folder
seq: 8 seq: 10
items: items:
- info: - info:
name: Ping name: Ping
+77 -2
View File
@@ -319,6 +319,77 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tupl
return name, False 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: def _route_lookup() -> str:
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt. """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 # (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen. # wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
geraet, adresse = local_address() 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, 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") status = _read_json(run_dir / "status.json")
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, "imageset": imageset, "imageset_ok": imageset_ok,
"activity": _recent_request(status)} "activity": _recent_request(status),
"image_version": delivered_version(conf, imageset, mode)}
if mode == "recovery": if mode == "recovery":
state["ssid"] = conf.get("SSID") state["ssid"] = conf.get("SSID")
+53
View File
@@ -76,6 +76,11 @@ TEXT_MARGIN_PX = 2
# Migration. # Migration.
ROLE_HEADER = "header" ROLE_HEADER = "header"
ROLE_HEADER_PHASE = "header_phase" 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_HERO = "hero"
ROLE_DETAIL = "detail" ROLE_DETAIL = "detail"
ROLE_BIG = "big" 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 # verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite. # Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
ARMED_LABEL = "ARMED" 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: " ARMED_DETAIL_PREFIX = "Armed: "
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert # 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)) out.append((f"{geraet}: {ip}", ROLE_DETAIL))
else: else:
out.append((f"IP: {ip}", ROLE_DETAIL)) out.append((f"IP: {ip}", ROLE_DETAIL))
out += _delivered_version_blocks(state, ROLE_DETAIL_RIGHT)
return out + footer return out + footer
if mode not in ("recovery", "migration"): if mode not in ("recovery", "migration"):
@@ -348,6 +357,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
ssid = state.get("ssid") ssid = state.get("ssid")
if mode == "recovery": if mode == "recovery":
out += _delivered_version_blocks(state, ROLE_HEADER_PHASE)
# Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die # Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
# Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin # Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
# wie vor der zweiten Hardware-Abnahme, siehe dort). # 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") lamp_version = last_seen.get("lamp_version")
if lamp_version: if lamp_version:
out.append((f"FW: {lamp_version}", ROLE_DETAIL)) 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: if ssid and armed_target is None:
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die # Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
# SSID zweimal auf dem Panel. # SSID zweimal auf dem Panel.
@@ -462,6 +479,19 @@ def blocks(state: dict) -> list[tuple[str, str]]:
return out + footer 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]]: def _imageset_blocks(state: dict) -> list[tuple[str, str]]:
"""Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt """Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt
(aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test). (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_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
ROLE_HERO: (HERO_FONT_PX, True), ROLE_HERO: (HERO_FONT_PX, True),
ROLE_DETAIL: (DETAIL_FONT_PX, False), ROLE_DETAIL: (DETAIL_FONT_PX, False),
ROLE_DETAIL_RIGHT: (DETAIL_FONT_PX, False),
ROLE_BIG: (BIG_FONT_PX, True), ROLE_BIG: (BIG_FONT_PX, True),
ROLE_FOOTER: (FOOTER_FONT_PX, True), ROLE_FOOTER: (FOOTER_FONT_PX, True),
} }
@@ -700,6 +731,10 @@ def compose(state: dict):
fill=0, align="center") fill=0, align="center")
y = TEXT_MARGIN_PX 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: for raw_text, role in block_list:
font = font_for(role) font = font_for(role)
@@ -740,6 +775,22 @@ def compose(state: dict):
# Ueberlappen mit dem Modus ist nicht erlaubt. # Ueberlappen mit dem Modus ist nicht erlaubt.
continue 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) text = fit(raw_text, font)
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
if y + line_height > flow_limit_px: if y + line_height > flow_limit_px:
@@ -748,6 +799,8 @@ def compose(state: dict):
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert. # Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
break break
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0) 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 y += line_height
return image return image
+60 -2
View File
@@ -957,9 +957,11 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
last_mode_path=tmp_path / "last-mode") last_mode_path=tmp_path / "last-mode")
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im # "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 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) 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 " "{}: {!r} misst {:.1f} px; mit {:.0f} % Reserve fuer eine breitere Schrift "
"bleiben nur {:.1f} px Platz".format( "bleiben nur {:.1f} px Platz".format(
source, text, width_px, (FOOTER_WIDTH_SAFETY - 1) * 100, max_width_px)) 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