Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0379445e8 | ||
|
|
58cdc73afc |
+84
-1
@@ -234,6 +234,16 @@ items:
|
||||
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
|
||||
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:
|
||||
name: Get System Settings
|
||||
type: http
|
||||
@@ -251,6 +261,12 @@ items:
|
||||
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
|
||||
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
|
||||
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:
|
||||
name: Set WiFi
|
||||
type: http
|
||||
@@ -350,10 +366,77 @@ items:
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
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:
|
||||
name: Commands
|
||||
type: folder
|
||||
seq: 8
|
||||
seq: 10
|
||||
items:
|
||||
- info:
|
||||
name: Ping
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user