Author SHA1 Message Date
Robin von Bülow 19a0d6539a docs(bulb-map): Montana-Black-Shield (f960) ergaenzt 2026-09-25 17:53:43 +02:00
Robin von Bülow 41088b13ae chore(tools): qconnex-Migrationswerkzeug und HA-Integration ins Repo
qconnex-listen/ (OTA-Falle: AP + DHCP + Range-Server, Onboarding-Client) und
ha-integration/ (Home-Assistant-custom_component ninlight) waren seit der
Erfindung nur auf diesem Rechner. Bilder, Request-Log, hostapd-Log und PIDs
ueber .gitignore draussen - die Images sind generierbar, sobald die
Signierschluessel erreichbar sind. otaserve.py bildet jetzt auch die
Montana-Black-Produkt-ID auf ledshield_rgb ab.
2026-09-25 17:41:47 +02:00
Robin von Bülow 09ee206906 feat(station): Montana-Black-Produkt-ID -> Ziel ledshield_rgb
Meldet eine Lampe @qcx.montana.black.ledshield.rgb(-dev), sucht die Station
jetzt ledshield_rgb_(dev|prod).img statt zufaellig das E27-Image.
Produktname/Ziel aus qcl_product_info.c der Original-Firmware.
2026-09-25 17:39:41 +02:00
Robin von Bülow 8294688e37 feat(board): ledshield_rgb — Montana Black RGB-LED-Shield (analoge RGB-Streifen)
Viertes Analog-Profil neben den SM2135EH-Lampen: PWM-Kanaele R=PB13 (ch1),
G=PB16 (ch2), B=PB15 (ch3) bei 800 Hz, PIN-belegt aus der Original-Firmware
(qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD). Kein CW/WW-Zweig:
ct_min == ct_max ist das Erkennungszeichen fuer 'weiss = RGB-Mischung',
nl_color.c gibt in diesem Fall alle drei Farbkanalgleich.

Neues Bauziel in allen Skripten; test_release zaehlt Dateizahlen jetzt aus
TARGETS statt hart 8/16.
2026-09-25 17:26:46 +02:00
Robin von Bülow a3a48cdf68 build: tools/podman.sh — docker.sh-Pendant für podman (gleiches Image, gleicher Mount) 2026-09-21 17:50:36 +02:00
Robin von Bülow 1c960ad5cc Überträgt lokale Änderungen (nl_crc8 unsigned-shift, docker.sh cd -P) in neuen Top-Level-Tree und entfernt altes ninlight-w600/ 2026-09-21 14:35:22 +02:00
Robin von Bülow cc14efea01 Merge ninharp/ninlight-w600 origin/main 2026-09-21 14:05:38 +02:00
Robin von Bülow ea711b6264 Initial commit 2026-09-15 20:54:23 +02:00
Michael Sauer 71a579c0f9 fix(display): Abschlussbild sagt 'Strom trennen erlaubt' - der alte Satz war abgeschnitten
Zweiter Fund derselben Art wie die Bildsatz-Fusszeile: 'Strom kann getrennt werden'
misst in der Hero-Schrift 262,1 px bei 246 px Platz und kam am Geraet als
'Strom kann getrennt wer...' an.

Der Test deckt jetzt ALLE festen Beschriftungen ab (Abschlussbilder, beide Bildsaetze,
beide Fehlt-Warnungen, Freigabe), nicht nur die zuerst entdeckte Stelle - sonst waere
genau dieser zweite Fall wieder durchgerutscht. Der Wortlauttest prueft die Zusage
('trennen erlaubt' gegen 'NICHT trennen') statt des genauen Satzes.
2026-09-15 10:41:42 +02:00
Michael Sauer f4dd1386ef fix(display): Fusszeile des Bildsatzes passt aufs Panel, auch die Fehlt-Warnung
Am Geraet kam die Zeile gekuerzt an ('... (ohne WLA...'), und mit dem Zusatz
' - SATZ FEHLT' war die Warnung in beiden Richtungen ausserhalb der Panelbreite -
also genau die Zeile, die sagt, dass die Station nichts ausliefern kann.

Unbemerkt blieb das, weil _load_font() auf dem Pi DejaVu nimmt und auf dem Mac bis
Arial durchfaellt: dieselbe Zeile misst dort 246,0 von 246 px, auf dem Pi 283,5 px.
Der neue Test misst deshalb mit 25 % Reserve gegen eine breitere Schrift.

Die Fehlt-Warnung ersetzt die Zeile jetzt, statt sie zu verlaengern.
2026-09-15 10:37:30 +02:00
Michael Sauer c482194503 fix(station): deploy.sh prueft Bildsatz 'ohne-wlan' auf Zugangsdaten
release.sh prueft sein fertiges Archiv binaer gegen die Werte aus
config.local.cmake und bricht ab, wenn es etwas findet. deploy.sh signierte
dagegen ungeprueft, was in build-nocreds/ lag - und genau dieser Satz ist die
sichere Vorgabe, die im Automatikmodus an fremde Lampen in Funkreichweite geht.

Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand
konfiguriertes build-nocreds/<ziel>, ein gesetztes
NINLIGHT_NOCREDS_BUILD_REL=build. Das Ergebnis waere, dass das Panel
"ohne WLAN" anzeigt, waehrend das WLAN-Passwort des Bedieners unwiderruflich
in fremde Lampen wandert.

Derselbe Pruefkoerper wie in release.sh, angewandt auf $STAGE/migrate-nocreds
und VOR dem Uebertragen - bei einem Treffer geht kein Byte an den Pi. Szenario
33 stellt den undichten Abzug nach (mit Gegenprobe, dass der Abzug die
Zugangsdaten wirklich traegt), Szenario 34 grenzt ab, dass migrate-wlan sie
weiterhin tragen darf.

Mutationsproben: Pruefblock entfernt = 6 Zusagen rot (die Zugangsdaten landen
beim Ziel); Pruefung auf den ganzen STAGE ausgeweitet = 4 Zusagen rot.
2026-09-15 04:05:03 +02:00
Michael Sauer fa93259426 test(station): Freigabe bleibt bei fehlendem Image und Abbruch stehen
Von den drei restore_armed()-Pfaden in _do_get_migration() war nur der
juengste (fehlender Bildsatz) getestet. Das Abschlussreview hat die beiden
aelteren mit je einem "if False:" ausgehebelt - beide Male blieben alle 225
Tests gruen, obwohl eine Freigabe dann verfaellt, ohne dass ein Byte geflossen
ist.

Zwei Tests: Image fehlt im vorhandenen Bildsatz (Status notfound) und Transfer
abgebrochen (--stall-after, Verbindung geschlossen, Status aborted). Beide
pruefen phase UND armed_target. Beide Mutationen anschliessend gegengeprueft -
je genau der zugehoerige Test wird rot.

MigrationTestServer nimmt jetzt zusaetzliche Serverargumente entgegen, damit
--stall-after auch auf der Migrationsroute nutzbar ist.
2026-09-15 03:48:25 +02:00
Michael Sauer bbe2e842b6 test(station): Ausfallpfad der Anzeige in allen vier Modi festhalten
Die Zusage "fehlt Panel, SPI, Pillow oder Schrift: genau eine Protokollzeile
und sys.exit(0)" war ungetestet - das Abschlussreview hat sys.exit(0) durch
sys.exit(1) ersetzt und alle 225 Tests blieben gruen. Die vorhandenen Tests
pruefen nur, dass die richtigen Ausnahmetypen bis main() durchkommen, niemand
hat main() selbst in den except-Zweig getrieben.

Acht neue Faelle: DisplayUnavailable und ImportError, je fuer --test, --clear,
--final und den Daemon. Mutationsproben: exit 0 -> 1 macht alle acht rot, die
Ausnahmeliste auf DisplayUnavailable verengt macht die vier ImportError-Faelle
rot.
2026-09-15 03:46:37 +02:00
Michael Sauer 95ae51bc1d docs: Abnahmeprotokoll nennt den off/Anzeigedienst-Befund als behoben
Der Absatz stand noch auf "Reparatur laeuft", obwohl 0fe9ca6 den Befund
behoben hat. Das Protokoll haengt am naechsten Geraetetermin - dort darf nichts
Falsches stehen. Der Befund bleibt sichtbar, ergaenzt um die Loesung und die
beiden Testfaelle, die sie festhalten.
2026-09-15 03:45:16 +02:00
Michael Sauer c296e2626e fix(station): nmcli-Werte unmaskiert lesen, Doppelpunkt im Profilnamen
nmcli -t maskiert Doppelpunkte in Werten als \: und cut -d: -f2 schneidet
ohnehin am ersten Doppelpunkt ab. Ein Profilname mit Doppelpunkt lieferte der
Nachpruefung in ap_switch() damit einen abgeschnittenen Wert - und der Fehler
zeigte in die unsichere Richtung: "Station aus" gemeldet, waehrend der
Zugangspunkt weitersendet. Jetzt --escape no plus cut -f2-, auch bei den beiden
Profilabfragen und in der Statusausgabe.

Der nmcli-Stub in test_cli.sh maskiert jetzt wie das echte nmcli, sonst waere
der neue Zusatzfall M1 mit und ohne Fix gruen.
2026-09-15 03:44:50 +02:00
Michael Sauer 4e1a965cbb feat(station): Abschlussbild beim Herunterfahren und beim Neustart
E-Paper behaelt sein Bild dauerhaft, auch stromlos. Faehrt der Pi
herunter, wird der Anzeigedienst mit abgeraeumt und das zuletzt
gezeichnete Bild bleibt fuer immer stehen - bei der Abnahme stand dort
die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als
warte er noch auf eine Eingabe.

Zwei neue Units, ninlight-poweroff.service (WantedBy=poweroff.target)
und ninlight-reboot.service (WantedBy=reboot.target). Sie haengen am
System, nicht am Tasterdienst: so greifen sie bei jedem Herunterfahren -
Menue, "poweroff" per SSH, "reboot" - und nicht nur auf einem von drei
Wegen. Zwei Units statt einer Fallunterscheidung, weil systemd einem
ExecStart nicht sagt, wohin die Reise geht.

Die Texte unterscheiden sich: "AUSGESCHALTET / Strom kann getrennt
werden" gegen "NEUSTART / Strom NICHT trennen!" samt Hinweis auf die
rund zwei Minuten, die der Zero W bis zum Anzeigedienst braucht. An
diesem Pi hat ein Stromzug in genau dieser Wartezeit schon eine
SD-Karte gekostet.

Reihenfolge: die Units ordnen sich per After=ninlight-display.service
hinter dessen Stop ein, und "ninlight-station display final <kind>"
wartet zusaetzlich, bis systemctl is-active nicht mehr anschlaegt -
dasselbe Muster wie display_handover(), nur ohne Wiederanfahren. Gibt
der Dienst die Leitungen nicht frei, wird nicht gezeichnet (GPIO busy),
sondern gewarnt: ein Anzeigefehler haelt das Herunterfahren nie auf.

Nebenbei behoben: display_run() reichte nur "$1" an display.py weiter
und haette "--final poweroff" halbiert.
2026-09-15 03:02:21 +02:00
Michael Sauer 300edafc3e 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.
2026-09-15 02:17:36 +02:00
Michael Sauer 3642672c71 feat(station): Automatikmodus als eigener Menueeintrag am Taster
Das Tastermenue bot bisher nur "Migration starten" an, fest verdrahtet
auf die Horchphase; den Automatikmodus gab es nur ueber die
Kommandozeile - also ausgerechnet dort nicht, wo man ihn braucht, denn
"on" macht wlan0 zum Zugangspunkt und reisst jede SSH-Sitzung ab.

Jetzt zwei getrennte, namentlich unterscheidbare Eintraege:
"Migration: horchen" (--listen) zuerst, danach "Migration: AUTOMATIK"
(ohne --listen). Der Automatikeintrag bekommt eine andere, deutlichere
Rueckfrage ("ACHTUNG" statt "RUECKFRAGE", plus zwei Warnzeilen), weil
der Migrations-Zugangspunkt ein oeffentlich bekanntes Passwort hat und
im Automatikmodus jede alte Klyqa-Lampe in Funkreichweite ungefragt
migriert wird, auch fremde. Die Bedienhinweise stehen weiterhin und
zuletzt.
2026-09-15 01:20:42 +02:00
Michael Sauer 2fe2f7d9f4 feat(tools): Auslieferungsarchiv ohne WLAN-Zugangsdaten
Baut alle acht Ziele ueber den wiederverwendbaren Bauweg tools/build_nocreds.sh
(config.local.cmake liegt per trap beiseite, damit sie nicht als set() jedes -D
schlaegt) und packt ota/, migrate/ (prod+dev signiert), flash/, bruno/,
onboard.sh, README.md und SHA256SUMS nach dist/ninlight-<version>.tar.gz.

Vor dem Packen wird jede Datei binaer gegen SSID, WLAN-Passwort und Token aus
config.local.cmake geprueft; ein Fund bricht den Lauf ab.
2026-09-15 01:07:44 +02:00
Michael Sauer 6325ce073f fix(station): Signiervariante aus Produkt-ID und Version bestimmen
Eine Entwicklungs-G95 meldete sich am 2026-09-15 mit einer Produkt-ID OHNE
"-dev", aber einer Version MIT "-dev". products.py leitete die Variante
ausschliesslich aus der Produkt-ID ab, die Lampe galt als Produktionsgeraet,
bekam das produktionssigniertes Image und lehnte es ab - ihr Bootloader prueft
gegen den Entwicklungsschluessel.

Die Variante ist damit keine Eigenschaft der Produkt-ID, sondern der Anfrage.
PRODUCTS bildet nur noch auf Geraetetyp und Anzeigename ab; beide Schreibweisen
der Produkt-ID fuehren auf denselben Eintrag. classify_variant() in
parse_request() wertet Produkt-ID UND Version aus: "-dev" an einer der beiden
Stellen bedeutet Entwicklungsgeraet, in der Version nur als ganzes Wort
("-devel"/"-dev2" zaehlen nicht).

Damit eine Fehlklassifikation nicht wieder erst an der Lampe auffaellt, fuehren
status.json (last_seen und jeder recent-Eintrag) und das Panel die Quelle der
Einstufung mit: "g95_cw (dev/Ver)", "dev/ID", "dev/ID+Ver" oder "prod".

Tests: die drei am Geraet mitgeschnittenen Anfragen plus die vierte denkbare
Kombination, die Wortgrenze in der Version, zwei Ende-zu-Ende-Tests im Server
(dev-Image statt prod-Image) und die Panel-Beschriftung.
2026-09-15 00:24:29 +02:00
Michael Sauer 6f0137908c fix: SM2135EH-Weissreste beim Wechsel zurueck auf Farbe loeschen
Der RGB-Zweig von nl_sm2135eh_set() schrieb Stromregister, Kanalbyte und
die drei RGB-Werte, liess SM_ADDR_WY (0xC5) aber unangetastet. Der Chip
behielt die zuletzt gesetzte Weiss-Mischung, und beim direkten Wechsel
Weiss -> Farbe (ohne Zwischenschritt ueber dunkel) schaelte sich der
Weissanteil beim Ausblenden der Farbe wieder frei - an einer migrierten
GU10 RGB gemessen.

Das ist dieselbe Klasse Fehler wie in 00d5a4c fuer die Gegenrichtung
behoben (WY-Zweig schrieb nie die RGB-Register), nur bislang unentdeckt
in dieser Richtung. Der RGB-Zweig schreibt jetzt zusaetzlich WY mit
0/0 - ein zweiter Bus-Transfer pro Aktualisierung, symmetrisch zum
WY-Zweig, der ebenfalls zwei Transfers braucht.
2026-09-14 23:59:38 +02:00
Michael Sauer 24b37ee58f feat(station): Tasterdienst per deploy.sh/setup_pi.sh ausliefern 2026-09-14 22:34:56 +02:00
Michael Sauer 82a3942554 feat(station): Bedienung ueber einen Taster mit Panel-Rueckmeldung
Ein Taster auf GPIO23 (active low, 50 ms entprellt) bedient die Station am
Geraet: kurz oeffnet das Menue bzw. blaettert, gehalten waehlt (1,5 s),
bricht ab (3 s, feuert sofort) und faehrt herunter (10 s, nur ueber die
sichtbare Abbruchstufe hinweg). Menue schliesst nach 20 s, Rueckfrage
verfaellt nach 10 s als Abbruch; jede veraendernde Auswahl bekommt eine
Rueckfrage.

Noetig, weil "on"/"migrate on" wlan0 zum Zugangspunkt macht und damit jede
SSH-Sitzung abreisst - bisher blieb dort nur warten oder Stecker ziehen.

"Migration starten" startet ueber das Menue IMMER mit --listen: der
Migrations-AP hat ein oeffentlich bekanntes Passwort, ein Tastendruck im
Automatikmodus wuerde jede alte Klyqa-Lampe in Reichweite ungefragt
migrieren.

Der Tasterdienst fasst den SPI-Bus nicht an (sonst "GPIO busy"), sondern
schreibt seinen Bedienzustand atomar mit 0644 nach
/run/ninlight-button/ui.json - bewusst nicht ins RuntimeDirectory der
Hauptunit, das systemd beim "off" aufraeumt. Der Anzeigedienst liest die
Datei jede halbe Sekunde (der teure build_state()-Fork bleibt beim
5-Sekunden-Takt) und zeichnet sie als Teilaufbau, kostet dem Panel also
keinen Vollaufbau je Tastendruck.

Fehlt gpiozero oder der Taster, gibt es genau eine Protokollzeile und
sys.exit(0) - dieselbe Zusage wie bei der Anzeige.

Ausliefern/Einrichten folgt in einem eigenen Durchgang.
2026-09-14 21:50:37 +02:00
Michael Sauer f0f6894bc9 docs: Ausschaltfade an der migrierten GU10 bestaetigt 2026-09-14 21:10:46 +02:00
Michael Sauer d1535433ce fix(station): Anzeigedienst bleibt ueber on/off hinweg durchgehend an
'off' stoppte bisher den Anzeigedienst und leerte das Panel, aber kein
'on'-Pfad hat ihn je wieder gestartet - ab dem zweiten Ein/Aus blieb das
Panel bis zum naechsten Pi-Neustart dunkel. Die Begruendung dafuer trug
nicht: der Anzeigedienst erkennt den ausgeschalteten Zustand selbst
(systemctl is-active ninlight-station) und zeichnet von sich aus
"Station aus".

'off' fasst den Anzeigedienst jetzt nicht mehr an; er laeuft durchgehend
(wie seit dem Booten ohnehin) und zeigt den jeweils aktuellen Zustand.
display_off() bleibt fuer den expliziten Diagnosebefehl
'ninlight-station display off' erhalten. Enthaelt zusaetzlich die schon
im Arbeitsbaum liegende PHASE_LABELS-Korrektur in render.py
(HORCHT -> LISTEN).
2026-09-14 21:09:50 +02:00
Michael Sauer 5f3cf25e41 docs: Abnahme der Migrationsstation an einer echten GU10 RGB 2026-09-14 20:53:01 +02:00
Michael Sauer 1f01b29b8e feat(station): optionaler Zeitschalter schaltet die Station selbst ab
"on"/"recovery on"/"migrate on" nehmen jetzt "--for <minuten>" (1-240): die
Station schaltet sich danach selbsttaetig ab und kehrt ins Heim-WLAN zurueck.
Das hilft, wenn man ueber WLAN angemeldet ist - "on" macht wlan0 zum
Zugangspunkt und reisst jede SSH-Sitzung ab.

Ausdruecklich optional: ohne "--for" entsteht kein Zeitschalter und alles
verhaelt sich exakt wie bisher. Dazu "renew [<minuten>]" (Uhr zurueckstellen),
"hold" (abbestellen, Station bleibt an) und eine Restzeit in "status"; "off"
raeumt einen laufenden Zeitschalter mit ab.

Umgesetzt als transienter systemd-Timer unter festem Unit-Namen (systemd-run),
Zustand in RUN_DIR/autooff.json. Vor dem Abschalten wird per "ss" geprueft, ob
noch eine Verbindung auf Port 80 aus dem Stationsnetz offen ist - dann wird um
rund eine Minute verschoben, hoechstens zehnmal (Begruendung im Kommentar).
Das Panel zeigt den Zeitschalter als eigene Zeile, aber nur wenn einer laeuft.
2026-09-14 19:39:42 +02:00
Michael Sauer f105a0eb69 fix(display): Freigabe-Ziel lesbar, Tests schriftunabhaengig
Sechs Review-Befunde an e2d836a/2aab1b0.

C1: die Pixelschwellen in test_render.py waren gegen Pillow 12/Arial
kalibriert und auf dem Pi wie auf Debian 13 (DejaVu) rot - "> 150
schwarze Pixel in Zeile 11" sind dort nur 143. Die Zusagen ueber den
Kopfbalken sind jetzt Anteile und Symmetrien statt Pixelzahlen
(mindestens 55 % des Balkens schwarz, weisse Schrift links UND rechts
der Mitte, Balken ueber die volle Breite). Zwei weitere schriftabhaengige
Schwellen ebenso: die grosse Zeile hebt sich ueber das VERHAELTNIS der
Tintenhoehen vom Beiwerk ab, und die Randgrenze zaehlt Zeilenbaender
statt Tinten-Cluster.

I2: im Zustand "armed" stand "FREIGEGEBEN: <ziel>" in der Kopfzeile,
wo 112 px Platz sind und der Text 182 px misst - gezeichnet wurde
"FREIGEGEBE…", bei jedem Ziel gleich. Im Kopf steht jetzt nur noch das
Label, der Zielname als eigene Beiwerkzeile ueber die volle Breite; die
SSID-Zeile gibt dafuer in "armed" ihren Platz ab. Geprueft wird das
gezeichnete Bild (Pixelvergleich gegen eine Referenzzeile), nicht
lines().

I3: der Kommentar an display_handover() begruendete die
Rueckgabewert-Durchreichung damit, dass der Handgriff bei stummem Panel
rot werde. Das stimmt nicht - display.py beendet sich bei fehlendem
Panel/SPI/Pillow mit 0. Der Kommentar sagt jetzt, dass man die Revision
am Panel beurteilt und nicht am Exit-Code.

I4: zwischen "systemctl stop" und "systemctl start" gab es keinen
Signalhandler - ein Strg-C waehrend "display test" (der dokumentierte
Weg, die Revision zu bestimmen, bei falscher Revision haengt das Panel)
liess den Anzeigedienst gestoppt zurueck. trap auf INT/TERM/HUP, das
Wiederanfahren in display_service_resume(), Handler beenden selbst mit
128 + Signalnummer.

M2: HEADER_PHASE_MIN_WIDTH_PX bleibt als Sicherheitsnetz, ist jetzt aber
getestet statt toter Code.

M4: compose() baute je Aufruf fuenf Schriften neu. Prozess-Cache (mit
ImageFont-Modul und FONT_SEARCH_PATHS im Schluessel, damit die Tests
nicht verfaelscht werden) und faules Laden je Rolle: 25,0 ms -> 7,0 ms.

Die Pixeltests laufen zusaetzlich unter Pillow 11.1 mit echtem DejaVu
und unter fuenf weiteren Schriften. Sieben Mutationsproben angewandt,
angesehen und zurueckgenommen. tools/test_host.sh gruen.
2026-09-14 15:12:45 +02:00
Michael Sauer e77d3da292 fix(station): SPI in setup_pi.sh automatisch einschalten
Auf frischem Raspberry Pi OS ist SPI aus, das E-Paper bleibt schwarz mit
irrefuehrender "nicht verfuegbar"-Meldung. setup_pi.sh erkennt jetzt beide
config.txt-Pfade, normalisiert dtparam=spi=on (fehlend/auskommentiert/off),
sichert vorher nach config.txt.bak-ninlight und weist nur bei echter
Aenderung auf den noetigen Neustart hin.
2026-09-14 14:13:56 +02:00
Michael Sauer 03d6328dd5 fix(display): Phase im Kopfbalken, Rand unten, Wants-Fehler behoben
Drei Befunde der Hardware-Abnahme: die Migrationsphase stand als eigene
kleine Zeile unter dem Lampennamen und ging dort unter - sie steht jetzt
rechtsbuendig neben dem Modus in der invertierten Kopfzeile (mit
Kuerzung/kleinerer Schrift/Weglassen als gestaffelte Antwort auf eine zu
lange Phase, nie mit Ueberlappung). Die letzte Beiwerkzeile klebte am
unteren Panelrand - durch das Verschieben der Phase in den Kopf bleiben
nur noch fuenf statt sechs Beiwerkzeilen, zusaetzlich durchgesetzt per
BOTTOM_MARGIN_PX in der Abbruchbedingung von compose().

Ausserdem ein am Geraet nachgewiesener Fund: ninlight-display.service
trug Wants=ninlight-station.service NEBEN dem gewollten After= - Wants=
aktiviert die genannte Unit aber, statt nur zu ordnen, und zog den
absichtlich deaktivierten Stationsdienst bei jedem Boot ohne
Zugangspunkt in einen "failed"-Zustand. Wants= entfernt, After= bleibt.
2026-09-14 03:11:32 +02:00
Michael Sauer bb39105ac4 feat(display): grosse Schrift, invertierter Kopf, GPIO-Uebergabe
Zwei Befunde der Hardware-Abnahme.

1) Die Schrift war zu klein: compose() zeichnete alles in
ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern
nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big),
compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem
Balken, die wichtigste Zeile gross (Migration: erkannte Lampe,
Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig.

Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit
DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden
Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst
ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt
load_default() ohne Argument, damit eine fehlende Schrift die Anzeige
(und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow
importierbar, die Breitenkuerzung misst weiter in Pixeln.

2) "display test" scheiterte bei laufendem Anzeigedienst mit einem
rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine
gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in
epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin
gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler -
und die Meldung nennt bei belegten Leitungen den Anzeigedienst.
"display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs
und startet ihn danach wieder, aber nur, wenn er vorher lief.
2026-09-14 02:24:14 +02:00
Michael Sauer f138e88f4e Merge branch 'main' into plan-4c-migrationsstation
* main:
  fix(board): Farbtemperaturbereiche der Weiss-Varianten korrigieren
2026-09-14 01:40:30 +02:00
Michael Sauer 46b0a198d9 fix(display-service): HOME/WorkingDirectory fuer lgpio unter systemd setzen
Am Pi diagnostiziert: unter systemd ist HOME leer und cwd "/", lgpio legt seine
Benachrichtigungs-Pipe aber unter "$HOME/.lgd-nfy0" an - das wird "//.lgd-nfy0"
und scheitert an ProtectSystem=strict. gpiozero faellt bis zur experimentellen
NativeFactory durch, die dann mit EINVAL abbricht; display.py faengt das als
DisplayUnavailable ab und beendet sich mit 0, sodass der Ausfall wie ein
fehlendes Panel aussieht statt wie die eigentliche Sandbox-Ursache.

RuntimeDirectory=ninlight-display, WorkingDirectory= und Environment=HOME=
zeigen jetzt auf ein eigenes, beschreibbares /run/ninlight-display - bewusst
getrennt vom RuntimeDirectory der Hauptunit, da dieser Dienst dort nur liest.

Szenario 19 in test_setup_pi.sh prueft die drei Zeilen in der ausgelieferten
Unit; per Mutationsprobe bestaetigt, dass das Entfernen jeder einzelnen davon
den Test rot werden laesst.
2026-09-14 01:32:53 +02:00
Michael Sauer 47532a6820 fix(station): ehrliche Abbruchmeldung, apt-update darf scheitern
Fixrunde 2 zu Task 6b.

- Die Vorbedingung "display.py liegt in der Ablage" steht jetzt VOR dem
  ersten sudo install. Vorher stand sie dahinter, und die Meldung "Nichts
  installiert" war schlicht unwahr: server.py, products.py, das CLI und
  die Stations-Unit lagen dann schon auf dem Pi. Ausgerechnet an dem
  Werkbank-Moment, fuer den die Pruefung gedacht ist, log das Skript den
  Bediener an. Die Ablage raeumt ein EXIT-Trap jetzt auf beiden Wegen aus
  dem Block ab, nicht nur im Erfolgsfall.
- "apt-get update" ist keine Voraussetzung mehr fuer "apt-get install":
  update meldet schon dann einen Fehler, wenn eine einzige Quelle nicht
  erreichbar ist, und ein Pi, auf dem das Paket aus dem Cache gekommen
  waere, bekam so still kein Pillow.
- Die Signalhandler beenden jetzt selbst (130/143/129). Ein Handler ohne
  exit laesst bash weiterlaufen - ein Strg-C konnte in "fertig, Exit 0"
  muenden. Die Begruendung im Kommentar war falsch und ist korrigiert:
  zum Aufraeumen haette der EXIT-Trap allein genuegt.
- test_host.sh ruft die Shell-Suiten aus einer Liste auf, und der
  Coverage-Test prueft diese Liste gegen das Dateisystem statt im
  Skripttext nach Dateinamen zu suchen - ein nachgestellter Kommentar
  hebelte die alte Regel aus. Er deckt jetzt ganz tools/ ab, nicht nur
  station/tests.
- Neue Szenarien: Wahrheitsgehalt der Abbruchmeldung, apt-update-Fall,
  und ein Strg-C, das aus den Trap-Zeilen des Skripts selbst geprueft
  wird.
2026-09-14 00:45:04 +02:00
Michael Sauer 7fdccbce72 fix(station): Anzeige-Installation absichern, apt darf nur warnen
Fixrunde 1 zu Task 6b.

- setup_pi.sh raeumt die Ablage vor dem scp ab und bricht ab, wenn
  display.py dort fehlt. Ein Reststand aus einem abgebrochenen Lauf liess
  "scp -r" ins vorhandene Ziel hineinkopieren; die Installationsschleife
  ueberspringt Verzeichnisse und fand nichts - der Lauf meldete Erfolg,
  aktivierte den Anzeigedienst und hinterliess ein leeres display/:
  203/EXEC im 5-Sekunden-Takt bei schwarzem Panel.
- Der Paketschritt steht jetzt hinter der Installation und warnt nur.
  Eine Migrationsstation steht typischerweise nicht am Netz; unter set -e
  davor hiess ein gescheitertes apt: gar keine Station statt einer Station
  ohne Panel. "apt-get update" kommt vor dem install, der Anzeigedienst
  startet nach dem Paketschritt.
- setup_pi.sh raeumt display/ auf dem Pi wie deploy.sh auf (im Repo
  geloeschte Dateien verschwinden), der Trap haengt auch an INT/TERM/HUP,
  und mktemp bekommt eine Vorlage: ohne sie beachtet BSD-mktemp TMPDIR
  nicht, und die Temp-Datei landete je nach Plattform woanders.
- test_host.sh ruft test_deploy.sh auf - 95 Zusagen liefen nur bei
  Handaufruf, darunter die Deploy-Haelfte der Anzeige-Auslieferung. Der
  Coverage-Test deckt jetzt auch die Shell-Suiten ab und zaehlt dabei nur
  nicht auskommentierte Zeilen.
- Neue Szenarien fuer alle vier Punkte, darunter die Zusage, dass beim
  gescheiterten Schreiben eines Profils keine Datei mit dem WLAN-Passwort
  liegenbleibt.
2026-09-14 00:03:49 +02:00
Michael Sauer cc398ef57a feat(station): Anzeige ausliefern, Migrationsprofil, display-Befehle
Schritt 8 des Plans: die fertige E-Paper-Anzeige wird endlich ausgeliefert
und verdrahtet, dazu zwei liegengebliebene Dinge.

- deploy.sh/setup_pi.sh uebertragen display/ (mit Panel-Treibern und
  HERKUNFT.md) und ninlight-display.service; setup_pi.sh installiert und
  aktiviert die Unit und zieht python3-pil nach - ohne Pillow beendet sich
  der Anzeigedienst still mit 0, das faellt sonst nirgends auf.
- setup_pi.sh legt auch das Profil fuer den Migrations-Zugangspunkt an
  (qconnex-firmware, von der alten Klyqa-Firmware fest vorgegeben, gleiche
  Adresse wie das Rettungsprofil). Ohne das scheiterte "migrate on" auf
  einem frisch eingerichteten Pi an need_profile. Bestehende Profile
  bleiben unangetastet.
- "ninlight-station display test|clear|off"; der Hilfetext erklaert die
  Revisionsbestimmung, denn EPD_REVISION laesst sich nur am Testbild
  bestimmen.
- "off" nimmt die Anzeige mit: erst Dienst stoppen, dann Panel leeren
  (sonst schreibt der Daemon binnen 5 s sein altes Bild zurueck) und beides
  vor ap_switch, das bei haengendem NetworkManager abbricht. Ein
  stehengebliebenes "MIGRATION" wuerde einen Betriebszustand behaupten, den
  es nicht mehr gibt.
- test_host.sh sammelt tools/mkimage als Ganzes ein: die 19 Tests in
  test_nl_mkimage.py liefen in keiner Suite mehr mit, darunter die Wache
  fuer die Python-3.8-Randbedingung des Build-Containers. Ein neuer Test
  haelt die Regel fest, dass keine Testdatei mehr herausfallen kann.
- Nebenbei: die Temp-Datei mit dem WLAN-Passwort wird wieder zuverlaessig
  aufgeraeumt, und leere Konfigurationswerte legen kein kaputtes Profil an.
2026-09-13 23:19:14 +02:00
Michael Sauer 22f92988dd test(station): run_daemon selbst treiben statt eine Kopie der Schleife
Die Verdrahtung zwischen RefreshPlanner und Panel - Aktion auf Panel-Methode,
was_full=upgraded, record_sleep(), Reihenfolge decide/ausfuehren/zurueckmelden -
ueberlebte jede Mutation, weil der Test eine nachgebaute Schleife trieb. Am
schlimmsten: der "full"-Zweig auf draw_partial() umgebogen haette bedeutet, dass
es auf einem wachen Panel nie wieder einen echten Vollaufbau gibt.

run_daemon() nimmt jetzt Uhr, Panel und Zustandsquelle entgegen (Vorgaben
unveraendert) und wird im Test mit Fake-Uhr und protokollierendem Panel-Doppel
direkt getrieben; die Nachbildung _run_schedule() entfaellt ersatzlos.
Dazu: RefreshPlanner liest die Intervallkonstanten wieder pro Entscheidung statt
sie als Default-Argument zur Importzeit zu binden, record_sleep() verliert den
toten now-Parameter.
2026-09-13 22:20:11 +02:00
Michael Sauer b8c7febb8f fix(station): Ghosting-Ausgleich nach Buendel, Zeitsteuerung testbar
Der Reset von partials_since_full beim Einschlafen liess den faelligen
Ausgleichs-Vollaufbau dauerhaft ausfallen: nach einem Buendel Teilaufbauten
schlief das Panel mit dem Geisterbild ein und zeigte es stundenlang weiter,
weil stale nie mehr feuern konnte. Reset ersatzlos gestrichen - der Ausgleich
bleibt ausstehend und wird zum 30-Minuten-Termin nachgeholt; der in Fixrunde 3
beseitigte Bias-Zyklus kommt dadurch nicht zurueck.

Die gesamte Zeitsteuerung steckt jetzt in RefreshPlanner statt in run_daemon()s
Endlosschleife und ist ohne Schleife, ohne time.sleep() und ohne Hardware
pruefbar. Neue Tests spielen sechs Stunden Betrieb durch (statischer Inhalt,
Aenderung alle 3/9/11 Minuten, Buendel-dann-Ruhe) und schliessen die drei vom
Reviewer per Mutation belegten Luecken aus Fixrunde 3.
2026-09-13 22:04:39 +02:00
Michael Sauer 38c70ac5ce fix(station): EnvironmentFile-Syntax, Bias-Zyklus, echte Panel-Tests
Fixrunde 3 zu Task 6a. EnvironmentFile=-/etc/ninlight-station.conf statt
-EnvironmentFile= (Fixrunde 2 hatte das Praefix vor den Direktivennamen statt
vor den Dateipfad gesetzt - systemd verwarf die Zeile komplett, die
Konfigurationsdatei wurde ueberhaupt nicht mehr gelesen). clear() stellte die
in Panel dokumentierte Awake-Invariante nicht her (kein 0x26-Referenzpuffer)
und verwendet jetzt wie draw_full() displayPartBaseImage(). Die 30-Minuten-
Vollaufbau-Uhr feuerte bisher auch ohne vorherige Teilaufbauten und erzeugte
im eingeschwungenen Ruhezustand einen endlosen Schlaf-/Wach-Zyklus mit 33
Prozent Bias-Anteil - sie greift jetzt nur noch, wenn seit dem letzten
Vollaufbau tatsaechlich Teilaufbauten stattfanden, und der Zaehler wird beim
Einschlafen zurueckgesetzt. draw_partial() meldet zurueck, ob es intern zu
einem Vollaufbau aufgewertet hat, damit die 30-Minuten-Uhr korrekt bleibt.
compose() ersetzt eingebettete Zeilenumbrueche vor der Breitenmessung.

Dazu ein Kommando-Rekorder fuer die Tests, der die tatsaechlich gesendete
SPI-Byte-Folge protokolliert: die vorherigen Tests fuer den Referenzpuffer
(N2) und die Registerfolge nach dem Schlafen (N3) pruefften nur "keine
Ausnahme" und blieben laut Reviewer-Mutation gruen, wenn die eigentliche
Fehlerursache wieder eingefuehrt wurde. Die neuen Tests pruefen die
gesendeten Kommandos direkt und wurden gegen genau diese Mutationen
verifiziert.
2026-09-13 21:46:59 +02:00
Michael SauerandClaude Opus 5 7a5f26d717 fix(board): Farbtemperaturbereiche der Weiss-Varianten korrigieren
Die vier reinen Weiss-Lampen standen flach auf 2500-6500 K, was fuer keine
von ihnen stimmte. Nach Michaels Produktdaten:

- E27 CW/WW, E14 CW/WW, GU10 CW/WW: 2700-6500 K (vorher 2500-5000)
- G95 Vintage:                      2000-5000 K (vorher 2500-5000)

Die drei erstgenannten haben damit oben 1500 K verschenkt, die G95 unten
500 K. Die RGB-Varianten und das EVK standen schon richtig auf 2700-6500.

test_board.c prueft die Bereiche jetzt pro Board statt ueber die Faustregel
"RGB oder nicht" - die G95 ist die Ausnahme, und eine Faustregel, die eine
Ausnahme hat, ist keine. Die beiden Clamp-Tests in test_boot_state.c lesen
die Obergrenze jetzt vom Board, statt die Zahl zu wiederholen: sie pruefen
das Begrenzen, nicht die Konstante.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:28:33 +02:00
Michael Sauer eb9efa12f6 fix(station): Panel schlaeft nicht mehr nach jedem Bild, Breitenkuerzung nach Pixeln
Fixrunde 2 zu Task 6a: der sleep()-Einbau aus Fixrunde 1 hatte drei neue Critical
erzeugt. epd.sleep() schliesst ueber epdconfig.module_exit() die GPIO-Pins; ein
Teilaufbau direkt danach fasste bei V3 eine bereits geschlossene Pin-Referenz an
(AttributeError, ungefangen, Neustartschleife - binnen Sekunden nach dem Start, V3 ist
die Vorgaberevision) und war bei V2 nach Deep Sleep ohnehin keine belastbare Sequenz
(init(PART_UPDATE) allein setzt nicht die Register, die nur der FULL_UPDATE-Zweig setzt).
Ausserdem setzte der V3-Vollaufbau nie den Referenzpuffer, den ein Teilaufbau zum Diffen
braucht (displayPartBaseImage() statt display()).

Panel bekommt ein internes _awake-Flag: draw_partial() wertet sich selbst zu einem
Vollaufbau auf, wenn das Panel nicht wach ist (frisch erzeugt oder seit dem letzten
Aufbau geschlafen), unabhaengig von der Aufrufreihenfolge. sleep() wird nur noch nach
laengerer Ruhe (10 Minuten ohne Aenderung) gerufen, nicht mehr nach jedem Bild, und ist
selbst idempotent. Dazu: epdconfig faengt jetzt gpiozero.GPIOZeroError statt nur
GPIODeviceError (die PinError-Familie haengt nicht darunter), EnvironmentFile= in der
Unit bekommt ein "-"-Praefix, und die Zeilenkuerzung wandert von einer Zeichenzahl-Grenze
(ungueltig bei einer Proportionalschrift) zu einer echten Pixelbreitenmessung in
compose() - lines() bleibt dafuer ungekuerzt und weiterhin ohne Pillow testbar.
2026-09-13 21:24:42 +02:00
Michael Sauer 9b158c8c4f fix(station): V2/V3-Panelkapselung, wiedereintrittsfaehiges module_init, Unit-Fixes
Fixrunde 1 zu Task 6a: epd2in13_V2.init() braucht ein Pflichtargument und dessen
displayPartial() setzt selbst keine LUT (neue Panel-Klasse in display.py kapselt das statt
es zu verstreuen); epdconfig.module_init() war nicht wiedereintrittsfaehig und ein zu
breites except tarnte GPIOPinInUse als "Panel fehlt" (jetzt idempotent, gezielter
except); ninlight-display.service hatte ReadOnlyPaths=/run/ninlight-station (verhindert
den Start, wenn die Station regulaer aus ist), StartLimit* in der falschen Sektion und
SupplementaryGroups=spi gpio (harter Fehler auf reinem Debian ohne diese Gruppen); ein
fehlendes Pillow fuehrte zu ModuleNotFoundError statt zum vorgesehenen stillen Exit 0.
Dazu: Panel schlaeft nach jedem Zeichnen (Alterung), Modus-Vorrang liegt jetzt bei
RUN_DIR/mode statt bei status.json, export-Praefix in der Konfigurationsdatei wird
erkannt, MAX_COLS auf das mit Pillow nachgemessene Mass korrigiert, und build_state()
hat jetzt eigene Tests.
2026-09-13 21:07:01 +02:00
Michael Sauer 4a029a5d1c feat(station): E-Paper-Anzeige fuer Modus, Lampe und Auslieferungen
render.lines() zerlegt den zusammengefuehrten Zustand (status.json + control.json +
SSID aus der Konfigurationsdatei) in Textzeilen, ohne Pillow und ohne Hardware zu
brauchen - render.compose() importiert Pillow erst innerhalb der Funktion. epdconfig.py
ist eine eigene GPIO/SPI-Schicht auf gpiozero/spidev, weil RPi.GPIO auf Debian 13 mit
Python 3.13 nicht mehr traegt; epd2in13_V2.py/epd2in13_V3.py sind unveraendert aus dem
Waveshare-Repository uebernommen (siehe HERKUNFT.md), nur der epdconfig-Import zeigt auf
unser Modul. display.py laeuft als eigener systemd-Dienst (Wants=, Restart=on-failure),
fehlendes Panel/SPI/Bibliothek beendet ihn sauber mit sys.exit(0) statt einer
Neustartschleife - eine Migration darf davon nie abhaengen.

Verdrahtung in setup_pi.sh/deploy.sh/ninlight-station folgt in einem eigenen Durchgang.
2026-09-13 20:44:02 +02:00
Michael Sauer 1cb5359754 fix(station): Manifest-Merge robust gegen Fehlform, Version pro Eintrag
Fixrunde 2 zu "Migrationsimages beim Deploy signieren":

- N1 (Important): das Merge-Skript im SSH-Heredoc von deploy.sh fing beim
  Bestandsmanifest nur Lesefehler und kaputtes JSON ab (OSError/ValueError).
  War die Datei gueltiges JSON mit falscher Form ("[]", "null", ein String,
  oder ein images-Eintrag, der selbst kein Objekt ist), krachte .get() mit
  AttributeError - und das VOR der Installation von
  server.py/products.py/CLI/Unit und vor dem Neustart, liess einen Pi also
  mit neuen Images, aber altem Stationscode zurueck. Jetzt wird alte wie
  neue Manifest-Struktur konsequent auf isinstance(dict)/isinstance(list)
  geprueft, jede Fehlform faellt auf ein leeres Manifest zurueck statt
  abzustuerzen. Neues Szenario 21 deckt alle vier Faelle aus der
  Review-Tabelle ab (kaputt, leer, falsch geformtes JSON, kaputter Eintrag).

- N2 (Important): das zusammengefuehrte Manifest hat "ninlight_version"
  bisher blind vom neuen Manifest uebernommen, obwohl uebernommene
  Alt-Eintraege (deren Images unveraendert liegen bleiben, siehe Fixrunde 1
  I1) eine andere Firmware-Version tragen koennen - eine konkrete falsche
  Version beim Protokollieren ist schlechter als das vorherige "unbekannt".
  Die Version wandert jetzt PRO EINTRAG ins Manifest (deploy.sh erzeugt sie
  dort, der Merge behaelt sie); das manifestweite Feld bleibt nur als
  Bestandskompatibilitaet und wird nur gesetzt, wenn wirklich alle Eintraege
  uebereinstimmen. server.py liest beim Ausliefern jetzt die Version DES
  AUSGELIEFERTEN EINTRAGS (Station.image_version(target, variant) ersetzt
  manifest_version()) und faellt nur auf das manifestweite Feld zurueck,
  wenn der Eintrag selbst keine eigene traegt. test_server.py bekommt einen
  neuen Test fuer den Vorrang des Eintrags vor dem manifestweiten Feld,
  Szenario 14 in test_deploy.sh prueft jetzt zusaetzlich unterschiedliche
  Versionen zwischen zusammengefuehrten Eintraegen und dass das
  manifestweite Feld dann fehlt statt falsch zu sein. Die Spec
  (docs/superpowers/specs/.../design.md, Abschnitt 4, lokal/nicht
  versioniert) ist nachgezogen. Die dadurch ueberfluessig gewordene
  "sorted(versions)[0]"-Stelle aus Fixrunde 1 ist entfallen.

- Kleinigkeit: die Remote-Meldung, wenn migrate/ von diesem Rechner leer
  ist, behauptet nicht mehr "kein Schluesselverzeichnis" (das stimmt im
  Fall "Verzeichnis vorhanden, aber leer" nicht) - sie nennt jetzt keinen
  bestimmten Grund mehr, nur die Konsequenz.
- Kleinigkeit: --exclude=manifest.json ist jetzt --exclude=/manifest.json,
  konsistent mit dem daneben verankerten --exclude=/migrate.

Alle drei Aenderungen (N1, N2, beide Kleinigkeiten) sind vor dem Fix per
Rot-Probe an einer Kopie von deploy.sh verifiziert: mit den urspruenglichen
Fehlern schlagen Szenario 21 (N1) bzw. Szenario 14 (N2) zuverlaessig fehl.

Bewusst nicht angefasst (Review-Vorgabe): der fehlende HOME-Testfall, und
dass der Merge kein os.path.exists() prueft.
2026-09-13 20:40:03 +02:00
Michael Sauer ad2f1b8486 fix(station): Manifest-Form, Teilschluessel-Faelle und Schluessel-Testisolation
Fixrunde 1 zu "Migrationsimages beim Deploy signieren":

- manifest.json traegt ninlight_version und ein neues built jetzt auf oberster
  Ebene statt pro Eintrag, dazu "file" statt der Redundanz einer Version pro
  Bild - genau die Form aus der Spec (Abschnitt 4) und aus server.py's
  Station.manifest_version(), die vorher immer "unbekannt" gelesen hat, weil
  sie ausschliesslich auf oberster Ebene sucht (C1). Ein Testfall schickt das
  Manifest jetzt zusaetzlich durch genau diese Funktion statt nur durch
  json.load.

- Ein Schluesselverzeichnis, das existiert, aber weder Produktions- noch
  Entwicklungsschluessel enthaelt, legt kein STAGE/migrate mehr an - das
  vorherige Verhalten liess dafuer trotzdem ein (leeres) migrate/ mit
  Manifest entstehen, das der volle rsync-Abgleich dann mit --delete gegen
  ein vorhandenes migrate/ auf dem Pi durchgesetzt und es geleert hat (C2).
  Aus demselben Grund war die _gz.img-Pflichtpruefung faelschlich
  unconditional: fehlte sie, brach sogar ein Deploy ab, der gar nicht
  signieren wollte, und nahm die Rettungsimages gleich mit (C3).

- Ein neues MIGRATE_COMPLETE-Flag (nur 1, wenn dieser Lauf mit BEIDEN
  Schluesseln signiert hat) entscheidet jetzt, ob der Abgleich auf dem Pi
  --delete benutzen und das Manifest ersetzen darf. Bei nur einem der beiden
  Schluessel oder einem gezielten Deploy synct migrate/ ohne --delete und
  merged das Manifest serverseitig mit dem vorhandenen zusammen, statt es zu
  ueberschreiben - vorher raeumte ein Voll-Deploy mit nur dem dev-Schluessel
  saemtliche _prod.img weg (C2, Teilfall), und ein gezielter Deploy liess ein
  Manifest zurueck, das die unveraendert gebliebenen Ziele nicht mehr
  erwaehnte (I1).

- ${HOME:-} statt $HOME beim Ermitteln von SIGN_DIR, damit ein Aufruf unter
  `set -u` ohne gesetztes HOME (cron, env -i) nicht unnoetig abbricht, obwohl
  nur die Rettungsimages gebraucht werden.

- tests/test_deploy.sh: die Sicherheitsabsicherung aus dem letzten Durchgang
  hatte selbst eine Luecke (C4) - "${NINLIGHT_SIGN_DIR:-default}" in
  run_deploy() greift nur, wenn die Variable NICHT gesetzt ist, und genau die
  ist es, mit der man auf ~/workspace/fw-sign (das ECHTE Schluesselverzeichnis
  auf diesem Rechner) zeigt. run_deploy() setzt NINLIGHT_SIGN_DIR jetzt hart
  und liest dafuer nur noch die eigene Testvariable TEST_SIGN_DIR, die
  deploy.sh nicht kennt und die deshalb nie von aussen vorbelegt sein kann;
  am Dateikopf werden zur Sicherheit beide Variablen ge-unset. Verifiziert:
  die Suite bleibt gruen, wenn NINLIGHT_SIGN_DIR vorher exportiert ist wie in
  einem echten Entwicklerprofil.

- Szenario 15 vergleicht jetzt ALLE Zeilen beider privater Testschluessel
  gegen Ausgabe UND Log, nicht nur eine einzelne Zeile des Produktions-
  schluessels gegen die Ausgabe.

Vier neue/erweiterte Testfaelle (18-20) plus die Ergaenzung an Szenario 14:
Verzeichnis vorhanden aber leer, Voll-Deploy mit nur dev-Schluessel, echter
Server-Leser fuer das Manifest, gezielter Deploy fuehrt das Manifest zusammen
statt es zu verkuerzen. Jeder der vier Critical-Befunde wurde vor dem Fix per
Rot-Probe am kaputten Code bestaetigt.
2026-09-13 20:14:19 +02:00
Michael Sauer 59c8c150bd feat(station): Migrationsimages beim Deploy signieren
deploy.sh signiert pro Ziel das rohe _gz.img (nie das _ota.img) mit den
Produktions- und Entwicklungsschluesseln aus $NINLIGHT_SIGN_DIR und legt
die entstandenen _prod.img/_dev.img samt manifest.json unter migrate/ ab.
Fehlt das Schluesselverzeichnis oder einzelne Schluessel, entfaellt nur
die jeweilige Migrationsvariante mit einer Meldung auf stderr - die
Rettungsimages werden unveraendert weiter ausgeliefert.

migrate/ wird beim rsync-Abgleich auf dem Pi bewusst getrennt von den
_ota.img behandelt: fehlt es lokal (kein Schluesselverzeichnis auf dem
Entwicklungsrechner), bleibt ein vorhandenes migrate/ auf dem Pi
unangetastet statt durch --delete geleert zu werden - derselbe Fehler,
der frueher schon einmal fremde Geraetetypen weggeraeumt hat, diesmal auf
die Migrationsimages angewendet.

test_deploy.sh bekommt acht neue Szenarien fuer die sieben im Task-Brief
verlangten Faelle plus den zusaetzlichen Schutz vor dem geleerten
migrate/. Alle Szenarien erzeugen ihre eigenen Wegwerf-Testschluessel
ueber nl_sign.generate_test_key() und setzen NINLIGHT_SIGN_DIR bei jedem
Aufruf explizit (auch im "ohne Schluessel"-Fall) - der Default
$HOME/workspace/fw-sign zeigt auf diesem Rechner auf echte Schluessel,
und kein Testlauf darf sie je lesen.
2026-09-13 19:47:51 +02:00
Michael Sauer 74198f6d31 fix(station): stat-Reihenfolge, Phase nur im Migrationsmodus, eingerueckte Konfig-Werte, kein /tmp-Zugriff im Test
Fixrunde 2 zu Task 4:

- N1 (wichtig): file_mode() in test_cli.sh/test_setup_pi.sh probierte
  "stat -f '%Lp'" vor "stat -c '%a'" - auf GNU-coreutils (dem Pi) deutet
  "-f" dort "Dateisystemstatus" und liefert Muell statt der Rechte-Zahl,
  faellt aber trotzdem in den "||"-Zweig durch, der zusaetzlich "644"
  anhaengt. Reihenfolge umgedreht und mit einem per gstat erzwungenen
  GNU-Testlauf zusaetzlich abgesichert.
- N2 (wichtig): status zeigte nach einem Wechsel zurueck in den
  Rettungsmodus weiterhin die letzte Migrationsphase aus einer liegen
  gebliebenen control.json (moeglich seit RuntimeDirectoryPreserve=
  restart aus Fixrunde 1). Phase wird jetzt nur noch angezeigt, wenn
  Modus tatsaechlich "migration" ist.
- N3 (klein): die Schluesselerkennung in setup_pi.sh war rein
  zeilenverankert ("^KEY=") und haette einen eingerueckten Bestandswert
  als fehlend gewertet - beim naechsten Sourcen der Konfiguration haette
  die zusaetzlich angehaengte Vorlagenzeile das eigene Passwort
  ueberschrieben. Muster auf fuehrenden Leerraum/export gehaertet.
- N4 (klein): test_setup_pi.sh liess setup_pi.sh ueber den scp-Stub echte
  Dateien im /tmp des Entwicklungsrechners anlegen und wieder loeschen.
  Neue Variable NINLIGHT_STAGE_DIR biegt das auf eine Sandbox um.

Alle vier Punkte mit Regressionstests, N2 und N3 zusaetzlich per
Mutationsprobe (Fix zurueckgenommen, Test wird rot) verifiziert.
2026-09-13 19:28:42 +02:00
Michael Sauer 1c077a15d0 fix(station): RuntimeDirectory-Race, Statusphase, off/ap_switch-Nachpruefung, Pi-Bestandskonfiguration
Fixrunde 1 zu Task 4:

- C1 (kritisch): die Unit leerte /run/ninlight-station bei jedem
  systemctl restart, weil RuntimeDirectoryPreserve fehlte - genau der
  Neustart, mit dem migrate/recovery on den Dienst hochfahren, wischte
  die eben geschriebene Modus-/Steuerdatei wieder weg, und der Server
  fiel still auf den Recovery-Default zurueck. Unit bekommt
  RuntimeDirectoryPreserve=restart, und start_and_verify() prueft nach
  dem Neustart zusaetzlich ausdruecklich nach, dass Modus und Phase noch
  stimmen.
- I1: status las die Phase aus status.json, die arm/listen (die den
  Dienst bewusst nicht neu starten) nie aktualisieren - jetzt kommt sie
  aus control.json.
- I2: off pruefte nach dem Herunterfahren nichts nach und konnte
  "Station aus" melden, waehrend ein Profil wegen haengendem
  NetworkManager weiterhin aktiv war - ap_switch() verifiziert das jetzt
  auch im Aus-Fall.
- I3: der Fehlerzweig von ap_switch (Mismatch nach dem Umschalten) war
  im bisherigen Test nie erreichbar; der nmcli-Stub kann das jetzt
  erzwingen.
- I4: neue Pflichtschluessel blockierten jeden Unterbefehl auf einem
  bereits eingerichteten Pi. setup_pi.sh ergaenzt fehlende Schluessel in
  einer bestehenden Konfiguration jetzt einzeln, statt sie unangetastet
  zu lassen; dafuer musste das Skript per bash-s-Heredocs und
  ueberschreibbaren Pfadvariablen erst testbar gemacht werden (neuer
  test_setup_pi.sh).

Dazu vier kleinere Punkte aus derselben Review: grep -Fqx statt grep -qx
fuer Profilnamen, install-Stub durch tee+chmod ersetzt (POSIX, kein
Sonderfall mehr fuer macOS), Rechtepruefung auf control.json in den
arm/listen-Testfaellen, stderr sauber von stdout getrennt in den
Warnungs-/Fehlerfaellen.
2026-09-13 19:04:23 +02:00
Michael Sauer 1d3627bacd feat(station): Unterbefehle fuer Migrationsmodus und Freigabe
ninlight-station bekommt recovery/migrate/arm/listen als Unterbefehle statt nur
on/off/status; "on" bleibt Kurzform fuer "recovery on". Eine gemeinsame
ap_switch()-Funktion faehrt vor jedem Wechsel erst jedes andere Stationsprofil
herunter und prueft per nmcli nach, dass auf wlan0 wirklich nur eines aktiv
ist - die beiden Zugangspunkte (Rettung/Migration) schliessen sich damit
gegenseitig aus. Der Modus wandert per /run/ninlight-station/mode an den
(ohnehin neu gestarteten) Dienst statt ueber die Konfigurationsdatei; arm/listen
schreiben die Steuerdatei aus Task 3 mit demselben atomaren 0644-Muster wie
server.py selbst. station.conf.example und die Pflichtschluesselpruefung
bekommen die neuen Migrations- und Anzeige-Schluessel dazu.
2026-09-13 18:39:48 +02:00
Michael Sauer d3e608dca0 fix(station): Freigabe atomar verbrauchen, Schreibfehler nicht stillschweigend armed lassen
Fixrunde 1 zu Task 3 (Migrationsroute):

- C1 (Critical): read_control()/Phasenpruefung/Auslieferung/write_control("listen") liefen
  ohne gemeinsame Sperre - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst- und
  Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und beide
  ausgeliefert bekommen. Neues Station.claim_delivery() liest und verbraucht die Freigabe
  jetzt atomar unter station.lock, vor Beginn der Auslieferung; Station.restore_armed()
  macht das bei einem Abbruch rueckgaengig.
- I2 (Important): eine unerwartet kurz gelesene Datei zaehlte im Sendeloop als "ok" statt
  als "aborted" - relevant geworden, weil dieser Status jetzt entscheidet, ob eine armed-
  Freigabe restauriert wird.
- I3 (Important): ein fehlschlagendes write_control() (z.B. schreibgeschuetztes /run)
  verschluckte den Fehler und liess "armed" dauerhaft scharf. Der Fehler landet jetzt in
  status.json (control_error), und claim_delivery() laesst nur die eine ausloesende
  Anfrage noch durch, bis ein Schreibversuch wieder gelingt.
- I4 (Important): die neuen Server-Tests synchronisierten sich per fester Pause statt wie
  Station.read_state() bis zu einem Praedikat zu pollen. status()/read_control() im
  Testklienten tun das jetzt ebenfalls.
- I5 (Important): resolve_mode()/--mode-file waren ungetestet trotz bindender
  Vorrangregel, auf der Task 4 aufbaut - fuenf neue reine Funktionstests.

Dazu: recent[0] -> recent[-1] in allen betroffenen Tests (Brief-Fehler), ein neuer
Rechte-Test fuer control.json (0644 unter restriktivem umask), und eine neue
Nebenlaeufigkeits-Regression fuer C1.
2026-09-13 18:22:49 +02:00
Michael Sauer b41ed51018 feat(station): Migrationsroute mit Horch- und Freigabephase
server.py bedient jetzt zwei Modi ueber denselben Dienst: --mode/--mode-file/--control
legen fest, ob ninlight_<target>_ota.img (Rettung) oder qcx_firmware_<pid>_..._ota_light.img
(Migration alter Klyqa-Lampen, via products.parse_request) ausgeliefert wird. Der Modus
kommt wahlweise aus einer Datei statt einem Startparameter, damit Task 4 ihn spaeter ohne
Dienst-Neustart umschalten kann. Die Steuerdatei control.json (Phase auto/listen/armed)
wird bei jeder Anfrage frisch gelesen und atomar mit 0644 geschrieben - eine fehlende oder
kaputte Steuerdatei blockiert eine Migration nie, sondern faellt auf "auto" zurueck. Nach
einer erfolgreichen Auslieferung in Phase "armed" faellt die Freigabe erst nach dem
vollstaendigen Senden auf "listen" zurueck, nie davor, damit ein abgebrochener Transfer die
Freigabe fuer den naechsten Bootversuch stehen laesst. status.json bekommt die
Migrationsfelder aus Spec 5.2 (mode, phase, armed_target, last_seen).

deploy.sh liefert products.py mit aus, sonst startet der Dienst nach dem naechsten Deploy
mit ModuleNotFoundError; test_deploy.sh zieht die Sandbox-Dummy-Datei entsprechend nach.

Neue Tests in test_server.py verwenden eigene server/migration_server-Fixtures samt
Testklient (get/status/set_control/read_control/delete_control/write_raw_control), ohne die
bestehende station-Fixture und ihre Tests anzufassen.
2026-09-13 18:06:25 +02:00
Michael Sauer e897164960 feat(station): Produkt-ID-Tabelle der alten Klyqa-Firmware 2026-09-13 17:53:18 +02:00
Michael Sauer 78cf01ca6c fix(tools): SignError statt roher ValueError bei kaputtem Schluesselmaterial
nl_sign.py verify/sign gaben bei syntaktisch ungueltigem --pub/--key-Inhalt
(falsches DER/PEM-Format) bisher eine unbehandelte ValueError (pycryptodome
teils IndexError/TypeError) an den Aufrufer durch - die CLI brach dadurch
mit Python-Traceback ab statt mit der vorgesehenen "error: ..."-Meldung und
Exitcode 1. Beide Backends (cryptography und pycryptodome/pycryptodomex)
kapseln das Einlesen des privaten wie des oeffentlichen Schluessels jetzt in
try/except und werfen SignError mit einer festen, schluesselinhaltsfreien
Meldung (via "from None", damit die urspruengliche Bibliotheksausnahme nicht
implizit mitgeschleppt wird). Ein ungueltiger Schluessel kann dadurch nie zu
verify_image() == True fuehren.

Vier neue Tests in test_nl_sign.py: je ein Modul- und CLI-Test fuer kaputtes
--pub und kaputtes --key, die SignError bzw. Exitcode != 0 ohne Traceback auf
stderr festnageln.
2026-09-13 03:24:02 +02:00
Michael Sauer e50659d954 feat(tools): Signierwerkzeug fuer Migrationsimages
nl_sign.py signiert/prueft W600-Images im RSA-4096-PKCS#1-v1.5-Format der
alten Klyqa-Firmware (Check_OTA_Image() in qc_ssl_layer.c): SHA256 ueber die
gesamte Datei, angehaengt als exakt 512 Byte - derselbe Block wie der
ninlight-Trailer, daher die Ablehnung bereits getrailerter Eingaben.
Unterstuetzt sowohl cryptography als auch pycryptodome/pycryptodomex als
Backend. Gegen das echte Klyqa-Schluesselpaar aus ~/workspace/fw-sign
bitgleich verifiziert.

tools/test_host.sh haengt jetzt die Python-Tests aus tools/mkimage/tests
und tools/station/tests an ctest an.
2026-09-13 03:15:08 +02:00
Michael SauerandClaude Opus 5 00d5a4c532 feat: Default-Transition, Weckschalter und farbfreier Ausschaltfade
Zwei neue Einstellungen, beide wie fade_in/fade_out ueber
POST /api/v1/device/state les- und schreibbar und sofort wirksam:

- transition_default (Voreinstellung 300 ms) fuellt die Transition fuer
  Farb-, Weiss- und Helligkeitswechsel bei eingeschalteter Lampe, wenn der
  Aufruf kein eigenes "transition" mitbringt. Beim Ein- und Ausschalten
  behalten fade_in/fade_out weiterhin Vorrang.
- wake_on_change (Voreinstellung aus) schaltet die Lampe bei einer Farb-,
  Weiss- oder Helligkeitsaenderung ein, wenn sie aus war. Ein
  ausdrueckliches "on": false im selben Aufruf gewinnt immer.

Beide Felder haengen am Ende von nl_settings_t, damit bestehende Lampen
ueber die Laengentoleranz von read_rec() ihre echten Voreinstellungen
bekommen statt Nullen. nl_ctrl_set_fades() heisst jetzt
nl_ctrl_set_options().

Dazu ein Treiberfehler, der erst an der migrierten GU10 RGB auffiel: der
WY-Zweig von nl_sm2135eh_set() schrieb nur Header und WY-Daten, nie die
RGB-Datenregister. Der Chip behielt also die zuletzt gesetzte Farbe, und
bei einem direkten Wechsel Farbe -> Weiss (ohne Zwischenschritt ueber
dunkel) schaelte sie sich beim Ausblenden wieder frei. Der Header sendet
jetzt die - in Weiss immer nullwertigen - RGB-Daten mit.

Die Bruno-Collection zeigt Transition jetzt zusammen mit einem Farb- und
einem Weisswechsel im selben Aufruf und hat einen Request fuer die
beiden neuen Einstellungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:56:56 +02:00
Michael SauerandClaude Opus 5 62a2b43175 docs: Migration zweier echter Lampen und den SM2135EH-Standby-Fund festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:37:08 +02:00
Michael SauerandClaude Opus 5 f32216d17f fix: SM2135EH beim Ausschalten in den Standby legen
Bei allen Kanaelen auf null schrieb der Treiber nur Nullen ins
Kanal-Register, liess den Chip aber aktiv - er laesst dann genug
Reststrom durch, dass die Lampe schwach weiterglimmt. An einer
migrierten GU10 RGB gemessen. Jetzt wird Bit 5 (Standby) gesetzt.

Die alte QConnex-Firmware hatte diese Logik, aber auskommentiert
(qcl_lamp.c) - das Verhalten ist von dort geerbt. Lampen an direkten
PWM-Pins (G95, CW-Varianten) sind nicht betroffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:32:31 +02:00
Michael SauerandClaude Sonnet 5 9768b25d10 docs: passwortloses sudo als Voraussetzung der Rettungsstation dokumentieren
CLI und beide Skripte (setup_pi.sh, deploy.sh) rufen sudo nicht-interaktiv
auf; ohne passwortloses sudo fuer den SSH-Benutzer bricht der gesamte
Ablauf mit einem Passwort-Prompt ab, den nichts davon behandeln kann. Das
war bisher eine unausgesprochene Voraussetzung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:30:03 +02:00
Michael SauerandClaude Sonnet 5 1d7d087cff fix: Rettungsstation loggt auch abgelehnte Methoden und Protokollfehler
do_POST (und PUT/DELETE/HEAD) riefen reject() auf, ohne eine Logzeile zu
schreiben, und das ueberschriebene log_message() legte damit unbeabsichtigt
auch log_error() lahm (BaseHTTPRequestHandler ruft es standardmaessig auf,
z.B. bei einer kaputten Request-Zeile oder einem Timeout, bevor ueberhaupt
ein Handler wie do_GET erreicht wird). Fuer das einzige Diagnosewerkzeug im
Rettungsfall darf keine Anfrage stumm bleiben.

do_POST loggt jetzt wie do_GET explizit, und log_error() ist separat
ueberschrieben statt implizit mit log_message() stillgelegt zu werden.

Die neuen Tests fuer den 405-Fall lesen den rohen Datei-Deskriptor des
Server-Prozesses statt ueber TextIOWrapper.readline() zu gehen - Letzteres
kann mehr Zeilen aus der Pipe in den eigenen Puffer lesen, als es
zurueckgibt, was select() auf dem naechsten Datei-Deskriptor faelschlich
"nichts da" melden liess. Der Test fuer die kaputte Request-Zeile ruft
log_error() direkt auf: ein echter kaputter Rohsocket wird in dieser
Sandbox von einer Netzwerkschicht abgefangen, bevor er den Server erreicht
(sichtbar an einem untergeschobenen "Server: aiohttp"-Header).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:56 +02:00
Michael SauerandClaude Sonnet 5 435884039b feat: ninlight-station warnt beim Einschalten vor dem oeffentlichen Default-Passwort
Die Spec (§9) verlangt beide Betriebsregeln in der Ausgabe von "on": die
Erinnerung ans Ausschalten stand schon da, die zum eigenen Passwort fehlte.
Bleibt PSK auf dem oeffentlichen Default 'ninlight-recovery' stehen, kann
jeder in Funkreichweite, der ihn kennt, jeder Lampe beliebige Firmware
unterschieben - das wird jetzt bei jedem Einschalten sichtbar gemacht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:43 +02:00
Michael SauerandClaude Sonnet 5 2dfb1fd1c2 feat: GET system/settings zeigt Herkunft der WLAN-Zugangsdaten
Die Antwort gab bisher nur die gespeicherte SSID aus. Wurde eine Lampe nie
per REST onboardet, ist die leer, und die Lampe laeuft in Wirklichkeit auf
den einkompilierten NINLIGHT_WIFI_SSID/-PASSWORD (siehe app_main.c) -
GET system/settings suggerierte damit "kein WLAN konfiguriert", obwohl die
Lampe zuverlaessig verbunden ist, und widersprach scheinbar system/info.

Additiv, ohne die Bedeutung von "ssid" zu aendern: wifi.source ist jetzt
"stored" oder "compiled". Spec (§7.2) und bruno/ninlight.yml dokumentieren
das Feld, ein neuer Hosttest deckt den compiled-Fall ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:34 +02:00
Michael SauerandClaude Sonnet 5 d464bb167e fix: Voll-Deploy der Rettungsstation gegen doppelte Images und stillen Loeschverlust absichern
Zwei Build-Verzeichnisse mit demselben Image-Dateinamen (z.B. build/evk und
build/evk-onb, letzteres ohne WLAN-Zugangsdaten) landeten flach im selben
Staging-Namen - die Sortierreihenfolge entschied zufaellig, welches gewinnt,
und die Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp
steht. deploy.sh bricht jetzt ab und nennt beide Fundorte, statt zu raten.

Der Voll-Deploy (deploy.sh <host> ohne target - der in README.md gezeigte
Normalfall) loeschte per rsync --delete zudem die Images aller gerade nicht
gebauten Geraetetypen, ohne je zu sagen, welche. Vor dem Loeschen wird jetzt
per Namensvergleich ermittelt und ausgegeben, welche Dateien verschwinden -
sichtbar statt still, ohne den Ablauf fuer Skripte zu blockieren.

Neue Testszenarien 7-9 in tools/station/tests/test_deploy.sh decken beide
Faelle ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:25 +02:00
Michael SauerandClaude Opus 5 6daf36540c docs: Rettungsfall und Abschlusspruefung der Station belegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:05:11 +02:00
Michael SauerandClaude Opus 5 b169fae89d docs: Stillstandstest und Wiederaufnahme am EVK belegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:54:15 +02:00
Michael SauerandClaude Sonnet 5 3d69412f5a docs: Wiederaufnahme-Verhalten bei Firmware-Updates differenzieren
Der Abschnitt beschrieb pauschal ein rund siebenminütiges TCP-Timeout ohne
Wiederaufnahme. Heute gemessen gilt das nur, wenn die Gegenstelle komplett
stirbt (Prozess weg). Bleibt sie erreichbar und liefert nur nichts mehr,
greift das 5-s-RECV_TIMEOUT der Firmware, und die Lampe setzt per Range
sauber fort – am EVK fünfmal hintereinander bis das Image vollständig war.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:40:55 +02:00
Michael SauerandClaude Sonnet 5 3f27ef4b26 fix: --stall-after Budget der Rettungsstation client-kumulativ zählen
Der Testschalter zählte bisher pro Anfrage, sodass eine Lampe nach ihrem
Empfangs-Timeout einfach per Range neu verband und ein frisches Budget bekam
– am EVK reichten fünf Reconnects, um trotz --stall-after das komplette
Image zu übertragen. Station verfolgt die ausgelieferten Bytes jetzt pro
Client-IP über alle Anfragen hinweg (Station.reserve_stall_bytes, unter dem
bestehenden Lock), sodass kein erneutes Verbinden das Budget auffüllt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:40:55 +02:00
Michael SauerandClaude Opus 5 2f25714248 docs: Abnahme der Pi-Station am EVK festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:32:22 +02:00
Michael SauerandClaude Sonnet 5 908fda8240 fix: OTA-Log im Recovery-Skip-Pfad nicht mehr als Fehlschlag ausgeben
Auf dem Überspring-Pfad (Recovery lehnt ein Image ab, weil es bereits die
laufende Version trägt) bleibt s_error absichtlich NULL, landete aber
trotzdem in printf("ota: failed (rc=%d, %s)\r\n", rc, s_error) - ein
Nullzeiger an %s ist undefiniertes Verhalten und wurde bislang nur zufällig
von newlib als "(null)" abgefangen. Zusätzlich widersprach die Meldung
"failed" der direkt folgenden Zeile "recovery: image already up to date,
continuing normal boot".

ota_task() unterscheidet jetzt explizit zwischen dem Skip-Fall (eigene,
ehrliche "ota: skipped"-Zeile) und dem echten Fehlschlag (bestehende
"ota: failed"-Zeile, weiterhin mit dem "download"-Fallback für ein leeres
s_error). rc bleibt reiner Diagnosewert im Log, da das SDK den Transfer in
beiden Fällen abbricht, sobald der Trailer-Hook das Image ablehnt.

Restliche Leser von s_error/nl_ota_last_error() (nl_recovery.c, nl_api.c)
waren bereits NULL-sicher und mussten nicht angepasst werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:29:12 +02:00
Michael SauerandClaude Sonnet 5 62903a7f05 fix: status.json fuer normale Nutzer lesbar machen, Lesefehler nicht mehr verschlucken
server.py legt status.json ueber mkstemp() (Modus 0600) + os.replace() an; da
os.replace() den Modus der temporaeren Datei uebernimmt, war die Datei nach jedem
Schreiben nur fuer root lesbar - der Dienst laeuft als root, das CLI liest aber als
normaler Benutzer. os.chmod(tmp, 0o644) vor dem Rename behebt das umask-fest, auch
bei wiederholten Schreibvorgaengen.

ninlight-station status hat den fehlenden Lesezugriff bisher stillschweigend wie
"noch nie ausgeliefert" behandelt (beides fiel durch denselben "-r"-Test). Jetzt wird
zwischen "Datei fehlt" (unauffaellig) und "Datei da, aber nicht lesbar" (Fehlermeldung
auf stderr) unterschieden.

Neuer Test prueft, dass status.json nach dem ersten und nach einem weiteren
Schreibvorgang world-readable bleibt, auch unter restriktivem umask.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:26:05 +02:00
Michael SauerandClaude Opus 5 cb54dac066 build: uv.lock der Station mit einchecken
tools/mkimage/uv.lock ist getrackt, tools/station/uv.lock war es nicht -
ohne die Datei sind die Testlaeufe der Station nicht reproduzierbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:17:49 +02:00
Michael SauerandClaude Sonnet 5 456058956c fix: setup_pi.sh und deploy.sh nutzen /usr/bin/ssh und /usr/bin/scp
Auf dem Rechner des Nutzers liegt im PATH ein ssh, das sich anders
verhaelt als das System-ssh - die Skripte sollen sich darauf nicht
verlassen. setup_pi.sh und deploy.sh riefen ssh, scp und rsync bisher
ohne vollen Pfad auf.

Beide Skripte nutzen jetzt SSH_BIN/SCP_BIN mit Default /usr/bin/ssh bzw.
/usr/bin/scp, ueberschreibbar per NINLIGHT_SSH_BIN/NINLIGHT_SCP_BIN -
dasselbe Prinzip wie NINLIGHT_STATION_ROOT weiter unten in deploy.sh, das
Tests ohne echten Pi ermoeglicht. rsync bekommt "-e $SSH_BIN", damit auch
dessen Transport ueber /usr/bin/ssh laeuft. Die Beispielaufrufe in
setup_pi.sh's Abschlussmeldung und in der README bleiben bewusst bei
schlichtem "ssh" - dort geht es um das, was der Nutzer selbst eintippt.

test: tools/station/tests/test_deploy.sh auf den neuen Override
umgestellt (NINLIGHT_SSH_BIN zeigt auf den ssh-Stub) und um ein Szenario
ergaenzt, das den /usr/bin/ssh- bzw. /usr/bin/scp-Default in beiden
Skripten absichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:13:15 +02:00
Michael SauerandClaude Sonnet 5 3032178247 fix: ninlight-station status gibt Dienstzustand nur einmal aus, Exit 0
"systemctl is-active" schreibt den Zustand ("inactive", ...) auf stdout
und liefert dabei einen Rueckgabewert ungleich 0 - der "|| echo inaktiv"-
Zweig lief also zusaetzlich mit, und beide Zeilen landeten in der
Ausgabe. Ausserdem endete "status" ueber den letzten Befehl im
case-Zweig faktisch mit dessen Exit-Code, der 1 war, sobald noch nie
ausgeliefert wurde (kein /run/ninlight-station/status.json) - das machte
"status" in jedem Skript, das den Exit-Code abfragt, unbrauchbar.

Beide Stellen behoben: der Dienstzustand wird nur noch einmal ausgegeben,
und der Zweig endet mit explizitem "exit 0", sobald die Auskunft erteilt
werden konnte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:13:03 +02:00
Michael SauerandClaude Sonnet 5 4a8a5d658f fix: nl_mkimage wieder Python-3.8-tauglich machen
CMake ruft nl_mkimage.py bei jedem Firmware-Build ueber das plain
"python3" im Docker-Build-Container auf (Python 3.8.10), unabhaengig von
den Werkzeugen auf Mac/Pi, die Python >= 3.11 voraussetzen. Die zuletzt
hinzugekommene Annotation "expect_device: str | None" wird dort zur
Laufzeit ausgewertet und wirft "TypeError: unsupported operand type(s)
for |: 'type' and 'NoneType'", was jeden Firmware-Build abbrechen liess.

"from __future__ import annotations" (wie in tools/station/server.py)
sorgt dafuer, dass Annotationen nur noch als Text existieren und nie
ausgewertet werden - der Rest der Datei kam ohne weitere 3.8-Stolpersteine
aus.

test: zwei Regressionstests ergaenzt, die ohne installiertes Python 3.8
auskommen: einer prueft per AST, dass der future-Import direkt nach dem
Docstring steht, der andere laesst vermin (dev-Dependency) die
Mindestversion aus Syntax/Stdlib-Nutzung ableiten. vermins
"union-types"-Feature wird bewusst nicht aktiviert, weil es "str | None"
unabhaengig vom future-Import als 3.10-Anforderung meldet - das wuerde
ausgerechnet den korrekten Fix durchfallen lassen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:12:54 +02:00
Michael SauerandClaude Sonnet 5 d23b0d4e02 fix: Rechte-Bug und Passwort-Leck im NM-Profil, Loesch-Bug in deploy.sh
setup_pi.sh sourcte /etc/ninlight-station.conf (0600 root:root) als
unprivilegierter Benutzer und scheiterte damit bei jedem Lauf, bevor
das NetworkManager-Profil angelegt wurde. Die Konfiguration wird jetzt
per "sudo cat" gelesen; ninlight-station macht denselben Fallback fuer
den dokumentierten Aufruf als normaler Benutzer, ohne die Datei fuer
alle lesbar zu machen.

Das AP-Profil wird nicht mehr per "nmcli ... wifi-sec.psk $PSK"
angelegt (das WLAN-Passwort waere sonst fuer jeden lokalen Prozess in
der Kommandozeile sichtbar), sondern als NetworkManager-Keyfile
(0600 root:root) geschrieben und per "nmcli connection load"
eingelesen.

deploy.sh loeschte bei einem gezielten Deploy eines einzelnen
Geraetetyps per "rsync --delete" alle Images anderer Geraetetypen aus
dem live ausgelieferten Verzeichnis. --delete laeuft jetzt nur noch
beim Vollabgleich (kein target-Argument); ein gezielter Deploy ersetzt
nur die genannten Dateien. Dabei zusaetzlich -c (Checksumme statt
Groesse+Zeitstempel) ergaenzt, sonst uebersieht rsync ein neues Image
mit zufaellig gleicher Byte-Groesse. Ausserdem meldet deploy.sh jetzt
einen fehlgeschlagenen Dienst-Neustart als Fehler statt ihn mit "Dienst
laeuft nicht" zu verwechseln.

Stub-Tests unter tools/station/tests/test_deploy.sh (ssh/rsync im PATH
ersetzt, Installationsschritt laeuft gegen ein Sandbox-Verzeichnis)
decken beide Bugfixes sowie die bereits bestehenden Faelle ab: Image
besteht die Trailer-Pruefung nicht, keine Images gefunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 12:52:44 +02:00
Michael SauerandClaude Sonnet 5 fda732d6c4 feat: Aufsetz- und Ausliefer-Skripte der Recovery-Station
setup_pi.sh ist idempotent und laesst eine vorhandene Konfiguration in
Ruhe; deploy.sh prueft jedes Image mit nl_mkimage --verify, bevor es
etwas uebertraegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 12:36:03 +02:00
Michael SauerandClaude Haiku 4.5 df8e796f1e fix: nl_mkimage Verifikation robuster gegen Edge Cases und Misuse
Critical 1: path.read_bytes() fiel auf nonexistent file oder Directory mit
Traceback statt einer Fehlerzeile. Jetzt OSError-Familie abgefangen.

Critical 2: --device/--version mit Leerzeichen wurden stillschweigend
akzeptiert; das zerlegt das Ausgabeformat und bricht Task 4s Skript.
Jetzt whitespace beim Bauen abgelehnt.

Important 1: Gerätefeld muss in C wie in Python null-terminiert sein.
Die Firmware checkt das unabhängig von --expect-device; Python nur mit
Flag. Jetzt Terminierung immer verlangt.

Important 2: Modi waren nicht exklusiv; --verify mit Baumodus-Argumenten
wurde stillschweigend ignoriert. Jetzt Fehler statt falschem Vertrauen.

Neue Tests für alle vier Szenarien + nonexistent file, directory, empty
file, Trailer ohne Payload, Mode-Mischung.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-12 12:30:42 +02:00
Michael SauerandClaude Haiku 4.5 0ba269a39a feat: nl_mkimage prüft Images mit --verify
deploy.sh soll kein Image auf die Station schieben, das jede Lampe
ablehnen würde. Die Prüfung sitzt in nl_mkimage, weil dort das
Trailer-Layout ohnehin steht - eine zweite Kopie derselben Offsets
wäre genau die Doppelung, die beim Abweichen jedes Update killt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-12 12:25:02 +02:00
Michael SauerandClaude Sonnet 5 cb0a4b035d fix: Startaufruf absichern und Neustartschleife der Station deckeln
- 'on' fing bisher nur einen bereits laufenden, aber toten Dienst ab. Scheiterte
  schon der Aufruf "systemctl start" selbst (z.B. start-limit-hit nach einer
  Absturzserie durch Restart=always), beendete set -eu das Skript dort, bevor
  die Lebendigkeitspruefung je erreicht wurde - der eben hochgefahrene
  Zugangspunkt blieb offen ohne Server dahinter stehen. Der Startaufruf wird
  jetzt selbst abgefangen und faehrt im Fehlerfall dieselbe Rueckabwicklung wie
  die is-active-Pruefung. "systemctl reset-failed" vor dem Start verhindert,
  dass eine fruehere Absturzserie den naechsten ehrlichen Versuch blockiert.
- Stirbt der Dienst nach einem erfolgreichen Start, stoppt das Skript ihn jetzt
  explizit, bevor es den Zugangspunkt herunterfaehrt und "abgeschaltet" meldet -
  sonst haette ein mit Restart=always weiterlaufender Dienst im Hintergrund
  einen saubereren Zustand suggeriert, als tatsaechlich vorlag.
- ninlight-station.service setzt StartLimitIntervalSec/StartLimitBurst jetzt
  explizit statt sich stillschweigend auf systemds Vorgabewerte zu verlassen,
  mit Begruendung als Kommentar: ohne Zugangspunkt kann server.py nicht binden
  und stirbt sofort, Restart=always soll das nach kurzer Zeit ehrlich aufgeben
  statt das Journal zu fluten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 12:21:26 +02:00
Michael SauerandClaude Sonnet 5 10cc67d490 fix: Fehlerbehandlung und Quoting der Recovery-Station nach Review
- 'on' prueft nach dem Start wirklich, ob der Dienst aktiv ist (Type=simple
  meldet sonst schon Erfolg, wenn der Prozess nur gestartet wurde), und faehrt
  den Zugangspunkt bei einem toten Server wieder herunter statt ihn offen mit
  bekanntem Passwort ohne Server dahinter stehen zu lassen.
- 'off' faengt einen fehlschlagenden Wechsel zurueck ins Heim-WLAN ab statt
  unter set -eu mit nmclis Rohausgabe abzubrechen.
- Fehlende Schluessel in der Konfiguration werden nach dem Sourcen explizit
  geprueft und mit einem Verweis auf setup_pi.sh gemeldet statt mit
  "parameter not set" abzubrechen.
- station.conf.example verwendet einfache statt doppelter Anfuehrungszeichen:
  POSIX ". datei" wertet $ und ` auch in doppelten Anfuehrungszeichen aus,
  ein Passwort mit "$1" waere sonst lautlos durch ein Shell-Argument ersetzt
  worden. systemds EnvironmentFile= fuehrt ohnehin nie eine Shell aus und
  entfernt einfache wie doppelte Anfuehrungszeichen gleichermassen.
- ninlight-station.service: BindsTo=NetworkManager.service entfernt (ein
  dadurch ausgeloester Stop gilt fuer systemd als sauber und wird von keiner
  Restart=-Politik erneut gestartet - ein NetworkManager-Neustart haette den
  Server sonst still und dauerhaft mitgerissen); stattdessen nur
  After=NetworkManager.service fuer die Startreihenfolge und Restart=always,
  damit der Prozess nach einem Absturz (z.B. Bind-Fehler) automatisch
  wiederkommt.
- Trailer-Reader im 'status'-Zweig nutzt jetzt errors="replace" wie
  trailer_version() in server.py, statt bei nicht-ASCII-Bytes im
  Versionsfeld abzustuerzen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 12:14:49 +02:00
Michael SauerandClaude Sonnet 5 d41c385375 feat: Konfiguration, systemd-Unit und CLI der Recovery-Station
Die Station ist bewusst nicht dauerhaft an - 'ninlight-station on'
schaltet Zugangspunkt und Dienst gemeinsam ein, 'off' schaltet wlan0
zurueck ins Heim-WLAN. Der Dienst laeuft als root (Port 80 und die
DHCP-Leases fuer 4c) und bekommt dafuer systemd-Einschraenkungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 12:02:32 +02:00
Michael SauerandClaude Sonnet 5 a2e87b8855 fix: Nebenläufigkeit und verfrühtes "ok" in der Recovery-Station beheben
Fix-Runde 1 nach Review: Station.served und Station.recent wurden ohne
Lock von mehreren Handler-Threads (ein Thread pro Verbindung unter
ThreadingHTTPServer) mutiert - der Normalfall, wenn mehrere Lampen
gleichzeitig laden. Ein threading.RLock schuetzt jetzt Mutation und
Serialisierung nach status.json als eine Einheit.

Ausserdem hatte mein vorheriger Fix fuer das Status-Race einen neuen
Fehler eingefuehrt: "ok" wurde vor dem letzten wfile.write() vermerkt,
sodass ein danach scheiternder Schreibvorgang trotzdem als Erfolg in
status.json stand. record() laeuft jetzt erst, nachdem die Uebertragung
tatsaechlich abgeschlossen oder gescheitert ist; die betroffenen Tests
warten stattdessen auf den Eintrag statt sofortige Konsistenz
vorauszusetzen.

test_stall_after_stops_sending_but_keeps_the_connection prüfte bisher
nur die gesendete Bytezahl und haette einen faelschlich schliessenden
Server nicht bemerkt - ergaenzt um eine explizite Pruefung, dass ein
weiterer recv() in einen Timeout laeuft statt EOF zu liefern.

Neuer Test test_concurrent_downloads_are_all_recorded faehrt 20
gleichzeitige Downloads und prueft served/recent auf Vollstaendigkeit;
er schlaegt zuverlaessig fehl, wenn das Lock entfernt wird (lokal
gegengeprueft: 3 von 10 Laeufen ohne Lock verloren Eintraege, 10 von 10
mit Lock bestanden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 11:58:18 +02:00
Michael SauerandClaude Sonnet 5 37f4dd425e feat: HTTP-Server der Recovery-Station
Liefert ausschliesslich Images aus, deren Name dem Muster
ninlight_<target>_ota.img entspricht, unterstuetzt Range-Anfragen fuer
den Wiederaufnahmeversuch der Firmware und schreibt seinen Zustand nach
status.json - die Schnittstelle fuer die E-Paper-Anzeige in 4c.

Behebt zwei Races aus der Referenzimplementierung des Task-Briefs: der
Server trug Erfolg/404 erst nach dem letzten gesendeten Byte in
status.json ein, wodurch ein Client, der die Antwort vollstaendig
gelesen hat, noch den alten Zustand sehen konnte. Ausserdem wurde ein
ganzer 32-KiB-Chunk auch dann komplett gesendet, wenn --stall-after
schon ueberschritten war; die Lesegroesse wird jetzt an die Stall-Grenze
gekappt, und ein Verbindungsabbruch waehrend des Stalls wird per
select/MSG_PEEK erkannt und als "aborted" vermerkt statt den Thread
endlos zu blockieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 11:45:05 +02:00
Michael SauerandClaude Opus 5 76a3253322 docs: Recovery am EVK gegen die Pi-Station verifiziert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:24:18 +02:00
Michael SauerandClaude Opus 5 37a5fe2ca9 fix: uebersprungenes Recovery-Image ist kein Fehler mehr
Lehnte die Recovery ein Image ab, weil es bereits die laufende Version
traegt, meldete die Firmware das als last_ota_error=trailer. Wer eine
Lampe im Feld ausliest, haette dort einen Trailer-Fehler gesehen, wo in
Wahrheit nichts passiert ist - und die Meldung in der Konsole lautete
"update failed". Das bewusste Ueberspringen hat jetzt einen eigenen
Zustand (nl_ota_skipped()), setzt keinen Fehlerwert und wird als
"image already up to date" protokolliert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:22:17 +02:00
Michael SauerandClaude Opus 5 c5d76efb25 docs: auth_all-Ergebnis und die beiden offenen Punkte der EVK-Runde vermerken
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:00:50 +02:00
89 changed files with 18148 additions and 68 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release) set(CMAKE_BUILD_TYPE Release)
endif() endif()
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
set(NINLIGHT_DEVICE "" CACHE STRING "Target device") set(NINLIGHT_DEVICE "" CACHE STRING "Target device")
if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES) if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES)
message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>") message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>")
+1
View File
@@ -0,0 +1 @@
An open source replacement firmware for the no longer supported Klyqa smart wifi light bulbs based on the Winner Micro W600 chip
+14 -6
View File
@@ -24,7 +24,7 @@ Native Builds (mit lokal installierter ARM-Toolchain):
cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
cmake --build build/evk cmake --build build/evk
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw. **Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb (Montana Black LED Shield, nur RGB).
**Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`. **Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`.
@@ -95,11 +95,19 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
sauber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt), aber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
am EVK gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout — das `RECV_TIMEOUT` von 5 s des
HTTP-Clients greift dort nicht — und bricht danach ohne Wiederaufnahme ab. Die Lampe bleibt in dieser Zeit - **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab. `RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
diesen Fall darf man sich also verlassen.
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist): Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
+30 -4
View File
@@ -96,7 +96,7 @@ items:
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
- info: - info:
name: Color Red name: Color Red + Transition
type: http type: http
seq: 5 seq: 5
http: http:
@@ -110,7 +110,8 @@ items:
"r": 255, "r": 255,
"g": 0, "g": 0,
"b": 0 "b": 0
} },
"transition": 1500
} }
settings: settings:
encodeUrl: true encodeUrl: true
@@ -118,7 +119,7 @@ items:
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
- info: - info:
name: Color Temp 2700 name: Color Temp 2700 + Transition
type: http type: http
seq: 6 seq: 6
http: http:
@@ -128,7 +129,8 @@ items:
type: json type: json
data: |- data: |-
{ {
"color_temp": 2700 "color_temp": 2700,
"transition": 1500
} }
settings: settings:
encodeUrl: true encodeUrl: true
@@ -192,6 +194,25 @@ items:
timeout: 0 timeout: 0
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
- info:
name: Default Transition + Wake Switch
type: http
seq: 10
http:
method: POST
url: '{{device_address}}:{{device_port}}/api/v1/device/state'
body:
type: json
data: |-
{
"transition_default": 300,
"wake_on_change": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
- info: - info:
name: System name: System
type: folder type: folder
@@ -225,6 +246,11 @@ items:
timeout: 0 timeout: 0
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
docs: |-
`wifi.source` ist `"stored"`, solange ein per PUT onboardetes WLAN gespeichert ist,
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.
- info: - info:
name: Set WiFi name: Set WiFi
type: http type: http
+68 -8
View File
@@ -43,8 +43,8 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
- [x] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter — 2026-09-12 (Ablehnung nach ~4,5 s, Uptime lief durch) - [x] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter — 2026-09-12 (Ablehnung nach ~4,5 s, Uptime lief durch)
- [x] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset — 2026-09-12 (`crc`, kein Reset) - [x] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset — 2026-09-12 (`crc`, kein Reset)
- [x] OTA: HTTP-Server während des Downloads abschalten → Lampe bleibt über REST erreichbar und schaltbar, weist weitere `fw_update` mit `409` ab, und beendet den Versuch mit `last_ota_error = download` — 2026-09-12 (dauerte ~7 min, kein Range-Resume; siehe README) - [x] OTA: HTTP-Server während des Downloads abschalten → Lampe bleibt über REST erreichbar und schaltbar, weist weitere `fw_update` mit `409` ab, und beendet den Versuch mit `last_ota_error = download` — 2026-09-12 (dauerte ~7 min, kein Range-Resume; siehe README)
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler - [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler — *offen, WLAN am Teststandort nicht abschaltbar*
- [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen - [x] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen — 2026-09-12 (Pi Zero 2W, NetworkManager-AP; Scan → join → fetch → flash → Reboot)
- [x] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) — 2026-09-12 (`scan done after 1500 ms, 7 AP(s)`, danach normaler WLAN-Start) - [x] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) — 2026-09-12 (`scan done after 1500 ms, 7 AP(s)`, danach normaler WLAN-Start)
- [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben - [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben
- [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand) - [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand)
@@ -53,17 +53,17 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
- [x] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war — 2026-09-12 (pob=off + Licht an → blieb an; pob=on + Licht aus → blieb aus) - [x] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war — 2026-09-12 (pob=off + Licht an → blieb an; pob=on + Licht aus → blieb aus)
- [x] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen) — 2026-09-12 - [x] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen) — 2026-09-12
- [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware - [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware
- [ ] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht) - [x] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht) — 2026-09-12 (zweiter Boot: `image already carries the running version, not flashing`, kein Werksreset, beide Boots melden `boot #1`)
- [ ] OTA im AP-/Fallback-Modus (`mode=apsta`, kein Router erreichbar) und mit `auth_all = true` → Update läuft durch wie im normalen `sta`-Modus - [ ] OTA im **AP-/Fallback-Modus** (`mode=apsta`, kein Router erreichbar) → Update läuft durch wie im normalen `sta`-Modus — *offen, braucht einen abschaltbaren Access Point*. Mit `auth_all = true` am 2026-09-12 geprüft: Update läuft durch, `device/*` antwortet ohne Token korrekt mit 401 (`system/info` bleibt laut Spec §7.2 immer offen)
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht. Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
## Später (Lampen) ## Später (Lampen)
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie) - [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) - [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei - [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen - [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: unteres Bereichsende deutlich warm, oberes deutlich kühler (damals noch 2500–5000 K, inzwischen 2000–5000 K), Dimmen auf 10 % flackerfrei, weiches Ausblenden
- [ ] Farbtemperaturbereiche aller Varianten - [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.*
## Offene Beobachtung aus der EVK-Session vom 2026-09-12 ## Offene Beobachtung aus der EVK-Session vom 2026-09-12
@@ -73,3 +73,63 @@ unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läuf
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers. geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären. Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
## Recovery gegen die installierte Pi-Station (2026-09-13)
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
sieht pro Boot einen vollen Transfer.
## Migration echter Lampen (Plan 4b, 2026-09-13)
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
## Migrationsstation (Plan 4c) — Abnahme 2026-09-14
Station: Raspberry Pi Zero W Rev 1.1, Raspberry Pi OS Bookworm 12 (Python 3.11.2, Pillow 9.4.0,
armv6), Waveshare 2,13" E-Paper **Revision V3** als HAT, ein Taster auf GPIO23 (noch ohne Funktion).
- [x] `setup_pi.sh` richtet einen **nackten** Pi vollständig ein — Pakete, SPI, beide
NetworkManager-Profile, Server, CLI, Anzeigedienst. Zweimal von Grund auf durchgelaufen.
- [x] SPI wird automatisch eingeschaltet, der Neustart-Hinweis erscheint nur bei echter Änderung.
- [x] `deploy.sh` liefert 8 OTA-Images und 16 signierte Migrationsimages aus.
- [x] Panel-Revision am Testbild bestimmt: **V3**.
- [x] Horchphase: GU10 RGB meldet sich, wird erkannt, bekommt **404** — es floss kein Byte.
Protokoll: `/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.1.103_ota_light.img 404 0B`
Daraus abgeleitet: Ziel `gu10_rgb`, Variante **prod** (kein `-dev`), Lampenversion V1.1.1.103.
- [x] Erkennung am Panel sichtbar, vom Bediener bestätigt.
- [x] Zeitschalter holt die Station selbsttätig ins Heim-WLAN zurück (3 und 15 Minuten geprüft).
- [x] `arm gu10_rgb` freigegeben, Lampe migriert, startet neu, läuft mit `0.3.8-24-geaaef`,
per mDNS auffindbar und über REST steuerbar. `last_ota_error: null`.
- [x] Migrierte Lampe meldet `color: true`, `color_temp: [2700, 6500]`, `transition_default: 300`,
`wake_on_change: false`.
- [x] Ausschaltfade ohne Farbrest an der frisch migrierten Lampe — 2026-09-14 bestätigt.
- [ ] **Nicht geprüft und weiterhin offen: E27 RGB, E14 (RGB und CW), GU10 CW.** Von diesen
Varianten lag kein Exemplar vor. Ihre Pinbelegung stammt aus der alten Firmware und war
nie an echter Hardware verifiziert.
**Befund während der Abnahme, behoben in `0fe9ca6`:** `ninlight-station off` stoppte den
Anzeigedienst, aber kein `on`-Pfad startete ihn wieder — nach dem ersten `off` (das auch der
Zeitschalter auslöst) blieb das Panel bis zum Neustart des Pi dunkel. `off` fasst den
Anzeigedienst seitdem gar nicht mehr an: er läuft durchgehend und erkennt den ausgeschalteten
Zustand selbst (`systemctl is-active ninlight-station`), zeichnet also von sich aus
**Station aus**. Der ausdrückliche Diagnosebefehl `ninlight-station display off` bleibt erhalten.
Abgesichert durch die Fälle D4 und D4b in `tools/station/tests/test_cli.sh`.
**Hardware-Lehre:** Vier Kernel-Paniken an einem Tag, alle mit derselben Fehleradresse und
unabhängig von Karte und Konfiguration, kamen von einem Mehrfach-Netzteil, das sein Budget
dynamisch neu verhandelt. Mit einem eigenen Netzteil: `throttled=0x0` auch unter Schreiblast.
Die zerschossene erste SD-Karte war die Folge, nicht die Ursache.
@@ -0,0 +1,54 @@
"""The Ninlight integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_TOKEN, DEFAULT_PORT, PLATFORMS
from .coordinator import NinlightConfigEntry, NinlightCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
"""Set up a Ninlight bulb from a config entry."""
coordinator = NinlightCoordinator(hass, entry)
# Register the teardown before the first refresh: HA runs the on_unload callbacks both
# when setup fails and when the entry unloads, and awaits the coroutine they return.
# That covers every exit path, so no bulb client can be left with an open connection
# slot. The bulb serves one request per connection, so this matters.
entry.async_on_unload(coordinator.async_shutdown)
try:
await coordinator.async_config_entry_first_refresh()
except Exception:
# ConfigEntryNotReady / ConfigEntryAuthFailed propagate to HA, which retries or
# raises reauth. Close now rather than waiting for the retry.
await coordinator.async_shutdown()
raise
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate an entry written by an earlier version of this integration."""
if config_entry.version > 1:
return False
if "port" not in config_entry.data or CONF_TOKEN not in config_entry.data:
new = dict(config_entry.data)
new.setdefault("port", DEFAULT_PORT)
new.setdefault(CONF_TOKEN, new.pop("api_key", ""))
hass.config_entries.async_update_entry(config_entry, data=new, version=1)
_LOGGER.debug("Migrated ninlight entry %s to v1", config_entry.entry_id)
return True
@@ -0,0 +1,214 @@
"""Asynchronous client for the Ninlight REST API.
The firmware (src/net/nl_api.c) speaks plain HTTP/1.1 on port 3333 with a raw token in
the `Authorization` header - no "Bearer" scheme prefix, no TLS, no sessions. The bulb
serves one request per connection and accepts at most four queued connections, so the
client must not keep a hot connection pool open.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import httpx
from .const import DEFAULT_PORT
class NinlightError(Exception):
"""Base error, raised on connection problems and non-JSON replies."""
class NinlightAuthError(NinlightError):
"""The bulb rejected our token (HTTP 401)."""
class NinlightBadRequest(NinlightError):
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
def __init__(self, message: str, reason: str | None) -> None:
super().__init__(f"{message}: {reason}" if reason else message)
self.reason = reason
@dataclass(slots=True)
class NinlightState:
"""A parsed `GET /api/v1/device/state` plus the identity from `system/info`."""
on: bool
brightness: int # 1..100, the firmware's native unit
mode: str # "color" | "white"
r: int
g: int
b: int
color_temp: int # kelvin
ct_min: int
ct_max: int
has_rgb: bool
# Settings echoed by the same reply; the coordinator exposes them as entities.
power_on_behaviour: str
fade_in: int
fade_out: int
transition_default: int
wake_on_change: bool
# From system/info, merged in by the coordinator.
device_type: str | None = None
product_name: str | None = None
fw_version: str | None = None
mac: str | None = None
name: str | None = None
rssi: int | None = None
ip: str | None = None
# Segment model only (ninstrip): the raw segment list; the light maps onto
# segment 0 while a scene across the remaining segments stays untouched.
segments: list[dict[str, Any]] = field(default_factory=list)
# True for ninstrip firmware, whose light state lives in segments. Decided by
# presence of `segment_count` in device/state and `product` in system/info -
# both keys are ninstrip-only (nl_api_device.c / nl_api_system.c). Bulb
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
# name is not a signal, but the keys are.
strip: bool = False
@classmethod
def from_api(
cls,
state: dict[str, Any],
info: dict[str, Any] | None = None,
segments: list[dict[str, Any]] | None = None,
) -> NinlightState:
color = state.get("color") or {}
caps = state.get("capabilities") or {}
ct = caps.get("color_temp") or [2700, 6500]
wifi = (info or {}).get("wifi") or {}
seg = (segments or [None])[0] or {}
# `segment_count` and `product` exist only on ninstrip firmware; the bulb's
# device_type is a board id and carries no signal either way.
strip = "segment_count" in state or "product" in (info or {})
# On a strip the light lives in segment 0; mode "off" carries no color at all,
# so mode/color fall back to white there and is_on decides darkness anyway.
seg_mode = seg.get("mode")
return cls(
on=bool(state.get("on", False)),
brightness=int(state.get("brightness", 0)),
mode="color" if seg_mode == "solid" else str(state.get("mode", "white")),
r=int((seg.get("color") or color).get("r", 0)),
g=int((seg.get("color") or color).get("g", 0)),
b=int((seg.get("color") or color).get("b", 0)),
color_temp=int(seg.get("color_temp", state.get("color_temp", 2700))),
ct_min=int(ct[0]),
ct_max=int(ct[1]),
has_rgb=bool(caps.get("color", True)) if strip else bool(caps.get("color", False)),
power_on_behaviour=str(state.get("power_on_behaviour", "last")),
fade_in=int(state.get("fade_in", 0)),
fade_out=int(state.get("fade_out", 0)),
transition_default=int(state.get("transition_default", 0)),
wake_on_change=bool(state.get("wake_on_change", False)),
device_type=(info or {}).get("device_type"),
product_name=(info or {}).get("product_name"),
fw_version=(info or {}).get("fw_version"),
mac=(info or {}).get("mac"),
name=(info or {}).get("name"),
rssi=wifi.get("rssi"),
ip=wifi.get("sta_ip") or wifi.get("ip"),
segments=list(segments or []),
strip=strip,
)
class NinlightClient:
"""One client per bulb (per config entry)."""
def __init__(
self,
host: str,
token: str,
*,
port: int = DEFAULT_PORT,
session: httpx.AsyncClient | None = None,
) -> None:
self._base = f"http://{host}:{port}/api/v1"
# Raw token, not "Bearer <token>": nl_http_parse hands the header value straight to
# token_ok(), which compares it against the stored token byte for byte.
self._headers = {"Authorization": token}
self._owns_session = session is None
self._session: httpx.AsyncClient = session or httpx.AsyncClient(
headers=self._headers,
timeout=httpx.Timeout(8.0, connect=4.0),
# keepalive_expiry of ~0 makes httpx close after each response instead of
# holding one of the bulb's four listen-queue slots open between polls.
limits=httpx.Limits(max_connections=1, max_keepalive_connections=0, keepalive_expiry=0.1),
)
async def close(self) -> None:
if self._owns_session:
await self._session.aclose()
async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
except httpx.InvalidURL as err: # programming error, not a transient one
raise NinlightError(f"invalid bulb URL: {err}") from err
except httpx.HTTPError as err:
raise NinlightError(f"request to bulb failed: {err}") from err
if resp.status_code == 401:
raise NinlightAuthError("bulb rejected the API token")
if resp.status_code == 400:
raise NinlightBadRequest("bulb rejected the request", _error_field(resp))
if resp.status_code != 200:
raise NinlightError(f"bulb answered {resp.status_code}: {_error_field(resp)}")
try:
data = resp.json()
except ValueError as err:
raise NinlightError("bulb sent a non-JSON body") from err
if not isinstance(data, dict):
raise NinlightError("bulb sent an unexpected JSON shape")
return data
async def get_state(self) -> dict[str, Any]:
return await self._request("GET", "/device/state")
async def get_segments(self) -> dict[str, Any]:
"""Segment model of the ninstrip firmware (bulbs have no such endpoint)."""
return await self._request("GET", "/device/segments")
async def set_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]:
"""Replace the whole segment list. ninstrip answers with `{"status": "ok"}`."""
return await self._request("PUT", "/device/segments", {"segments": segments})
async def patch_segment(self, index: int, patch: dict[str, Any]) -> dict[str, Any]:
"""Update one segment; omitted fields keep their current value."""
return await self._request("PUT", f"/device/segments/{index}", patch)
async def get_info(self) -> dict[str, Any]:
return await self._request("GET", "/system/info")
async def get_settings(self) -> dict[str, Any]:
"""`system/settings` is the one endpoint that always requires the token.
`system/info` and a state GET answer without auth unless `auth_all` is set
(nl_api.c: the state branch tests `s.auth_all && !authed`, the info branch tests
nothing), so only settings proves the token is right.
"""
return await self._request("GET", "/system/settings")
async def set_state(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/device/state", payload)
async def ping(self) -> dict[str, Any]:
return await self._request("PUT", "/system/command", {"command": {"type": "ping"}})
async def reboot(self) -> dict[str, Any]:
return await self._request("PUT", "/system/command", {"command": {"type": "reboot"}})
def _error_field(resp: httpx.Response) -> str | None:
try:
body = resp.json()
except ValueError:
return resp.text[:120] or None
if isinstance(body, dict):
err = body.get("error")
return str(err) if err is not None else None
return None
@@ -0,0 +1,166 @@
"""Config flow for Ninlight."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .client import NinlightAuthError, NinlightClient, NinlightError
from .const import CONF_TOKEN, DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
vol.Optional("port", default=DEFAULT_PORT): vol.All(vol.Coerce(int), vol.Range(1, 65535)),
vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): vol.All(str, vol.Length(min=1, max=64)),
}
)
def _normalize_mac(mac: str) -> str:
"""Keep one canonical form for the unique id (zeroconf and system/info both use `aa:bb:...`)."""
return mac.strip().lower()
def _is_full_mac(mac: str) -> bool:
"""True for a colon-separated 12-hex-digit MAC, the only TXT form usable as unique id.
ninstrip devices publish the 4-byte suffix (`eb4b98`, nl_wifi.c's mac4) in their TXT
record; two bulbs sharing a last octet would collide, so those need a probe instead.
"""
parts = mac.strip().lower().split(":")
return len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts)
class NinlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ninlight."""
VERSION = 1
async def _probe_mac(self, host: str, port: int) -> str:
"""Return the bulb's MAC. Needs no token: `system/info` is unauthenticated."""
client = NinlightClient(host, DEFAULT_TOKEN, port=port)
try:
info = await client.get_info()
finally:
await client.close()
mac = info.get("mac")
if not isinstance(mac, str) or not mac:
raise NinlightError("bulb did not report a MAC address")
return _normalize_mac(mac)
async def _probe_mac_and_token(self, host: str, port: int, token: str) -> str:
"""As above, plus verify the token against `system/settings`.
Only that endpoint authenticates, so a probe that stops at system/info would
happily accept a wrong token (nl_api.c's info branch never checks `authed`).
"""
client = NinlightClient(host, token, port=port)
try:
info = await client.get_info()
await client.get_settings()
finally:
await client.close()
mac = info.get("mac")
if not isinstance(mac, str) or not mac:
raise NinlightError("bulb did not report a MAC address")
return _normalize_mac(mac)
async def _entry_from_data(self, data: dict[str, Any], title: str) -> ConfigFlowResult:
await self.async_set_unique_id(data["mac"])
# Addresses come from DHCP, so rediscovering a known MAC at a new IP must move the
# existing entry rather than abort a re-add.
self._abort_if_unique_id_configured(updates=data)
return self.async_create_entry(title=title, data=data)
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
host = user_input["host"].strip()
port = int(user_input["port"])
token = user_input[CONF_TOKEN].strip()
try:
mac = await self._probe_mac_and_token(host, port, token)
except NinlightAuthError:
errors[CONF_TOKEN] = "invalid_auth"
except NinlightError:
errors["base"] = "cannot_connect"
else:
return await self._entry_from_data(
{"host": host, "port": port, CONF_TOKEN: token, "mac": mac}, title=host
)
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery of a `_ninlight._tcp` service."""
props = {str(k).lower(): str(v) for k, v in discovery_info.properties.items()}
port = discovery_info.port or DEFAULT_PORT
mac = props.get("mac", "")
# A TXT record may hold no MAC at all (bulb still announcing) or only the short
# 4-byte suffix form ninstrip publishes (`eb4b98`). Unique ids are full MACs, so
# anything that is not one colon-form is replaced by a probe of system/info.
if not _is_full_mac(mac):
try:
mac = await self._probe_mac(discovery_info.host, port)
except NinlightError:
raise AbortFlow("cannot_connect") from None
mac = _normalize_mac(mac)
await self.async_set_unique_id(mac)
updates = {
"host": discovery_info.host,
"port": port,
"mac": mac,
"device_type": props.get("type", ""),
}
self._abort_if_unique_id_configured(updates=updates)
# Instance name is `ninlight-<mac4>` until someone names the bulb; a name set on
# the device is published in the TXT record and wins.
title = props.get("name") or discovery_info.name.split(".", 1)[0]
self.context["title_placeholders"] = {"name": title}
# Create rather than prompt. A confirm step would leave the flow parked in
# `in_progress`, where it blocks a manual add of the same bulb with
# already_in_progress - and a bulb that is reachable but not announcing mDNS
# (two of ours) has to be added manually, so that collision is not theoretical.
return self.async_create_entry(title=title, data={**updates, CONF_TOKEN: DEFAULT_TOKEN})
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert entry is not None
errors: dict[str, str] = {}
if user_input is not None:
token = user_input[CONF_TOKEN].strip()
try:
await self._probe_mac_and_token(entry.data["host"], entry.data.get("port", DEFAULT_PORT), token)
except NinlightAuthError:
errors[CONF_TOKEN] = "invalid_auth"
except NinlightError:
errors["base"] = "cannot_connect"
else:
self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_TOKEN: token})
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_TOKEN, default=entry.data.get(CONF_TOKEN, "")): str}),
errors=errors,
description_placeholders={"name": entry.title},
)
@@ -0,0 +1,25 @@
"""Constants for the Ninlight integration."""
from __future__ import annotations
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN = "ninlight"
PLATFORMS: Final = [Platform.LIGHT]
CONF_TOKEN = "token"
DEFAULT_PORT = 3333
DEFAULT_TOKEN = "aabbccddeeff0011223344"
# The firmware has no push channel, so state is polled. A bulb answers in well under
# a second on the LAN; 30 s keeps the UI honest without adding noticeable REST traffic
# for a nine bulb setup.
SCAN_INTERVAL = timedelta(seconds=30)
# Mirror of NL_MAX_TRANSITION_MS in src/light/nl_control.h.
MAX_TRANSITION_MS = 60000
@@ -0,0 +1,107 @@
"""Data coordinator for a single Ninlight bulb."""
from __future__ import annotations
import logging
from contextlib import suppress
from typing import Any, TypeAlias
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .client import (
NinlightAuthError,
NinlightBadRequest,
NinlightClient,
NinlightError,
NinlightState,
)
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
class NinlightCoordinator(DataUpdateCoordinator[NinlightState]):
"""Polls one bulb. One coordinator, and one HTTP client, per config entry.
`system/info` is fetched on the first successful refresh only. It carries identity
(MAC, product name, firmware) that does not change while the bulb runs, and the bulb
accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so
skipping it on every poll keeps one of those slots free for the UI.
"""
config_entry: NinlightConfigEntry
def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} {entry.title}",
update_interval=SCAN_INTERVAL,
)
self.client = NinlightClient(
entry.data["host"],
entry.data.get(CONF_TOKEN, ""),
port=entry.data.get("port", DEFAULT_PORT),
)
self.info: dict[str, Any] | None = None
self._identity_ok = False
self._is_strip = False
async def async_shutdown(self) -> None:
"""Close the HTTP client alongside the coordinator's own teardown."""
await self.client.close()
await super().async_shutdown()
async def _async_update_data(self) -> NinlightState:
try:
state = await self.client.get_state()
except NinlightAuthError as err:
# Only possible when the bulb has `auth_all` set; a wrong token will not fix
# itself, so surface a re-auth flow instead of retrying every interval.
raise ConfigEntryAuthFailed(str(err)) from err
except NinlightBadRequest as err:
# A poll sends no body, so this can only mean a firmware we do not match.
raise UpdateFailed(str(err)) from err
except NinlightError as err:
raise UpdateFailed(str(err)) from err
if not self._identity_ok:
# One extra pair of requests, then never again: the bulb accepts at most four
# queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll
# path to a single GET leaves slots free for the UI.
with suppress(NinlightError):
# system/info carries identity that only changes on a reboot or an OTA.
self.info = await self.client.get_info()
try:
# system/settings always demands the token, so it is the only call that
# proves ours is right. A failure here on a bulb whose state endpoint
# answered happily means the token is wrong while `auth_all` is off - the
# light would work and silently never be able to be renamed or updated.
await self.client.get_settings()
except NinlightAuthError as err:
raise ConfigEntryAuthFailed(str(err)) from err
# `product` is an ninstrip-only key in system/info (bulbs send
# product_name); once known, the heavier segment poll follows every refresh.
self._is_strip = "product" in (self.info or {})
self._identity_ok = True
segments: list[dict[str, Any]] | None = None
if self._is_strip:
# The light lives in the segment list; the plain state GET above carries
# only on/brightness for it. A firmware without the endpoint (misdetected
# strip) must not fail the whole poll for that.
try:
segments = (await self.client.get_segments()).get("segments")
except NinlightError as err:
_LOGGER.debug("segment poll failed: %s", err)
return NinlightState.from_api(state, self.info, segments)
# Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the
# project's 3.11 tooling and runs on HA's 3.14.
NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator]
@@ -0,0 +1,40 @@
"""Base entity for Ninlight devices."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import NinlightCoordinator, NinlightConfigEntry
class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
"""Common device info for every entity of one bulb."""
_attr_has_entity_name = True
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
super().__init__(coordinator, context=entry.entry_id)
mac = (entry.unique_id or "unknown").lower()
info = coordinator.info or {}
# Deliberately no `connections={("mac", mac)}`: HA treats a MAC in `connections`
# as a network-topology link, so a bulb the FRITZ!Box tracker already knows merged
# into that router-adjacent device and its entity id fell back to the IP. The MAC
# still identifies the device via unique_id; identifiers are the sole identity here.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
# The bulb itself has no user-visible name field that survives rediscovery
# cleanly, so the entry title (set from mDNS or by the user) names the device.
name=entry.title,
manufacturer="Klyqa",
model=info.get("product_name") or info.get("product"),
model_id=info.get("device_type"),
sw_version=info.get("fw_version"),
configuration_url=f"http://{entry.data['host']}:{entry.data.get('port', DEFAULT_PORT)}/",
)
def _entry_mac(entry: NinlightConfigEntry) -> str:
mac: str = entry.unique_id or "unknown"
return mac.lower()
@@ -0,0 +1,182 @@
"""Light platform for Ninlight bulbs."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .client import NinlightBadRequest, NinlightError, NinlightState
from .const import MAX_TRANSITION_MS
from .coordinator import NinlightConfigEntry, NinlightCoordinator
from .entity import NinlightEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: NinlightConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the light for a Ninlight bulb config entry."""
async_add_entities([NinlightLight(entry.runtime_data, entry)])
def _to_ha_brightness(pct: int) -> int:
"""Bulb brightness is 1..100; HA brightness is 0..255."""
return max(1, min(255, round(pct * 255 / 100)))
def _to_bulb_brightness(level: int) -> int:
"""The bulb rejects brightness 0 and anything above 100 (invalid brightness)."""
return max(1, min(100, round(level * 100 / 255)))
class NinlightLight(NinlightEntity, LightEntity):
"""A Klyqa bulb running the Ninlight firmware."""
# No translation_key: with has_entity_name and a single entity per device, HA names the
# light after the device, giving light.schreibtisch_links rather than ..._bulb.
_attr_name = None
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
super().__init__(coordinator, entry)
state = coordinator.data
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
# Both modes are advertised for RGB bulbs: the firmware keeps the last color and
# the last color temperature and picks between them with `mode`, which is exactly
# how HA models a color_temp + rgb light.
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {
ColorMode.COLOR_TEMP
}
self._attr_supported_features = LightEntityFeature.TRANSITION
self._attr_min_color_temp_kelvin = state.ct_min
self._attr_max_color_temp_kelvin = state.ct_max
@property
def is_on(self) -> bool:
return self.coordinator.data.on
@property
def brightness(self) -> int | None:
return _to_ha_brightness(self.coordinator.data.brightness)
@property
def color_mode(self) -> ColorMode:
state = self.coordinator.data
if not state.on:
# Nothing is lit; reporting a mode would contradict is_on=False.
return ColorMode.UNKNOWN
if state.has_rgb and state.mode == "color":
return ColorMode.RGB
return ColorMode.COLOR_TEMP
@property
def color_temp_kelvin(self) -> int | None:
state = self.coordinator.data
return state.color_temp if self.color_mode == ColorMode.COLOR_TEMP else None
@property
def rgb_color(self) -> tuple[int, int, int] | None:
state = self.coordinator.data
if self.color_mode == ColorMode.RGB:
return (state.r, state.g, state.b)
return None
async def async_turn_on(self, **kwargs: Any) -> None:
state = self.coordinator.data
payload: dict[str, Any] = {"on": True}
if (level := kwargs.get(ATTR_BRIGHTNESS)) is not None:
payload["brightness"] = _to_bulb_brightness(int(level))
elif not state.on:
# HA expects turn_on without a brightness to restore the previous level. The
# firmware already does that (it keeps `brightness` while off), so sending it
# would only re-assert a value the bulb may have clamped itself - but a bulb
# that has never been on reports brightness 0, which its API rejects. Mirror
# the last known value only when the bulb actually has one.
if state.brightness < 1:
payload["brightness"] = 100
# The firmware rejects color and color_temp in one request with
# "color and color_temp are exclusive", so pick one. HA only sends both when a
# caller passes them together; color is the more specific intent and wins.
kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
rgb = kwargs.get(ATTR_RGB_COLOR)
seg: dict[str, Any] | None = None
if rgb is not None and state.has_rgb:
r, g, b = (int(max(0, min(255, v))) for v in rgb)
if state.strip:
seg = {"mode": "solid", "color": {"r": r, "g": g, "b": b}}
else:
payload["color"] = {"r": r, "g": g, "b": b}
elif kelvin is not None:
ct = max(state.ct_min, min(state.ct_max, int(kelvin)))
if state.strip:
seg = {"mode": "white", "color_temp": ct}
else:
payload["color_temp"] = ct
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
await self._apply(payload, seg)
async def async_turn_off(self, **kwargs: Any) -> None:
payload: dict[str, Any] = {"on": False}
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
await self._apply(payload, None)
async def _apply(self, payload: dict[str, Any], seg_patch: dict[str, Any] | None) -> None:
"""Send one command and update immediately.
POST /device/state answers with the full new state, so the coordinator does not
need a refresh round-trip and the UI settles on the real bulb state at once.
On the segment-model firmware the color goes into segment 0 as a separate PATCH
(`PUT /device/segments/0`, fields omitted keep their value); the master on and
brightness stay in the state POST, because the firmware's fade rides on them.
Segment brightness stays untouched so master scaling remains the single knob.
"""
try:
data = await self.coordinator.client.set_state(payload)
if seg_patch is not None:
segs = self.coordinator.data.segments
start = segs[0]["start"] if segs else 0
end = segs[0]["end"] if segs else max(0, int(data.get("pixel_count", 1)) - 1)
await self.coordinator.client.patch_segment(
0, {**seg_patch, "start": start, "end": end}
)
except NinlightBadRequest as err:
# A rejected command is a bug or a firmware mismatch, not a network fault:
# surface it to the caller and leave the entity's state alone.
raise HomeAssistantError(str(err)) from err
except NinlightError as err:
self.coordinator.async_set_update_error(err)
raise HomeAssistantError(str(err)) from err
# Push the device's own reply through the coordinator so every entity of this
# device (light, sensors) settles on the real state at once.
segments = None
if self.coordinator.data.strip:
try:
segments = (await self.coordinator.client.get_segments()).get("segments")
except NinlightError:
pass # the next poll recovers it; the state reply above is still right
self.coordinator.async_set_updated_data(
NinlightState.from_api(data, self.coordinator.info, segments)
)
@@ -0,0 +1,12 @@
{
"domain": "ninlight",
"name": "Ninlight",
"codeowners": [],
"config_flow": true,
"documentation": "https://github.com/ninharp/ninlight-w600",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": [],
"version": "0.1.0",
"zeroconf": ["_ninlight._tcp.local.", "_ninstrip._tcp.local."]
}
@@ -0,0 +1,35 @@
{
"config": {
"steps": {
"user": {
"title": "Ninlight bulb",
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
"data": {
"host": "Host",
"port": "Port",
"token": "API token"
},
"data_description": {
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
}
},
"reauth_confirm": {
"title": "Reauthenticate {name}",
"description": "The bulb rejected the stored API token. Enter its current token.",
"data": {
"token": "API token"
}
}
},
"error": {
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
"invalid_auth": "Wrong API token."
},
"abort": {
"already_configured": "This bulb is already configured.",
"already_in_progress": "A setup flow for this bulb is already running.",
"cannot_connect": "Could not reach the bulb.",
"reauth_successful": "The API token was updated."
}
}
}
@@ -0,0 +1,35 @@
{
"config": {
"steps": {
"user": {
"title": "Ninlight bulb",
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
"data": {
"host": "Host",
"port": "Port",
"token": "API token"
},
"data_description": {
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
}
},
"reauth_confirm": {
"title": "Reauthenticate {name}",
"description": "The bulb rejected the stored API token. Enter its current token.",
"data": {
"token": "API token"
}
}
},
"error": {
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
"invalid_auth": "Wrong API token."
},
"abort": {
"already_configured": "This bulb is already configured.",
"already_in_progress": "A setup flow for this bulb is already running.",
"cannot_connect": "Could not reach the bulb.",
"reauth_successful": "The API token was updated."
}
}
}
+5
View File
@@ -0,0 +1,5 @@
# Laufzeitarteifakte
qcx-requests.log
hostapd.log
*.pid
images/
+11
View File
@@ -0,0 +1,11 @@
# Ninlight Bulbs — MAC → Raum (identifiziert 2026-09-21)
f7ce Küche (grün)
fd1e Küche (rot)
fadb Küche (magenta; erster Converter vom 20.9.)
f860 Schlafzimmer
faed Schlafzimmer
fb38 Schlafzimmer (blau)
63e9 Sofa (hellblau)
10a7 unbekannt (on:false, IP .29)
1fe9 unbekannt (on:false, IP .31)
f960 Montana Black LED-Shield (28:6d:ce:3c:f9:60, ledshield_rgb, 25.9. migriert)
+13
View File
@@ -0,0 +1,13 @@
# dnsmasq for the qconnex-firmware listener (no service integration)
interface=wlan0
bind-interfaces
listen-address=192.168.8.1
no-resolv
server=192.168.178.1
address=/#/192.168.8.1
dhcp-range=192.168.8.10,192.168.8.200,255.255.255.0,10m
dhcp-option=3,192.168.8.1
dhcp-option=6,192.168.8.1
log-dhcp
log-queries
no-ping
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Tear down the qconnex-firmware migration listener.
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
SUDO=${SUDO:-sudo -n}
[ -f "$HERE/otaserve.pid" ] && $SUDO pkill -f "python3 $HERE/otaserve.py" 2>/dev/null; rm -f "$HERE/otaserve.pid"
[ -f "$HERE/dnsmasq.pid" ] && $SUDO kill "$(cat "$HERE/dnsmasq.pid")" 2>/dev/null && rm -f "$HERE/dnsmasq.pid"
$SUDO pkill -f "hostapd -B -f $HERE/hostapd.log" 2>/dev/null
$SUDO ip addr flush dev wlan0 2>/dev/null
$SUDO nmcli dev set wlan0 managed yes 2>/dev/null || true
echo "listener down"
+15
View File
@@ -0,0 +1,15 @@
# qconnex-firmware listener AP — temporary experiment, do not enable as a service
interface=wlan0
driver=nl80211
ssid=qconnex-firmware
hw_mode=g
channel=6
ieee80211n=0
auth_algs=1
# WPA2 with the password the firmware's bootup-OTA STA config expects.
# (Open AP worked for the W600 bulbs; ESP32-C3 STA threshold is WPA_PSK.)
wpa=2
wpa_passphrase=qconnex-firmware
wpa_pairwise=CCMP
rsn_pairwise=CCMP
wmm_enabled=0
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Logging HTTP server for the qconnex-firmware listener experiment.
Logs every request (method, path, headers, body snippet) to qcx-requests.log
and answers 404 for everything. No images served yet — this run only watches
what the original Klyqa firmware asks for after joining the AP.
"""
import http.server
import sys
LOG = sys.path[0] + "/qcx-requests.log"
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # keep our own log format only
pass
def handle_one_request_log(self):
body = b""
n = int(self.headers.get("Content-Length") or 0)
if n:
body = self.rfile.read(min(n, 2048))
with open(LOG, "a") as f:
f.write("=== %s\n" % self.command)
f.write("url: %s\n" % self.path)
f.write("client: %s:%s\n" % self.client_address)
for k, v in self.headers.items():
f.write("%s: %s\n" % (k, v))
if body:
f.write("body: %r\n" % body)
f.flush()
print("%s %s %s" % (self.command, self.client_address[0], self.path), flush=True)
payload = b"not found\n"
self.send_response(404)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_OPTIONS = handle_one_request_log
if __name__ == "__main__":
srv = http.server.ThreadingHTTPServer(("0.0.0.0", 80), Handler)
print("qcx log server on 0.0.0.0:80", flush=True)
srv.serve_forever()
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Onboard a qconnex/KLYQA bulb via its softAP TLS onboarding socket (port 2223).
Usage: onboard.py <bulb-ip> <ssid> <wifi-password> [access-token]
Wire framing: every message is a 4-byte header (u16 BE payload length + 2
reserved bytes) followed by the JSON payload. Sends
{"type":"configure","ssid":"<hex>","password":"***","access_token":...}
and prints replies. Device IP is 10.42.42.10 while its KLYQA-xxxxxx softAP is up.
"""
import socket, ssl, sys, json, struct
def frame(obj):
b = json.dumps(obj).encode()
return struct.pack(">HH", len(b), 0) + b
def main():
ip, ssid, pswd = sys.argv[1], sys.argv[2], sys.argv[3]
token = sys.argv[4] if len(sys.argv) > 4 else "aabbccddeeff0011223344"
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT (mbedTLS server)
s = ctx.wrap_socket(socket.create_connection((ip, 2223), timeout=10))
print(f"connected {ip}:2223 {s.cipher()[0]}", file=sys.stderr)
buf = b""
def drain(sec, label):
nonlocal buf
s.settimeout(sec)
n = 0
try:
while True:
chunk = s.recv(4096)
if not chunk: break
buf += chunk
while len(buf) >= 4:
ln = struct.unpack(">H", buf[:2])[0]
if len(buf) < 4 + ln: break
msg, buf = buf[4+ln:], buf[4+ln:]
n += 1
print(f"{label}{msg.decode(errors='replace')[:400]}")
except socket.timeout:
pass
return n
drain(2, "recv: ")
cfg = {"type": "configure",
"ssid": ssid.encode().hex(),
"password": pswd.encode().hex(),
"access_token": token}
s.sendall(frame(cfg))
print("sent configure", file=sys.stderr)
if not drain(10, "recv: "):
print("no reply (device may just proceed to join the WLAN)", file=sys.stderr)
s.close()
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Serve qcx OTA images from ./images with HTTP Range support, plus request logging."""
import http.server, os, re, sys, datetime
ROOT = os.path.join(sys.path[0], "images")
LOG = os.path.join(sys.path[0], "qcx-requests.log")
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def do_GET(self):
path = self.path.lstrip("/")
with open(LOG, "a") as f:
f.write("=== GET %s (%s) %s\nPATH: %s\n" % (datetime.datetime.now(), self.client_address[0],
dict(self.headers), path))
fp = os.path.join(ROOT, path)
# any bootup-OTA request for a qcx image that doesn't exist on purpose
# (unknown product/version combo) -> serve the ninlight migrate image
if not os.path.isfile(fp) and path.startswith("qcx_firmware_"):
if "ledshield" in path:
guess = "ninlight_ledshield_rgb_migrate_dev.img"
elif "rgbc3" in path:
guess = "ninlight_ninstrip-rgbc3_migrate_dev.img"
elif ".gu10" in path:
guess = "ninlight_gu10_rgb_migrate_dev.img"
else:
guess = "ninlight_e27_rgb_migrate_dev.img"
fp = os.path.join(ROOT, guess)
if not os.path.isfile(fp):
p = b"not found\n"
self.send_response(404)
self.send_header("Content-Length", str(len(p)))
self.end_headers()
self.wfile.write(p)
return
size = os.path.getsize(fp)
start, rng = 0, self.headers.get("Range")
if rng and (m := re.match(r"bytes=(\d+)-(\d*)$", rng)):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else size - 1
else:
end = size - 1
end = min(end, size - 1)
length = max(0, end - start + 1)
self.send_response(206 if rng else 200)
self.send_header("Content-Length", str(length))
self.send_header("Content-Type", "application/octet-stream")
if rng:
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
self.end_headers()
with open(fp, "rb") as f:
f.seek(start)
left = length
while left > 0:
chunk = f.read(min(8192, left))
if not chunk:
break
self.wfile.write(chunk)
left -= len(chunk)
print("served %s %d-%d to %s" % (path, start, end, self.client_address[0]), flush=True)
do_POST = do_HEAD = do_PUT = do_GET
if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True)
print("qcx ota server on 0.0.0.0:80 root=%s" % ROOT, flush=True)
http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Dump raw bytes clients send to 192.168.8.1:3333 (what would ninlight poll?)."""
import socket, sys, datetime, binascii
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("192.168.8.1", 3333))
s.listen(8)
print("raw 3333 listener", flush=True)
while True:
c, a = s.accept()
try:
c.settimeout(5)
data = b""
try:
while len(data) < 65536:
ch = c.recv(2048)
if not ch:
break
data += ch
except socket.timeout:
pass
print("=== %s %s %d bytes\n%s" % (datetime.datetime.now(), a[0], len(data), repr(data)), flush=True)
finally:
c.close()
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""TCP 443 sniffer: accept, read first bytes, log hex+printable, close.
For an HTTPS client this captures the TLS ClientHello in plaintext — the SNI
extension names the host the bulb actually wants to talk to.
"""
import socket, sys, datetime, binascii
LOG = sys.path[0] + "/qcx-tls.log"
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 443))
s.listen(8)
print("qcx tls sniffer on 0.0.0.0:443", flush=True)
while True:
c, addr = s.accept()
try:
c.settimeout(3)
data = b""
try:
while len(data) < 4096:
chunk = c.recv(2048)
if not chunk:
break
data += chunk
except socket.timeout:
pass
with open(LOG, "a") as f:
f.write("=== %s %s:%s %d bytes\n" % (datetime.datetime.now(), addr[0], addr[1], len(data)))
f.write(binascii.hexlify(data).decode() + "\n")
f.write(repr(data) + "\n")
print("tls from %s: %d bytes" % (addr[0], len(data)), flush=True)
finally:
c.close()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Bring up the qconnex-firmware migration listener (AP + DHCP/DNS + OTA server).
# Takes wlan0 as an open AP "qconnex-firmware" on 192.168.8.1. Machine stays
# online via ethernet. Teardown: ./down.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
SUDO=${SUDO:-sudo -n}
# wlan0 must be free of any NetworkManager claim
$SUDO nmcli dev set wlan0 managed no 2>/dev/null || true
$SUDO ip link set wlan0 up
$SUDO ip addr replace 192.168.8.1/24 dev wlan0
$SUDO dnsmasq --conf-file="$HERE/dnsmasq-qcx.conf" --pid-file="$HERE/dnsmasq.pid"
$SUDO hostapd -B -f "$HERE/hostapd.log" "$HERE/hostapd.conf"
sleep 1
# serve images on :80 (needs root to bind <1024)
nohup $SUDO python3 "$HERE/otaserve.py" >/dev/null 2>&1 &
echo $! > "$HERE/otaserve.pid"
echo "AP qconnex-firmware up on 192.168.8.1; serving $HERE/images (log: $HERE/qcx-requests.log)"
+2 -1
View File
@@ -202,7 +202,8 @@ static void app_task(void *arg)
after_ota); after_ota);
nl_app_lock(); nl_app_lock();
nl_engine_init(&cfg); nl_engine_init(&cfg);
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms); nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_ms,
s_settings.wake_on_change != 0);
nl_ctrl_boot(&initial); nl_ctrl_boot(&initial);
nl_app_unlock(); nl_app_unlock();
+12 -4
View File
@@ -21,16 +21,24 @@ const nl_board_t nl_boards[] = {
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x73}, {NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x73},
{"gu10_rgb", "Klyqa GU10 RGB CW/WW", NL_DRV_SM2135EH, true, 2700, 6500, 0, {"gu10_rgb", "Klyqa GU10 RGB CW/WW", NL_DRV_SM2135EH, true, 2700, 6500, 0,
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x63}, {NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x63},
{"e27_cw", "Klyqa E27 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600, {"e27_cw", "Klyqa E27 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
{NONE, NONE, NONE, CHA(1, NL_PB(13)), CH(4, NL_PB(8))}, NO_SM}, {NONE, NONE, NONE, CHA(1, NL_PB(13)), CH(4, NL_PB(8))}, NO_SM},
{"e14_cw", "Klyqa E14 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600, {"e14_cw", "Klyqa E14 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM}, {NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
{"gu10_cw", "Klyqa GU10 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600, {"gu10_cw", "Klyqa GU10 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM}, {NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
/* G95: current table: CW=PB13, WW=PB15 (new assignment); old bulbs may use /* G95: current table: CW=PB13, WW=PB15 (new assignment); old bulbs may use
* PB16=WW, PB15=CW - verify on a series bulb. */ * PB16=WW, PB15=CW - verify on a series bulb. */
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2500, 5000, 600, {"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2000, 5000, 600,
{NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM}, {NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM},
/* Montana Black LED Shield (Klyqa LED-Strippen-Steuergeraet,
product_id @qcx.montana.black.ledshield.rgb): analog RGB-Streifen an
lowseitigen MOSFETs, kein CW/WW-Zweig. PIN-Kanaele und 800 Hz aus der
Original-Firmware (fw-w600 qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD);
dort invertiert init mit invertiertem Duty, effektive Wirkung wie hier
active-high. ct_min == ct_max markiert "weiss = RGB-Kombination". */
{"ledshield_rgb", "Montana Black RGB LED Shield", NL_DRV_PWM, true, 4000, 4000, 800,
{CH(1, NL_PB(13)), CH(2, NL_PB(16)), CH(3, NL_PB(15)), NONE, NONE}, NO_SM},
}; };
const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]); const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]);
+2
View File
@@ -47,6 +47,8 @@ void nl_settings_defaults(nl_settings_t *s)
s->power_on_behaviour = NL_POB_LAST; s->power_on_behaviour = NL_POB_LAST;
s->fade_in_ms = 500; s->fade_in_ms = 500;
s->fade_out_ms = 500; s->fade_out_ms = 500;
s->transition_ms = 300;
s->wake_on_change = 0;
} }
void nl_settings_init(uint32_t addr_a, uint32_t addr_b) void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
+9
View File
@@ -19,6 +19,15 @@ typedef struct {
uint16_t fade_in_ms; uint16_t fade_in_ms;
uint16_t fade_out_ms; uint16_t fade_out_ms;
uint8_t ota_pending; /* set before an OTA; the next boot restores the last state */ uint8_t ota_pending; /* set before an OTA; the next boot restores the last state */
/* Appended fields: read_rec() in nl_settings.c pre-fills the struct with
* nl_settings_defaults() and only overwrites the bytes actually stored, so records
* written by an older firmware pick up the real default here, not zero. Keep adding new
* fields at the end for that to keep working. */
uint16_t transition_ms; /* default transition (ms) for a color/brightness/CT change
* while the lamp stays on; fade_in/fade_out still win when
* switching on or off. */
uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was
* off, unless the same call says "on": false. */
} nl_settings_t; } nl_settings_t;
void nl_settings_defaults(nl_settings_t *s); void nl_settings_defaults(nl_settings_t *s);
+27 -3
View File
@@ -7,6 +7,7 @@
#define SM_ADDR_CURRENT 0xC0u #define SM_ADDR_CURRENT 0xC0u
#define SM_ADDR_WY 0xC5u #define SM_ADDR_WY 0xC5u
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */ #define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
#define SM_CHANNEL_STBY 0x20u /* standby bit 5: powers the outputs down */
extern void delay_us(unsigned int us); /* provided by libwlan.a */ extern void delay_us(unsigned int us); /* provided by libwlan.a */
@@ -76,12 +77,35 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
s_have_last = true; s_have_last = true;
if (!white) { if (!white) {
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, 0x00, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]}; /* All channels dark: put the driver into standby instead of just writing zero duty.
Without bit 5 the chip stays active and leaks enough current to keep the lamp
faintly glowing - measured on a migrated GU10 RGB, 2026-09-13. The old QConnex
firmware had this logic but left it commented out (qcl_lamp.c), which is why the
behaviour was inherited. Lamps driven straight from PWM pins (G95, the CW variants)
are unaffected. Turning back on writes 0x00 or SM_CHANNEL_WY here and clears it. */
bool dark = !ch[NL_CH_R] && !ch[NL_CH_G] && !ch[NL_CH_B];
uint8_t chan = dark ? SM_CHANNEL_STBY : 0x00u;
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
sm_send(d, 6); sm_send(d, 6);
/* The WY data registers are cleared here too, even though this is the RGB branch:
the chip keeps whatever white value was last written, and a direct white -> colour
change never passes through the all-dark frame above. The stale white then bleeds
through as soon as the colour dims - the same defect as the colour -> white case
below (fixed in 00d5a4c, 2026-09-13), just mirrored: that fix made the WY branch
also write the (always-zero) RGB registers, this makes the RGB branch also write
the (always-zero) WY registers. Both directions had it; only one was caught first. */
uint8_t w[3] = {SM_ADDR_WY, 0, 0};
sm_send(w, 3);
} else { } else {
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY}; /* The RGB data registers are written along with the channel select, even though they
are always zero here: the chip keeps whatever colour was last written, and a direct
colour -> white change never passes through the all-dark frame above. The stale
colour then bleeds through as soon as the white dims - visible as a coloured
fade-out on a migrated GU10 RGB, 2026-09-13. */
uint8_t h[6] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY,
ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
uint8_t d[3] = {SM_ADDR_WY, ch[NL_CH_WW], ch[NL_CH_CW]}; uint8_t d[3] = {SM_ADDR_WY, ch[NL_CH_WW], ch[NL_CH_CW]};
sm_send(h, 3); sm_send(h, 6);
sm_send(d, 3); sm_send(d, 3);
} }
} }
+5
View File
@@ -62,6 +62,11 @@ void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_ma
/* keep the lowest brightness step visible */ /* keep the lowest brightness step visible */
if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0) if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0)
out[NL_CH_R + maxi] = 1; out[NL_CH_R + maxi] = 1;
} else if (ct_min == ct_max) {
/* RGB-only board (ledshield_rgb): no white channels wired, ct range
* collapsed to a point. White shows as an equal RGB mix. */
uint8_t w = to8(level);
out[NL_CH_R] = out[NL_CH_G] = out[NL_CH_B] = w ? w : 1;
} else { } else {
uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u; uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u;
uint32_t ww = level - cw; uint32_t ww = level - cw;
+17 -4
View File
@@ -7,23 +7,29 @@ static const nl_board_t *s_board;
static nl_light_state_t s_state; static nl_light_state_t s_state;
static uint16_t s_fade_in; static uint16_t s_fade_in;
static uint16_t s_fade_out; static uint16_t s_fade_out;
static uint16_t s_transition;
static bool s_wake_on_change;
static bool s_dirty; static bool s_dirty;
static uint32_t s_changed_at; static uint32_t s_changed_at;
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms) void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms,
uint16_t transition_ms, bool wake_on_change)
{ {
s_board = board; s_board = board;
s_state = nl_boot_default_state(board); s_state = nl_boot_default_state(board);
s_state.on = false; s_state.on = false;
s_dirty = false; s_dirty = false;
s_changed_at = 0; s_changed_at = 0;
nl_ctrl_set_fades(fade_in_ms, fade_out_ms); nl_ctrl_set_options(fade_in_ms, fade_out_ms, transition_ms, wake_on_change);
} }
void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms) void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms,
bool wake_on_change)
{ {
s_fade_in = fade_in_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_in_ms; s_fade_in = fade_in_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_in_ms;
s_fade_out = fade_out_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_out_ms; s_fade_out = fade_out_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_out_ms;
s_transition = transition_ms > NL_MAX_TRANSITION_MS ? NL_MAX_TRANSITION_MS : transition_ms;
s_wake_on_change = wake_on_change;
} }
void nl_ctrl_boot(const nl_light_state_t *initial) void nl_ctrl_boot(const nl_light_state_t *initial)
@@ -62,6 +68,13 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms)
n.mode = NL_MODE_WHITE; n.mode = NL_MODE_WHITE;
} }
/* Weckschalter: a color/CT/brightness change arriving while the lamp is off switches it
* on, unless this same call also says "on": false explicitly - that always wins. Applied
* before the duration below so an off->on transition here uses fade_in like any other. */
if (s_wake_on_change && !s_state.on && !n.on && !(c->has_on && !c->on) &&
(c->has_color || c->has_ct || c->has_brightness))
n.on = true;
if (c->has_transition) if (c->has_transition)
dur = c->transition_ms; dur = c->transition_ms;
else if (!s_state.on && n.on) else if (!s_state.on && n.on)
@@ -69,7 +82,7 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms)
else if (s_state.on && !n.on) else if (s_state.on && !n.on)
dur = s_fade_out; dur = s_fade_out;
else else
dur = 0; dur = s_transition;
s_state = n; s_state = n;
nl_engine_set(&s_state, dur); nl_engine_set(&s_state, dur);
+11 -3
View File
@@ -32,9 +32,17 @@ typedef enum {
NL_CTRL_ERR_TRANSITION NL_CTRL_ERR_TRANSITION
} nl_ctrl_err_t; } nl_ctrl_err_t;
/* The engine must be initialised before. Not thread safe: callers serialise access. */ /* The engine must be initialised before. Not thread safe: callers serialise access.
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms); *
void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms); * transition_ms: default transition applied to a color/brightness/CT change while the lamp
* stays on and the caller did not send its own "transition" (fade_in/fade_out still take
* precedence when the lamp is switching on or off).
* wake_on_change: if true, a color/CT/brightness change arriving while the lamp is off
* switches it on (using fade_in), unless the same call also says "on": false. */
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms,
uint16_t transition_ms, bool wake_on_change);
void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms,
bool wake_on_change);
void nl_ctrl_boot(const nl_light_state_t *initial); void nl_ctrl_boot(const nl_light_state_t *initial);
nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms); nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms);
void nl_ctrl_get(nl_light_state_t *out); void nl_ctrl_get(nl_light_state_t *out);
+23
View File
@@ -115,6 +115,11 @@ static cJSON *state_json(const nl_api_ctx_t *ctx)
POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]); POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]);
cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms); cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms);
cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms); cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms);
/* Named "transition_default", not "transition": the latter is already the per-call field
* above the settings section of this same request/response and a second field with the
* same key in one JSON object would collide with it. */
cJSON_AddNumberToObject(o, "transition_default", s.transition_ms);
cJSON_AddBoolToObject(o, "wake_on_change", s.wake_on_change != 0);
cap = cJSON_AddObjectToObject(o, "capabilities"); cap = cJSON_AddObjectToObject(o, "capabilities");
cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb); cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb);
ct[0] = ctx->board->ct_min; ct[0] = ctx->board->ct_min;
@@ -193,6 +198,17 @@ static void handle_post_state(const nl_api_ctx_t *ctx, const nl_http_request_t *
case 1: s.fade_out_ms = (uint16_t)v; settings = true; break; case 1: s.fade_out_ms = (uint16_t)v; settings = true; break;
default: break; default: break;
} }
switch (get_int(j, "transition_default", 0, NL_MAX_TRANSITION_MS, &v)) {
case -1: err = "invalid transition_default"; goto out;
case 1: s.transition_ms = (uint16_t)v; settings = true; break;
default: break;
}
it = cJSON_GetObjectItemCaseSensitive(j, "wake_on_change");
if (it) {
if (!cJSON_IsBool(it)) { err = "invalid wake_on_change"; goto out; }
s.wake_on_change = cJSON_IsTrue(it) ? 1u : 0u;
settings = true;
}
if (light) { if (light) {
switch (ctx->apply(&ch)) { switch (ctx->apply(&ch)) {
@@ -262,6 +278,13 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
cJSON_AddStringToObject(o, "name", s.name); cJSON_AddStringToObject(o, "name", s.name);
w = cJSON_AddObjectToObject(o, "wifi"); w = cJSON_AddObjectToObject(o, "wifi");
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid); cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0); cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
reply_json(resp, 200, o); reply_json(resp, 200, o);
} }
+7 -1
View File
@@ -162,8 +162,14 @@ void nl_recovery_try(void)
tls_os_time_delay(HZ / 2); tls_os_time_delay(HZ / 2);
waited += 500; waited += 500;
} }
if (feeding) if (feeding) {
/* A skipped image (same version as the running firmware) is not a failure - saying so
would leave a healthy lamp looking like it had rejected a broken image. */
if (nl_ota_skipped())
printf("recovery: image already up to date, continuing normal boot\r\n");
else
printf("recovery: update failed (%s), continuing normal boot\r\n", printf("recovery: update failed (%s), continuing normal boot\r\n",
nl_ota_last_error() ? nl_ota_last_error() : "unknown"); nl_ota_last_error() ? nl_ota_last_error() : "unknown");
} }
} }
}
+1 -1
View File
@@ -58,7 +58,7 @@ static int save_settings(const nl_settings_t *s)
memcpy(merged.wifi_pass, cur->wifi_pass, sizeof merged.wifi_pass); memcpy(merged.wifi_pass, cur->wifi_pass, sizeof merged.wifi_pass);
*cur = merged; *cur = merged;
} }
nl_ctrl_set_fades(cur->fade_in_ms, cur->fade_out_ms); nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0);
memcpy(name, cur->name, sizeof name); memcpy(name, cur->name, sizeof name);
nl_app_unlock(); nl_app_unlock();
if (name_changed) if (name_changed)
+1 -1
View File
@@ -78,7 +78,7 @@ static void exec(const nl_cmd_t *c)
nl_app_lock(); nl_app_lock();
cfg->fade_in_ms = (uint16_t)c->arg[0]; cfg->fade_in_ms = (uint16_t)c->arg[0];
cfg->fade_out_ms = (uint16_t)c->arg[1]; cfg->fade_out_ms = (uint16_t)c->arg[1];
nl_ctrl_set_fades(cfg->fade_in_ms, cfg->fade_out_ms); nl_ctrl_set_options(cfg->fade_in_ms, cfg->fade_out_ms, cfg->transition_ms, cfg->wake_on_change != 0);
nl_app_unlock(); nl_app_unlock();
printf(nl_app_save_settings() == 0 ? "ok\r\n" : "error: save failed\r\n"); printf(nl_app_save_settings() == 0 ? "ok\r\n" : "error: save failed\r\n");
print_state(); print_state();
+17 -2
View File
@@ -23,6 +23,7 @@ static char s_url[NL_OTA_URL_MAX];
static volatile bool s_busy; static volatile bool s_busy;
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */ static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
static const char * volatile s_error; static const char * volatile s_error;
static volatile bool s_skipped;
static u32 s_stack[NL_OTA_STACK]; static u32 s_stack[NL_OTA_STACK];
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */ static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
@@ -33,6 +34,7 @@ void nl_ota_init(void)
} }
bool nl_ota_busy(void) { return s_busy; } bool nl_ota_busy(void) { return s_busy; }
bool nl_ota_skipped(void) { return s_skipped; }
const char *nl_ota_last_error(void) { return s_error; } const char *nl_ota_last_error(void) { return s_error; }
void nl_ota_set_error(const char *e) { s_error = e; } void nl_ota_set_error(const char *e) { s_error = e; }
@@ -61,7 +63,10 @@ bool nl_fwup_verify_tail(const uint8_t sha256[32], const uint8_t *tail, uint32_t
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) { if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
printf("ota: recovery image already carries the running version %s, not flashing\r\n", printf("ota: recovery image already carries the running version %s, not flashing\r\n",
s_tail_version); s_tail_version);
s_error = "trailer"; /* Deliberately skipped, not failed: leave last_ota_error alone. Reporting "trailer"
* here used to make a perfectly healthy lamp look like it had rejected a malformed
* image, which is exactly the wrong thing to read off a lamp in the field. */
s_skipped = true;
return false; return false;
} }
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION); printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
@@ -89,10 +94,19 @@ static void ota_task(void *arg)
(void)arg; (void)arg;
log_url(s_url); log_url(s_url);
rc = t_http_fwup(s_url); rc = t_http_fwup(s_url);
/* Only reached on failure - the SDK resets the chip itself after a good image. */ /* Only reached on failure or a deliberate skip - the SDK resets the chip itself after a good
* image. rc is not a reliable success/failure signal here: the SDK aborts the transfer as
* soon as the trailer hook returns false, which happens both for a real rejection and for the
* deliberate "recovery image already up to date" skip. s_error/s_skipped are what actually
* distinguish the two, so branch on those instead of folding everything into one printf that
* would risk passing a NULL s_error to %s. */
if (s_skipped) {
printf("ota: skipped (rc=%d, image already up to date)\r\n", rc);
} else {
if (s_error == NULL) if (s_error == NULL)
s_error = "download"; s_error = "download";
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error); printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
}
{ {
nl_settings_t *s; nl_settings_t *s;
nl_app_lock(); nl_app_lock();
@@ -125,6 +139,7 @@ static int ota_start(const char *url, bool reject_running_version)
} }
s_busy = true; s_busy = true;
s_error = NULL; s_error = NULL;
s_skipped = false;
s_reject_running_version = reject_running_version; s_reject_running_version = reject_running_version;
memcpy(s_url, url, n + 1); memcpy(s_url, url, n + 1);
+3
View File
@@ -12,6 +12,9 @@ int nl_ota_start(const char *url);
* forever. A deliberate re-flash of the same version stays possible over the REST API. */ * forever. A deliberate re-flash of the same version stays possible over the REST API. */
int nl_ota_start_recovery(const char *url); int nl_ota_start_recovery(const char *url);
bool nl_ota_busy(void); bool nl_ota_busy(void);
/* True when the last recovery attempt deliberately skipped an image whose version already
matches the running firmware - that is not a failure and sets no last_ota_error. */
bool nl_ota_skipped(void);
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */ /* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
const char *nl_ota_last_error(void); const char *nl_ota_last_error(void);
void nl_ota_set_error(const char *e); void nl_ota_set_error(const char *e);
+1 -1
View File
@@ -19,7 +19,7 @@ uint8_t nl_crc8(const void *data, size_t len)
while (len--) { while (len--) {
crc ^= *p++; crc ^= *p++;
for (int k = 0; k < 8; k++) for (int k = 0; k < 8; k++)
crc = (uint8_t)((crc & 0x80u) ? (crc << 1) ^ 0x07u : (crc << 1)); crc = (uint8_t)((crc & 0x80u) ? (int)(((unsigned)crc << 1) ^ 0x07u) : (int)((unsigned)crc << 1));
} }
return crc; return crc;
} }
+48
View File
@@ -55,6 +55,8 @@ static void test_get_state(void)
TEST_ASSERT_EQUAL_STRING("last", cJSON_GetStringValue(field("power_on_behaviour"))); TEST_ASSERT_EQUAL_STRING("last", cJSON_GetStringValue(field("power_on_behaviour")));
TEST_ASSERT_EQUAL_INT(700, field("fade_in")->valueint); TEST_ASSERT_EQUAL_INT(700, field("fade_in")->valueint);
TEST_ASSERT_EQUAL_INT(500, field("fade_out")->valueint); TEST_ASSERT_EQUAL_INT(500, field("fade_out")->valueint);
TEST_ASSERT_EQUAL_INT(300, field("transition_default")->valueint);
TEST_ASSERT_TRUE(cJSON_IsFalse(field("wake_on_change")));
cap = field("capabilities"); cap = field("capabilities");
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(cap, "color"))); TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(cap, "color")));
TEST_ASSERT_EQUAL_INT(2700, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 0)->valueint); TEST_ASSERT_EQUAL_INT(2700, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 0)->valueint);
@@ -114,6 +116,8 @@ static void test_post_validation(void)
{"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"}, {"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"},
{"{\"fade_in\":10001}", "invalid fade_in"}, {"{\"fade_in\":10001}", "invalid fade_in"},
{"{\"fade_out\":-1}", "invalid fade_out"}, {"{\"fade_out\":-1}", "invalid fade_out"},
{"{\"transition_default\":60001}", "invalid transition_default"},
{"{\"wake_on_change\":\"ja\"}", "invalid wake_on_change"},
}; };
for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) { for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) {
call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body); call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body);
@@ -151,6 +155,33 @@ static void test_post_settings_fields(void)
TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour"))); TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour")));
} }
static void test_post_transition_default_and_wake(void)
{
call(NL_HTTP_POST, "/api/v1/device/state", NULL,
"{\"transition_default\":1200,\"wake_on_change\":true}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
TEST_ASSERT_EQUAL_UINT16(1200, fake_settings.transition_ms);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.wake_on_change);
TEST_ASSERT_EQUAL_INT(1200, field("transition_default")->valueint);
TEST_ASSERT_TRUE(cJSON_IsTrue(field("wake_on_change")));
}
/* A colour and a per-call transition travel in the same request - the light change is
applied, and nothing is treated as a settings write. */
static void test_post_color_with_transition(void)
{
call(NL_HTTP_POST, "/api/v1/device/state", NULL,
"{\"color\":{\"r\":255,\"g\":0,\"b\":0},\"transition\":2000}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_INT(1, fake_apply_calls);
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
TEST_ASSERT_TRUE(fake_last_change.has_transition);
TEST_ASSERT_EQUAL_UINT32(2000, fake_last_change.transition_ms);
TEST_ASSERT_TRUE(fake_last_change.has_color);
TEST_ASSERT_EQUAL_UINT8(255, fake_last_change.r);
}
static void test_post_save_failure(void) static void test_post_save_failure(void)
{ {
fake_save_result = -1; fake_save_result = -1;
@@ -233,6 +264,7 @@ static void test_settings_auth(void)
TEST_ASSERT_EQUAL_INT(200, resp.status); TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name"))); TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid"))); TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
TEST_ASSERT_EQUAL_STRING("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all"))); TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */ TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
fake_open = true; fake_open = true;
@@ -240,6 +272,19 @@ static void test_settings_auth(void)
TEST_ASSERT_EQUAL_INT(200, resp.status); TEST_ASSERT_EQUAL_INT(200, resp.status);
} }
/* F1: nie onboardet -> gespeicherte ssid ist leer, die Lampe laeuft auf den
* einkompilierten Zugangsdaten (siehe app_main.c). "source" muss das additiv
* sichtbar machen, ohne "ssid" umzudeuten - sonst widerspricht die Antwort
* scheinbar system/info, das die tatsaechlich verbundene SSID zeigt. */
static void test_settings_wifi_source_reflects_compiled_fallback(void)
{
fake_settings.wifi_ssid[0] = '\0';
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_STRING("", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
TEST_ASSERT_EQUAL_STRING("compiled", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
}
static void test_settings_wifi(void) static void test_settings_wifi(void)
{ {
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}"); call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
@@ -350,12 +395,15 @@ int main(void)
RUN_TEST(test_post_validation); RUN_TEST(test_post_validation);
RUN_TEST(test_post_control_errors); RUN_TEST(test_post_control_errors);
RUN_TEST(test_post_settings_fields); RUN_TEST(test_post_settings_fields);
RUN_TEST(test_post_transition_default_and_wake);
RUN_TEST(test_post_color_with_transition);
RUN_TEST(test_post_save_failure); RUN_TEST(test_post_save_failure);
RUN_TEST(test_auth_all_protects_device_state); RUN_TEST(test_auth_all_protects_device_state);
RUN_TEST(test_auth_too_long_is_unauthenticated); RUN_TEST(test_auth_too_long_is_unauthenticated);
RUN_TEST(test_info_is_open); RUN_TEST(test_info_is_open);
RUN_TEST(test_info_with_sta_ip_and_error); RUN_TEST(test_info_with_sta_ip_and_error);
RUN_TEST(test_settings_auth); RUN_TEST(test_settings_auth);
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
RUN_TEST(test_settings_wifi); RUN_TEST(test_settings_wifi);
RUN_TEST(test_settings_name_token_auth); RUN_TEST(test_settings_name_token_auth);
RUN_TEST(test_commands); RUN_TEST(test_commands);
+20 -5
View File
@@ -5,12 +5,12 @@
void setUp(void) {} void setUp(void) {}
void tearDown(void) {} void tearDown(void) {}
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw"}; static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"};
static void test_all_devices_present(void) static void test_all_devices_present(void)
{ {
TEST_ASSERT_EQUAL_UINT(8, nl_boards_count); TEST_ASSERT_EQUAL_UINT(9, nl_boards_count);
for (size_t i = 0; i < 8; i++) { for (size_t i = 0; i < 9; i++) {
const nl_board_t *b = nl_board_find(IDS[i]); const nl_board_t *b = nl_board_find(IDS[i]);
TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]); TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]);
TEST_ASSERT_EQUAL_STRING(IDS[i], b->id); TEST_ASSERT_EQUAL_STRING(IDS[i], b->id);
@@ -29,9 +29,17 @@ static void test_rgb_flags_and_ct_ranges(void)
for (size_t i = 0; i < nl_boards_count; i++) { for (size_t i = 0; i < nl_boards_count; i++) {
const nl_board_t *b = &nl_boards[i]; const nl_board_t *b = &nl_boards[i];
bool rgb = strstr(b->id, "rgb") != NULL || strcmp(b->id, "evk") == 0; bool rgb = strstr(b->id, "rgb") != NULL || strcmp(b->id, "evk") == 0;
/* Measured on real bulbs, 2026-09-13: every lamp but the G95 covers 2700..6500 K.
The G95 Vintage is a warm bulb and reaches 2000 K at the bottom but stops at
5000 K - hard-coded per board rather than derived, because "warm variant" is a
property of that one product, not of a class of them. */
bool g95 = strcmp(b->id, "g95_cw") == 0;
/* ledshield_rgb has no white LEDs at all: ct_min == ct_max is the
sentinel "white mode renders as an RGB mix" (see nl_color.c). */
bool shield = strcmp(b->id, "ledshield_rgb") == 0;
TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id); TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id);
TEST_ASSERT_EQUAL_UINT16(rgb ? 2700 : 2500, b->ct_min); TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 2000 : 2700), b->ct_min, b->id);
TEST_ASSERT_EQUAL_UINT16(rgb ? 6500 : 5000, b->ct_max); TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 5000 : 6500), b->ct_max, b->id);
} }
} }
@@ -42,8 +50,15 @@ static void test_pwm_boards_consistent(void)
if (b->driver != NL_DRV_PWM) if (b->driver != NL_DRV_PWM)
continue; continue;
TEST_ASSERT_TRUE(b->pwm_freq_hz > 0); TEST_ASSERT_TRUE(b->pwm_freq_hz > 0);
if (b->ct_min == b->ct_max) {
/* RGB-only board: white is an RGB mix, no white channels wired. */
TEST_ASSERT_TRUE(b->has_rgb);
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_CW].pwm_ch);
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_WW].pwm_ch);
} else {
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0); TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0); TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
}
for (int c = NL_CH_R; c <= NL_CH_B; c++) for (int c = NL_CH_R; c <= NL_CH_B; c++)
TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id); TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id);
for (int c = 0; c < NL_CH_COUNT; c++) { for (int c = 0; c < NL_CH_COUNT; c++) {
+9 -4
View File
@@ -55,11 +55,15 @@ static void test_off_forces_off(void)
static void test_sanitizes_stored_values(void) static void test_sanitizes_stored_values(void)
{ {
const nl_board_t *b = nl_board_find("e27_cw");
nl_light_state_t last = {true, 0, NL_MODE_COLOR, 1, 2, 3, 9000}; nl_light_state_t last = {true, 0, NL_MODE_COLOR, 1, 2, 3, 9000};
nl_light_state_t s = nl_boot_state(NL_POB_LAST, &last, nl_board_find("e27_cw"), false); nl_light_state_t s = nl_boot_state(NL_POB_LAST, &last, b, false);
TEST_ASSERT_EQUAL_UINT8(100, s.brightness); /* invalid 0 -> 100 */ TEST_ASSERT_EQUAL_UINT8(100, s.brightness); /* invalid 0 -> 100 */
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode); /* no RGB on this board */ TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode); /* no RGB on this board */
TEST_ASSERT_EQUAL_UINT16(5000, s.ct); /* clamped */ /* Clamped to whatever this board can do. Deliberately read from the board instead of
repeating the number: the range itself is nailed down in test_board.c, and this test
is about the clamping, not about the constant. */
TEST_ASSERT_EQUAL_UINT16(b->ct_max, s.ct);
} }
/* Spec 8.2: a reboot caused by an OTA is not a power cut - the light comes back effectively as /* Spec 8.2: a reboot caused by an OTA is not a power cut - the light comes back effectively as
@@ -84,12 +88,13 @@ static void test_after_ota_ignores_pob_on(void)
static void test_after_ota_still_sanitizes(void) static void test_after_ota_still_sanitizes(void)
{ {
const nl_board_t *b = nl_board_find("e27_cw");
nl_light_state_t last = {true, 200, NL_MODE_COLOR, 1, 2, 3, 9000}; nl_light_state_t last = {true, 200, NL_MODE_COLOR, 1, 2, 3, 9000};
nl_light_state_t s = nl_boot_state(NL_POB_OFF, &last, nl_board_find("e27_cw"), true); nl_light_state_t s = nl_boot_state(NL_POB_OFF, &last, b, true);
TEST_ASSERT_TRUE(s.on); TEST_ASSERT_TRUE(s.on);
TEST_ASSERT_EQUAL_UINT8(100, s.brightness); TEST_ASSERT_EQUAL_UINT8(100, s.brightness);
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode); TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode);
TEST_ASSERT_EQUAL_UINT16(5000, s.ct); TEST_ASSERT_EQUAL_UINT16(b->ct_max, s.ct);
} }
/* Nothing stored (first boot after a migration): there is no "last" to restore, so the normal /* Nothing stored (first boot after a migration): there is no "last" to restore, so the normal
+17
View File
@@ -88,6 +88,22 @@ static void test_one_percent_is_visible(void)
TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]); TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]);
} }
static void test_rgb_only_white_mode(void)
{
uint8_t ch[NL_CH_COUNT];
/* ct_min == ct_max: RGB-only board, white = equal RGB mix, no division. */
nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 4000);
nl_visual_to_channels(&v, 4000, 4000, ch);
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_G]);
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_B]);
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]);
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]);
v.bri = 100; /* 1 % stays visible */
nl_visual_to_channels(&v, 4000, 4000, ch);
TEST_ASSERT_EQUAL_UINT8(1, ch[NL_CH_R]);
}
static void test_zero_brightness_is_dark(void) static void test_zero_brightness_is_dark(void)
{ {
uint8_t ch[NL_CH_COUNT]; uint8_t ch[NL_CH_COUNT];
@@ -107,6 +123,7 @@ int main(void)
RUN_TEST(test_white_mix_keeps_total); RUN_TEST(test_white_mix_keeps_total);
RUN_TEST(test_color_mode); RUN_TEST(test_color_mode);
RUN_TEST(test_one_percent_is_visible); RUN_TEST(test_one_percent_is_visible);
RUN_TEST(test_rgb_only_white_mode);
RUN_TEST(test_zero_brightness_is_dark); RUN_TEST(test_zero_brightness_is_dark);
return UNITY_END(); return UNITY_END();
} }
+115 -3
View File
@@ -10,18 +10,24 @@ static void out(const uint8_t ch[NL_CH_COUNT], void *ctx)
(void)ctx; (void)ctx;
} }
static void start(const char *board_id, bool on) static void start_opts(const char *board_id, bool on, uint16_t transition_ms, bool wake)
{ {
const nl_board_t *b = nl_board_find(board_id); const nl_board_t *b = nl_board_find(board_id);
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL}; nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
nl_engine_init(&cfg); nl_engine_init(&cfg);
nl_ctrl_init(b, 500, 800); nl_ctrl_init(b, 500, 800, transition_ms, wake);
nl_light_state_t s = nl_boot_default_state(b); nl_light_state_t s = nl_boot_default_state(b);
s.on = on; s.on = on;
nl_ctrl_boot(&s); nl_ctrl_boot(&s);
nl_engine_tick(10000); /* finish a possible boot fade */ nl_engine_tick(10000); /* finish a possible boot fade */
} }
/* Most tests want the historical behaviour: no default transition, no wake switch. */
static void start(const char *board_id, bool on)
{
start_opts(board_id, on, 0, false);
}
static uint16_t bri(void) static uint16_t bri(void)
{ {
nl_visual_t v; nl_visual_t v;
@@ -188,13 +194,112 @@ static void test_boot_fades_in_when_on(void)
const nl_board_t *b = nl_board_find("evk"); const nl_board_t *b = nl_board_find("evk");
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL}; nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
nl_engine_init(&cfg); nl_engine_init(&cfg);
nl_ctrl_init(b, 500, 800); nl_ctrl_init(b, 500, 800, 0, false);
nl_light_state_t s = nl_boot_default_state(b); nl_light_state_t s = nl_boot_default_state(b);
nl_ctrl_boot(&s); nl_ctrl_boot(&s);
nl_engine_tick(250); nl_engine_tick(250);
TEST_ASSERT_EQUAL_UINT16(5000, bri()); TEST_ASSERT_EQUAL_UINT16(5000, bri());
} }
static void test_default_transition_applies_while_on(void)
{
start_opts("evk", true, 400, false);
nl_light_change_t c = chg();
c.has_brightness = true;
c.brightness = 50;
nl_ctrl_apply(&c, 0);
TEST_ASSERT_EQUAL_UINT16(10000, bri()); /* not instant any more */
nl_engine_tick(200);
TEST_ASSERT_EQUAL_UINT16(7500, bri());
nl_engine_tick(200);
TEST_ASSERT_EQUAL_UINT16(5000, bri());
}
static void test_explicit_transition_beats_default(void)
{
start_opts("evk", true, 5000, false);
nl_light_change_t c = chg();
c.has_brightness = true;
c.brightness = 50;
c.has_transition = true;
c.transition_ms = 100;
nl_ctrl_apply(&c, 0);
nl_engine_tick(100);
TEST_ASSERT_EQUAL_UINT16(5000, bri());
}
static void test_fades_beat_default_transition_when_switching(void)
{
start_opts("evk", false, 5000, false);
nl_light_change_t c = chg();
c.has_on = true;
c.on = true;
nl_ctrl_apply(&c, 0);
nl_engine_tick(500); /* fade_in, not the 5 s default */
TEST_ASSERT_EQUAL_UINT16(10000, bri());
c = chg();
c.has_on = true;
c.on = false;
nl_ctrl_apply(&c, 1000);
nl_engine_tick(800); /* fade_out */
TEST_ASSERT_EQUAL_UINT16(0, bri());
}
static void test_wake_switch_off_leaves_lamp_off(void)
{
start_opts("evk", false, 300, false);
nl_light_change_t c = chg();
c.has_ct = true;
c.ct = 3000;
nl_ctrl_apply(&c, 0);
nl_light_state_t s;
nl_ctrl_get(&s);
TEST_ASSERT_FALSE(s.on);
}
static void test_wake_switch_turns_lamp_on_with_fade_in(void)
{
start_opts("evk", false, 300, true);
nl_light_change_t c = chg();
c.has_ct = true;
c.ct = 3000;
nl_ctrl_apply(&c, 0);
nl_light_state_t s;
nl_ctrl_get(&s);
TEST_ASSERT_TRUE(s.on);
nl_engine_tick(250); /* fade_in = 500, not the 300 ms default transition */
TEST_ASSERT_EQUAL_UINT16(5000, bri());
}
static void test_wake_switch_also_reacts_to_brightness(void)
{
start_opts("evk", false, 300, true);
nl_light_change_t c = chg();
c.has_brightness = true;
c.brightness = 40;
nl_ctrl_apply(&c, 0);
nl_light_state_t s;
nl_ctrl_get(&s);
TEST_ASSERT_TRUE(s.on);
TEST_ASSERT_EQUAL_UINT8(40, s.brightness);
}
static void test_explicit_off_beats_wake_switch(void)
{
start_opts("evk", false, 300, true);
nl_light_change_t c = chg();
c.has_on = true;
c.on = false;
c.has_brightness = true;
c.brightness = 40;
nl_ctrl_apply(&c, 0);
nl_light_state_t s;
nl_ctrl_get(&s);
TEST_ASSERT_FALSE(s.on);
TEST_ASSERT_EQUAL_UINT8(40, s.brightness);
}
int main(void) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -209,5 +314,12 @@ int main(void)
RUN_TEST(test_persist_debounce); RUN_TEST(test_persist_debounce);
RUN_TEST(test_change_during_write_stays_dirty); RUN_TEST(test_change_during_write_stays_dirty);
RUN_TEST(test_boot_fades_in_when_on); RUN_TEST(test_boot_fades_in_when_on);
RUN_TEST(test_default_transition_applies_while_on);
RUN_TEST(test_explicit_transition_beats_default);
RUN_TEST(test_fades_beat_default_transition_when_switching);
RUN_TEST(test_wake_switch_off_leaves_lamp_off);
RUN_TEST(test_wake_switch_turns_lamp_on_with_fade_in);
RUN_TEST(test_wake_switch_also_reacts_to_brightness);
RUN_TEST(test_explicit_off_beats_wake_switch);
return UNITY_END(); return UNITY_END();
} }
+16
View File
@@ -42,6 +42,8 @@ static void test_defaults(void)
TEST_ASSERT_EQUAL_UINT8(NL_POB_LAST, s.power_on_behaviour); TEST_ASSERT_EQUAL_UINT8(NL_POB_LAST, s.power_on_behaviour);
TEST_ASSERT_EQUAL_UINT16(500, s.fade_in_ms); TEST_ASSERT_EQUAL_UINT16(500, s.fade_in_ms);
TEST_ASSERT_EQUAL_UINT16(500, s.fade_out_ms); TEST_ASSERT_EQUAL_UINT16(500, s.fade_out_ms);
TEST_ASSERT_EQUAL_UINT16(300, s.transition_ms);
TEST_ASSERT_EQUAL_UINT8(0, s.wake_on_change);
} }
static void test_save_and_load(void) static void test_save_and_load(void)
@@ -108,6 +110,19 @@ static void test_load_accepts_shorter_record(void)
TEST_ASSERT_EQUAL_UINT16(500, out.fade_in_ms); TEST_ASSERT_EQUAL_UINT16(500, out.fade_in_ms);
TEST_ASSERT_EQUAL_UINT16(500, out.fade_out_ms); TEST_ASSERT_EQUAL_UINT16(500, out.fade_out_ms);
TEST_ASSERT_EQUAL_UINT8(0, out.ota_pending); TEST_ASSERT_EQUAL_UINT8(0, out.ota_pending);
TEST_ASSERT_EQUAL_UINT16(300, out.transition_ms);
TEST_ASSERT_EQUAL_UINT8(0, out.wake_on_change);
}
static void test_transition_and_wake_roundtrip(void)
{
nl_settings_t in = sample("neu"), out;
in.transition_ms = 1500;
in.wake_on_change = 1;
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_UINT16(1500, out.transition_ms);
TEST_ASSERT_EQUAL_UINT8(1, out.wake_on_change);
} }
static void test_load_rejects_longer_record(void) static void test_load_rejects_longer_record(void)
@@ -171,6 +186,7 @@ int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_defaults); RUN_TEST(test_defaults);
RUN_TEST(test_transition_and_wake_roundtrip);
RUN_TEST(test_save_and_load); RUN_TEST(test_save_and_load);
RUN_TEST(test_alternates_slots); RUN_TEST(test_alternates_slots);
RUN_TEST(test_power_loss_keeps_previous); RUN_TEST(test_power_loss_keeps_previous);
+1 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd "$(dirname "$0")/.." && pwd)
OUT=${BUILD_ROOT:-$ROOT/build} OUT=${BUILD_ROOT:-$ROOT/build}
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
for d in "${DEVICES[@]}"; do for d in "${DEVICES[@]}"; do
echo "== $d" echo "== $d"
cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \ cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit
# im Onboarding-AP (ninlight-<mac4>), statt sich in ein fremdes WLAN einzuwaehlen.
#
# Aufruf: tools/build_nocreds.sh [ziel ...] (ohne Argumente: alle Ziele)
#
# Ergebnis: je Ziel ein Build-Verzeichnis unter $NINLIGHT_NOCREDS_BUILD_REL (Vorgabe
# "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten
# (ninlight_<ziel>_gz.img, ninlight_<ziel>_ota.img, ...). Bewusst NICHT build/: die
# dortigen Abzuege sind die des Entwicklers - mit seinem WLAN - und bleiben unangetastet.
#
# Dieses Skript ist ausdruecklich zum Wiederverwenden da: tools/release.sh baut damit das
# Auslieferungsarchiv, und die Migrationsstation wird damit die Wahl "Lampe startet im
# Onboarding-AP" bedienen. Deshalb steht die Logik hier und nicht in release.sh.
#
# Umgebungsvariablen:
# NINLIGHT_DOCKER Build-Vorspann (Vorgabe tools/docker.sh); Tests stubben ihn
# NINLIGHT_NOCREDS_BUILD_REL Ausgabeverzeichnis relativ zur Repo-Wurzel
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh}
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
if [ $# -gt 0 ]; then
TARGETS=("$@")
else
TARGETS=("${DEVICES[@]}")
fi
for t in "${TARGETS[@]}"; do
ok=0
for d in "${DEVICES[@]}"; do [ "$t" = "$d" ] && ok=1; done
[ "$ok" -eq 1 ] || { echo "Fehler: unbekanntes Ziel '$t' (bekannt: ${DEVICES[*]})" >&2; exit 1; }
done
# config.local.cmake wird in CMakeLists.txt mit gewoehnlichem set() eingebunden und
# SCHLAEGT damit jedes -D auf der Kommandozeile. Ein Bau ohne Zugangsdaten ist deshalb
# nur moeglich, wenn die Datei fuer die Dauer des Baus beiseite liegt. In ihr stehen die
# echte SSID, das WLAN-Passwort und das API-Token - sie muss auch dann zurueckkommen,
# wenn der Bau abbricht oder jemand Strg-C drueckt. Darum ein trap auf EXIT UND auf
# INT/TERM (EXIT allein laeuft bei einem Signal zwar auch, aber der Exit-Code soll das
# Signal widerspiegeln, statt als Erfolg durchzugehen).
LOCAL="$ROOT/config.local.cmake"
STASH_DIR=""
restore_local() {
[ -n "$STASH_DIR" ] || return 0
if [ -f "$STASH_DIR/config.local.cmake" ]; then
cp -p "$STASH_DIR/config.local.cmake" "$LOCAL" || {
echo "ACHTUNG: config.local.cmake konnte nicht zurueckgelegt werden - Kopie liegt unter $STASH_DIR" >&2
STASH_DIR=""
return 1
}
fi
rm -rf "$STASH_DIR"
STASH_DIR=""
}
if [ -f "$LOCAL" ]; then
STASH_DIR=$(mktemp -d)
cp -p "$LOCAL" "$STASH_DIR/config.local.cmake"
trap 'restore_local' EXIT
trap 'restore_local; trap - INT; kill -INT $$' INT
trap 'restore_local; exit 143' TERM
rm -f "$LOCAL"
echo "config.local.cmake liegt fuer diesen Bau beiseite (keine WLAN-Zugangsdaten im Abzug)"
fi
for t in "${TARGETS[@]}"; do
echo "== $t (ohne Zugangsdaten)"
# Relative Pfade, weil der Bau ueblicherweise im Container laeuft, der das Repo unter
# /work einhaengt - absolute Hostpfade gaebe es dort nicht.
"$DOCKER" cmake -S . -B "$BUILD_REL/$t" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE="$t" >/dev/null
"$DOCKER" cmake --build "$BUILD_REL/$t"
done
echo "fertig - Abzuege ohne Zugangsdaten unter $ROOT/$BUILD_REL"
+1 -1
View File
@@ -2,6 +2,6 @@
# Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4). # Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4).
# Usage: tools/docker.sh cmake --build build/evk # Usage: tools/docker.sh cmake --build build/evk
set -euo pipefail set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker" docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@" exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
+73 -4
View File
@@ -4,7 +4,19 @@
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding. Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
firmware hashes while writing the image to flash. firmware hashes while writing the image to flash.
Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the
x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware
build, and that container has not been upgraded past 3.8. This is independent of the
"Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that
runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this
file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`)
in annotations stay text and are never evaluated - without it, that syntax raises
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See
test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts.
""" """
from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import sys import sys
@@ -19,18 +31,75 @@ def field(value: str, name: str) -> bytes:
raw = value.encode() raw = value.encode()
if len(raw) >= FIELD: if len(raw) >= FIELD:
sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)") sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)")
if any(c.isspace() for c in value):
sys.exit(f"error: {name} '{value}' contains whitespace")
return raw.ljust(FIELD, b"\x00") return raw.ljust(FIELD, b"\x00")
def verify(path: Path, expect_device: str | None) -> int:
"""Checks an _ota.img the way the firmware does before accepting it."""
try:
data = path.read_bytes()
except OSError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if len(data) <= TRAILER_SIZE:
print(f"error: {path} is too short to carry a trailer", file=sys.stderr)
return 1
payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:]
if trailer[0:4] != b"NINL":
print(f"error: {path} has no ninlight trailer", file=sys.stderr)
return 1
if trailer[4] != TRAILER_VERSION:
print(f"error: {path} has trailer version {trailer[4]}, expected {TRAILER_VERSION}",
file=sys.stderr)
return 1
# Device field must be null-terminated within its 16 bytes (same as firmware check)
device_field = trailer[5:21]
if b"\x00" not in device_field:
print(f"error: {path} has unterminated device field", file=sys.stderr)
return 1
device = device_field.rstrip(b"\x00").decode("ascii", "replace")
version = trailer[21:37].rstrip(b"\x00").decode("ascii", "replace")
if expect_device is not None and device != expect_device:
print(f"error: {path} carries device '{device}', expected '{expect_device}'",
file=sys.stderr)
return 1
if trailer[37:69] != hashlib.sha256(payload).digest():
print(f"error: {path} fails its sha256 - the payload does not match the trailer",
file=sys.stderr)
return 1
print(f"{device} {version} {len(data)}")
return 0
def main() -> None: def main() -> None:
p = argparse.ArgumentParser(description="append the ninlight OTA trailer") p = argparse.ArgumentParser(description="append the ninlight OTA trailer")
p.add_argument("--image", required=True, type=Path, help="input _gz.img") p.add_argument("--image", type=Path, help="input _gz.img")
p.add_argument("--device", required=True, help="device id, e.g. e27_rgb") p.add_argument("--device", help="device id, e.g. e27_rgb")
p.add_argument("--version", required=True, help="firmware version") p.add_argument("--version", help="firmware version")
p.add_argument("--out", required=True, type=Path, help="output _ota.img") p.add_argument("--out", type=Path, help="output _ota.img")
p.add_argument("--verify", type=Path, help="check an existing _ota.img instead of building one")
p.add_argument("--expect-device", help="with --verify: require this device id")
a = p.parse_args() a = p.parse_args()
if a.verify is not None:
# Verify mode: check that build arguments are not provided
build_args = ("image", "device", "version", "out")
if any(getattr(a, name) is not None for name in build_args):
p.error("--verify cannot be combined with build mode arguments (--image, --device, --version, --out)")
raise SystemExit(verify(a.verify, a.expect_device))
# Build mode: require all arguments
for name in ("image", "device", "version", "out"):
if getattr(a, name) is None:
p.error(f"--{name} is required unless --verify is given")
try:
data = a.image.read_bytes() data = a.image.read_bytes()
except OSError as e:
sys.exit(f"error: {e}")
if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL": if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL":
sys.exit("error: image already carries a trailer") sys.exit("error: image already carries a trailer")
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Signiert ein W600-Firmwareimage so, wie es die alte Klyqa-Firmware erwartet.
Format: RSA-4096 PKCS#1 v1.5 ueber den SHA256 der GESAMTEN Datei, angehaengt als
exakt 512 Byte. Belegt durch Check_OTA_Image() in qc_ssl_layer.c der alten Firmware
und durch sign_w600_fw.py im Schluesselverzeichnis.
Die 512 Byte belegen denselben Block wie der ninlight-Trailer. Ein Migrationsimage
traegt deshalb KEINEN Trailer - die alte Firmware prueft die Signatur, die neue
prueft an dieser Stelle nichts mehr, weil sie dann schon laeuft.
Muss unter Python 3.8.10 laufen (siehe nl_mkimage.py fuer den Hintergrund): keine
`str | None`-Annotationen ohne `from __future__ import annotations`, kein
`list[str]` zur Laufzeit.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import NamedTuple
SIG_LEN = 512
TRAILER_MAGIC = b"NINL"
KEY_BITS = 4096
class SignError(Exception):
pass
class TestKey(NamedTuple):
private_pem: bytes
public_der: bytes
def _backend():
"""Liefert ein einheitliches (sign, verify, keygen)-Tripel, egal ob `cryptography`
oder `pycryptodome`/`pycryptodomex` installiert ist - auf keinem Rechner ist sicher
dieselbe Bibliothek vorhanden."""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.exceptions import InvalidSignature
def sign(private_pem: bytes, data: bytes) -> bytes:
try:
key = serialization.load_pem_private_key(private_pem, password=None)
except (ValueError, TypeError):
raise SignError(
"privater Schluessel ist kein gueltiger PEM-Schluessel") from None
if key.key_size != KEY_BITS:
raise SignError(
f"Schluessel hat {key.key_size} Bit, benoetigt werden {KEY_BITS}")
return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
def verify(public_key_bytes: bytes, data: bytes, signature: bytes) -> bool:
# akzeptiert sowohl DER als auch PEM, wie im CLI-Interface vorgesehen
try:
key = serialization.load_der_public_key(public_key_bytes)
except ValueError:
try:
key = serialization.load_pem_public_key(public_key_bytes)
except (ValueError, TypeError):
raise SignError(
"oeffentlicher Schluessel ist weder gueltiges DER noch PEM") from None
try:
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
return True
except InvalidSignature:
return False
def keygen() -> TestKey:
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_der = key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return TestKey(private_pem, public_der)
return sign, verify, keygen
except ImportError:
pass
try:
try:
from Cryptodome.Signature import pkcs1_15
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
except ImportError:
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
def sign(private_pem: bytes, data: bytes) -> bytes:
try:
key = RSA.import_key(private_pem)
except (ValueError, TypeError, IndexError):
raise SignError(
"privater Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
if key.size_in_bits() != KEY_BITS:
raise SignError(
f"Schluessel hat {key.size_in_bits()} Bit, benoetigt werden {KEY_BITS}")
digest = SHA256.new(data)
return pkcs1_15.new(key).sign(digest)
def verify(public_der: bytes, data: bytes, signature: bytes) -> bool:
try:
key = RSA.import_key(public_der)
except (ValueError, TypeError, IndexError):
raise SignError(
"oeffentlicher Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
digest = SHA256.new(data)
try:
pkcs1_15.new(key).verify(digest, signature)
return True
except (ValueError, TypeError):
return False
def keygen() -> TestKey:
key = RSA.generate(KEY_BITS)
private_pem = key.export_key("PEM")
public_der = key.publickey().export_key("DER")
return TestKey(private_pem, public_der)
return sign, verify, keygen
except ImportError:
pass
raise SignError(
"weder 'cryptography' noch 'pycryptodome'/'pycryptodomex' ist installiert")
def generate_test_key() -> TestKey:
"""Nur fuer Tests: erzeugt einen frischen 4096-Bit-Schluessel im Speicher.
Schreibt niemals eine Datei."""
_sign, _verify, keygen = _backend()
return keygen()
def _check_raw(raw: bytes) -> None:
if len(raw) < 1024:
raise SignError(f"Eingabe ist mit {len(raw)} Byte kuerzer als die Mindestlaenge 1024")
if len(raw) % 4 != 0:
raise SignError(f"Eingabe ist mit {len(raw)} Byte nicht durch 4 teilbar")
if raw[-SIG_LEN:][:4] == TRAILER_MAGIC:
raise SignError(
"Eingabe traegt bereits einen ninlight-Trailer - das ist ein OTA-Image, "
"kein Rohbild")
def sign_image(raw: bytes, key_pem: bytes) -> bytes:
"""Haengt eine 512-Byte-PKCS#1-v1.5-Signatur ueber den SHA256 von `raw` an."""
_check_raw(raw)
sign, _verify, _keygen = _backend()
signature = sign(key_pem, raw)
if len(signature) != SIG_LEN:
raise SignError(
f"Signatur ist {len(signature)} Byte lang, erwartet werden {SIG_LEN}")
return raw + signature
def verify_image(signed: bytes, pub_der: bytes) -> bool:
"""Prueft eine mit `sign_image` erzeugte Signatur. Gibt bei ungueltiger Signatur
False zurueck; wirft nur bei echten Formatfehlern."""
if len(signed) <= SIG_LEN:
raise SignError(f"Eingabe ist mit {len(signed)} Byte zu kurz fuer eine Signatur")
payload, signature = signed[:-SIG_LEN], signed[-SIG_LEN:]
_sign, verify, _keygen = _backend()
return verify(pub_der, payload, signature)
def _cmd_sign(args: argparse.Namespace) -> int:
key_pem = Path(args.key).read_bytes()
raw = Path(args.in_).read_bytes()
try:
signed = sign_image(raw, key_pem)
except SignError as e:
print(f"error: {e}", file=sys.stderr)
return 1
Path(args.out).write_bytes(signed)
print(f"{args.out} ({len(signed)} bytes)")
return 0
def _cmd_verify(args: argparse.Namespace) -> int:
pub_der = Path(args.pub).read_bytes()
signed = Path(args.in_).read_bytes()
try:
ok = verify_image(signed, pub_der)
except SignError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if ok:
print(f"{args.in_}: signature valid")
return 0
print(f"error: {args.in_}: signature invalid", file=sys.stderr)
return 1
def main() -> None:
p = argparse.ArgumentParser(
description="signs/verifies a W600 image the way the old Klyqa firmware expects")
sub = p.add_subparsers(dest="command", required=True)
p_sign = sub.add_parser("sign", help="sign a raw _gz.img")
p_sign.add_argument("--key", required=True, help="private key, PEM")
p_sign.add_argument("--in", dest="in_", required=True, help="input _gz.img")
p_sign.add_argument("--out", required=True, help="output signed image")
p_sign.set_defaults(func=_cmd_sign)
p_verify = sub.add_parser("verify", help="verify a signed image")
p_verify.add_argument("--pub", required=True, help="public key, PEM or DER")
p_verify.add_argument("--in", dest="in_", required=True, help="signed image to check")
p_verify.set_defaults(func=_cmd_verify)
args = p.parse_args()
raise SystemExit(args.func(args))
if __name__ == "__main__":
main()
+1 -1
View File
@@ -4,4 +4,4 @@ version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.11"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8"] dev = ["pytest>=8", "vermin>=1.6"]
+223
View File
@@ -53,3 +53,226 @@ def test_refuses_image_that_already_has_a_trailer(tmp_path):
) )
assert r.returncode != 0 assert r.returncode != 0
assert "trailer" in r.stderr assert "trailer" in r.stderr
def verify(path, *extra):
return subprocess.run(
[sys.executable, str(SCRIPT), "--verify", str(path), *extra],
capture_output=True, text=True,
)
def test_verify_accepts_a_good_image(tmp_path):
_, out = build(tmp_path, device="e27_rgb", version="1.2.3")
r = verify(out)
assert r.returncode == 0, r.stderr
assert r.stdout.split()[:2] == ["e27_rgb", "1.2.3"]
def test_verify_rejects_a_file_without_trailer(tmp_path):
p = tmp_path / "plain.img"
p.write_bytes(b"x" * 5000)
r = verify(p)
assert r.returncode == 1
assert "trailer" in r.stderr.lower()
def test_verify_rejects_a_corrupted_payload(tmp_path):
_, out = build(tmp_path)
d = bytearray(out.read_bytes())
d[100] ^= 0x01
out.write_bytes(d)
r = verify(out)
assert r.returncode == 1
assert "sha256" in r.stderr.lower()
def test_verify_checks_the_expected_device(tmp_path):
_, out = build(tmp_path, device="evk")
assert verify(out, "--expect-device", "evk").returncode == 0
r = verify(out, "--expect-device", "g95_cw")
assert r.returncode == 1
assert "evk" in r.stderr and "g95_cw" in r.stderr
def test_verify_rejects_a_file_shorter_than_the_trailer(tmp_path):
p = tmp_path / "tiny.img"
p.write_bytes(b"NINL" + b"\x00" * 10)
r = verify(p)
assert r.returncode == 1
def test_verify_rejects_nonexistent_file(tmp_path):
p = tmp_path / "does_not_exist.img"
r = verify(p)
assert r.returncode == 1
assert r.stdout == ""
assert len(r.stderr.splitlines()) == 1
assert "error:" in r.stderr.lower()
def test_verify_rejects_directory(tmp_path):
d = tmp_path / "dir"
d.mkdir()
r = verify(d)
assert r.returncode == 1
assert r.stdout == ""
assert len(r.stderr.splitlines()) == 1
assert "error:" in r.stderr.lower()
def test_verify_rejects_empty_file(tmp_path):
p = tmp_path / "empty.img"
p.write_bytes(b"")
r = verify(p)
assert r.returncode == 1
assert "trailer" in r.stderr.lower()
def test_verify_rejects_file_with_only_trailer_no_payload(tmp_path):
p = tmp_path / "only_trailer.img"
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
trailer[5:21] = b"test\x00" + b"\x00" * 11
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
trailer[37:69] = hashlib.sha256(b"").digest()
p.write_bytes(bytes(trailer))
r = verify(p)
assert r.returncode == 1
assert "trailer" in r.stderr.lower()
def test_build_rejects_device_with_whitespace(tmp_path):
src = tmp_path / "fw_gz.img"
src.write_bytes(b"x")
r = subprocess.run(
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "dev ice",
"--version", "1.0", "--out", str(tmp_path / "o.img")],
capture_output=True, text=True,
)
assert r.returncode != 0
assert "whitespace" in r.stderr
def test_build_rejects_version_with_whitespace(tmp_path):
src = tmp_path / "fw_gz.img"
src.write_bytes(b"x")
r = subprocess.run(
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "device",
"--version", "1.0 beta", "--out", str(tmp_path / "o.img")],
capture_output=True, text=True,
)
assert r.returncode != 0
assert "whitespace" in r.stderr
def test_verify_rejects_unterminated_device_field(tmp_path):
p = tmp_path / "bad_device.img"
payload = b"x" * 1000
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
# Fill entire device field with no null terminator
trailer[5:21] = b"a" * 16
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
trailer[37:69] = hashlib.sha256(payload).digest()
p.write_bytes(payload + bytes(trailer))
r = verify(p)
assert r.returncode == 1
assert "unterminated" in r.stderr.lower()
def test_verify_rejects_unterminated_device_field_without_expect_device(tmp_path):
# Same as above but without --expect-device to ensure the check happens regardless
p = tmp_path / "bad_device2.img"
payload = b"x" * 1000
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
# Fill entire device field with no null terminator
trailer[5:21] = b"b" * 16
trailer[21:37] = b"2.0.0\x00" + b"\x00" * 10
trailer[37:69] = hashlib.sha256(payload).digest()
p.write_bytes(payload + bytes(trailer))
r = verify(p)
assert r.returncode == 1
assert "unterminated" in r.stderr.lower()
def test_verify_with_build_arguments_is_an_error(tmp_path):
_, out = build(tmp_path)
r = subprocess.run(
[sys.executable, str(SCRIPT), "--verify", str(out), "--device", "x"],
capture_output=True, text=True,
)
assert r.returncode != 0
assert "cannot be combined" in r.stderr
# --- Python 3.8 compatibility -------------------------------------------------------------
#
# CMake calls this script via plain `python3` inside the build container on every firmware
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
# git history around the "unsupported operand type(s) for |" break for what happens when it
# regresses.
#
# Two checks, because neither alone covers the failure mode we actually hit:
# 1. `from __future__ import annotations` must be the first statement after the module
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
# them get added later.
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
# accounting for `from __future__ import annotations` deferring evaluation - which would
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
# what actually guards against that pattern.
def test_future_annotations_import_is_present():
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
import ast
tree = ast.parse(SCRIPT.read_text())
body = tree.body
# Skip a leading module docstring, if any.
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
body = body[1:]
assert body, "nl_mkimage.py is unexpectedly empty"
first = body[0]
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
and any(alias.name == "annotations" for alias in first.names), (
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
)
def test_stays_python38_compatible():
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
version from syntax/stdlib usage instead of actually executing the file.
"""
vermin = __import__("vermin")
config = vermin.Config()
config.set_quiet(True)
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
# they need runtime support either. This is vermin's default, spelled out here so it's
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
config.set_eval_annotations(False)
source = SCRIPT.read_text()
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
assert py3_min is not None and py3_min <= (3, 8), (
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
"Python 3.8.10 in the Docker build container (see module docstring)"
)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
TOOL = HERE.parent / "nl_sign.py"
sys.path.insert(0, str(HERE.parent))
import nl_sign # noqa: E402
@pytest.fixture(scope="module")
def key():
"""Ein eigener 4096-Bit-Schluessel, damit kein echter Signierschluessel in die Tests
gerät. Einmal pro Modul, weil das Erzeugen ein paar Sekunden dauert."""
return nl_sign.generate_test_key()
def test_signature_is_512_bytes(key, tmp_path):
raw = b"\xa5" * 4096
signed = nl_sign.sign_image(raw, key.private_pem)
assert len(signed) == len(raw) + 512
assert signed[: len(raw)] == raw
def test_signature_verifies(key):
raw = b"firmware" * 512
signed = nl_sign.sign_image(raw, key.private_pem)
assert nl_sign.verify_image(signed, key.public_der) is True
def test_modified_payload_fails_verification(key):
raw = bytearray(b"firmware" * 512)
signed = bytearray(nl_sign.sign_image(bytes(raw), key.private_pem))
signed[17] ^= 0xFF
assert nl_sign.verify_image(bytes(signed), key.public_der) is False
def test_signing_is_deterministic(key):
raw = b"x" * 1024
assert nl_sign.sign_image(raw, key.private_pem) == nl_sign.sign_image(raw, key.private_pem)
def test_cli_roundtrip(key, tmp_path):
src = tmp_path / "in_gz.img"
dst = tmp_path / "out.img"
pub = tmp_path / "pub.der"
priv = tmp_path / "key.pem"
src.write_bytes(b"\x01\x02\x03" * 1000)
priv.write_bytes(key.private_pem)
pub.write_bytes(key.public_der)
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
"--in", str(src), "--out", str(dst)], check=True)
assert dst.stat().st_size == src.stat().st_size + 512
subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
"--in", str(dst)], check=True)
def test_cli_verify_rejects_tampered(key, tmp_path):
src = tmp_path / "in_gz.img"
dst = tmp_path / "out.img"
pub = tmp_path / "pub.der"
priv = tmp_path / "key.pem"
src.write_bytes(b"\x07" * 2000)
priv.write_bytes(key.private_pem)
pub.write_bytes(key.public_der)
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
"--in", str(src), "--out", str(dst)], check=True)
data = bytearray(dst.read_bytes())
data[0] ^= 0x01
dst.write_bytes(bytes(data))
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
"--in", str(dst)])
assert r.returncode != 0
def test_refuses_already_signed_looking_input(key, tmp_path):
"""Ein Image mit ninlight-Trailer ist ein OTA-Image, kein Rohbild - zweimal 512 Byte
anzuhaengen ergaebe eine Datei, die die alte Firmware ablehnt."""
raw = b"\x00" * 1024 + b"NINL" + b"\x01" + b"\x00" * 507
with pytest.raises(nl_sign.SignError):
nl_sign.sign_image(raw, key.private_pem)
def test_verify_rejects_garbage_pub_key(key):
"""Ein kaputter --pub-Inhalt (kein gueltiges DER/PEM) muss als SignError gemeldet
werden, nicht als roher ValueError/Traceback - und darf niemals True ergeben."""
raw = b"x" * 1024
signed = nl_sign.sign_image(raw, key.private_pem)
with pytest.raises(nl_sign.SignError):
nl_sign.verify_image(signed, b"das ist definitiv kein Schluessel")
def test_sign_rejects_garbage_private_key():
"""Ein kaputter --key-Inhalt muss ebenso als SignError gemeldet werden."""
raw = b"x" * 1024
with pytest.raises(nl_sign.SignError):
nl_sign.sign_image(raw, b"das ist definitiv kein Schluessel")
def test_cli_verify_rejects_garbage_pub_key(key, tmp_path):
src = tmp_path / "in_gz.img"
dst = tmp_path / "out.img"
pub = tmp_path / "pub.der"
priv = tmp_path / "key.pem"
src.write_bytes(b"\x01\x02\x03" * 1000)
priv.write_bytes(key.private_pem)
pub.write_bytes(b"das ist definitiv kein Schluessel")
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
"--in", str(src), "--out", str(dst)], check=True)
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
"--in", str(dst)], capture_output=True, text=True)
assert r.returncode != 0
assert "Traceback" not in r.stderr
assert "error:" in r.stderr.lower()
def test_cli_sign_rejects_garbage_private_key(tmp_path):
src = tmp_path / "in_gz.img"
dst = tmp_path / "out.img"
priv = tmp_path / "key.pem"
src.write_bytes(b"\x01\x02\x03" * 1000)
priv.write_bytes(b"das ist definitiv kein Schluessel")
r = subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
"--in", str(src), "--out", str(dst)], capture_output=True, text=True)
assert r.returncode != 0
assert "Traceback" not in r.stderr
assert "error:" in r.stderr.lower()
+142
View File
@@ -0,0 +1,142 @@
"""Wache dagegen, dass eine Testdatei aus allen Suiten herausfaellt.
Genau das war schon einmal passiert: tools/test_host.sh rief pytest auf
tools/mkimage/tests und tools/station/tests auf - tools/mkimage/test_nl_mkimage.py
liegt aber DIREKT in tools/mkimage/ und lief damit in keiner Suite mehr mit. 19 Tests
waren still verschwunden, darunter test_stays_python38_compatible, die Wache fuer die
bindende 3.8-Randbedingung des Docker-Build-Containers (`str | None` in nl_mkimage.py
hatte den Build schon einmal gebrochen).
Eine stumm ausgefallene Testdatei ist schlimmer als eine fehlende: die Suite bleibt
gruen und behauptet damit eine Deckung, die es nicht gibt. Deshalb wird hier nicht ein
einzelner Pfad festgenagelt, sondern die Regel selbst - JEDE test_*.py unter tools/
muss von einem der Pfade erfasst sein, die tools/test_host.sh an pytest uebergibt.
Eine neue Testdatei an einer neuen Stelle faellt damit sofort auf, statt erst dann,
wenn jemand sie vermisst.
"""
import re
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parents[2]
REPO_ROOT = TOOLS_DIR.parent
TEST_HOST = TOOLS_DIR / "test_host.sh"
# Verzeichnisse, die keine Tests DIESES Projekts enthalten: virtuelle Umgebungen und
# installierte Fremdpakete bringen eigene test_*.py mit, die hier nichts zu suchen haben.
IGNORED_PARTS = (".venv", "__pycache__", "site-packages", "node_modules", ".git")
def pytest_paths():
"""Die Pfade, die tools/test_host.sh an pytest uebergibt, als aufgeloeste Pfade."""
text = TEST_HOST.read_text()
match = re.search(r"^\s*python3 -m pytest (.+?)(?:\s+-\S+)*\s*$", text, re.M)
assert match, "in tools/test_host.sh ist kein 'python3 -m pytest'-Aufruf zu finden"
paths = []
for raw in match.group(1).split():
arg = raw.strip('"')
if arg.startswith("-"):
continue
paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve())
assert paths, "der pytest-Aufruf in tools/test_host.sh uebergibt keine Pfade"
return paths
def own_test_files(suffix=".py"):
return sorted(
p.resolve()
for p in TOOLS_DIR.rglob("test_*" + suffix)
if not any(part in IGNORED_PARTS for part in p.parts)
)
def listed_shell_suites():
"""Die Eintraege des SHELL_SUITES-Feldes in tools/test_host.sh, aufgeloest.
Ausdruecklich gegen das FELD geprueft und nicht per Textsuche nach Dateinamen: eine
Textsuche ist schon durch einen nachgestellten Kommentar ("echo x # test_deploy.sh"),
ein ": \"test_deploy.sh\"" oder einen Heredoc-Inhalt erfuellt - sie belegt nur, dass
der Name irgendwo vorkommt, nicht dass die Suite laeuft (Task 6b F2).
"""
text = TEST_HOST.read_text()
block = re.search(r"^SHELL_SUITES=\((.*?)^\)", text, re.M | re.S)
assert block, (
"in tools/test_host.sh gibt es kein 'SHELL_SUITES=(...)'-Feld mehr - die "
"Vollstaendigkeitspruefung der Shell-Suiten haengt daran"
)
# Und das Feld muss auch wirklich abgearbeitet werden, sonst ist es nur Deko.
assert re.search(r'for\s+\w+\s+in\s+"\$\{SHELL_SUITES\[@\]\}"', text), (
"tools/test_host.sh arbeitet SHELL_SUITES nicht in einer Schleife ab - die "
"aufgefuehrten Suiten wuerden gar nicht laufen"
)
paths = []
for raw in block.group(1).split():
arg = raw.strip('"')
if not arg or arg.startswith("#"):
continue
paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve())
return paths
def test_every_test_file_is_reached_by_test_host():
roots = pytest_paths()
orphans = []
for test_file in own_test_files():
if not any(test_file == r or r in test_file.parents for r in roots):
orphans.append(test_file.relative_to(REPO_ROOT))
assert not orphans, (
"diese Testdateien laufen in keiner Suite mit - tools/test_host.sh sammelt sie "
"nicht ein: {}. Entweder den Pfad in test_host.sh erweitern oder die Datei "
"dorthin verschieben, wo sie eingesammelt wird.".format(
", ".join(str(o) for o in orphans)
)
)
def test_every_shell_suite_is_called_by_test_host():
"""Dieselbe Regel fuer die Shell-Suiten (Task 6b F1, I3).
test_deploy.sh war in tools/test_host.sh nicht aufgefuehrt und lief damit nur bei
Handaufruf - 95 Zusagen, die niemand mehr sah. Die .py-Regel oben deckt genau diese
Haelfte nicht ab, weil die Shell-Suiten nicht ueber pytest laufen, sondern einzeln
aufgerufen werden.
"""
listed = listed_shell_suites()
# Symmetrisch zur .py-Regel: das GANZE tools/ wird durchsucht, nicht nur
# tools/station/tests - eine kuenftige Shell-Suite an einer neuen Stelle faellt sonst
# weiterhin durch (Task 6b F2).
# tools/test_host.sh heisst selbst "test_*.sh", ist aber der Aufrufer und keine
# Suite - es kann sich schlecht selbst auffuehren.
missing = [
s.relative_to(REPO_ROOT)
for s in own_test_files(".sh")
if s != TEST_HOST.resolve() and s not in listed
]
assert not missing, (
"diese Shell-Suiten stehen nicht in SHELL_SUITES von tools/test_host.sh, sie "
"laufen also nur bei Handaufruf: {}".format(", ".join(str(m) for m in missing))
)
# Und andersherum: ein Eintrag, den es nicht mehr gibt, laesst test_host.sh beim
# naechsten Lauf hart scheitern - das faellt hier schon vorher auf.
verschwunden = [str(p) for p in listed if not p.exists()]
assert not verschwunden, (
"SHELL_SUITES in tools/test_host.sh nennt Dateien, die es nicht gibt: {}".format(
", ".join(verschwunden)
)
)
def test_python38_guard_is_among_them():
"""Die konkrete Datei, deren Ausfall den Build-Bruch damals durchgelassen haette."""
guard = (TOOLS_DIR / "mkimage" / "test_nl_mkimage.py").resolve()
assert guard.exists(), "tools/mkimage/test_nl_mkimage.py fehlt"
roots = pytest_paths()
assert any(guard == r or r in guard.parents for r in roots), (
"tools/mkimage/test_nl_mkimage.py wird von tools/test_host.sh nicht eingesammelt "
"- darin steht test_stays_python38_compatible, die einzige Wache gegen erneute "
"3.10-Syntax in nl_mkimage.py (der Docker-Build-Container hat Python 3.8.10)"
)
# Kein eigener "ist vermin installiert"-Test hier: test_nl_mkimage.py importiert vermin
# ohnehin hart (kein Skip), und ein fehlendes Paket faellt dort mit derselben Deutlichkeit
# auf. Zwei Tests fuer dieselbe Zusage sind nur zwei Stellen zum Pflegen.
+14 -1
View File
@@ -28,12 +28,16 @@ source = { virtual = "." }
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "vermin" },
] ]
[package.metadata] [package.metadata]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }] dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "vermin", specifier = ">=1.6" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
@@ -77,3 +81,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]]
name = "vermin"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/7b/2c1b403f2a844e1acb36694fc336e323df742f7f752edb4188311ad43f9e/vermin-1.8.0.tar.gz", hash = "sha256:3621955ac2a2950175c5b4a9b2fc3bd24bd416da0388893c9eb6971264e4ca1f", size = 99913, upload-time = "2025-11-20T17:29:31.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/fd/6d2167767488e162efd01300a679c7f7d913dbb65baa0b15760ebb369854/vermin-1.8.0-py3-none-any.whl", hash = "sha256:6f2b98ad697a16ed3442e81a0f0baabfc38c185b78aaab1b1fecd87bdd6640b8", size = 98030, upload-time = "2025-11-20T17:29:29.931Z" },
]
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# docker.sh equivalent using podman (same image, same mount, no emulation flag needed if native)
set -euo pipefail
ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
podman image exists ninlight-build || podman build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
exec podman run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
# Baut ein Auslieferungsarchiv: alle Ziele, OHNE einkompilierte WLAN-Zugangsdaten
# (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration
# (dev und prod signiert) und serielle Erstbespielung.
#
# Aufruf: tools/release.sh <version>
# Ergebnis: dist/ninlight-<version>.tar.gz
#
# Umgebungsvariablen:
# NINLIGHT_SIGN_DIR Schluesselverzeichnis (Vorgabe ~/workspace/fw-sign)
# NINLIGHT_NOCREDS_BUILD_REL Build-Verzeichnis relativ zur Repo-Wurzel (s. build_nocreds.sh)
# NINLIGHT_DOCKER Build-Vorspann (s. build_nocreds.sh); Tests stubben ihn
set -euo pipefail
VERSION=${1:?Aufruf: tools/release.sh <version>}
ROOT=$(cd "$(dirname "$0")/.." && pwd)
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
BUILD_DIR="$ROOT/$BUILD_REL"
NAME="ninlight-$VERSION"
TGZ="$ROOT/dist/$NAME.tar.gz"
# Ein vorhandenes Archiv wird NICHT ueberschrieben: eine schon weitergegebene Version
# still durch einen anderen Inhalt zu ersetzen, ist der Fehler, den man am spaetesten
# bemerkt.
[ -e "$TGZ" ] && { echo "Fehler: $TGZ existiert bereits - Version erhoehen oder Datei entfernen" >&2; exit 1; }
# sha256: GNU zuerst (Debian/Pi), BSD/macOS als Rueckfall.
if command -v sha256sum >/dev/null 2>&1; then
SHA_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
SHA_CMD="shasum -a 256"
else
echo "Fehler: weder sha256sum noch shasum gefunden" >&2; exit 1
fi
echo "== Firmware ohne Zugangsdaten bauen =="
# Der Bauweg liegt bewusst in einem eigenen, wiederverwendbaren Skript (die
# Migrationsstation braucht dieselben Abzuege) - inklusive des Beiseitelegens von
# config.local.cmake, das jedes -D schlaegt.
"$ROOT/tools/build_nocreds.sh" "${TARGETS[@]}"
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
OUT="$STAGE/$NAME"
mkdir -p "$OUT/ota" "$OUT/flash"
echo "== Images einsammeln =="
for t in "${TARGETS[@]}"; do
ota="$BUILD_DIR/$t/ninlight_${t}_ota.img"
gz="$BUILD_DIR/$t/ninlight_${t}_gz.img"
[ -f "$ota" ] || { echo "Fehler: $ota fehlt" >&2; exit 1; }
[ -f "$gz" ] || { echo "Fehler: $gz fehlt" >&2; exit 1; }
cp "$ota" "$OUT/ota/"
cp "$gz" "$OUT/flash/"
done
echo "== Migrationsimages signieren =="
# Wie tools/station/deploy.sh: die Schluessel werden nur gelesen, sie gehoeren nie ins
# Repo, nie auf den Pi und schon gar nicht ins Archiv. Fehlt das Verzeichnis oder ein
# einzelner Schluessel, entfaellt die jeweilige Variante mit einer Meldung - das Archiv
# entsteht trotzdem, und README.md sagt dann, warum migrate/ fehlt.
# ${HOME:-} statt $HOME, damit `set -u` unter env -i/cron nicht unnoetig abbricht.
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
KEY_DEV="$SIGN_DIR/fw_sign.pem"
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
MIGRATE_NOTE=""
HAVE_PROD=0
HAVE_DEV=0
if [ -d "$SIGN_DIR" ]; then
if [ -f "$KEY_PROD" ]; then HAVE_PROD=1; else
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img" >&2
fi
if [ -f "$KEY_DEV" ]; then HAVE_DEV=1; else
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img" >&2
fi
else
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
fi
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
mkdir -p "$OUT/migrate"
for t in "${TARGETS[@]}"; do
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide belegen
# denselben 512-Byte-Block am Dateiende (Signatur bzw. Trailer).
gz="$BUILD_DIR/$t/ninlight_${t}_gz.img"
if [ "$HAVE_PROD" -eq 1 ]; then
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$OUT/migrate/${t}_prod.img" \
|| { echo "Fehler: Signieren von $t (prod) fehlgeschlagen" >&2; exit 1; }
fi
if [ "$HAVE_DEV" -eq 1 ]; then
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$OUT/migrate/${t}_dev.img" \
|| { echo "Fehler: Signieren von $t (dev) fehlgeschlagen" >&2; exit 1; }
fi
done
fi
if [ "$HAVE_PROD" -eq 0 ] && [ "$HAVE_DEV" -eq 0 ]; then
MIGRATE_NOTE="**\`migrate/\` fehlt in diesem Archiv.** Beim Bauen war kein Signierschluessel
erreichbar (\`$SIGN_DIR\`). Ohne Signatur nimmt die alte Klyqa-Firmware kein Image an;
alte Lampen lassen sich mit diesem Archiv also nicht migrieren. Die Schluessel gehoeren
nie in ein Archiv - wer sie hat, baut sich das Archiv mit \`tools/release.sh\` neu."
elif [ "$HAVE_PROD" -eq 0 ]; then
MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_prod.img\`.** Beim Bauen war nur der
Entwicklungsschluessel erreichbar. Lampen mit Produktionssignatur lassen sich damit nicht migrieren."
elif [ "$HAVE_DEV" -eq 0 ]; then
MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_dev.img\`.** Beim Bauen war nur der
Produktionsschluessel erreichbar. Lampen mit Entwicklungssignatur lassen sich damit nicht migrieren."
fi
echo "== Bruno-Collection, Onboarding-Skript und README =="
mkdir -p "$OUT/bruno"
cp "$ROOT/bruno/ninlight.yml" "$OUT/bruno/"
cat >"$OUT/onboard.sh" <<'ONBOARD'
#!/usr/bin/env bash
# Bringt eine frisch geflashte ninlight-Lampe ins WLAN.
#
# Aufruf: ./onboard.sh <ssid> [wlan-passwort]
#
# VORHER VON HAND (das kann dir kein Skript portabel abnehmen - sich per Skript mit
# einem WLAN zu verbinden ist auf jedem Betriebssystem anders):
# 1. Lampe einschalten. Ohne gespeicherte Zugangsdaten pulsiert sie warmweiss und
# spannt den Zugangspunkt "ninlight-<mac4>" auf (Passwort: ninlight).
# 2. Diesen Rechner mit genau diesem WLAN verbinden.
# 3. Dieses Skript aufrufen.
# 4. Danach wieder ins normale WLAN wechseln; die Lampe ist per mDNS
# (ninlight-<mac4>.local, Port 3333) erreichbar.
#
# Optionen:
# --host <ip> Adresse der Lampe im AP (Vorgabe 192.168.4.1)
# --port <port> REST-Port (Vorgabe 3333)
# --token <token> API-Token. Im unkonfigurierten Onboarding-AP NICHT noetig (der
# Zugang ist dort offen). Noetig im Fallback-AP, den eine Lampe
# aufspannt, die bereits Zugangsdaten hat (Router > 5 min weg) -
# dort ist system/settings wie ueberall tokengeschuetzt.
# Vorgabewert der Firmware: aabbccddeeff0011223344
set -euo pipefail
command -v curl >/dev/null 2>&1 || { echo "Fehler: curl wird gebraucht" >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "Fehler: python3 wird gebraucht (JSON-Body)" >&2; exit 1; }
HOST=192.168.4.1
PORT=3333
TOKEN=""
SSID=""
PASSWORD=""
# Hilfetext ist der Kommentarblock oben - keine feste Zeilennummer, die beim naechsten
# Satz im Kopf stillschweigend falsch wird.
usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST=${2:?--host braucht einen Wert}; shift 2 ;;
--port) PORT=${2:?--port braucht einen Wert}; shift 2 ;;
--token) TOKEN=${2:?--token braucht einen Wert}; shift 2 ;;
-h|--help) usage; exit 0 ;;
-*) echo "Unbekannte Option: $1" >&2; usage >&2; exit 2 ;;
*) if [ -z "$SSID" ]; then SSID=$1; else PASSWORD=$1; fi; shift ;;
esac
done
[ -n "$SSID" ] || { echo "Fehler: SSID fehlt" >&2; usage >&2; exit 2; }
URL="http://$HOST:$PORT/api/v1/system/settings"
BODY=$(SSID="$SSID" PASSWORD="$PASSWORD" python3 -c '
import json, os
print(json.dumps({"type": "wifi", "ssid": os.environ["SSID"], "password": os.environ["PASSWORD"]}))')
set -- -X PUT "$URL" -H "Content-Type: application/json" -d "$BODY" --max-time 15
[ -n "$TOKEN" ] && set -- "$@" -H "Authorization: $TOKEN"
echo "== WLAN setzen ($URL) =="
curl -fsS "$@" || {
echo "Fehler: die Lampe hat die Zugangsdaten nicht angenommen." >&2
echo " 401 -> Fallback-AP: --token <token> mitgeben." >&2
echo " keine Verbindung -> bist du wirklich im WLAN ninlight-<mac4>?" >&2
exit 1
}
echo
echo "== Verbindung pruefen (system/info) =="
# Die Lampe braucht ein paar Sekunden, bis sie im neuen WLAN haengt; danach schliesst
# der AP nach 60 s. wifi.sta_ip zeigt die neue Adresse, wifi.error den Grund, falls es
# nicht geklappt hat (z.B. falsches Passwort). Wartezeit fuer Tests ueberschreibbar.
sleep "${NINLIGHT_ONBOARD_WAIT:-8}"
curl -fsS "http://$HOST:$PORT/api/v1/system/info" --max-time 15 || {
echo "Hinweis: system/info nicht mehr erreichbar - meist ein gutes Zeichen (der AP ist" >&2
echo " zu, die Lampe haengt im neuen WLAN). Im normalen WLAN nachsehen." >&2
}
echo
ONBOARD
chmod +x "$OUT/onboard.sh"
cat >"$OUT/README.md" <<README
# ninlight $VERSION
Ersatzfirmware fuer die abgekuendigten Klyqa-Lampen (WinnerMicro W600). Alle Images in
diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit bespielte
Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (Passwort \`ninlight\`) und
wird mit \`onboard.sh\` ins WLAN gebracht.
Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb.
## Was liegt wo
| Verzeichnis | Inhalt | wofuer |
|---|---|---|
| \`ota/\` | \`ninlight_<ziel>_ota.img\` | Update einer Lampe, auf der ninlight schon laeuft (REST \`fw_update\`), und die Auslieferung an die Rettungsstation |
| \`migrate/\` | \`<ziel>_prod.img\`, \`<ziel>_dev.img\` | Erstmigration einer Lampe, auf der noch die alte Klyqa-Firmware laeuft |
| \`flash/\` | \`ninlight_<ziel>_gz.img\` | serielle Erstbespielung ueber die Platine |
| \`bruno/\` | \`ninlight.yml\` | REST-Schnittstelle als Bruno-Collection |
\`SHA256SUMS\` deckt jede Datei ab: \`sha256sum -c SHA256SUMS\` (macOS: \`shasum -a 256 -c SHA256SUMS\`).
$MIGRATE_NOTE
## Alte Klyqa-Lampe migrieren
Die alte Firmware nimmt nur signierte Images an; welche Signatur (\`_prod\` oder \`_dev\`)
gilt, haengt an Produkt-ID und Version der Lampe. Der Weg laeuft ueber die
Migrationsstation (\`tools/station/\` im Repo, ein Raspberry Pi mit zwei Zugangspunkten):
1. Station einschalten und \`ninlight-station migrate on\` (oder den Taster druecken).
2. Lampe einschalten - sie waehlt sich in den Klyqa-Zugangspunkt ein und holt das
passende Image aus \`migrate/\`.
3. Nach dem Neustart laeuft ninlight; die Lampe spannt ihren Onboarding-AP auf.
## Frisch geflashte Lampe onboarden
./onboard.sh "MeinWLAN" "meinpasswort"
Vorher von Hand: Lampe einschalten (sie pulsiert warmweiss), diesen Rechner mit dem WLAN
\`ninlight-<mac4>\` verbinden (Passwort \`ninlight\`). \`./onboard.sh --help\` zeigt die
Schritte und die Optionen (\`--host\`, \`--port\`, \`--token\`). Im unkonfigurierten
Onboarding-AP ist der Zugang offen, im Fallback-AP einer bereits onboardeten Lampe ist das
Token noetig.
Serielle Erstbespielung: \`flash/ninlight_<ziel>_gz.img\` mit \`tools/flash.sh\` aus dem Repo
bzw. \`wm_tool\` auf die Platine schreiben.
## Vertrauensgrenze
Der 512-Byte-Trailer der \`_ota.img\` prueft Geraetetyp und SHA256 - das ist eine
Integritaets- und Geraetetyppruefung, **kein kryptografisches Signaturverfahren**. Der
Download laeuft ueber \`http://\` ohne TLS, und der Rettungskanal ist nur durch ein WLAN-
Passwort geschuetzt, das mit einem oeffentlichen Default im Repo steht. Wer im selben WLAN
ist und das API-Token kennt (bzw. beim Recovery in Funkreichweite ist und das Passwort
kennt), kann der Lampe beliebige Firmware unterschieben. Das ist eine bewusste
Entscheidung dieses Projekts: Ziel ist, die Signaturpruefung der alten, abgekuendigten
Original-Firmware zu umgehen, damit die Lampen ueberhaupt weiter nutzbar bleiben - nicht,
ein neues, gehaertetes Sicherheitsmodell zu bauen.
Die RSA-Signatur der \`migrate/\`-Images ist die der ALTEN Firmware und existiert nur, damit
die alte Firmware das Image ueberhaupt annimmt. Die privaten Schluessel liegen nie in
diesem Archiv.
README
echo "== Auf Zugangsdaten pruefen =="
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
# wird, wird JEDE Datei des Archivs binaer gegen die echten Werte aus
# config.local.cmake geprueft. Lieber ein abgebrochener Release als ein Archiv mit
# Michaels WLAN-Passwort bei Dritten.
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
import os
import re
import sys
stage, local = sys.argv[1], sys.argv[2]
text = open(local, encoding="utf-8", errors="replace").read()
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
# haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
needles = []
for name in SECRETS:
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
if m and m.group(1) not in PUBLIC:
needles.append((name, m.group(1).encode()))
hits = []
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
with open(path, "rb") as fh:
data = fh.read()
for name, needle in needles:
if needle in data:
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
if hits:
sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
+ "\n ".join(hits) + "\n")
sys.exit(1)
print(" sauber (%d geprueft)" % len(needles))
PY
fi
echo "== SHA256SUMS =="
(
cd "$OUT"
# $SHA_CMD absichtlich ohne Anfuehrungszeichen: "shasum -a 256" sind drei Woerter.
# shellcheck disable=SC2086
find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort | xargs $SHA_CMD >SHA256SUMS
)
echo "== packen =="
mkdir -p "$ROOT/dist"
tar czf "$TGZ" -C "$STAGE" "$NAME"
echo "fertig: $TGZ"
+154
View File
@@ -0,0 +1,154 @@
# ninlight-Recovery-Station
Ein Raspberry Pi spannt den Zugangspunkt `ninlight-recovery` auf und liefert unter
`http://192.168.8.1/ninlight_<target>_ota.img` Firmware-Images aus.
Jede ninlight-Lampe sucht beim Booten bis zu drei Sekunden nach dieser Station. Findet sie
sie, lädt sie das Image für ihren Gerätetyp und flasht es — ohne dass die Lampe im WLAN
erreichbar sein muss. Das ist der Rettungsweg für Lampen, an die man sonst nur noch mit dem
Lötkolben käme.
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
## Voraussetzung
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
würde dort nur mit einer Fehlermeldung enden.
## Bedienung
tools/station/setup_pi.sh <pi-host> einmalig einrichten
tools/station/deploy.sh <pi-host> [target] Images ausliefern
ssh <pi-host> ninlight-station on einschalten
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
ssh <pi-host> ninlight-station bildsatz welcher Migrations-Bildsatz gilt
ssh <pi-host> ninlight-station off ausschalten
## Bildsatz: mit oder ohne WLAN-Zugangsdaten
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es
gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor:
| Umschalterstellung | Verzeichnis auf dem Pi | gebaut mit | die migrierte Lampe … |
|---|---|---|---|
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
| `mit-wlan` | `images/migrate-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` |
Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration**: der
Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts und bleibt, bis
ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der Fußzeile des Panels** —
in jedem Modus, auch bei ausgeschalteter Station: man soll vor jedem Einschalten sehen,
was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu flashen, weil die
Einstellung von vorletzter Woche noch stand.
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
Klyqa-Lampe in Funkreichweite migriert, auch fremde. Mit dem Abzug ohne Zugangsdaten ist
ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und wartet. Mit
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden
Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie gelaufen),
wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz. Der Server
antwortet mit 404 und `status: noimageset`, `ninlight-station status` sagt „FEHLT", und auf
dem Panel steht „SATZ FEHLT". „Vorhanden" heißt dabei überall dasselbe: Verzeichnis da
**und** mindestens ein Image darin.
`deploy.sh` liefert beide Sätze aus. Fehlt `build-nocreds/`, bleibt der Satz `ohne-wlan`
auf dem Pi unangetastet (statt geleert zu werden) und der Lauf meldet, womit er entsteht.
## Taster am Gerät
Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**.
Für genau diesen Zustand gibt es einen Taster auf **GPIO23**; die Rückmeldung kommt aufs
E-Paper. Ein Taster, zwei Zusammenhänge:
| | kurz (unter 1 s) | gehalten |
|----------------------|------------------|-------------------------------------------------------|
| Ruhe (Statusbild) | Menü öffnen | 3 s → Abbruch (Station aus), weiter 10 s → Herunterfahren |
| Menü offen | nächster Eintrag | 1,5 s → Eintrag auswählen |
| Rückfrage | abbrechen | 1,5 s → bestätigen |
Der Abbruch feuert **bei 3 s**, nicht erst beim Loslassen — danach läuft auf dem Panel ein
Countdown bis zum Herunterfahren, den Loslassen jederzeit abbricht. Das Menü schließt nach
20 s ohne Eingabe, eine Rückfrage verfällt nach 10 s als Abbruch. Die Einträge hängen vom
Zustand ab (Station aus / Rettung läuft / Migration läuft); jede verändernde Auswahl
bekommt eine Rückfrage.
**Die beiden Migrationsarten sind getrennte Einträge.** „Migration: horchen"
(`migrate on --listen`) steht zuerst und ist der naheliegende: der Zugangspunkt steht,
aber es geht nichts raus, bis du eine erkannte Lampe freigibst. „Migration: AUTOMATIK"
(`migrate on`) folgt danach und bekommt eine **andere, deutlichere Rückfrage** — der
Migrations-Zugangspunkt hat ein öffentlich bekanntes Passwort (es steht in der alten
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
ungefragt migriert, auch fremde. Die Rückfrage benennt genau das, statt nur „Wirklich?"
zu fragen.
Der Taster ist nirgends Voraussetzung: fehlt `gpiozero` oder der Taster, protokolliert der
Dienst eine Zeile und beendet sich ohne Fehler. Er fasst den SPI-Bus **nicht** an (zwei
Prozesse auf denselben Leitungen ergeben `GPIO busy`), sondern schreibt seinen
Bedienzustand nach `/run/ninlight-button/ui.json`; der Anzeigedienst liest ihn und
zeichnet. Ausgeführt wird ausschließlich über das CLI.
Dienst und Unit liegen in `tools/station/button/` bzw.
`tools/station/ninlight-button.service`. `setup_pi.sh` installiert, aktiviert und startet
den Tasterdienst wie den Anzeigedienst; `deploy.sh` liefert `button/` bei jedem Deploy mit
aus. Ein fehlgeschlagenes Aktivieren/Starten ist dabei nur eine Warnung — eine Station
ohne Taster muss vollständig benutzbar bleiben.
## Abschlussbildschirm beim Herunterfahren und beim Neustart
E-Paper behält sein Bild **dauerhaft**, auch stromlos. Fährt der Pi herunter, wird der
Anzeigedienst mit abgeräumt, und das zuletzt gezeichnete Bild bleibt für immer stehen —
bei der Abnahme stand dort die **Rückfrage** des Tastermenüs, und der ausgeschaltete Pi
sah aus, als warte er noch auf eine Eingabe.
Dafür gibt es zwei systemd-Units, `ninlight-poweroff.service` (`WantedBy=poweroff.target`)
und `ninlight-reboot.service` (`WantedBy=reboot.target`). Sie hängen am **System**, nicht
am Tasterdienst: so greifen sie bei jedem Herunterfahren — über das Menü, über `poweroff`
per SSH, über `reboot` — und nicht nur auf einem von drei Wegen.
**Die Texte unterscheiden sich, und das ist der Punkt:**
| | Panel |
|---|---|
| Herunterfahren | „AUSGESCHALTET — Strom kann getrennt werden" |
| Neustart | „NEUSTART — Strom NICHT trennen!" plus der Hinweis, dass es rund 2 min dauert |
Ein Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder zeichnet. Stünde
dort währenddessen „Strom kann getrennt werden", wäre das eine Aufforderung zu genau dem
Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
**Reihenfolge:** der Anzeigedienst hält die GPIO-/SPI-Leitungen; zwei Prozesse darauf
ergeben `lgpio.error: 'GPIO busy'`. Die Units ordnen sich deshalb per
`After=ninlight-display.service` hinter dessen Stop ein, und `ninlight-station display
final <poweroff|reboot>` wartet zusätzlich, bis `systemctl is-active ninlight-display`
nicht mehr anschlägt — dasselbe Muster wie `display_handover()` bei `display test`, nur
ohne Wiederanfahren. Gibt der Dienst die Leitungen nicht frei, wird **nicht** gezeichnet,
sondern eine Zeile ins Journal geschrieben: ein Fehler an der Anzeige darf das
Herunterfahren nie aufhalten.
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
## Vertrauensgrenze
Der Trailer eines Images prüft Gerätetyp und SHA256. Er ist **keine** kryptografische
Signatur, und der Download läuft über `http://` ohne TLS. Die Station schützt allein ein
WPA2-Passwort, dessen Default im Repo steht. Wer in Funkreichweite ist und es kennt, kann
jeder Lampe beim nächsten Boot beliebige Firmware unterschieben.
Das ist gewollt: Die Signaturprüfung der alten Firmware auszuhebeln ist der Zweck dieses
Projekts. Daraus folgen zwei Regeln.
**Setz ein eigenes Passwort.** In `config.local.cmake` und in
`/etc/ninlight-station.conf` — an beiden Stellen denselben Wert.
**Lass die Station nur an, solange du sie brauchst.** Nicht nur wegen des Fremdzugriffs:
Die Firmware löscht den Power-Cycle-Zähler, bevor sie einen Recovery-Versuch startet.
Solange eine Station in Reichweite ist, funktioniert der Werksreset per fünf Stromzyklen
bei **keiner** Lampe in Reichweite mehr.
+673
View File
@@ -0,0 +1,673 @@
#!/usr/bin/env python3
"""Tasterdienst der ninlight-Station: EIN Taster auf GPIO23 (active low, interner
Pull-up), Rueckmeldung ueber das E-Paper.
Wozu das gut ist: "ninlight-station on"/"migrate on" macht wlan0 zum Zugangspunkt und
reisst damit JEDE SSH-Sitzung ueber WLAN ab. Genau in diesem Zustand hatte der Bediener
am Geraet bisher keinerlei Eingriff - es blieb warten oder Stecker ziehen, und Letzteres
hat an diesem Pi schon eine SD-Karte gekostet. Der Taster ist der Ausweg: Abbruch
(Station aus) und, ueber eine sichtbare Zwischenstufe, ein sauberes Herunterfahren.
Bedienung (ein Taster, zwei Zusammenhaenge - die Bedeutung haengt daran, ob das Menue
offen ist):
kurz (< 1 s) gehalten
Ruhe Menue oeffnen 3 s -> Abbruch, weiter 10 s -> Herunterfahren
Menue offen naechster Eintrag 1,5 s -> Eintrag auswaehlen
Rueckfrage abbrechen 1,5 s -> bestaetigen
Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen: der Bediener soll die Wirkung am
Panel sehen. Haelt er weiter, laeuft dort ein Countdown bis 10 s (Herunterfahren);
Loslassen bricht diese zweite Stufe jederzeit ab. Das Herunterfahren ist damit nur ueber
eine sichtbare Zustandsaenderung hinweg erreichbar - nicht aus Versehen.
DER TASTERDIENST FASST DEN SPI-BUS NICHT AN. Zwei Prozesse auf denselben GPIO-/SPI-
Leitungen enden in "lgpio.error: 'GPIO busy'" (am Geraet passiert, als
"ninlight-station display test" neben dem laufenden Anzeigedienst lief). Deshalb schreibt
dieser Dienst nur seinen Bedienzustand nach UI_FILE, und der Anzeigedienst liest ihn und
zeichnet (siehe display.read_ui()/render.blocks()).
Alle veraendernden Aktionen laufen ueber das vorhandene CLI (/usr/local/bin/
ninlight-station), nie ueber nachgebaute Logik: nur das CLI kennt die Profilumschaltung,
die Nachpruefungen ("es darf nie mehr als ein Stationsprofil aktiv sein") und den
Zeitschalter.
SICHERHEITSREGEL: die beiden Migrationseintraege sind GETRENNT und namentlich
unterscheidbar. "Migration: horchen" ruft "migrate on --listen" - der Zugangspunkt steht,
aber es geht nichts raus, bis der Bediener eine erkannte Lampe freigibt; das ist der
erste und naheliegende Eintrag. "Migration: AUTOMATIK" ruft "migrate on" ohne "--listen"
und bekommt eine ANDERE, deutlichere Rueckfrage (siehe action_migrate_auto()): der
Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
ungefragt migriert, auch fremde. Die bequeme Geste bleibt damit die sichere, ohne dass
der Automatikmodus - fuer eine ganze Kiste eigener Lampen am Stueck - nur noch ueber die
Kommandozeile erreichbar waere, die im Zugangspunktbetrieb gerade nicht zur Verfuegung
steht.
Der Taster ist NIRGENDS Voraussetzung: fehlt gpiozero, fehlt der Taster oder ist GPIO23
belegt, schreibt main() GENAU EINE Protokollzeile und beendet sich mit sys.exit(0) -
dieselbe Zusage (und derselbe Grund: Restart=on-failure darf daraus keine Neustartschleife
machen) wie bei der Anzeige, siehe display.py's Modul-Docstring. Und ein Fehler am Taster
darf niemals eine laufende Migration stoeren oder das Ein-/Ausschalten aufhalten - deshalb
faengt jeder Schreib- und Ausfuehrungspfad hier seine Fehler selbst ab.
"""
from __future__ import annotations
import json
import logging
import math
import os
import subprocess
import sys
import time
from pathlib import Path
log = logging.getLogger("ninlight-button")
# --- Zeiten und Pfade ---------------------------------------------------------------
BUTTON_PIN = int(os.environ.get("NINLIGHT_BUTTON_PIN", "23"))
# 50 ms statt der naheliegenden 20: ein sehr kurzer Tipper kam am Geraet durch eine
# 20-ms-Entprellung noch durch und oeffnete ungewollt das Menue. Die Entprellung macht
# gpiozero selbst (bounce_time), nicht diese Schleife - siehe open_button().
DEBOUNCE_S = 0.05
# Abtastrate der Hauptschleife. Deutlich feiner als die Entprellung, damit eine Flanke
# nicht erst eine halbe Entprellzeit spaeter gesehen wird; ein Durchlauf ohne Aenderung
# kostet nur einen gpiozero-Registerzugriff und einen Woerterbuchvergleich (UiWriter
# schreibt nur bei Aenderung).
TICK_S = 0.02
SHORT_PRESS_MAX_S = 1.0
SELECT_HOLD_S = 1.5 # Menue: Eintrag waehlen / Rueckfrage: bestaetigen
CANCEL_HOLD_S = 3.0 # Ruhe: Abbruch (Station aus)
SHUTDOWN_HOLD_S = 10.0 # Ruhe: Herunterfahren (nur ueber die Abbruchstufe hinweg)
MENU_TIMEOUT_S = 20.0 # ohne Eingabe zurueck aufs Statusbild
CONFIRM_TIMEOUT_S = 10.0 # Rueckfrage verfaellt als Abbruch
RESULT_S = 4.0 # wie lange die Rueckmeldung einer Aktion stehen bleibt
# Dieselben Umgebungsvariablen wie ninlight-station/display.py, damit Tests und
# Handbetrieb ohne echtes /run bzw. /etc auskommen.
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
# BEWUSST NICHT /run/ninlight-station/ui.json (das war der Vorschlag): dieses Verzeichnis
# ist das RuntimeDirectory der Hauptunit, und systemd raeumt es beim regulaeren Stop von
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue am
# dringendsten gebraucht wird (dort liegen "Rettung starten"/"Migration starten"). Die
# Datei liegt deshalb im eigenen RuntimeDirectory dieses Dienstes; sie verschwindet damit
# genau dann, wenn dieser Dienst endet - und ein stehengebliebenes Menue kann den
# Anzeigedienst nicht ueberdauern.
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
STATION_CLI = os.environ.get("NINLIGHT_STATION_BIN", "/usr/local/bin/ninlight-station")
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" - dieselbe Datei, die
# das CLI schreibt, der Server liest und der Anzeigedienst auf dem Panel zeigt. Hier wird
# sie NUR GELESEN (fuer die Beschriftung des Menueeintrags); umgeschaltet wird wie jede
# andere Aktion ueber das CLI.
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
"/var/lib/ninlight-station/imageset"))
IMAGESET_NAMES = ("ohne-wlan", "mit-wlan")
IMAGESET_DEFAULT = "ohne-wlan"
STATION_SERVICE = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
# Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige
# Aktion dieses Dienstes, die an systemd selbst geht.
SHUTDOWN_ARGV = ("systemctl", "poweroff")
# Obergrenze fuer einen CLI-Aufruf. "recovery on"/"migrate on" schalten NetworkManager-
# Profile um und pruefen danach nach; das dauert Sekunden, nicht Minuten. Ohne Deckel
# wuerde ein haengender NetworkManager die Bedienung dauerhaft einfrieren - mit ihm
# bleibt der Taster spaetestens danach wieder bedienbar.
ACTION_TIMEOUT_S = 120
class ButtonUnavailable(Exception):
"""Kein Taster da (keine Bibliothek, kein Pin, Pin belegt) - main() macht daraus
genau eine Protokollzeile und sys.exit(0)."""
# --- Aktionen -----------------------------------------------------------------------
class Action:
"""Was ein Menueeintrag bzw. die Haltegeste ausloest.
`confirm` = braucht eine Rueckfrage. Zusage: JEDE veraendernde Auswahl bekommt eine
("Anzeige auffrischen" veraendert nichts und braucht keine).
`confirm_title`/`confirm_details` machen die Rueckfrage bei Bedarf DEUTLICHER als die
uebliche. Die Vorgabe ("RUECKFRAGE" + die zwei Bedienhinweise) reicht fuer eine
Auswahl, deren Tragweite im Eintragsnamen steht ("Station aus", "Herunterfahren").
Sie reicht NICHT fuer den Automatikmodus: was dort passiert - jede alte Klyqa-Lampe
in Funkreichweite wird ungefragt migriert, auch fremde - steht in keinem
Eintragsnamen, und ein blosses "Wirklich?" fragt nach etwas, das der Bediener in
diesem Moment gar nicht vor Augen hat."""
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True,
done_text: str | None = None, confirm_title: str = "RUECKFRAGE",
confirm_details=None) -> None:
self.label = label
self.argv = tuple(argv)
self.kind = kind
self.confirm = confirm
self.done_text = done_text or label
self.confirm_title = confirm_title
self.confirm_details = list(confirm_details) if confirm_details else None
def __repr__(self) -> str: # nur fuer Testausgaben
return "Action({!r}, {!r}, kind={!r})".format(self.label, self.argv, self.kind)
def action_shutdown() -> Action:
return Action("Herunterfahren", kind="shutdown", confirm=True,
done_text="Faehrt herunter")
def action_migrate_listen() -> Action:
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus,
bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
return Action("Migration: horchen", ("migrate", "on", "--listen"),
done_text="Migration: horcht")
def action_migrate_auto() -> Action:
"""Der Automatikmodus - fuer die eigene Kiste Lampen am Stueck, ohne jede einzeln
freizugeben. Er steht hier, weil das Verabreden ueber die Kommandozeile genau dann
nicht geht, wenn man ihn braucht: "on" macht wlan0 zum Zugangspunkt und reisst jede
SSH-Sitzung ueber WLAN ab.
Seine Rueckfrage ist ABSICHTLICH eine andere als die der uebrigen Eintraege. Der
Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
ungefragt migriert - auch die des Nachbarn, der davon nichts weiss und dessen Lampe
danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der
Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
Rueckfrage selbst."""
return Action("Migration: AUTOMATIK", ("migrate", "on"),
done_text="Migration: automatisch",
confirm_title="ACHTUNG",
confirm_details=["Flasht JEDE alte Klyqa-Lampe",
"in Reichweite - auch fremde"])
def action_imageset(current: str) -> Action:
"""Der Umschalter zwischen den beiden Migrations-Bildsaetzen. Der Eintrag nennt, WOHIN
umgeschaltet wird, nicht was gerade gilt: waehrend das Menue offen ist, ersetzt es das
Statusbild samt Fusszeile - "Bildsatz: mit WLAN" waere dann nicht zu entscheiden.
Der Weg nach "mit-wlan" bekommt die deutlichere Rueckfrage, der Weg zurueck die
uebliche: nur die eine Richtung schreibt das WLAN-Passwort des Bedieners in jede
Lampe, die sie erwischt."""
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
if target == "mit-wlan":
return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"),
done_text="Bildsatz: mit WLAN",
confirm_title="ACHTUNG",
confirm_details=["Jede migrierte Lampe bekommt",
"dein WLAN-Passwort"])
return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"),
done_text="Bildsatz: ohne WLAN")
def action_cancel() -> Action:
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die
Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
return Action("Station aus", ("off",), confirm=False, done_text="Station aus")
def menu_actions(state: dict) -> list[Action]:
"""Die Menueeintraege fuer den gegebenen Stationszustand - nur das, was gerade
sinnvoll ist. Reine Funktion ueber dem von station_state() gelesenen Zustand."""
running = bool(state.get("running"))
mode = state.get("mode")
target = state.get("target")
imageset = state.get("imageset") or IMAGESET_DEFAULT
if not running:
return [
Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"),
# Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo
# loslaesst, landet nicht im Automatikmodus.
action_migrate_listen(),
action_migrate_auto(),
# Der beste Zeitpunkt zum Umschalten ist VOR dem Einschalten - deshalb steht
# der Eintrag auch (und gerade) hier.
action_imageset(imageset),
action_shutdown(),
]
items: list[Action] = []
if mode == "migration" and target:
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)),
done_text="Freigegeben: {}".format(target)))
items.append(Action("Station aus", ("off",), done_text="Station aus"))
items.append(action_imageset(imageset))
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False,
done_text="Anzeige aufgefrischt"))
items.append(action_shutdown())
return items
class CliActions:
"""Fuehrt eine Action aus - ueber das CLI bzw. systemctl, nie durch Nachbauen deren
Logik. Gibt zurueck, ob es geklappt hat; wirft nie: ein Fehler hier darf weder den
Tasterdienst beenden noch eine laufende Migration stoeren."""
def __init__(self, cli: str = STATION_CLI, shutdown_argv=SHUTDOWN_ARGV,
runner=subprocess.run) -> None:
self.cli = cli
self.shutdown_argv = tuple(shutdown_argv)
self.runner = runner
def argv_for(self, action: Action) -> list[str]:
if action.kind == "shutdown":
return list(self.shutdown_argv)
return [self.cli] + list(action.argv)
def run(self, action: Action) -> bool:
argv = self.argv_for(action)
log.info("Taster fuehrt aus: %s", " ".join(argv))
try:
result = self.runner(argv, check=False, timeout=ACTION_TIMEOUT_S)
except (OSError, subprocess.SubprocessError) as e:
log.warning("Aktion '%s' fehlgeschlagen: %s", action.label, e)
return False
rc = getattr(result, "returncode", 0)
if rc != 0:
log.warning("Aktion '%s' endete mit Rueckgabewert %s", action.label, rc)
return rc == 0
# --- Stationszustand (nur lesen) ----------------------------------------------------
def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _service_active(service_name: str = STATION_SERVICE) -> bool:
try:
result = subprocess.run(["systemctl", "is-active", "--quiet", service_name],
timeout=5, check=False)
except (OSError, subprocess.SubprocessError):
# Ohne systemd (Handlauf) lieber "laeuft" annehmen als das Menue mit
# "Rettung starten" anzubieten, waehrend die Station in Wahrheit laeuft.
return True
return result.returncode == 0
def _read_imageset(path: Path = IMAGESET_FILE) -> str:
"""Der eingestellte Bildsatz, oder IMAGESET_DEFAULT bei fehlender/unlesbarer Datei
bzw. unbekanntem Inhalt - dieselbe Vorgabe wie in server.py und im CLI."""
try:
value = path.read_text().strip()
except OSError:
return IMAGESET_DEFAULT
return value if value in IMAGESET_NAMES else IMAGESET_DEFAULT
def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE,
imageset_path: Path = IMAGESET_FILE) -> dict:
"""Der Zustand, von dem die Menueeintraege abhaengen. Liest dieselben Quellen wie
display.build_state() - RUN_DIR/mode (nicht status.json's "mode"-Feld, siehe dort)
und status.json fuer das zuletzt erkannte Ziel - dupliziert aber keine Logik des
CLI: geschaltet wird ausschliesslich ueber das CLI."""
running = _service_active(service_name)
try:
mode = (run_dir / "mode").read_text().strip()
except OSError:
mode = "recovery"
if mode not in ("recovery", "migration"):
mode = "recovery"
status = _read_json(run_dir / "status.json")
last_seen = status.get("last_seen")
target = last_seen.get("target") if isinstance(last_seen, dict) else None
return {"running": running, "mode": mode,
"target": target if isinstance(target, str) and target else None,
"imageset": _read_imageset(imageset_path)}
# --- Bedienzustand in eine Datei schreiben ------------------------------------------
class UiWriter:
"""Schreibt den Bedienzustand atomar mit 0644 nach UI_FILE - dasselbe Muster und
dieselben Rechte wie status.json/control.json (Temp-Datei im selben Verzeichnis,
Rechte setzen, dann umbenennen). Beides sind Muster, deren Verletzung in diesem
Projekt schon mehrfach Fehler verursacht hat: ohne 0644 kann der Anzeigedienst die
Datei nicht lesen, ohne das Umbenennen sieht er sie halb geschrieben.
Schreibt nur bei Aenderung (die Schleife laeuft alle TICK_S) und wirft nie."""
def __init__(self, path: Path = UI_FILE) -> None:
self.path = Path(path)
self._last: dict | None = None
self._failed_logged = False
def write(self, view: dict) -> bool:
"""True, wenn tatsaechlich geschrieben wurde."""
if view == self._last:
return False
payload = json.dumps(view, ensure_ascii=False, sort_keys=True)
tmp = self.path.with_name("{}.tmp-{}".format(self.path.name, os.getpid()))
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(payload)
os.chmod(tmp, 0o644)
os.replace(tmp, self.path)
except OSError as e:
# Nur einmal protokollieren: die Schleife kaeme sonst alle TICK_S wieder
# hierher und wuerde das Journal fluten. Der Taster bleibt trotzdem
# bedienbar, nur die Rueckmeldung am Panel fehlt.
if not self._failed_logged:
log.warning("Bedienzustand konnte nicht geschrieben werden (%s): %s",
self.path, e)
self._failed_logged = True
try:
tmp.unlink()
except OSError:
pass
return False
self._last = dict(view)
return True
# --- Die Zustandsfuehrung -----------------------------------------------------------
def _countdown(total_s: float, held_s: float) -> int:
"""Ganze Sekunden bis `total_s`, aufgerundet und nie negativ - "noch 6" solange auch
nur eine Zehntelsekunde davon uebrig ist."""
return max(0, int(math.ceil(total_s - held_s)))
class ButtonUI:
"""Die gesamte Bedienlogik - welcher Druck was bedeutet, welche Zeitschwelle was
ausloest, was auf dem Panel steht - als reine Zustandsmaschine ohne Schleife, ohne
time.sleep() und ohne GPIO. Dasselbe Muster wie display.RefreshPlanner, aus demselben
Grund: so laesst sich ein kompletter Bedienverlauf in Millisekunden durchspielen.
Benutzung pro Runde: update(now, pressed) -> Action oder None; eine zurueckgegebene
Action fuehrt der Aufrufer aus und meldet das Ergebnis per done() zurueck. view(now)
liefert jederzeit den Bedienzustand fuer die Anzeige.
`pressed` ist der ENTPRELLTE Pegel (gpiozero, bounce_time), nicht eine Flanke - alle
Schwellen rechnen deshalb gegen den Zeitpunkt der letzten steigenden Flanke. Das ist
auch der Grund, warum ein blockierender CLI-Aufruf mitten in einer Haltegeste nichts
kaputtmacht: nach der Rueckkehr wird derselbe Pegel wieder gegen dieselbe Uhr
gemessen."""
def __init__(self, now: float = 0.0, read_state=station_state) -> None:
self._read_state = read_state
self._pressed = False
self._press_t = now
# Diese Haltegeste hat schon etwas ausgeloest - ein spaeteres Loslassen darf
# dann nicht zusaetzlich als "kurzer Druck" gelten.
self._consumed = False
# 0 = nichts, 1 = Abbruch gefeuert, zweite Stufe (Herunterfahren) laeuft.
self._hold_stage = 0
# In welchem Zusammenhang diese Haltegeste BEGONNEN hat ("idle", "menu",
# "confirm"). Die Haltegeste wird danach beurteilt, NICHT nach dem inzwischen
# erreichten Bildschirm: waehlt man im Menue einen Eintrag (1,5 s), steht danach
# "Bitte warten"/die Rueckfrage - haelt der Bediener einfach weiter, duerfte das
# nicht plotzlich als die Ruhe-Haltegeste gelten und bei 3 s die Station
# abschalten. Genau das tat eine fruehere Fassung.
self._press_screen = "idle"
self.screen = "idle"
self.items: list[Action] = []
self.index = 0
self.pending: Action | None = None
self.deadline: float | None = None
self.busy: Action | None = None
self.result: tuple[str, bool] | None = None
self.result_until: float | None = None
# -- Eingabe ---------------------------------------------------------------------
def update(self, now: float, pressed: bool) -> Action | None:
action = None
if pressed and not self._pressed:
self._pressed = True
self._press_t = now
self._consumed = False
self._press_screen = self.screen if self.screen in ("menu", "confirm") else "idle"
elif pressed and self._pressed:
action = self._held(now)
elif self._pressed and not pressed:
self._pressed = False
held = now - self._press_t
if not self._consumed and held < SHORT_PRESS_MAX_S:
action = self._short(now)
# Loslassen bricht die zweite Stufe (Herunterfahren) jederzeit ab.
self._hold_stage = 0
self._expire(now)
else:
self._expire(now)
if action is not None:
self.busy = action
self.screen = "busy"
self.pending = None
self.deadline = None
return action
def _short(self, now: float) -> Action | None:
self.result = None
self.result_until = None
if self.screen == "menu":
if self.items:
self.index = (self.index + 1) % len(self.items)
self.deadline = now + MENU_TIMEOUT_S
return None
if self.screen == "confirm":
# Abbrechen - zurueck ins Menue, nicht aufs Statusbild: der Bediener ist
# noch am Bedienen und will meist einen anderen Eintrag.
self.pending = None
self.screen = "menu"
self.deadline = now + MENU_TIMEOUT_S
return None
# Ruhe (auch waehrend eine Rueckmeldung steht): Menue oeffnen.
self._open_menu(now)
return None
def _open_menu(self, now: float) -> None:
try:
state = self._read_state()
except Exception as e: # noqa: BLE001 - siehe Modul-Docstring: nie stoeren
log.warning("Stationszustand nicht lesbar, Menue bleibt zu: %s", e)
return
self.items = menu_actions(state)
if not self.items:
return
self.index = 0
self.screen = "menu"
self.deadline = now + MENU_TIMEOUT_S
def _held(self, now: float) -> Action | None:
held = now - self._press_t
# Nach dem Zusammenhang BEIM DRUCK beurteilt, nicht nach dem aktuellen
# Bildschirm - siehe _press_screen.
if self._press_screen == "menu":
if held >= SELECT_HOLD_S and not self._consumed and self.items:
self._consumed = True
return self._select(now, self.items[self.index])
return None
if self._press_screen == "confirm":
if held >= SELECT_HOLD_S and not self._consumed:
self._consumed = True
return self.pending
return None
# Ruhe (oder eine stehende Rueckmeldung/ein gerade gelaufener Abbruch):
# zweistufige Haltegeste.
if self._hold_stage == 1 and held >= SHUTDOWN_HOLD_S and not self._consumed:
self._consumed = True
return action_shutdown()
if self._hold_stage == 0 and held >= CANCEL_HOLD_S:
# Feuert BEI 3 s, nicht erst beim Loslassen - und setzt _consumed NICHT:
# die zweite Stufe muss aus derselben Geste noch erreichbar bleiben.
self._hold_stage = 1
self.result = None
self.result_until = None
return action_cancel()
return None
def _select(self, now: float, action: Action) -> Action | None:
if not action.confirm:
return action
self.pending = action
self.screen = "confirm"
self.deadline = now + CONFIRM_TIMEOUT_S
return None
def _expire(self, now: float) -> None:
if self.result_until is not None and now >= self.result_until:
self.result = None
self.result_until = None
if self.deadline is None or now < self.deadline:
return
if self.screen == "menu":
# Ein Geraet, das im Menue stehenbleibt, soll nicht aussehen, als sei es
# beschaeftigt - zurueck aufs Statusbild.
self.screen = "idle"
self.items = []
self.deadline = None
elif self.screen == "confirm":
# Verfaellt als ABBRUCH - eine Rueckfrage, die von selbst zustimmt, waere
# das Gegenteil einer Rueckfrage.
self.pending = None
self.screen = "idle"
self.items = []
self.deadline = None
def done(self, now: float, action: Action, ok: bool) -> None:
"""Ergebnis einer ausgefuehrten Aktion - setzt die Rueckmeldung fuers Panel."""
self.busy = None
self.screen = "idle"
self.items = []
self.pending = None
self.deadline = None
self.result = (action.done_text if ok else "{}: Fehler".format(action.label), ok)
self.result_until = now + RESULT_S
# -- Ausgabe ---------------------------------------------------------------------
def view(self, now: float) -> dict:
"""Der Bedienzustand, wie ihn der Anzeigedienst zeichnet. "active" sagt ihm, dass
gerade eine Bedienung laeuft (er sieht dann schneller nach)."""
if (self._pressed and self._press_screen == "idle"
and self.screen in ("idle", "busy")):
held = now - self._press_t
if self._hold_stage == 1:
return _view("hold", "STATION AUS",
"Herunterfahren in {} s".format(
_countdown(SHUTDOWN_HOLD_S, held)),
["Weiter halten - Loslassen bricht ab"])
if held >= SHORT_PRESS_MAX_S:
return _view("hold", "HALTEN",
"Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)),
["Weiter halten - Loslassen bricht ab"])
if self.screen == "menu" and self.items:
selected = self.items[self.index]
rest = self.items[self.index + 1:] + self.items[:self.index]
return _view("menu", "MENUE", "> " + selected.label,
[a.label for a in rest] + ["kurz: weiter, halten: waehlen"])
if self.screen == "confirm" and self.pending is not None:
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
details = list(self.pending.confirm_details or [])
details += ["halten (1,5 s): ja", "kurz: nein"]
return _view("confirm", self.pending.confirm_title, self.pending.label,
details)
if self.screen == "busy" and self.busy is not None:
return _view("busy", "BITTE WARTEN", self.busy.label, [])
if self.result is not None:
text, ok = self.result
return _view("result", "FERTIG" if ok else "FEHLER", text, [])
return {"view": "idle", "active": False}
def _view(view: str, title: str, hero: str, details: list[str]) -> dict:
return {"view": view, "title": title, "hero": hero, "details": list(details),
"active": True}
# --- Hardware und Hauptschleife -----------------------------------------------------
def open_button(pin: int = BUTTON_PIN):
"""Der einzige Hardwarezugriff dieses Dienstes: ein Eingangspin. KEIN SPI, kein
Zeichenpin - siehe Modul-Docstring ("GPIO busy").
Entprellt gpiozero selbst (bounce_time): ein Tipper, der kuerzer ist als
DEBOUNCE_S, kommt gar nicht erst als Druck an."""
try:
import gpiozero
except ImportError as e:
raise ButtonUnavailable("gpiozero fehlt ({})".format(e))
try:
return gpiozero.Button(pin, pull_up=True, bounce_time=DEBOUNCE_S)
except Exception as e: # noqa: BLE001
# Bewusst breit: ein belegter Pin kommt je nach Pin-Factory als
# gpiozero.GPIOPinInUse, als roher lgpio.error ('GPIO busy') oder als OSError
# zurueck - keine gemeinsame Oberklasse, die hier greifen wuerde. Alles davon
# heisst dasselbe: kein Taster, also lautlos ohne Fehler beenden, statt eine
# Neustartschleife auszuloesen.
raise ButtonUnavailable("GPIO{} nicht verfuegbar ({})".format(pin, e))
def run_daemon(button=None, ui: "ButtonUI | None" = None, writer: "UiWriter | None" = None,
actions: "CliActions | None" = None, clock=time,
read_state=station_state) -> None:
"""Endlosschleife: Pegel lesen, ButtonUI fragen, eine zurueckgegebene Aktion
ausfuehren, Ergebnis zurueckmelden, Bedienzustand schreiben.
`button`, `ui`, `writer`, `actions` und `clock` sind einschleusbar, damit die
VERDRAHTUNG hier selbst pruefbar ist und nicht nur eine Kopie davon (display.py hatte
genau diesen Fehler: die Tests trieben eine nachgebaute Schleife, und Mutationen an
den echten Zeilen blieben gruen). Im Betrieb bleiben die Vorgaben.
Die Reihenfolge ist wesentlich: der Bedienzustand wird VOR dem (sekundenlangen)
CLI-Aufruf geschrieben, sonst steht auf dem Panel bis zu dessen Ende noch das Menue
und der Bediener haelt das Geraet fuer eingefroren."""
if button is None:
button = open_button()
if writer is None:
writer = UiWriter()
if actions is None:
actions = CliActions()
if ui is None:
ui = ButtonUI(clock.monotonic(), read_state=read_state)
log.info("Taster bereit (GPIO%s), Bedienzustand in %s", BUTTON_PIN, writer.path)
writer.write(ui.view(clock.monotonic()))
while True:
now = clock.monotonic()
action = ui.update(now, bool(button.is_pressed))
if action is not None:
writer.write(ui.view(now))
ok = actions.run(action)
ui.done(clock.monotonic(), action, ok)
writer.write(ui.view(clock.monotonic()))
clock.sleep(TICK_S)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
try:
run_daemon()
except ButtonUnavailable as e:
# GENAU EINE Zeile, dann sys.exit(0) - siehe Modul-Docstring. Kein Fehler, damit
# Restart=on-failure daraus keine Neustartschleife macht und das Journal flutet.
log.info("Taster nicht verfuegbar, beende ohne Fehler: %s", e)
sys.exit(0)
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env bash
# Liefert Firmware-Images und den Stationscode an den Pi aus.
#
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
set -euo pipefail
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
shift || true
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
declare -a FILES=()
if [ $# -gt 0 ]; then
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
# unten).
FULL_SYNC=0
for t in "$@"; do
f="$ROOT/build/$t/ninlight_${t}_ota.img"
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
FILES+=("$f")
done
else
FULL_SYNC=1
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
# kein Zufall.
declare -A SEEN_AT=()
while IFS= read -r f; do
base=$(basename "$f")
if [ -n "${SEEN_AT[$base]:-}" ]; then
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
exit 1
fi
SEEN_AT[$base]=$f
FILES+=("$f")
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
fi
echo "== Images pruefen =="
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
declare -A NINL_VERSION=()
for f in "${FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
# das faellt sonst erst am Geraet auf.
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
# shellcheck disable=SC2086
set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
NINL_VERSION[$target]="$2"
cp "$f" "$STAGE/$base"
done
echo "== Migrationsimages signieren =="
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
# ausgeliefert (Exit-Code bleibt 0).
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
KEY_DEV="$SIGN_DIR/fw_sign.pem"
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
# MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
MIGRATE_COMPLETE=0
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
# einer, darf --delete auf dem Pi nicht laufen.
NOCREDS_COMPLETE=0
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
# dieselbe Variable wie dort und in tools/release.sh).
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
sign_set() {
local src_root=$1 stage_sub=$2 strictness=$3
local complete=1 produced=0
local f base target gz
for f in "${FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
# annimmt.
gz="$src_root/$target/ninlight_${target}_gz.img"
if [ ! -f "$gz" ]; then
if [ "$strictness" = "strict" ]; then
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
fi
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
complete=0
continue
fi
mkdir -p "$STAGE/$stage_sub"
if [ "$HAVE_PROD" -eq 1 ]; then
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
produced=1
fi
if [ "$HAVE_DEV" -eq 1 ]; then
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
produced=1
fi
done
[ "$produced" -eq 1 ] || return 1
local VERSION_ARGS=()
local t
for t in "${!NINL_VERSION[@]}"; do
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
done
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY'
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
migrate_dir = sys.argv[1]
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
entries = []
for name in sorted(os.listdir(migrate_dir)):
if name.endswith("_prod.img"):
target, variant = name[: -len("_prod.img")], "prod"
elif name.endswith("_dev.img"):
target, variant = name[: -len("_dev.img")], "dev"
else:
continue
with open(os.path.join(migrate_dir, name), "rb") as fh:
data = fh.read()
entries.append({
"target": target,
"variant": variant,
"file": name,
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"ninlight_version": version_map.get(target, ""),
})
manifest = {
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"images": entries,
}
versions = {e["ninlight_version"] for e in entries}
if len(versions) == 1:
(version,) = versions
if version:
manifest["ninlight_version"] = version
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
json.dump(manifest, fh, indent=2, sort_keys=True)
fh.write("\n")
PY
[ "$complete" -eq 1 ]
}
HAVE_PROD=0
HAVE_DEV=0
if [ -d "$SIGN_DIR" ]; then
if [ -f "$KEY_PROD" ]; then
HAVE_PROD=1
else
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2
fi
if [ -f "$KEY_DEV" ]; then
HAVE_DEV=1
else
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
fi
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
BOTH_KEYS=0
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
# und die Station beide vorhalten soll (der Bediener schaltet per
# "ninlight-station bildsatz" bzw. am Taster um):
#
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
#
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
echo " Bildsatz migrate-wlan (aus build/)"
if sign_set "$ROOT/build" migrate-wlan strict; then
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
fi
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
else
if [ ! -d "$STAGE/migrate-nocreds" ]; then
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
fi
fi
else
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
fi
else
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
fi
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
# dagegen ungeprueft, was in build-nocreds/ lag.
#
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
# Fehler. Lieber ein abgebrochener Deploy.
#
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
if [ -d "$STAGE/migrate-nocreds" ]; then
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1
import os
import re
import sys
stage, local = sys.argv[1], sys.argv[2]
text = open(local, encoding="utf-8", errors="replace").read()
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
# haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
needles = []
for name in SECRETS:
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
if m and m.group(1) not in PUBLIC:
needles.append((name, m.group(1).encode()))
hits = []
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
with open(path, "rb") as fh:
data = fh.read()
for name, needle in needles:
if needle in data:
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
if hits:
sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
sys.exit(1)
print(" sauber (%d geprueft)" % len(needles))
CHECKPY
else
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
fi
fi
echo "== uebertragen =="
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer
# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde
# rsync ein solches Update dann stillschweigend ueberspringen.
#
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
# ausgelieferte Verzeichnis.
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
"$HOST:/tmp/"
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
# __pycache__ dieses Rechners.
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" <<'REMOTE'
set -e
FULL_SYNC=$1
MIGRATE_COMPLETE=$2
NOCREDS_COMPLETE=$3
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
sudo install -d -m 0755 "$ROOT_DIR/images"
if [ "$FULL_SYNC" -eq 1 ]; then
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
REMOVED=""
if [ -n "$OLD_LIST" ]; then
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
fi
if [ -n "$REMOVED" ]; then
echo " voller Abgleich - folgende Images werden entfernt:"
echo "$REMOVED" | sed 's/^/ /'
else
echo " voller Abgleich - keine Images werden entfernt"
fi
# migrate/ wird gleich in einem eigenen Schritt behandelt (siehe unten) - hier
# ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne
# Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht
# existiert.
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
else
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
fi
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
# beschreibt (beide Schluessel, alle Ziele) und --delete deshalb erlaubt ist.
#
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
# statt beim Ersetzen).
sync_migrate_set() {
set=$1
complete=$2
if [ -d "/tmp/ninlight-images/$set" ]; then
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
else
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
import json
import sys
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
with open(new_manifest_path) as fh:
new_manifest = json.load(fh)
if not isinstance(new_manifest, dict):
new_manifest = {}
try:
with open(manifest_path) as fh:
old_manifest = json.load(fh)
except (OSError, ValueError):
old_manifest = {}
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
# Stationscode zurueck.
if not isinstance(old_manifest, dict):
old_manifest = {}
old_images = old_manifest.get("images")
if not isinstance(old_images, list):
old_images = []
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
new_images = new_manifest.get("images")
if not isinstance(new_images, list):
new_images = []
for e in new_images:
if isinstance(e, dict):
by_key[(e.get("target"), e.get("variant"))] = e
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
merged = {
"built": new_manifest.get("built"),
"images": merged_images,
}
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
# Fall, dass wirklich alle Eintraege uebereinstimmen.
versions = {e.get("ninlight_version") for e in merged_images}
if len(versions) == 1:
(version,) = versions
if isinstance(version, str) and version:
merged["ninlight_version"] = version
with open(manifest_path, "w") as fh:
json.dump(merged, fh, indent=2, sort_keys=True)
fh.write("\n")
MERGE
fi
fi
else
# Weder "kein Schluesselverzeichnis" noch "Verzeichnis vorhanden, aber leer" laesst
# sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von
# diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau;
# diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit).
echo " keine Migrationsimages fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
fi
}
# Beide Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem Rechner
# vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der andere auf
# dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der fehlt, und die
# Station liefert in dieser Stellung sichtbar nichts aus.
sync_migrate_set migrate-wlan "$MIGRATE_COMPLETE"
sync_migrate_set migrate-nocreds "$NOCREDS_COMPLETE"
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
if [ -d "$ROOT_DIR/images/migrate" ]; then
sudo rm -rf "$ROOT_DIR/images/migrate"
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
fi
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
sudo install -d -m 0755 "$ROOT_DIR/display"
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
sudo install -d -m 0755 "$ROOT_DIR/button"
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service
sudo systemctl daemon-reload
if systemctl is-active --quiet ninlight-station; then
if sudo systemctl restart ninlight-station; then
echo " Dienst neu gestartet"
else
echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2
exit 1
fi
else
echo " Dienst laeuft nicht (Station ist aus)"
fi
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
# uebersehen.
if systemctl is-active --quiet ninlight-display; then
if sudo systemctl restart ninlight-display; then
echo " Anzeigedienst neu gestartet"
else
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
fi
else
echo " Anzeigedienst laeuft nicht"
fi
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
if systemctl is-active --quiet ninlight-button; then
if sudo systemctl restart ninlight-button; then
echo " Tasterdienst neu gestartet"
else
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
fi
else
echo " Tasterdienst laeuft nicht"
fi
REMOTE
echo "fertig."
+46
View File
@@ -0,0 +1,46 @@
# Herkunft der Panel-Treiber
`epd2in13_V2.py` und `epd2in13_V3.py` sind unveraendert aus dem offiziellen
Waveshare-Repository uebernommen:
- Quelle: https://github.com/waveshareteam/e-Paper
- Pfad: `RaspberryPi_JetsonNano/python/lib/waveshare_epd/epd2in13_V2.py` bzw.
`.../epd2in13_V3.py`
- Commit: `85802dee4b938212b0ae069a34492c54a3d99878` (2023-02-15), abgerufen am
2026-09-13 per `git log --follow` auf diesen beiden Dateien im Repository.
- `epd2in13_V2.py`: Treiber-Version laut Dateikopf V4.0 (2019-06-20).
- `epd2in13_V3.py`: Treiber-Version laut Dateikopf V1.2 (2022-08-09).
## Was geaendert wurde
Ausschliesslich die Importzeile:
```
from . import epdconfig
```
wurde zu
```
import epdconfig
```
weil `display/` in diesem Repository kein Python-Package ist (kein `__init__.py`,
`sys.path`-Import wie bei `products.py` in `server.py`), sondern flach importiert wird -
sowohl von `display.py` als auch von `tools/station/tests/test_render.py`. Ein relativer
Import wuerde in dieser Umgebung mit `ImportError: attempted relative import with no known
parent package` fehlschlagen.
Init-Sequenz, LUT-Tabellen, Timings und der Urheberrechtsvermerk der Originaldateien
bleiben unangetastet. Ein kurzer Herkunftsvermerk mit denselben Angaben wie oben steht
zusaetzlich im Kopf jeder der beiden Dateien.
## Was NICHT uebernommen wurde
`epdconfig.py` in diesem Verzeichnis ist eine eigene Neuimplementierung (siehe deren
Modul-Docstring), keine Uebernahme aus dem Waveshare-Repository: Waveshares eigenes
`epdconfig.py` fuer den Raspberry Pi setzt `RPi.GPIO` voraus, das auf Debian 13 (trixie)
mit Python 3.13 nicht mehr baut/importiert. Unser `epdconfig.py` implementiert dieselbe
von den beiden Treibern erwartete Schnittstelle (Pin-Konstanten, `digital_write`,
`digital_read`, `delay_ms`, `spi_writebyte`, `spi_writebyte2`, `module_init`,
`module_exit`) gegen `gpiozero`/`spidev`, die auf dem Ziel-Pi vorhanden sind.
+689
View File
@@ -0,0 +1,689 @@
#!/usr/bin/env python3
"""Daemon fuer die E-Paper-Anzeige der ninlight-Station: alle 5 s status.json,
control.json und die Konfigurationsdatei lesen, daraus einen Text zeichnen und - nur bei
Aenderung - aufs Panel bringen.
Panel: Waveshare 2,13" (250x122 px), SPI. Ob das verbaute Panel Revision V2 oder V3 ist,
laesst sich am Geraet selbst nicht auslesen - die Revision kommt deshalb aus der
Umgebungsvariable EPD_REVISION (Vorgabe "V3", von setup_pi.sh in
/etc/ninlight-station.conf geschrieben und von der systemd-Unit als Environment=
weitergereicht).
So bestimmst du die richtige Revision empirisch: erst
EPD_REVISION=V3 python3 display.py --test
laufen lassen und das Panel ansehen. Zeigt es ein sauberes, seitenrichtiges Testbild ohne
Geisterbilder/Streifen, ist V3 richtig. Zeigt es nichts, ein invertiertes/verschobenes Bild
oder haengt beim SWRESET (ReadBusy() kehrt nie zurueck), mit
EPD_REVISION=V2 python3 display.py --test
wiederholen. Zwischen den beiden Versuchen kurz die Stromversorgung des Panels trennen
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
lassen).
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
# display.py kann aber von ueberall aus gestartet werden (die systemd-Unit ruft es mit
# vollem Pfad auf).
sys.path.insert(0, str(Path(__file__).resolve().parent))
import render # noqa: E402
from epdconfig import DisplayUnavailable # noqa: E402
log = logging.getLogger("ninlight-display")
# Dieselben Umgebungsvariablen wie tools/station/ninlight-station verwendet, damit Tests
# und Handbetrieb ohne echtes /run bzw. /etc auskommen.
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
CONF_PATH = Path(os.environ.get("NINLIGHT_STATION_CONF", "/etc/ninlight-station.conf"))
SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
# Bedienzustand des Tasterdienstes (tools/station/button/button.py). NUR GELESEN: der
# Tasterdienst fasst den SPI-Bus nicht an und dieser Dienst kein GPIO23 - zwei Prozesse
# auf denselben Leitungen enden in "lgpio.error: 'GPIO busy'". Die Datei liegt im
# RuntimeDirectory DES TASTERDIENSTES (nicht in RUN_DIR): RUN_DIR ist das
# 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
# bis zu 5 s ohne Reaktion). Die Schleife tickt deshalb in diesem Takt und sieht bei
# JEDEM Tick in der Bedienzustandsdatei nach - auch im Ruhezustand, sonst waere
# ausgerechnet der erste Druck (der das Menue oeffnet) der langsamste. Das kostet eine
# kleine Dateilesung je halber Sekunde; der teure Teil des Zustands (build_state() mit
# seinem "systemctl is-active"-Fork) bleibt beim 5-Sekunden-Takt.
#
# Am Panel gemessen kommt zu dieser halben Sekunde noch der Teilaufbau (rund 0,3 s) -
# also grob 0,5-0,8 s je Druck. Das ist spuerbar, aber bedienbar: die Geste ist
# "druecken und hinsehen", nicht "tippen wie auf einer Tastatur", und die Haltegesten
# (1,5/3/10 s) liegen alle weit ueber dieser Verzoegerung.
UI_POLL_INTERVAL_S = 0.5
# E-Paper altert mit jedem Bildaufbau - ein Vollaufbau nur bei Moduswechsel und
# mindestens alle 30 Minuten, sonst Teilaktualisierung (siehe Modul-Docstring der
# Panel-Treiber: haeufige Teilaktualisierungen ohne gelegentlichen Vollaufbau lassen
# Geisterbilder stehen).
FULL_REFRESH_INTERVAL_S = 30 * 60
# Panel erst schlafen legen, wenn seit dem letzten Bildaufbau schon eine Weile nichts
# mehr passiert ist - NICHT nach jedem einzelnen Bild (Fixrunde 2, N1/N2/N3: das liess
# V3 beim naechsten Teilaufbau abstuerzen und machte V2s Deep-Sleep-Aufwachsequenz
# unbrauchbar, siehe Panel-Klasse). Migrationsereignisse kommen oft in kurzen Buendeln
# (mehrere Lampen kurz nacheinander) - das Panel dafuer jedesmal schlafen zu legen und
# wieder aufzuwecken waere nur zusaetzlicher Verschleiss ohne Nutzen fuer die
# Bias-Alterung, die I4 eigentlich vermeiden soll. 10 Minuten ohne Aenderung sind lang
# genug, dass ein Buendel zusammenhaengender Ereignisse das Panel nicht staendig
# schlafen und wieder aufwachen laesst, aber kurz genug, dass es zwischen zwei
# tatsaechlich seltenen Ereignissen (z.B. stundenlanger Rettungsmodus ohne Anfrage)
# nicht dauerhaft unter Bias steht.
SLEEP_AFTER_IDLE_S = 10 * 60
def _read_json(path: Path) -> dict:
"""Geparster Inhalt von `path`, oder {} wenn er fehlt/unlesbar/kaputt/kein Objekt
ist - status.json und control.json koennen jederzeit in genau diesem Zustand sein
(frisch aufgesetzter Pi, Schreibfehler mitten im Schreiben)."""
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _read_conf(path: Path) -> dict[str, str]:
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
beim Nachtragen fehlender Schluessel ausdruecklich auf "^(export[[:space:]]+)?KEY=" -
ein bereits vorhandenes "export SSID=..." muss also erkannt werden, nicht nur die
unpraefigierte Form). Kein echtes Shell-Sourcing (das CLI braucht das fuer $- und
`-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
values: dict[str, str] = {}
try:
text = path.read_text()
except OSError:
return values
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export ") or line.startswith("export\t"):
line = line[len("export"):].lstrip()
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
if key:
values[key] = value
return values
def _read_autooff_minutes(run_dir: Path, now: float | None = None):
"""Restzeit des optionalen Zeitschalters in ganzen Minuten (aufgerundet), oder None,
wenn gerade keiner laeuft.
Quelle ist RUN_DIR/autooff.json, die "ninlight-station --for/renew" schreibt und
"hold"/"off" wieder loescht (siehe dort). Ein Zeitschalter, den man am Panel nicht
sieht, waere schlimmer als keiner: man glaubt, die Station laeuft, und sie ist
laengst aus.
"deadline" ist eine Unix-Zeit (der Zeitschalter selbst haengt an systemds monotoner
Uhr, die Anzeige rechnet in Wanduhrzeit) - eine Zeitverstellung verschiebt also die
angezeigte Restzeit, nicht das tatsaechliche Abschalten. Rundet auf: solange noch
eine Sekunde uebrig ist, steht dort "1 min" und nicht "0 min"."""
data = _read_json(run_dir / "autooff.json")
deadline = data.get("deadline")
if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
return None
remaining_s = deadline - (time.time() if now is None else now)
if remaining_s <= 0:
# Der Termin ist da (oder der Lauf verschiebt gerade) - "0 min" ist hier die
# ehrliche Auskunft, nicht das Verschwinden der Zeile.
return 0
whole, rest = divmod(remaining_s, 60)
return int(whole) + (1 if rest else 0)
def _read_mode_file(run_dir: Path) -> str:
"""Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die
Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert
wie in server.py:resolve_mode()."""
try:
value = (run_dir / "mode").read_text().strip()
except OSError:
return "recovery"
return value if value in ("recovery", "migration") else "recovery"
# Nur beim ersten Mal protokollieren, dass systemctl selbst nicht erreichbar war (siehe
# _service_active) - das passiert bei jedem der Aufrufe alle 5 s erneut, und eine Zeile
# alle 5 s wuerde das Journal fluten, obwohl es sich nie aendert (Fixrunde 1, Kleinigkeit).
_service_active_fallback_logged = False
def _service_active(service_name: str) -> bool:
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen -
ein reines Lesen von status.json wuerde nach "off" also faelschlich weiter den letzten
Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
global _service_active_fallback_logged
try:
result = subprocess.run(
["systemctl", "is-active", "--quiet", service_name],
timeout=5, check=False)
return result.returncode == 0
except (OSError, subprocess.SubprocessError):
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen.
if not _service_active_fallback_logged:
log.warning("systemctl nicht erreichbar - Anzeige raet den Modus anhand von "
"RUN_DIR/mode statt der echten Dienstaktivitaet")
_service_active_fallback_logged = True
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,
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
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
zuletzt geschrieben haben und unabhaengig davon verfuegbar, ob/wann der Serverprozess
seit dem letzten Start schon einmal status.json geschrieben hat. status.json selbst
liefert nur served/recent/last_seen; sein "mode"-Feld wird bewusst NICHT als
Modusquelle verwendet (Fixrunde 1: die urspruengliche Fassung hatte den Vorrang
umgekehrt).
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.
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", "imageset": imageset, "imageset_ok": imageset_ok}
mode = _read_mode_file(run_dir)
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}
if mode == "recovery":
state["ssid"] = conf.get("SSID")
else: # migration
state["ssid"] = conf.get("MIGRATE_SSID")
state["last_seen"] = status.get("last_seen")
control = _read_json(run_dir / "control.json")
phase = control.get("phase")
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
state["armed_target"] = control.get("armed_target")
# Der Zeitschalter ist optional und gilt in beiden Modi gleichermassen - ohne ihn
# bleibt das Feld weg und render.blocks() zeichnet keine Zeile dafuer.
autooff_minutes = _read_autooff_minutes(run_dir)
if autooff_minutes is not None:
state["autooff_minutes"] = autooff_minutes
return state
def read_ui(ui_path: Path = UI_FILE) -> dict:
"""Der Bedienzustand des Tasterdienstes, oder {} wenn gerade keine Bedienung laeuft
(Datei fehlt, ist unlesbar, halb geschrieben, aus einer aelteren Fassung oder meldet
ausdruecklich "idle").
Jedes Feld wird defensiv gelesen: die Datei kommt aus einem FREMDEN Prozess, der
abgestuerzt, aelter oder neuer sein kann - ein Fehler hier darf die Anzeige nicht
aufhalten und schon gar nicht eine Migration stoeren."""
data = _read_json(ui_path)
view = data.get("view")
if not data.get("active") or not isinstance(view, str) or view == "idle":
return {}
return data
def _load_epd_module(revision: str):
"""Laedt den passenden Panel-Treiber. Nicht ueber ein Dict aus Modulnamen, damit ein
statischer Import-Checker (und ein Mensch, der `grep -n epd2in13` tippt) beide
moeglichen Importe findet."""
if revision == "V2":
import epd2in13_V2 as epd_module
elif revision == "V3":
import epd2in13_V3 as epd_module
else:
raise DisplayUnavailable(
f"unbekannte EPD_REVISION '{revision}' - erwartet 'V2' oder 'V3'")
return epd_module
class Panel:
"""Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
(FULL_UPDATE/PART_UPDATE), epd2in13_V3.init() braucht keins.
Verfolgt zusaetzlich selbst, ob das Panel gerade "wach" ist (Register initialisiert,
Referenzpuffer fuer Teilaufbauten bekannt) oder zuletzt sleep() gerufen wurde
(Fixrunde 2, N1/N3): epd.sleep() schickt das Panel in einen Zustand, aus dem heraus
KEINE Revision direkt einen Teilaufbau vertraegt - V2s sleep() ist Deep Sleep Mode 2
(RAM/Register verworfen; ein nachfolgendes init(PART_UPDATE) setzt nur einen Teil der
Register, die ein Teilaufbau braucht - 0x01/0x11/0x44/0x45/0x4E/0x4F setzt
ausschliesslich der FULL_UPDATE-Zweig), V3s sleep() ruft epdconfig.module_exit() und
schliesst damit die GPIO-Pins, die epd2in13_V3.displayPartial() als Erstes anfasst
(roher AttributeError auf ein geschlossenes Pin-Objekt). Ein Teilaufbau nach dem
Schlafen wird deshalb hier, nicht beim Aufrufer, transparent zu einem Vollaufbau
aufgewertet - unabhaengig davon, in welcher Reihenfolge draw_full()/draw_partial()/
sleep() aufgerufen werden.
draw_full() (und clear(), Fixrunde 3, F2) verwendet fuer BEIDE Revisionen
displayPartBaseImage() statt display()/Clear() (Fixrunde 2, N2): das schreibt beide
RAM-Puffer des Controllers (0x24 UND 0x26) und legt damit erst die Referenz an, gegen
die ein nachfolgender Teilaufbau diffen kann - display() allein haette nur 0x24
beschrieben (und Clear() ebenso - bei V2 ist das 0x26-Schreiben in Clear() sogar
auskommentiert) und den ersten Teilaufbau danach gegen einen veralteten/undefinierten
Puffer laufen lassen. self._awake wird deshalb nur dort auf True gesetzt, wo
tatsaechlich ein gueltiger Referenzpuffer entstanden ist - vorher (Fixrunde 2) setzte
clear() es, obwohl es nur epd.Clear() (kein 0x26) rief, und die im Klassen-Docstring
behauptete Invariante stimmte nach clear() nicht."""
def __init__(self, revision: str) -> None:
self.revision = revision
self._epd_module = _load_epd_module(revision)
self.epd = self._epd_module.EPD()
# Vor dem ersten clear()/draw_full() ist nichts initialisiert - siehe Klassen-
# Docstring, warum draw_partial() das genauso wie ein sleep() davor behandeln
# muss (beides: kein gueltiger Referenzpuffer, keine vollstaendige Registerlage).
self._awake = False
def _full_init(self) -> None:
if self.revision == "V2":
self.epd.init(self.epd.FULL_UPDATE)
else:
self.epd.init()
def _blank_buffer(self) -> list[int]:
"""Ein reiner Weiss-Puffer in der Groesse, die epd.getbuffer() fuer ein
vollformatiges Bild liefern wuerde - dieselbe Formel wie in epd.Clear() selbst
(siehe epd2in13_V2.py/epd2in13_V3.py), nur ohne ueber Pillow ein echtes weisses
Bild zu erzeugen."""
width, height = self.epd.width, self.epd.height
linewidth = width // 8 if width % 8 == 0 else width // 8 + 1
return [0xFF] * (linewidth * height)
def clear(self) -> None:
self._full_init()
# displayPartBaseImage() statt epd.Clear() (Fixrunde 3, F2): dieselbe sichtbare
# Wirkung (Panel wird weiss), aber schreibt zusaetzlich den 0x26-Referenzpuffer,
# den ein nachfolgender draw_partial() braucht - epd.Clear() allein wuerde
# self._awake=True vortaeuschen, ohne die im Klassen-Docstring beschriebene
# Invariante wirklich herzustellen.
self.epd.displayPartBaseImage(self._blank_buffer())
self._awake = True
def draw_full(self, image) -> None:
buf = self.epd.getbuffer(image)
self._full_init()
self.epd.displayPartBaseImage(buf)
self._awake = True
def draw_partial(self, image) -> bool:
"""Zeichnet teilweise - oder, falls das Panel nicht wach ist, vollstaendig.
Gibt zurueck, ob intern zu einem Vollaufbau aufgewertet wurde (Fixrunde 3, F4):
der Aufrufer muss das wie einen regulaeren Vollaufbau behandeln (z.B. fuer die
30-Minuten-Uhr in run_daemon()), sonst kommt der naechste dortige Vollaufbau zu
frueh und altert das Panel zusaetzlich, ohne dass ein Teilaufbau ueberhaupt
stattgefunden haette."""
if not self._awake:
# Siehe Klassen-Docstring (Fixrunde 2, N1/N3): ein Teilaufbau direkt nach dem
# Schlafen (oder vor dem allerersten Vollaufbau) ist auf keiner Revision eine
# gueltige Sequenz - stattdessen einen vollwertigen Vollaufbau ausfuehren.
self.draw_full(image)
return True
buf = self.epd.getbuffer(image)
if self.revision == "V2":
self.epd.init(self.epd.PART_UPDATE)
self.epd.displayPartial(buf)
return False
def sleep(self) -> None:
# Idempotent: ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft,
# wuerde sonst versuchen, ueber bereits von module_exit() geschlossene GPIO-Pins
# SPI-Befehle zu schicken (derselbe AttributeError-Mechanismus wie N1, nur
# selbst herbeigefuehrt statt durch einen Teilaufbau).
if not self._awake:
return
self.epd.sleep()
self._awake = False
class RefreshPlanner:
"""Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann
geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne
Hardware.
Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch
ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die
Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung
in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde.
Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen
(siehe test_render.py, _run_schedule()).
Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und
das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert
nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat).
Aktionen:
"full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich)
"partial" - Teilaktualisierung (Inhalt hat sich geaendert)
"sleep" - Panel schlafen legen (laenger nichts passiert)
"idle" - nichts tun
"""
_NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
def __init__(self, now: float,
full_refresh_interval_s: float | None = None,
sleep_after_idle_s: float | None = None) -> None:
# Bewusst None statt der Modulkonstanten als Vorgabewert (Fixrunde 5): ein
# Default-Argument wird zur Importzeit ausgewertet, ein spaeteres
# monkeypatch.setattr(display, "FULL_REFRESH_INTERVAL_S", ...) haette dann keine
# Wirkung mehr auf neu gebaute Planer - die alte Schleife las die Konstanten pro
# Runde. Die Properties unten lesen sie weiterhin bei jeder Entscheidung.
self._full_refresh_interval_s = full_refresh_interval_s
self._sleep_after_idle_s = sleep_after_idle_s
self.last_lines: list[str] | None = None
self.last_mode: object = self._NO_MODE
self.last_full = now
self.last_draw = now
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. full_refresh_interval_s
# existiert nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten
# auszugleichen - ohne mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt
# es nichts auszugleichen, und ein Vollaufbau des UNVERAENDERTEN Bildes waere
# reine, nutzlose Alterung (Fixrunde 3, F3): ohne diese Absicherung entstand im
# eingeschwungenen Ruhezustand ein endloser 10-Minuten-schlafen/10-Minuten-wach-
# Zyklus (33% Bias-Anteil) rein aus der 30-Minuten-Uhr, obwohl sich der Inhalt nie
# aenderte.
#
# Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1):
# das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich
# damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in
# 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild
# ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen
# partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich
# ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal
# kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt
# wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen
# einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal,
# wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die
# das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll
# aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht
# weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen
# Termin.
self.partials_since_full = 0
self.asleep = False
@property
def full_refresh_interval_s(self) -> float:
return (FULL_REFRESH_INTERVAL_S if self._full_refresh_interval_s is None
else self._full_refresh_interval_s)
@property
def sleep_after_idle_s(self) -> float:
return (SLEEP_AFTER_IDLE_S if self._sleep_after_idle_s is None
else self._sleep_after_idle_s)
def decide(self, now: float, lines: list[str], mode: object) -> str:
"""Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*()
zurueckgemeldet."""
mode_changed = mode != self.last_mode
stale = (self.partials_since_full > 0
and (now - self.last_full) >= self.full_refresh_interval_s)
if lines != self.last_lines or mode_changed or stale:
return "full" if (mode_changed or stale) else "partial"
if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s:
# Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben -
# e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel
# deutlich weniger).
return "sleep"
return "idle"
def record_draw(self, now: float, lines: list[str], mode: object,
was_full: bool) -> None:
"""`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3,
F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel
zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde."""
self.last_lines = lines
self.last_mode = mode
self.last_draw = now
self.asleep = False
if was_full:
self.last_full = now
self.partials_since_full = 0
else:
self.partials_since_full += 1
def record_sleep(self) -> None:
"""Kein `now`-Parameter (Fixrunde 5): das Einschlafen verschiebt weder die
30-Minuten-Uhr noch last_draw - es merkt sich nur, dass ein weiteres
panel.sleep() nichts mehr zu tun haette."""
self.asleep = True
def _test_image(revision: str):
from PIL import Image, ImageDraw, ImageFont
width, height = 250, 122
image = Image.new("1", (width, height), 255)
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, width - 1, height - 1), outline=0)
draw.text((4, 4), "ninlight-display Test", font=ImageFont.load_default(), fill=0)
draw.text((4, 20), f"EPD_REVISION={revision}", font=ImageFont.load_default(), fill=0)
draw.line((4, 40, width - 4, 40), fill=0)
# Vier Kaestchen, abwechselnd gefuellt/leer - hilft, Kontrast/Inversion am
# angeschlossenen Panel auf einen Blick zu beurteilen.
for i in range(4):
x0 = 4 + i * 20
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
return image
def run_test(revision: str) -> None:
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
von V3 unterscheidet."""
panel = Panel(revision)
panel.clear()
panel.draw_full(_test_image(revision))
panel.sleep()
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
def run_final(revision: str, kind: str) -> None:
"""Zeichnet den Abschlussbildschirm ("poweroff"/"reboot") und legt das Panel schlafen.
Das ist das Bild, das nach dem Herunterfahren DAUERHAFT stehen bleibt - E-Paper ist
bistabil, es haelt sein Bild auch ohne Strom. Bewusst ein VOLLAUFBAU: ein Teilaufbau
liesse Reste des vorigen Bildes stehen, und dieses eine Bild ueberdauert alle anderen.
Aufgerufen wird das ausschliesslich ueber "ninlight-station display final <kind>" aus
einer systemd-Unit heraus, und zwar ERST, nachdem der Anzeigedienst gestoppt ist - er
haelt sonst die GPIO-/SPI-Leitungen ("lgpio.error: 'GPIO busy'"). Diese Uebergabe
macht das CLI (display_final()), nicht diese Funktion."""
panel = Panel(revision)
panel.clear()
panel.draw_full(render.compose({"mode": "final", "final": kind}))
panel.sleep()
log.info("Abschlussbild gezeichnet (%s, EPD_REVISION=%s)", kind, revision)
def run_clear(revision: str) -> None:
panel = Panel(revision)
panel.clear()
panel.sleep()
log.info("Panel geleert (EPD_REVISION=%s)", revision)
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
read_state=build_state, read_ui=read_ui) -> None:
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
POLL_INTERVAL_S Sekunden den Stationszustand einlesen und die von RefreshPlanner
entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik zur Panelalterung
mehr (Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne
Schleife, ohne time.sleep() und ohne Hardware pruefbar.
Die zwei Takte: der teure Teil (build_state() forkt fuer "systemctl is-active")
bleibt beim 5-Sekunden-Takt, der billige (eine kleine Datei lesen) laeuft in
UI_POLL_INTERVAL_S, damit eine Bedienung am Taster ueberhaupt benutzbar ist. Ein
laufender Bedienzustand ERSETZT das Statusbild (render.blocks() zweigt dafuer ganz
vorne ab); der Modus bleibt derselbe, sodass das Bild ueber Teilaufbauten kommt und
ein Tastendruck dem Panel keinen Vollaufbau kostet.
`clock` (braucht monotonic() und sleep()), `panel`, `read_state` und `read_ui` sind
einschleusbar, damit die VERDRAHTUNG hier - welche Aktion welche Panel-Methode ruft,
dass die Selbst-Aufwertung von draw_partial() zurueckgemeldet wird, dass das
Einschlafen zurueckgemeldet wird, und die Reihenfolge decide -> ausfuehren ->
zurueckmelden - selbst pruefbar ist und nicht nur eine Kopie davon (Fixrunde 5, N2:
alle drei Mutationen an genau diesen Zeilen blieben vorher gruen, weil der Test eine
nachgebaute Schleife statt dieser hier trieb). Im Betrieb bleiben die Vorgaben: echte
Uhr, echtes Panel, echtes build_state()/read_ui()."""
if panel is None:
panel = Panel(revision)
panel.clear()
planner = RefreshPlanner(clock.monotonic())
base_state = read_state()
last_state_poll = clock.monotonic()
while True:
now = clock.monotonic()
if now - last_state_poll >= POLL_INTERVAL_S:
base_state = read_state()
last_state_poll = now
ui = read_ui()
if ui:
state = dict(base_state)
state["ui"] = ui
else:
state = base_state
current_lines = render.lines(state)
mode = state.get("mode")
action = planner.decide(now, current_lines, mode)
if action == "full":
panel.draw_full(render.compose(state))
planner.record_draw(now, current_lines, mode, was_full=True)
elif action == "partial":
# Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das
# Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck.
upgraded = panel.draw_partial(render.compose(state))
planner.record_draw(now, current_lines, mode, was_full=upgraded)
elif action == "sleep":
panel.sleep()
planner.record_sleep()
clock.sleep(UI_POLL_INTERVAL_S)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="ninlight E-Paper-Anzeige")
group = parser.add_mutually_exclusive_group()
group.add_argument("--test", action="store_true", help="Testbild zeichnen und beenden")
group.add_argument("--clear", action="store_true", help="Panel leeren und beenden")
group.add_argument("--final", choices=sorted(render.FINAL_LABELS),
help="Abschlussbild zeichnen und beenden (beim Herunterfahren "
"bzw. Neustart; die Texte unterscheiden sich)")
args = parser.parse_args()
revision = os.environ.get("EPD_REVISION", "V3")
try:
if args.test:
run_test(revision)
elif args.clear:
run_clear(revision)
elif args.final:
run_final(revision, args.final)
else:
run_daemon(revision)
except (DisplayUnavailable, ImportError) as e:
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
# Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
# Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
# erst per "apt install python3-pil" nachkommt (Fixrunde 1, C4): render.compose()
# importiert Pillow erst hier, ein fehlendes Paket zeigt sich also erst als
# ModuleNotFoundError (eine ImportError-Unterklasse) beim ersten Zeichnen, nicht
# schon beim Start. Restart=on-failure (statt always) in der Unit greift hier
# nicht, weil 0 kein Fehlschlag ist; ein spaeter angestecktes Panel bzw.
# nachinstalliertes Paket braucht also einen manuellen/zeitgesteuerten Neustart
# der Unit, nicht eine Neustartschleife.
log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
sys.exit(0)
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()
+324
View File
@@ -0,0 +1,324 @@
# *****************************************************************************
# * | File : epd2in13_V2.py
# * | Author : Waveshare team
# * | Function : Electronic paper driver
# * | Info :
# *----------------
# * | This version: V4.0
# * | Date : 2019-06-20
# # | Info : python demo
# -----------------------------------------------------------------------------
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documnetation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# --- Herkunftsvermerk (ninlight-firmware) ---------------------------------
# Unveraendert uebernommen aus waveshareteam/e-Paper (RaspberryPi_JetsonNano/
# python/lib/waveshare_epd/epd2in13_V2.py), Commit 85802dee (2023-02-15).
# Einzige Aenderung: 'from . import epdconfig' -> 'import epdconfig', damit das
# Modul flach importierbar ist (kein Package). Alles andere - Init-Sequenz, LUTs,
# Timings - unveraendert. Details: tools/station/display/HERKUNFT.md
# -----------------------------------------------------------------------------
import logging
import epdconfig
# Display resolution
EPD_WIDTH = 122
EPD_HEIGHT = 250
logger = logging.getLogger(__name__)
class EPD:
def __init__(self):
self.reset_pin = epdconfig.RST_PIN
self.dc_pin = epdconfig.DC_PIN
self.busy_pin = epdconfig.BUSY_PIN
self.cs_pin = epdconfig.CS_PIN
self.width = EPD_WIDTH
self.height = EPD_HEIGHT
FULL_UPDATE = 0
PART_UPDATE = 1
lut_full_update= [
0x80,0x60,0x40,0x00,0x00,0x00,0x00, #LUT0: BB: VS 0 ~7
0x10,0x60,0x20,0x00,0x00,0x00,0x00, #LUT1: BW: VS 0 ~7
0x80,0x60,0x40,0x00,0x00,0x00,0x00, #LUT2: WB: VS 0 ~7
0x10,0x60,0x20,0x00,0x00,0x00,0x00, #LUT3: WW: VS 0 ~7
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT4: VCOM: VS 0 ~7
0x03,0x03,0x00,0x00,0x02, # TP0 A~D RP0
0x09,0x09,0x00,0x00,0x02, # TP1 A~D RP1
0x03,0x03,0x00,0x00,0x02, # TP2 A~D RP2
0x00,0x00,0x00,0x00,0x00, # TP3 A~D RP3
0x00,0x00,0x00,0x00,0x00, # TP4 A~D RP4
0x00,0x00,0x00,0x00,0x00, # TP5 A~D RP5
0x00,0x00,0x00,0x00,0x00, # TP6 A~D RP6
0x15,0x41,0xA8,0x32,0x30,0x0A,
]
lut_partial_update = [ #20 bytes
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT0: BB: VS 0 ~7
0x80,0x00,0x00,0x00,0x00,0x00,0x00, #LUT1: BW: VS 0 ~7
0x40,0x00,0x00,0x00,0x00,0x00,0x00, #LUT2: WB: VS 0 ~7
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT3: WW: VS 0 ~7
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT4: VCOM: VS 0 ~7
0x0A,0x00,0x00,0x00,0x00, # TP0 A~D RP0
0x00,0x00,0x00,0x00,0x00, # TP1 A~D RP1
0x00,0x00,0x00,0x00,0x00, # TP2 A~D RP2
0x00,0x00,0x00,0x00,0x00, # TP3 A~D RP3
0x00,0x00,0x00,0x00,0x00, # TP4 A~D RP4
0x00,0x00,0x00,0x00,0x00, # TP5 A~D RP5
0x00,0x00,0x00,0x00,0x00, # TP6 A~D RP6
0x15,0x41,0xA8,0x32,0x30,0x0A,
]
# Hardware reset
def reset(self):
epdconfig.digital_write(self.reset_pin, 1)
epdconfig.delay_ms(200)
epdconfig.digital_write(self.reset_pin, 0)
epdconfig.delay_ms(5)
epdconfig.digital_write(self.reset_pin, 1)
epdconfig.delay_ms(200)
def send_command(self, command):
epdconfig.digital_write(self.dc_pin, 0)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte([command])
epdconfig.digital_write(self.cs_pin, 1)
def send_data(self, data):
epdconfig.digital_write(self.dc_pin, 1)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte([data])
epdconfig.digital_write(self.cs_pin, 1)
# send a lot of data
def send_data2(self, data):
epdconfig.digital_write(self.dc_pin, 1)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte2(data)
epdconfig.digital_write(self.cs_pin, 1)
def ReadBusy(self):
while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy
epdconfig.delay_ms(100)
def TurnOnDisplay(self):
self.send_command(0x22)
self.send_data(0xC7)
self.send_command(0x20)
self.ReadBusy()
def TurnOnDisplayPart(self):
self.send_command(0x22)
self.send_data(0x0c)
self.send_command(0x20)
self.ReadBusy()
def init(self, update):
if (epdconfig.module_init() != 0):
return -1
# EPD hardware init start
self.reset()
if(update == self.FULL_UPDATE):
self.ReadBusy()
self.send_command(0x12) # soft reset
self.ReadBusy()
self.send_command(0x74) #set analog block control
self.send_data(0x54)
self.send_command(0x7E) #set digital block control
self.send_data(0x3B)
self.send_command(0x01) #Driver output control
self.send_data(0xF9)
self.send_data(0x00)
self.send_data(0x00)
self.send_command(0x11) #data entry mode
self.send_data(0x01)
self.send_command(0x44) #set Ram-X address start/end position
self.send_data(0x00)
self.send_data(0x0F) #0x0C-->(15+1)*8=128
self.send_command(0x45) #set Ram-Y address start/end position
self.send_data(0xF9) #0xF9-->(249+1)=250
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_command(0x3C) #BorderWavefrom
self.send_data(0x03)
self.send_command(0x2C) #VCOM Voltage
self.send_data(0x55) #
self.send_command(0x03)
self.send_data(self.lut_full_update[70])
self.send_command(0x04) #
self.send_data(self.lut_full_update[71])
self.send_data(self.lut_full_update[72])
self.send_data(self.lut_full_update[73])
self.send_command(0x3A) #Dummy Line
self.send_data(self.lut_full_update[74])
self.send_command(0x3B) #Gate time
self.send_data(self.lut_full_update[75])
self.send_command(0x32)
for count in range(70):
self.send_data(self.lut_full_update[count])
self.send_command(0x4E) # set RAM x address count to 0
self.send_data(0x00)
self.send_command(0x4F) # set RAM y address count to 0X127
self.send_data(0xF9)
self.send_data(0x00)
self.ReadBusy()
else:
self.send_command(0x2C) #VCOM Voltage
self.send_data(0x26)
self.ReadBusy()
self.send_command(0x32)
for count in range(70):
self.send_data(self.lut_partial_update[count])
self.send_command(0x37)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x40)
self.send_data(0x00)
self.send_data(0x00)
self.send_command(0x22)
self.send_data(0xC0)
self.send_command(0x20)
self.ReadBusy()
self.send_command(0x3C) #BorderWavefrom
self.send_data(0x01)
return 0
def getbuffer(self, image):
if self.width%8 == 0:
linewidth = int(self.width/8)
else:
linewidth = int(self.width/8) + 1
buf = [0xFF] * (linewidth * self.height)
image_monocolor = image.convert('1')
imwidth, imheight = image_monocolor.size
pixels = image_monocolor.load()
if(imwidth == self.width and imheight == self.height):
logger.debug("Vertical")
for y in range(imheight):
for x in range(imwidth):
if pixels[x, y] == 0:
x = imwidth - x
buf[int(x / 8) + y * linewidth] &= ~(0x80 >> (x % 8))
elif(imwidth == self.height and imheight == self.width):
logger.debug("Horizontal")
for y in range(imheight):
for x in range(imwidth):
newx = y
newy = self.height - x - 1
if pixels[x, y] == 0:
newy = imwidth - newy - 1
buf[int(newx / 8) + newy*linewidth] &= ~(0x80 >> (y % 8))
return buf
def display(self, image):
self.send_command(0x24)
self.send_data2(image)
self.TurnOnDisplay()
def displayPartial(self, image):
if self.width%8 == 0:
linewidth = int(self.width/8)
else:
linewidth = int(self.width/8) + 1
buf = [0x00] * self.height * linewidth
for j in range(0, self.height):
for i in range(0, linewidth):
buf[i + j * linewidth] = ~image[i + j * linewidth]
self.send_command(0x24)
self.send_data2(image)
self.send_command(0x26)
self.send_data2(buf)
self.TurnOnDisplayPart()
def displayPartBaseImage(self, image):
self.send_command(0x24)
self.send_data2(image)
self.send_command(0x26)
self.send_data2(image)
self.TurnOnDisplay()
def Clear(self, color=0xFF):
if self.width%8 == 0:
linewidth = int(self.width/8)
else:
linewidth = int(self.width/8) + 1
# logger.debug(linewidth)
buf = [0x00] * self.height * linewidth
for j in range(0, self.height):
for i in range(0, linewidth):
buf[i + j * linewidth] = color
self.send_command(0x24)
self.send_data2(buf)
# self.send_command(0x26)
# for j in range(0, self.height):
# for i in range(0, linewidth):
# self.send_data(color)
self.TurnOnDisplay()
def sleep(self):
# self.send_command(0x22) #POWER OFF
# self.send_data(0xC3)
# self.send_command(0x20)
self.send_command(0x10) #enter deep sleep
self.send_data(0x03)
epdconfig.delay_ms(2000)
epdconfig.module_exit()
### END OF FILE ###
+395
View File
@@ -0,0 +1,395 @@
# *****************************************************************************
# * | File : epd2in13_V3.py
# * | Author : Waveshare team
# * | Function : Electronic paper driver
# * | Info :
# *----------------
# * | This version: V1.2
# * | Date : 2022-08-9
# # | Info : python demo
# -----------------------------------------------------------------------------
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documnetation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# --- Herkunftsvermerk (ninlight-firmware) ---------------------------------
# Unveraendert uebernommen aus waveshareteam/e-Paper (RaspberryPi_JetsonNano/
# python/lib/waveshare_epd/epd2in13_V3.py), Commit 85802dee (2023-02-15).
# Einzige Aenderung: 'from . import epdconfig' -> 'import epdconfig', damit das
# Modul flach importierbar ist (kein Package). Alles andere - Init-Sequenz, LUTs,
# Timings - unveraendert. Details: tools/station/display/HERKUNFT.md
# -----------------------------------------------------------------------------
import logging
import epdconfig
# Display resolution
EPD_WIDTH = 122
EPD_HEIGHT = 250
logger = logging.getLogger(__name__)
class EPD:
def __init__(self):
self.reset_pin = epdconfig.RST_PIN
self.dc_pin = epdconfig.DC_PIN
self.busy_pin = epdconfig.BUSY_PIN
self.cs_pin = epdconfig.CS_PIN
self.width = EPD_WIDTH
self.height = EPD_HEIGHT
lut_partial_update= [
0x0,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x80,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x40,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x14,0x0,0x0,0x0,0x0,0x0,0x0,
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0,
0x22,0x17,0x41,0x00,0x32,0x36,
]
lut_full_update = [
0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0xF,0x0,0x0,0x0,0x0,0x0,0x0,
0xF,0x0,0x0,0xF,0x0,0x0,0x2,
0xF,0x0,0x0,0x0,0x0,0x0,0x0,
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0,
0x22,0x17,0x41,0x0,0x32,0x36,
]
'''
function :Hardware reset
parameter:
'''
def reset(self):
epdconfig.digital_write(self.reset_pin, 1)
epdconfig.delay_ms(20)
epdconfig.digital_write(self.reset_pin, 0)
epdconfig.delay_ms(2)
epdconfig.digital_write(self.reset_pin, 1)
epdconfig.delay_ms(20)
'''
function :send command
parameter:
command : Command register
'''
def send_command(self, command):
epdconfig.digital_write(self.dc_pin, 0)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte([command])
epdconfig.digital_write(self.cs_pin, 1)
'''
function :send data
parameter:
data : Write data
'''
def send_data(self, data):
epdconfig.digital_write(self.dc_pin, 1)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte([data])
epdconfig.digital_write(self.cs_pin, 1)
# send a lot of data
def send_data2(self, data):
epdconfig.digital_write(self.dc_pin, 1)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte2(data)
epdconfig.digital_write(self.cs_pin, 1)
'''
function :Wait until the busy_pin goes LOW
parameter:
'''
def ReadBusy(self):
logger.debug("e-Paper busy")
while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy
epdconfig.delay_ms(10)
logger.debug("e-Paper busy release")
'''
function : Turn On Display
parameter:
'''
def TurnOnDisplay(self):
self.send_command(0x22) # Display Update Control
self.send_data(0xC7)
self.send_command(0x20) # Activate Display Update Sequence
self.ReadBusy()
'''
function : Turn On Display Part
parameter:
'''
def TurnOnDisplayPart(self):
self.send_command(0x22) # Display Update Control
self.send_data(0x0f) # fast:0x0c, quality:0x0f, 0xcf
self.send_command(0x20) # Activate Display Update Sequence
self.ReadBusy()
'''
function : Set lut
parameter:
lut : lut data
'''
def Lut(self, lut):
self.send_command(0x32)
for i in range(0, 153):
self.send_data(lut[i])
self.ReadBusy()
'''
function : Send lut data and configuration
parameter:
lut : lut data
'''
def SetLut(self, lut):
self.Lut(lut)
self.send_command(0x3f)
self.send_data(lut[153])
self.send_command(0x03) # gate voltage
self.send_data(lut[154])
self.send_command(0x04) # source voltage
self.send_data(lut[155]) # VSH
self.send_data(lut[156]) # VSH2
self.send_data(lut[157]) # VSL
self.send_command(0x2c) # VCOM
self.send_data(lut[158])
'''
function : Setting the display window
parameter:
xstart : X-axis starting position
ystart : Y-axis starting position
xend : End position of X-axis
yend : End position of Y-axis
'''
def SetWindow(self, x_start, y_start, x_end, y_end):
self.send_command(0x44) # SET_RAM_X_ADDRESS_START_END_POSITION
# x point must be the multiple of 8 or the last 3 bits will be ignored
self.send_data((x_start>>3) & 0xFF)
self.send_data((x_end>>3) & 0xFF)
self.send_command(0x45) # SET_RAM_Y_ADDRESS_START_END_POSITION
self.send_data(y_start & 0xFF)
self.send_data((y_start >> 8) & 0xFF)
self.send_data(y_end & 0xFF)
self.send_data((y_end >> 8) & 0xFF)
'''
function : Set Cursor
parameter:
x : X-axis starting position
y : Y-axis starting position
'''
def SetCursor(self, x, y):
self.send_command(0x4E) # SET_RAM_X_ADDRESS_COUNTER
# x point must be the multiple of 8 or the last 3 bits will be ignored
self.send_data(x & 0xFF)
self.send_command(0x4F) # SET_RAM_Y_ADDRESS_COUNTER
self.send_data(y & 0xFF)
self.send_data((y >> 8) & 0xFF)
'''
function : Initialize the e-Paper register
parameter:
'''
def init(self):
if (epdconfig.module_init() != 0):
return -1
# EPD hardware init start
self.reset()
self.ReadBusy()
self.send_command(0x12) #SWRESET
self.ReadBusy()
self.send_command(0x01) #Driver output control
self.send_data(0xf9)
self.send_data(0x00)
self.send_data(0x00)
self.send_command(0x11) #data entry mode
self.send_data(0x03)
self.SetWindow(0, 0, self.width-1, self.height-1)
self.SetCursor(0, 0)
self.send_command(0x3c)
self.send_data(0x05)
self.send_command(0x21) # Display update control
self.send_data(0x00)
self.send_data(0x80)
self.send_command(0x18)
self.send_data(0x80)
self.ReadBusy()
self.SetLut(self.lut_full_update)
return 0
'''
function : Display images
parameter:
image : Image data
'''
def getbuffer(self, image):
img = image
imwidth, imheight = img.size
if(imwidth == self.width and imheight == self.height):
img = img.convert('1')
elif(imwidth == self.height and imheight == self.width):
# image has correct dimensions, but needs to be rotated
img = img.rotate(90, expand=True).convert('1')
else:
logger.warning("Wrong image dimensions: must be " + str(self.width) + "x" + str(self.height))
# return a blank buffer
return [0x00] * (int(self.width/8) * self.height)
buf = bytearray(img.tobytes('raw'))
return buf
'''
function : Sends the image buffer in RAM to e-Paper and displays
parameter:
image : Image data
'''
def display(self, image):
if self.width%8 == 0:
linewidth = int(self.width/8)
else:
linewidth = int(self.width/8) + 1
self.send_command(0x24)
for j in range(0, self.height):
for i in range(0, linewidth):
self.send_data(image[i + j * linewidth])
self.TurnOnDisplay()
'''
function : Sends the image buffer in RAM to e-Paper and partial refresh
parameter:
image : Image data
'''
def displayPartial(self, image):
epdconfig.digital_write(self.reset_pin, 0)
epdconfig.delay_ms(1)
epdconfig.digital_write(self.reset_pin, 1)
self.SetLut(self.lut_partial_update)
self.send_command(0x37)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x40)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_command(0x3C) #BorderWavefrom
self.send_data(0x80)
self.send_command(0x22)
self.send_data(0xC0)
self.send_command(0x20)
self.ReadBusy()
self.SetWindow(0, 0, self.width - 1, self.height - 1)
self.SetCursor(0, 0)
self.send_command(0x24) # WRITE_RAM
# for j in range(0, self.height):
# for i in range(0, linewidth):
# self.send_data(image[i + j * linewidth])
self.send_data2(image)
self.TurnOnDisplayPart()
'''
function : Refresh a base image
parameter:
image : Image data
'''
def displayPartBaseImage(self, image):
self.send_command(0x24)
self.send_data2(image)
self.send_command(0x26)
self.send_data2(image)
self.TurnOnDisplay()
'''
function : Clear screen
parameter:
'''
def Clear(self, color=0xFF):
if self.width%8 == 0:
linewidth = int(self.width/8)
else:
linewidth = int(self.width/8) + 1
# logger.debug(linewidth)
self.send_command(0x24)
self.send_data2([color] * int(self.height * linewidth))
self.TurnOnDisplay()
'''
function : Enter sleep mode
parameter:
'''
def sleep(self):
self.send_command(0x10) #enter deep sleep
self.send_data(0x01)
epdconfig.delay_ms(2000)
epdconfig.module_exit()
### END OF FILE ###
+210
View File
@@ -0,0 +1,210 @@
"""GPIO/SPI-Unterbau fuer die vendored Waveshare-Panel-Treiber (epd2in13_V2.py,
epd2in13_V3.py), geschrieben gegen gpiozero + spidev statt gegen Waveshares eigenes
epdconfig.py, das RPi.GPIO voraussetzt - ein Modul, das auf Debian 13 (trixie) mit
Python 3.13 nicht mehr baut/importiert. gpiozero, lgpio (gpiozero's Pin-Factory auf
trixie) und spidev sind auf dem Ziel-Pi vorhanden.
Stellt genau die Schnittstelle bereit, die die beiden Treiber erwarten: die Pin-Konstanten
RST_PIN/DC_PIN/CS_PIN/BUSY_PIN sowie digital_write, digital_read, delay_ms, spi_writebyte,
spi_writebyte2, module_init und module_exit als Modulfunktionen (kein Klassen-Wrapper -
die Treiber greifen direkt auf `epdconfig.<name>` zu).
CS_PIN wird NICHT als GPIO geschaltet: spidev.SpiDev(0, 0) steuert die Hardware-CS0-Leitung
selbst bei jeder Übertragung, ein zusaetzliches Software-Toggle wuerde nur mit der Hardware
um die Leitung konkurrieren. digital_write()/digital_read() sind fuer CS_PIN deshalb
No-ops - genau das Muster, das Waveshares eigene, neuere epdconfig.py-Fassung fuer
Raspberry Pi inzwischen ebenfalls verwendet (dort ist die GPIO-Instanziierung fuer CS_PIN
auskommentiert).
Ein fehlender Import (gpiozero/spidev nicht installiert) oder ein fehlendes
/dev/spidev0.0 wirft DisplayUnavailable - display.py faengt das ab, protokolliert eine
Zeile und beendet sich sauber (sys.exit(0)), statt dass ein fehlendes Panel eine Migration
verhindern koennte (siehe Modul-Docstring von display.py)."""
from __future__ import annotations
import os
import time
try:
import gpiozero
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden, hier nur defensiv
gpiozero = None
try:
import spidev
except ImportError: # pragma: no cover - dito
spidev = None
# lgpio ist die Pin-Factory, die gpiozero auf beiden Pis benutzt (Debian 13 trixie wie
# Bookworm). Sie meldet einen belegten Pin nicht als gpiozero-Ausnahme, sondern als
# lgpio.error - siehe _hardware_error_types(). Auf der Entwicklungsmaschine fehlt das
# Modul; dann bleibt es None und die Fehlerliste kommt ohne es aus.
try:
import lgpio
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden
lgpio = None
class DisplayUnavailable(Exception):
"""Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der
Hardwarezugriff selbst ist fehlgeschlagen (z.B. Berechtigung, Pin belegt)."""
# Dieselben Pin-Nummern wie in Waveshares eigenem epdconfig.py fuer den Raspberry Pi.
RST_PIN = 17
DC_PIN = 25
CS_PIN = 8
BUSY_PIN = 24
_SPI_BUS = 0
_SPI_DEVICE = 0
_SPI_HZ = 4_000_000
_rst_pin = None
_dc_pin = None
_busy_pin = None
_spi = None
def digital_write(pin: int, value: int) -> None:
if pin == RST_PIN:
_rst_pin.value = bool(value)
elif pin == DC_PIN:
_dc_pin.value = bool(value)
elif pin == CS_PIN:
pass # Hardware-CS ueber spidev, siehe Modul-Docstring.
# Andere Pins fragen die Treiber nicht ab - kein else-Zweig noetig.
def digital_read(pin: int) -> int:
if pin == BUSY_PIN:
return int(bool(_busy_pin.value))
if pin == RST_PIN:
return int(bool(_rst_pin.value))
if pin == DC_PIN:
return int(bool(_dc_pin.value))
return 0
def delay_ms(ms: int) -> None:
time.sleep(ms / 1000.0)
def spi_writebyte(data) -> None:
_spi.writebytes(data)
def spi_writebyte2(data) -> None:
_spi.writebytes2(data)
def _busy_hint(error: BaseException) -> str:
"""Zusatz fuer die Fehlermeldung, wenn die Leitungen belegt sind. "GPIO busy" heisst
auf dieser Station praktisch immer "der Anzeigedienst haelt die Pins" - und trifft
ausgerechnet den Befehl, den der Hilfetext als ersten Handgriff der Abnahme nennt.
Die Meldung soll den Bediener also nicht zum Suchen schicken, sondern sagen, was los
ist. Leer, wenn es nicht danach aussieht - ein erfundener Hinweis waere schlimmer als
keiner."""
if "busy" in str(error).lower():
return (" - haelt der Anzeigedienst die Leitungen? "
"Pruefen mit 'systemctl status ninlight-display'")
return ""
def _hardware_error_types() -> tuple:
"""Die Ausnahmen, die als "keine/blockierte Hardware" gelten und damit zu
DisplayUnavailable werden. Bewusst eine ENUMERATION, kein `except Exception`: ein
echter Programmierfehler an anderer Stelle (TypeError, AttributeError, NameError)
soll weiterhin als das erkennbar bleiben, was er ist, statt hier als "Panel fehlt"
getarnt zu werden (Fixrunde 1, C2, zweiter Teil).
gpiozero.GPIOZeroError (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-
Familie (PinInvalidPin, PinUnsupported, ... - z.B. bei falscher/fehlender
Pin-Factory-Zuordnung) haengt direkt unter GPIOZeroError, nicht unter
GPIODeviceError - per `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory
erbt zusaetzlich von ImportError und war dadurch schon abgedeckt.
lgpio.error (Hardware-Abnahme): laeuft der Anzeigedienst, haelt er die GPIO-Leitungen,
und ein gleichzeitiges "ninlight-station display test" bekommt beim Anlegen des ersten
Pins ein `lgpio.error: 'GPIO busy'`. Das ist KEINE gpiozero.GPIOZeroError - gpiozero
reicht die Ausnahme seiner Pin-Factory unveraendert durch - und fiel deshalb durch
diese Liste hindurch: statt der vorgesehenen einen Protokollzeile gab es einen rohen
Stapelabzug und rc=1. lgpio.error erbt direkt von Exception, die drei
Programmierfehler oben bleiben davon also unberuehrt.
Wird bei JEDEM Aufruf neu gebaut statt einmal beim Import: nur so wirkt ein
monkeypatch.setattr(epdconfig, "lgpio", ...) im Test ueberhaupt (dasselbe Muster wie
bei gpiozero/spidev in tools/station/tests/test_render.py)."""
types: tuple = (OSError, ImportError)
if gpiozero is not None:
types = types + (gpiozero.GPIOZeroError,)
lgpio_error = getattr(lgpio, "error", None) if lgpio is not None else None
if isinstance(lgpio_error, type) and issubclass(lgpio_error, BaseException):
types = types + (lgpio_error,)
return types
def module_init() -> int:
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss.
Idempotent: ein zweiter Aufruf, waehrend die Pins/das SPI-Geraet noch offen sind (die
Panel-Treiber rufen dies bei JEDEM epd.init() erneut auf - Vollaufbau bei Moduswechsel
oder alle 30 Minuten loest also ohne diese Absicherung einen zweiten, unnoetigen
Oeffnungsversuch aus), kehrt sofort zurueck statt gpiozero.DigitalOutputDevice(17)
ein zweites Mal anzulegen. Das wuerde mit GPIOPinInUse scheitern - vom breiten
except unten (Fixrunde 1, C2) frueher faelschlich als "Panel fehlt" verschluckt,
obwohl es ein Programmierfehler in der Aufrufreihenfolge ist. Nach module_exit()
(z.B. durch epd.sleep()) ist _spi wieder None, ein echter Neuaufbau laeuft dann
normal."""
global _rst_pin, _dc_pin, _busy_pin, _spi
if _spi is not None:
return 0
if gpiozero is None or spidev is None:
raise DisplayUnavailable("gpiozero/spidev nicht installiert")
spi_path = f"/dev/spidev{_SPI_BUS}.{_SPI_DEVICE}"
if not os.path.exists(spi_path):
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
hardware_errors = _hardware_error_types()
try:
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
_busy_pin = gpiozero.DigitalInputDevice(BUSY_PIN, pull_up=False)
_spi = spidev.SpiDev()
_spi.open(_SPI_BUS, _SPI_DEVICE)
_spi.max_speed_hz = _SPI_HZ
_spi.mode = 0b00
except hardware_errors as e:
module_exit()
raise DisplayUnavailable(
f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}{_busy_hint(e)}") from e
return 0
def module_exit() -> None:
"""Gibt GPIO-Pins und SPI-Geraet frei. Verzeiht, wenn module_init() nie (vollstaendig)
gelaufen ist - display.py ruft das u.a. im Fehlerpfad von module_init() selbst auf."""
global _rst_pin, _dc_pin, _busy_pin, _spi
if _spi is not None:
try:
_spi.close()
except Exception: # noqa: BLE001 - Aufraeumen darf nicht seinerseits scheitern
pass
_spi = None
for attr_name in ("_rst_pin", "_dc_pin", "_busy_pin"):
device = globals()[attr_name]
if device is not None:
try:
device.close()
except Exception: # noqa: BLE001
pass
globals()[attr_name] = None
+717
View File
@@ -0,0 +1,717 @@
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
blocks()/lines() sind bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe
tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb der
Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf den Pi
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
Die Schrift kommt aus FONT_SEARCH_PATHS (DejaVu auf beiden Pis) statt aus
ImageFont.load_default(): default ist rund 10 px hoch und auf 250x122 aus zwei Metern
nicht lesbar (Befund der Hardware-Abnahme) - siehe _load_font(), warum der
naheliegende Weg load_default(size=...) auf Pi 2 (Pillow 9.4.0) ausscheidet.
Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. blocks() liefert
UNGEKUERZTE Zeilen - die Breitenkuerzung (mit "..." statt Umbruch, Spec: eine zu lange
Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width()
gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist
seit Pillow 10.1 eine proportionale TrueType-Schrift, keine feste Bitmapschrift - eine
Kuerzung nach Zeichenzahl (fruehere Fassung: MAX_COLS) ist dafuer keine gueltige
Breitengrenze: "W"*40 misst bei dieser Schrift fast doppelt so breit wie "i"*40, und
ausgerechnet grossbuchstabenlastige Werte - Produktnamen aus der Lampenerkennung wie
"KLYQA GU10 RGB CW/WW" - waeren trotz weniger Zeichen als die alte Grenze ueber die
Panelbreite hinausgelaufen (Fixrunde 2, MAX_COLS-Kleinigkeit war in Fixrunde 1 nicht
wirklich behoben). _truncate_to_width() selbst importiert kein Pillow (sie nimmt eine
Messfunktion entgegen, die der Aufrufer liefert) und bleibt damit wie lines() ohne Pillow
testbar; nur compose() verdrahtet sie mit einer echten PIL-Textmessung.
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
/etc/ninlight-station.conf. display.py liest beides und reicht ein gemeinsames dict
herein. Jedes Feld wird ueber .get() mit Vorgabewert gelesen: status.json,
control.json und die Konfigurationsdatei koennen jederzeit fehlen, halb geschrieben oder
aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
from __future__ import annotations
from typing import Callable
PANEL_WIDTH_PX = 250
PANEL_HEIGHT_PX = 122
TEXT_MARGIN_PX = 2
# --- Rollen und ihre Groessen ------------------------------------------------------
#
# Bis zur Hardware-Abnahme zeichnete compose() alle Zeilen in derselben
# ImageFont.load_default() (rund 10 px hoch) - auf 250x122 war das Kleingedrucktes und
# aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt
# deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders:
#
# header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle
# Panelbreite. Invertiert kostet auf e-Paper nichts (bistabil, kein
# Stromverbrauch pro Pixel) und trennt die Kopfzeile auf einen Blick
# vom Rest.
# header_phase - die Phase (AUTO/LISTEN/FREIGEGEBEN), NUR im Migrationsmodus und
# nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie
# header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme:
# die Phase ging als eigene kleine Zeile unter dem Lampennamen unter).
# Rettung/aus kennen keine Phase - dort bleibt die Kopfzeile
# unveraendert (nur header, mittig, wie zuvor). NUR das kurze Label -
# der Zielname der Freigabe steht als eigene Beiwerkzeile, siehe
# ARMED_DETAIL_PREFIX.
# hero - die eine wichtigste Information des Zustands (Migration: der
# erkannte Produktname; Rettung: die SSID), fett.
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung).
# 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
# Abstand nach unten uebrig (naechster Kommentar, BOTTOM_MARGIN_PX):
# 22 + 2 + 20 + 5*13 = 109. Die Phase (frueher eine sechste Beiwerkzeile: "AUTO"/
# "FREIGEGEBEN: ...") steht seit der zweiten Hardware-Abnahme in der Kopfzeile
# (ROLE_HEADER_PHASE) statt als eigene Zeile - das ist der Grund, warum nur noch fuenf
# statt sechs Beiwerkzeilen uebrig bleiben, nicht eine nachtraeglich erfundene Kuerzung.
HEADER_BAR_HEIGHT_PX = 22
HERO_LINE_HEIGHT_PX = 20
DETAIL_LINE_HEIGHT_PX = 13
# Hardware-Abnahme, zweiter Befund: auf dem echten Panel frisst ein Rahmen den unteren
# Rand, der volleste Zustand (s.o., endet bei 109 px) wirkte dort gedraengt. compose()
# bricht Beiwerkzeilen deshalb schon ab, bevor sie in die letzten BOTTOM_MARGIN_PX
# Zeilen hineinragen wuerden (siehe die Abbruchbedingung dort) - mit den obigen 109 px
# 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>")
# ineinanderlaufen.
HEADER_PHASE_GAP_PX = 6
# Bleibt nach Modus-Text und Puffer weniger als das fuer die Phase uebrig, wird sie ganz
# weggelassen statt ein einzelnes "…" hinzuzeichnen - das waere ohnehin keine Auskunft
# mehr wert (dritte Option aus der Abnahme: kuerzen, verkleinern oder weglassen; die
# ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer).
#
# Dritte Hardware-Abnahme, Befund M2: der Reviewer hat zu Recht angemerkt, dass dieser
# Zweig mit den heutigen Werten nicht feuern KANN - der Modus-Text im Migrationsmodus ist
# die Konstante "MIGRATION" (MODE_LABELS), available_px ist damit fest (112 px DejaVu,
# 127 px Arial) und nie < 24. Der Zweig bleibt trotzdem, aber er ist jetzt getestet
# (test_header_phase_is_dropped_when_too_little_room_is_left): was ihn aufweckt, ist
# nicht ein langer Zielname (den gibt es seit Befund I2 im Kopf gar nicht mehr), sondern
# ein schmalerer Rest neben dem Modus - eine groessere HEADER_FONT_PX, eine deutlich
# breitere Schrift, ein kuenftiger dritter Modus mit laengerem Namen oder ein groesserer
# HEADER_PHASE_GAP_PX. Ohne den Zweig waere die Folge kein fehlendes Wort, sondern ein
# "…", das sich mit dem Modus ueberlagert; das ist der Grund, warum hier ein
# totgeglaubtes Sicherheitsnetz billiger ist als sein Fehlen.
HEADER_PHASE_MIN_WIDTH_PX = 24
# Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert
# (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber
# erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und
# dort wirft load_default(size=...) TypeError ("unexpected keyword argument 'size'"), am
# Geraet gegengeprueft. Deshalb fuehrt der Hauptweg ueber ImageFont.truetype() auf eine
# Datei im Dateisystem: /usr/share/fonts/truetype/dejavu/ liegt auf BEIDEN Pis
# (Debian 13 trixie wie Bookworm) und ist damit der einzige Pfad, auf den es im Betrieb
# ankommt. Alles darunter existiert nur, damit compose() auch auf Rechnern ohne den
# Debian-Pfad eine skalierbare Schrift findet - insbesondere auf der macOS-
# Entwicklungsmaschine, wo tools/station/tests/test_render.py laufen muss.
#
# Letzter Rueckfall (in _load_font(), nicht in dieser Liste) ist load_default() OHNE
# Argument: dann ist die Schrift wieder klein, aber nichts stuerzt ab - die Zusage
# "eine fehlende Schrift darf die Anzeige (und damit nie eine Migration) aufhalten"
# gilt unveraendert.
FONT_SEARCH_PATHS = (
# Debian/Raspberry Pi OS - der Betriebsfall auf beiden Pis.
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
# Andere Linux-Distributionen legen dieselbe Schrift woanders ab.
("/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
("/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"),
# DejaVu per Homebrew (macOS), falls vorhanden.
("/opt/homebrew/share/fonts/DejaVuSans.ttf",
"/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf"),
# macOS-Bordmittel - nur fuer die Entwicklungsmaschine, nie fuer den Pi.
("/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
)
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
# Zielnamen nach sich), deshalb hier nicht gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
# Dritte Hardware-Abnahme, Befund I2: in der Kopfzeile stand frueher
# "FREIGEGEBEN: <zielname>" - der Platz neben dem Modus reicht dafuer nicht (mit DejaVu
# auf dem Pi bleiben 112 px, der Text misst 182 px), gezeichnet wurde also "FREIGEGEBE…":
# ein Wortfragment, das bei JEDEM Ziel gleich aussieht. Ausgerechnet in "armed" muss der
# Bediener aber sehen, WELCHES Ziel freigegeben ist, sonst gibt er im Zweifel die falsche
# Lampe frei. Deshalb im Kopf nur noch das kurze Label (das auch bei einer deutlich
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
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.
# Die Zeile muss UNGEKUERZT passen, und zwar auch mit einer breiteren Schrift als der
# hier gerade installierten: am Geraet (DejaVu) kam die alte, laengere Fassung als
# "Lampe -> Onboarding-AP (ohne WLA..." an - der Zusatz in der Klammer, also gerade die
# Aussage, fiel weg. Auf dem Mac des Entwicklers (Arial, schmaler) passte dieselbe Zeile
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe
# test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
IMAGESET_LABELS = {
"ohne-wlan": "ohne WLAN-Daten (Onboarding-AP)",
"mit-wlan": "MIT WLAN-Daten (eigenes Netz)",
}
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie
# zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in
# BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige
# Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
IMAGESET_MISSING_LABELS = {
"ohne-wlan": "SATZ FEHLT: ohne WLAN-Daten",
"mit-wlan": "SATZ FEHLT: MIT WLAN-Daten",
}
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
# oder neu gestartet wird. E-Paper ist bistabil - das zuletzt gezeichnete Bild bleibt fuer
# immer stehen, auch stromlos. Ohne diesen Schritt behauptet das Panel nach dem
# Ausschalten weiter den Zustand von vorher (bei der Abnahme stand dort die RUECKFRAGE,
# und der Pi sah aus, als warte er noch auf eine Eingabe).
#
# Die beiden Texte unterscheiden sich, und das ist der Punkt: beim Ausschalten DARF man
# den Stecker ziehen, beim Neustart NICHT - ein Zero W braucht gut anderthalb Minuten, bis
# der Anzeigedienst wieder zeichnet, und an genau diesem Pi hat ein Stromzug in dieser
# Wartezeit schon eine SD-Karte gekostet.
FINAL_LABELS = {
# "Strom kann getrennt werden" war in der Hero-Schrift 262,1 px breit und kam am
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
"poweroff": ("AUSGESCHALTET", "Strom trennen erlaubt", []),
"reboot": ("NEUSTART", "Strom NICHT trennen!",
["Der Pi kommt in rund 2 min wieder.",
"Solange bleibt dieses Bild stehen."]),
}
# 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
# ausgeliefert wurde: wer vor der Freigabe sieht, dass "dev" nur aus der Version kommt
# (oder dass "prod" bedeutet: nirgends ein "-dev"), kann eine Fehlklassifikation abfangen,
# bevor die Lampe das Image ablehnt. Kurzformen, weil die Beiwerkzeile schmal ist.
VARIANT_SOURCE_LABELS = {"both": "ID+Ver", "product_id": "ID", "version": "Ver"}
def _variant_label(last_seen: dict) -> str:
""""dev/Ver", "dev/ID", "dev/ID+Ver", "prod" - die Quelle nur, wo sie etwas aussagt.
Bei "prod" gibt es keine ("none"), und eine aeltere Serverfassung ohne das Feld
liefert wie bisher nur die Variante."""
variant = str(last_seen.get("variant") or "?")
label = VARIANT_SOURCE_LABELS.get(last_seen.get("variant_source"))
if not label:
return variant
return variant + "/" + label
def _hhmm(ts: object) -> str:
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T":
return ts[11:16]
return "--:--"
def blocks(state: dict) -> list[tuple[str, str]]:
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE
(siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst
nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt
eine Ausnahme zu werfen.
Bleibt wie lines() ohne Pillow importierbar und testbar: hier entsteht nur Text, kein
Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt
ausschliesslich compose()."""
state = state if isinstance(state, dict) else {}
# Laeuft gerade eine Bedienung am Taster, ERSETZT sie das Statusbild komplett - eine
# Bedienung, die neben dem Statusbild um Platz kaempft, waere auf 250x122 unlesbar,
# und in diesem Moment ist der Bedienzustand die einzige Auskunft, auf die der
# Bediener wartet. display.read_ui() liefert {} (und damit kein "ui"), sobald nichts
# laeuft - dann geht es unveraendert weiter wie zuvor.
ui = state.get("ui")
if isinstance(ui, dict) and ui.get("view") not in (None, "", "idle"):
return _ui_blocks(ui)
mode = state.get("mode")
out: list[tuple[str, str]] = []
if mode == "final":
# Abschlussbildschirm - KEINE Fusszeile und keine weiteren Zeilen: die Station ist
# im Begriff zu verschwinden, und die einzige Auskunft, auf die es jetzt ankommt,
# ist "darf ich den Stecker ziehen?".
label = FINAL_LABELS.get(state.get("final"))
if label is None:
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
# zu sagen, wenn gleich wieder gebootet wird.
return [("Faehrt herunter", ROLE_BIG)]
title, hero, details = label
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
+ [(d, ROLE_DETAIL) for d in details])
# 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
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
# (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 + 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 + footer
out.append((MODE_LABELS[mode], ROLE_HEADER))
ssid = state.get("ssid")
if mode == "recovery":
# 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).
#
# Die SSID ist das, was der Bediener am Geraet braucht (er muss sich mit genau
# diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix, das den
# Platz nur wegnaehme.
if ssid:
out.append((str(ssid), ROLE_HERO))
else:
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
# nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
# Phase, deshalb dieser Block nur hier drin.
#
# Seit der zweiten Hardware-Abnahme steht die Phase NICHT mehr als eigene
# Beiwerkzeile unter dem Lampennamen (dort ging sie unter), sondern direkt
# NEBEN dem Modus in der Kopfzeile (ROLE_HEADER_PHASE, rechtsbuendig) - deshalb
# wird sie hier direkt nach dem ROLE_HEADER-Block angehaengt, nicht erst nach
# der grossen Zeile.
phase = state.get("phase")
armed_target = None
if phase == "armed":
# Nur das Label in den Kopf, der Zielname kommt weiter unten als eigene
# Beiwerkzeile (siehe ARMED_DETAIL_PREFIX) - in den Kopf passt er nicht,
# und dort war er bis zur dritten Abnahme faktisch unsichtbar.
out.append((ARMED_LABEL, ROLE_HEADER_PHASE))
armed_target = str(state.get("armed_target") or "?")
elif phase in PHASE_LABELS:
out.append((PHASE_LABELS[phase], ROLE_HEADER_PHASE))
last_seen = state.get("last_seen")
if not isinstance(last_seen, dict):
last_seen = {}
product_name = last_seen.get("product_name")
# Reihenfolge bewusst anders als frueher (da stand die SSID als zweite Zeile):
# gross kommt das, was sich gerade aendert und worauf man wartet - die erkannte
# Lampe. Solange keine erkannt ist, ruecke die SSID nach oben, damit die grosse
# Zeile nie leer bleibt.
if product_name:
out.append((str(product_name), ROLE_HERO))
elif ssid:
out.append((str(ssid), ROLE_HERO))
if armed_target is not None:
# ERSTE Beiwerkzeile, also die, die als letzte der unteren Randgrenze zum
# Opfer fiele (compose() bricht von unten ab, blocks() ist nach Wichtigkeit
# sortiert): in "armed" ist das freigegebene Ziel die Auskunft, wegen der
# der Bediener ueberhaupt aufs Panel sieht.
out.append((f"{ARMED_DETAIL_PREFIX}{armed_target}", ROLE_DETAIL))
if product_name:
target = last_seen.get("target")
variant = last_seen.get("variant")
if target or variant:
out.append(("{} ({})".format(target or "?", _variant_label(last_seen)),
ROLE_DETAIL))
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append((f"FW: {lamp_version}", 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.
#
# Und nur ohne Freigabezeile: aufs Panel passen neben der grossen Zeile
# fuenf Beiwerkzeilen (siehe BOTTOM_MARGIN_PX), die Freigabezeile ist die
# sechste. Von den beiden gibt die SSID nach - sie ist die Auskunft, mit
# der man das Netz FINDET, und in "armed" hat die Lampe es laengst
# gefunden (sie wurde ja erkannt, sonst gaebe es kein product_name);
# ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da.
out.append((f"SSID: {ssid}", ROLE_DETAIL))
# Der optionale Zeitschalter, in BEIDEN Modi und vor den reinen Zaehlerzeilen: ein
# Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner - man glaubt,
# die Station laeuft, und sie ist laengst aus. Fehlt er (der Regelfall, denn er
# entsteht nur mit "--for"), fehlt die Zeile ersatzlos.
#
# Er steht damit VOR "Ausgeliefert"/"zuletzt ausgeliefert": im vollsten Zustand
# passen fuenf Beiwerkzeilen aufs Panel (siehe BOTTOM_MARGIN_PX), die sechste faellt
# unten heraus. Von diesen beiden Auskuenften ist "wann geht die Station aus"
# diejenige, nach der man ueberhaupt aufs Panel sieht - der Zaehler der letzten
# Auslieferung steht auch noch in "ninlight-station status".
autooff_minutes = state.get("autooff_minutes")
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
if autooff_minutes > 0:
out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL))
else:
out.append(("Auto-Aus steht an", ROLE_DETAIL))
served = state.get("served")
out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL))
recent = state.get("recent")
if isinstance(recent, list) and recent:
last = recent[-1]
if isinstance(last, dict):
out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}", ROLE_DETAIL))
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_LABELS[name]
return [(label, ROLE_FOOTER)]
def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
"""Der Bedienzustand des Tasterdienstes in Zeilen mit Rolle. Bewusst DUMM: welcher
Text bei welcher Geste erscheint, entscheidet allein der Tasterdienst (dort ist es
ohne Hardware pruefbar) - hier wird er nur noch den vorhandenen Rollen zugeordnet,
damit Kopfbalken, Schriftgroessen, Breitenkuerzung und unterer Rand dieselben bleiben
wie im Statusbild.
Jedes Feld wird auf seinen Typ geprueft: die Datei kommt aus einem anderen Prozess
(der aelter, neuer oder halb geschrieben sein kann) - genau wie status.json darf sie
die Anzeige nie zum Absturz bringen."""
out: list[tuple[str, str]] = []
title = ui.get("title")
if isinstance(title, str) and title:
out.append((title, ROLE_HEADER))
hero = ui.get("hero")
if isinstance(hero, str) and hero:
out.append((hero, ROLE_HERO))
details = ui.get("details")
if isinstance(details, list):
for detail in details:
if isinstance(detail, str) and detail:
out.append((detail, ROLE_DETAIL))
if not out:
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
out.append(("Bedienung", ROLE_BIG))
return out
def lines(state: dict) -> list[str]:
"""Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer
display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle
ist dafuer unerheblich, sie haengt nur am Modus). Wie blocks() ohne Pillow benutzbar."""
return [text for text, _role in blocks(state)]
def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str:
"""Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt.
Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion
(und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit
`measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine
echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht
wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende
abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener
waere eins."""
if measure(text) <= max_width_px:
return text
ellipsis = "…"
trimmed = text
while trimmed and measure(trimmed + ellipsis) > max_width_px:
trimmed = trimmed[:-1]
return (trimmed + ellipsis) if trimmed else ellipsis
# Prozess-Cache fuer _load_font(). Dritte Hardware-Abnahme, Befund M4: compose() baute je
# Aufruf FUENF Schriften neu (auf der Entwicklungsmaschine gemessen: 25,0 ms je compose(),
# davon 21,2 ms Schriftaufrufe - auf einem ARMv6-Kern ohne NEON grob 0,5-1 s, und das auf
# demselben Kern, auf dem server.py die Lampen bedient, bei JEDER ausgelieferten Lampe,
# weil sich der Zaehler aendert).
#
# Der Schluessel enthaelt das hereingereichte ImageFont-MODUL und FONT_SEARCH_PATHS, nicht
# nur Groesse/Fettung: beide werden in den Tests ersetzt (nachgebautes Modul,
# monkeypatch auf eine leere oder andere Suchkette), und ein Cache, der das ignoriert,
# wuerde einem Test die Schrift eines anderen unterschieben - genau der Grund, aus dem
# Task 7b noch bewusst auf einen Cache verzichtet hatte.
_FONT_CACHE: dict = {}
def _load_font(image_font, size_px: int, bold: bool = False):
"""Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der
erste Pfad, der sich oeffnen laesst, gewinnt. Das Ergebnis wird prozessweit
zwischengespeichert (siehe _FONT_CACHE): dieselbe Schrift in derselben Groesse wird
je Prozess genau einmal von der Platte gelesen und geparst.
`image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so
bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe
Muster wie die Messfunktion in _truncate_to_width() - und die Kette laesst sich mit
einem nachgebauten Modul pruefen, ohne Schriftdateien anzulegen oder Pillow
herunterzustufen.
Gefangen wird OSError (Datei fehlt/unlesbar/kein gueltiges Schriftformat) UND
ImportError: ein ohne FreeType gebautes Pillow laesst truetype() mit genau dieser
Ausnahme scheitern - dann darf die Anzeige nicht ausfallen, sondern faellt bis auf
load_default() durch.
load_default() wird BEWUSST OHNE size-Argument gerufen: das gibt es erst ab
Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort
ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte
Rettungsanker ist."""
# Das Modul selbst (nicht seine id()) im Schluessel: Module und die nachgebauten
# Testmodule sind ueber ihre Identitaet hashbar, und solange der Schluessel das
# Objekt festhaelt, kann keine recycelte id() zwei verschiedene Module
# zusammenwerfen.
key = (image_font, size_px, bold, FONT_SEARCH_PATHS)
if key in _FONT_CACHE:
return _FONT_CACHE[key]
for regular_path, bold_path in FONT_SEARCH_PATHS:
path = bold_path if bold else regular_path
try:
font = image_font.truetype(path, size_px)
break
except (OSError, ImportError):
continue
else:
font = image_font.load_default()
_FONT_CACHE[key] = font
return font
def _draw_text(draw, text: str, font, top: int, height: int, fill: int,
align: str = "center") -> int:
"""Zeichnet `text` senkrecht mittig in das Band [top, top+height) und waagerecht
nach `align`: "center" (Panelmitte - bisheriges Verhalten, fuer ROLE_BIG und die
Kopfzeile ohne Phase), "left" (linker Rand + TEXT_MARGIN_PX - der Modus, sobald eine
Phase daneben steht) oder "right" (rechter Rand - TEXT_MARGIN_PX - die Phase).
Misst mit textbbox() statt mit der nominellen Schriftgroesse: die tatsaechliche
Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich kleiner als die
Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf einem 122 px hohen
Panel sichtbar zu tief aus.
Gibt die gezeichnete Tintenbreite zurueck - compose() braucht sie fuer den Modus in
der Kopfzeile, um daraus auszurechnen, wie viel Platz rechts noch fuer die Phase
uebrig ist."""
bbox = draw.textbbox((0, 0), text, font=font)
ink_width = bbox[2] - bbox[0]
if align == "left":
visual_x = TEXT_MARGIN_PX
elif align == "right":
visual_x = PANEL_WIDTH_PX - TEXT_MARGIN_PX - ink_width
else:
visual_x = (PANEL_WIDTH_PX - ink_width) // 2
y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1]
draw.text((visual_x - bbox[0], y), text, font=font, fill=fill)
return ink_width
def compose(state: dict):
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus blocks().
Importiert Pillow erst hier - siehe Modul-Docstring."""
from PIL import Image, ImageDraw, ImageFont
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
draw = ImageDraw.Draw(image)
# Je Rolle FAUL geladen, nicht alle fuenf im Voraus (Befund M4): "Station aus" und
# "Status unbekannt" zeichnen genau eine Rolle (ROLE_BIG) - vier der fuenf Schriften
# waeren dort reine Ladezeit. Zusammen mit _FONT_CACHE kostet jede Rolle im Betrieb
# hoechstens einmal je Prozess etwas, danach nur noch einen Woerterbuchzugriff.
role_fonts = {
ROLE_HEADER: (HEADER_FONT_PX, True),
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
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 = {}
def font_for(role: str):
if role not in loaded:
size_px, bold = role_fonts[role]
loaded[role] = _load_font(ImageFont, size_px, bold=bold)
return loaded[role]
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
def fit(raw_text: str, font, width_px: float = max_width_px) -> str:
# draw.textlength() wirft ValueError("can't measure length of multiline text"),
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
# F5). blocks() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
# Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
text = raw_text.replace("\n", " ")
return _truncate_to_width(
text, measure=lambda t: draw.textlength(t, font=font),
max_width_px=width_px)
block_list = blocks(state)
# Nur wenn eine Phase folgt, ruckt der Modus in der Kopfzeile nach links (siehe
# Rollen-Kommentar oben) - Rettung und "aus" kennen keinen ROLE_HEADER_PHASE-Block
# und behalten die zentrierte Kopfzeile von vor der zweiten Hardware-Abnahme.
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)
if role == ROLE_BIG:
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
# Panel.
text = fit(raw_text, font)
# 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
if role == ROLE_HEADER:
text = fit(raw_text, font)
draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0)
align = "left" if has_header_phase else "center"
ink_width = _draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
fill=255, align=align)
if align == "left":
header_mode_right_edge_px = TEXT_MARGIN_PX + ink_width
y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX
continue
if role == ROLE_HEADER_PHASE:
# Rechtsbuendig NEBEN dem Modus (der links steht, s.o.) im selben Balken -
# gemessen ab dem tatsaechlichen rechten Rand des Modus-Texts, nicht ab
# einer angenommenen festen Breite: "MIGRATION" und "RETTUNG" sind
# unterschiedlich breit, und nur "MIGRATION" hat je eine Phase daneben.
available_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - header_mode_right_edge_px
- HEADER_PHASE_GAP_PX)
if available_px >= HEADER_PHASE_MIN_WIDTH_PX:
text = fit(raw_text, font, width_px=available_px)
_draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
fill=255, align="right")
# sonst: Phase weglassen (dritte Option aus der Abnahme) - bei so wenig
# Platz waere selbst ein gekuerztes "…" keine Auskunft mehr wert, und ein
# Ueberlappen mit dem Modus ist nicht erlaubt.
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:
# 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.
break
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
y += line_height
return image
+75
View File
@@ -0,0 +1,75 @@
[Unit]
Description=ninlight Taster (Bedienung der Station ueber GPIO23)
# Wie beim Anzeigedienst nur After=, NICHT Requires=/BindsTo=/Wants=: ein fehlender oder
# abgestuerzter Taster darf server.py niemals mitreissen, und server.py muss starten
# koennen, auch wenn dieser Dienst fehlt oder deaktiviert ist. KEIN
# Wants=ninlight-station.service - das wuerde die absichtlich deaktivierte Station bei
# jedem Boot mit hochziehen (Fund der Hardware-Abnahme, ausfuehrlich in
# ninlight-display.service begruendet).
After=ninlight-station.service
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort werden sie mit
# "Unknown key name" ignoriert, und die Deckelung unten greift dann nie).
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
Environment=NINLIGHT_UI_FILE=/run/ninlight-button/ui.json
# "-"-Praefix VOR dem Dateipfad (nicht vor dem Schluesselnamen - so herum ist es ein
# ungueltiger Schluessel und systemd verwirft die Zeile ganz): ohne die
# Konfigurationsdatei startet der Taster trotzdem.
EnvironmentFile=-/etc/ninlight-station.conf
# Eigenes RuntimeDirectory, aus zwei Gruenden:
#
# 1. Hier liegt ui.json. Es darf NICHT in /run/ninlight-station liegen (dem
# RuntimeDirectory der Hauptunit): systemd raeumt das beim regulaeren Stop von
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue
# ("Rettung starten"/"Migration starten") am dringendsten gebraucht wird. So
# verschwindet ui.json genau dann, wenn dieser Dienst endet, und ein
# stehengebliebenes Menue kann ihn nicht ueberdauern.
# 2. lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start "$HOME/.lgd-nfy0" an.
# Unter systemd ist HOME per Vorgabe LEER und das Arbeitsverzeichnis "/" - der Pfad
# wird "//.lgd-nfy0" und scheitert. Am Geraet fuer den Anzeigedienst nachgemessen,
# siehe dort; derselbe Stolperstein trifft jeden gpiozero-Dienst.
RuntimeDirectory=ninlight-button
RuntimeDirectoryMode=0755
WorkingDirectory=/run/ninlight-button
Environment=HOME=/run/ninlight-button
ExecStart=/usr/bin/python3 /opt/ninlight-station/button/button.py
# button.py faengt einen fehlenden Taster (keine Bibliothek, kein Pin, Pin belegt) als
# ButtonUnavailable ab, protokolliert eine Zeile und beendet sich mit sys.exit(0) - das
# ist nie ein "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. on-failure
# (nicht always) deckelt damit genau das Richtige: echte Abstuerze werden neu versucht,
# ein dauerhaft fehlender Taster nicht.
Restart=on-failure
RestartSec=5
# BEWUSST KEIN NoNewPrivileges=/ProtectSystem=strict/ProtectHome= hier - anders als beim
# Anzeigedienst, und das ist kein Versehen:
#
# Dieser Dienst ruft /usr/local/bin/ninlight-station auf, und dessen einziger Zweck ist
# es, NetworkManager-Profile umzuschalten, Units zu starten/stoppen und nach
# /run/ninlight-station zu schreiben - alles ueber "sudo". Kindprozesse erben den
# Namensraum und das no_new_privs-Flag dieser Unit: mit ProtectSystem=strict waere
# /run/ninlight-station fuer das CLI schreibgeschuetzt (mode/control.json liessen sich
# nicht mehr schreiben), und no_new_privs laesst kein setuid-Binary mehr zu (sudo). Eine
# Absicherung, die genau die Aktionen unmoeglich macht, wegen derer es den Dienst gibt,
# ist keine. Die Angriffsflaeche ist stattdessen eng gehalten: EIN Eingangspin, kein
# Netzwerk, keine Eingabe ausser der Taste selbst, und ausgefuehrt wird ausschliesslich
# die feste Liste von CLI-Unterbefehlen aus button.py.
MemoryMax=64M
# Laeuft als root (wie Haupt- und Anzeigedienst) fuer GPIO23 und fuer das CLI - kein
# SupplementaryGroups=gpio: auf reinem Debian gibt es die Gruppe oft gar nicht, und eine
# fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP), ausgerechnet in
# einer Unit, die nie stoeren darf.
# KEIN Zugriff auf SPI und keine Zeichenleitung: der Tasterdienst fasst den Panel-Bus
# nicht an (zwei Prozesse darauf ergeben "lgpio.error: 'GPIO busy'"). Er schreibt seinen
# Bedienzustand nach /run/ninlight-button/ui.json, der Anzeigedienst liest ihn und
# zeichnet.
[Install]
WantedBy=multi-user.target
+109
View File
@@ -0,0 +1,109 @@
[Unit]
Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation)
# Nur eine Anzeige fuer den Hauptdienst - After=, NICHT Requires=/BindsTo=/Wants=: ein
# fehlendes oder abgestuerztes Panel darf server.py niemals mitreissen, und server.py
# darf umgekehrt starten, auch wenn dieser Dienst fehlt oder deaktiviert ist. Siehe
# display.py's Modul-Docstring: die Anzeige ist nirgends Voraussetzung fuer die
# Migration.
#
# KEIN Wants=ninlight-station.service (stand hier zuerst, Fund der Hardware-Abnahme auf
# Pi 2): Wants= ist keine reine Reihenfolgeangabe, sondern AKTIVIERT die genannte Unit
# beim Start dieser Unit, unabhaengig davon, ob sie enabled ist - "ninlight-station" ist
# absichtlich disabled (siehe deren Kommentar: die Station ist per Vorgabe aus), wurde
# aber trotzdem bei jedem Boot mitgestartet, weil dieser (enabled) Anzeigedienst sie ueber
# Wants= mitzog. Ohne Zugangspunkt kann server.py 192.168.8.1 nicht binden, stirbt, wird
# von Restart=always dort mehrfach neu versucht und bleibt auf "failed" haengen - das
# flutet das Journal bei jedem Boot und laesst "ninlight-station status" faelschlich
# einen Fehler ("failed" statt "inactive") vermuten, wo absichtlich nichts laeuft.
# After= allein ordnet nur die Startreihenfolge, FALLS beide Units ohnehin gestartet
# werden (z.B. wenn jemand die Station von Hand einschaltet) - und genau das war hier
# gemeint, nicht "zieh die Station mit hoch". Nicht aus falscher Vorsicht wieder
# einbauen: der Nebeneffekt ist genau der oben beschriebene, kein Sicherheitsnetz.
After=ninlight-station.service
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort standen sie zuerst -
# Fixrunde 1, I1: systemd ignoriert sie dort mit "Unknown key name", die Vorgabe (5
# Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten
# eigentlich verhindern). ninlight-station.service (dieselbe Sektion) macht es richtig.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
# Reihenfolge wichtig: systemd wendet Environment=/EnvironmentFile= in der Reihenfolge
# an, in der sie hier stehen, und eine spaetere Zuweisung ueberschreibt eine gleichnamige
# fruehere. EPD_REVISION=V3 hier ist nur der Vorgabewert, falls die Konfigurationsdatei
# (unten, per EnvironmentFile=) den Schluessel (noch) nicht enthaelt - z.B. auf einem Pi,
# der vor Task 6 aufgesetzt wurde. Steht er dort, gewinnt er.
Environment=EPD_REVISION=V3
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
Environment=NINLIGHT_STATION_CONF=/etc/ninlight-station.conf
# "-"-Praefix VOR dem Dateipfad, nicht vor dem Direktivennamen (Fixrunde 3, F1 - das war
# in Fixrunde 2 falsch herum: "-EnvironmentFile=..." ist kein gueltiger Schluesselname,
# systemd verwirft die Zeile komplett mit "Unknown key name", und die Konfigurationsdatei
# wurde dadurch ueberhaupt nicht mehr gelesen - Environment=EPD_REVISION=V3 oben haette
# IMMER gewonnen, unabhaengig davon, was in /etc/ninlight-station.conf steht). Mit dem
# Praefix am richtigen Platz gilt: ohne die Konfigurationsdatei startet die Anzeige
# trotzdem (mit den Environment=-Vorgaben oben) statt mit einem harten Fehler - "die
# Anzeige darf nirgends Voraussetzung sein" gilt auch fuer ihre eigene Konfiguration.
EnvironmentFile=-/etc/ninlight-station.conf
# RuntimeDirectory=/WorkingDirectory=/Environment=HOME= zusammen beheben einen Fund aus
# der Hardware-Abnahme: lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start eine
# Benachrichtigungs-Pipe unter "$HOME/.lgd-nfy0" an. Unter systemd ist HOME per Vorgabe
# LEER und das Arbeitsverzeichnis "/" - der Pfad wird "//.lgd-nfy0", und ProtectSystem=
# strict (unten) macht "/" schreibgeschuetzt. lgpio scheitert dann mit "Can't set
# permissions", gpiozero faellt ueber rpigpio/pigpio bis zur experimentellen NativeFactory
# durch, und DIE scheitert ihrerseits mit [Errno 22] Invalid argument - display.py faengt
# das korrekt als DisplayUnavailable ab und beendet sich mit 0 (siehe Kommentar zu
# Restart=on-failure unten), aber das Panel bleibt schwarz und das Journal sieht aus wie
# "kein Panel angeschlossen", obwohl die Hardware in Ordnung ist. Am Geraet nachgemessen
# (systemd-run mit denselben zwei Property=, gegen ein Shell-echo von $HOME und pwd).
#
# RuntimeDirectory=ninlight-display ist ABSICHTLICH ein eigenes Verzeichnis, nicht das
# RuntimeDirectory=ninlight-station der Hauptunit: dieser Dienst liest von dort nur
# (status.json/control.json ueber NINLIGHT_RUN_DIR, s.o.), er soll dort nichts anlegen
# koennen. Schriebe die Anzeige in fremden Laufzeitzustand, waere das eine unnoetige
# Kopplung an dessen Lebenszyklus (siehe die Begruendung zu ReadOnlyPaths= weiter unten:
# server.py's RuntimeDirectory wird beim Stop/Neustart geleert). systemd legt
# /run/ninlight-display beim Start dieser Unit selbst an (Vorgabe 0755, vom User dieses
# Dienstes - hier root, s.u. - beschreibbar) und raeumt es beim Stop wieder auf.
RuntimeDirectory=ninlight-display
WorkingDirectory=/run/ninlight-display
Environment=HOME=/run/ninlight-display
ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
# display.py faengt ein fehlendes/abgestuerztes Panel als DisplayUnavailable ab,
# protokolliert eine Zeile und beendet sich mit sys.exit(0) - das ist also nie ein
# "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. Restart=on-failure (nicht
# always) greift nur fuer echte Abstuerze (z.B. ein Python-Traceback aus einem
# Programmierfehler); ohne den Deckel wuerde ein dauerhaft fehlendes Panel sonst NICHT
# staendig neu versucht (gut so), waehrend ein echter Bug endlos neu starten wuerde und
# so das Journal fluten koennte.
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
# KEIN ReadOnlyPaths=/run/ninlight-station hier (stand hier zuerst, Fixrunde 1, C3):
# server.py's Unit traegt RuntimeDirectory=ninlight-station, und "ninlight-station off"
# stoppt den Hauptdienst regulaer - dabei raeumt systemd dessen RuntimeDirectory auf,
# sofern nicht gerade ein Neustart lief (RuntimeDirectoryPreserve=restart deckt nur den
# Neustart-Fall ab, nicht den regulaeren Stop). Ein nicht mit "-" prefigierter Pfad in
# ReadOnlyPaths=/ReadWritePaths=/BindPaths=, der beim Start fehlt, laesst systemd die
# Namensraum-Einrichtung mit 226/NAMESPACE abbrechen - ausgerechnet dann, wenn die
# Anzeige "Station aus" zeigen soll, waere sie selbst nicht mehr startbar gewesen. Die
# Zeile war obendrein wirkungslos: ProtectSystem=strict macht "/" (und damit "/run",
# soweit nicht per ReadWritePaths= explizit freigegeben) bereits schreibgeschuetzt.
RestrictAddressFamilies=AF_UNIX AF_NETLINK
MemoryMax=64M
# Laeuft als root (wie der Hauptdienst) fuer /dev/spidev0.0 und die GPIO-Zeichenleitung -
# kein SupplementaryGroups=spi gpio hier (stand hier zuerst, Fixrunde 1, I2): auf reinem
# Debian (im Unterschied zu Raspberry Pi OS) gibt es diese Gruppen oft gar nicht, und
# eine fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP) - ausgerechnet
# in der Unit, die nie stoeren soll, wenn etwas an der Anzeige fehlt. Als root sind die
# Gruppen fuer den Geraetezugriff ohnehin nicht noetig.
[Install]
WantedBy=multi-user.target
+42
View File
@@ -0,0 +1,42 @@
[Unit]
Description=ninlight-Station: "Ausgeschaltet"-Bild aufs E-Paper
Documentation=file:/opt/ninlight-station/README.md
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
# stromlos. Faehrt der Pi herunter, wird der Anzeigedienst mit abgeraeumt - ohne diese
# Unit behauptet das Panel danach dauerhaft den Zustand von vorher. Bei der Abnahme stand
# dort die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als warte er
# noch auf eine Eingabe.
#
# Sie haengt am SYSTEM, nicht am Tasterdienst: so greift sie bei jedem Herunterfahren -
# ueber das Tastermenue (systemctl poweroff), ueber "poweroff" per SSH, ueber jeden
# anderen Weg nach poweroff.target. Am Taster allein haetten wir die Anzeige nur auf
# einem von drei Wegen.
#
# Die Gegenstueck-Unit ninlight-reboot.service macht dasselbe fuer den Neustart, mit
# ANDEREM Text: beim Neustart darf man den Stecker gerade nicht ziehen.
DefaultDependencies=no
# Erst zeichnen, wenn der Anzeigedienst die GPIO-/SPI-Leitungen losgelassen hat: zwei
# Prozesse darauf enden in "lgpio.error: 'GPIO busy'" (in diesem Projekt schon zweimal
# passiert). "After=" auf eine Unit, die gerade gestoppt wird, ordnet deren Stop VOR den
# Start dieser hier - genau die gesuchte Reihenfolge. Das CLI wartet zusaetzlich, bis
# "systemctl is-active ninlight-display" nicht mehr anschlaegt (display_final()).
After=ninlight-display.service ninlight-button.service
# ... und noch BEVOR das System die Leitungen verliert: nach umount.target gibt es weder
# /opt/ninlight-station noch die Schriftdateien, aus denen das Bild entsteht.
Before=umount.target shutdown.target final.target
[Service]
Type=oneshot
# Das Bild entsteht ueber das vorhandene CLI, nicht durch nachgebaute Logik: nur dort
# stehen EPD_REVISION aus /etc/ninlight-station.conf, die Uebergabe der GPIO-Leitungen
# und die Zusage "ein Fehler an der Anzeige haelt das Herunterfahren nie auf" (das CLI
# beendet sich hier immer mit 0).
ExecStart=/usr/local/bin/ninlight-station display final poweroff
# Ein Bildaufbau auf diesem Panel dauert Sekunden, nicht Minuten; dazu die Wartezeit auf
# die Freigabe der Leitungen. Der Deckel ist da, damit ein haengendes Panel das
# Herunterfahren nicht dauerhaft aufhaelt - systemds Vorgabe von 90 s waere hier eine
# sehr lange Zeit vor einem dunklen Geraet.
TimeoutStartSec=30
[Install]
WantedBy=poweroff.target
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=ninlight-Station: "Neustart"-Bild aufs E-Paper
Documentation=file:/opt/ninlight-station/README.md
# Das Gegenstueck zu ninlight-poweroff.service, mit einem ANDEREN Text - und das ist hier
# kein Schoenheitsfehler, sondern der Zweck der zweiten Unit:
#
# Herunterfahren -> "AUSGESCHALTET / Strom kann getrennt werden"
# Neustart -> "NEUSTART / Strom NICHT trennen!"
#
# Ein Raspberry Pi Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder
# zeichnet. Stuende dort waehrenddessen "Strom kann getrennt werden", waere das eine
# Aufforderung zu genau dem Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
#
# Zwei Units statt einer mit Fallunterscheidung: systemd sagt einem ExecStop/ExecStart
# nicht, wohin die Reise geht - man muesste "systemctl list-jobs" nach "reboot.target"
# durchsuchen. Ueber WantedBy= ist es dagegen der Abschalt-Zieleinheit selbst entnommen
# und damit nicht zu verwechseln.
DefaultDependencies=no
# Reihenfolge und Begruendung wie in ninlight-poweroff.service (GPIO-Uebergabe, und noch
# vor umount.target zeichnen).
After=ninlight-display.service ninlight-button.service
Before=umount.target shutdown.target final.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ninlight-station display final reboot
TimeoutStartSec=30
[Install]
WantedBy=reboot.target
+1194
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
[Unit]
Description=ninlight recovery station (HTTP image server)
# Ohne den Zugangspunkt hat der Server beim Start keine Adresse zum Binden.
# Bewusst kein BindsTo=NetworkManager.service: ein dadurch ausgeloester Stop
# gilt fuer systemd als sauber und wuerde von KEINER Restart=-Politik erneut
# gestartet - ein NetworkManager-Neustart wuerde den Server dann still und
# dauerhaft mitreissen. Stattdessen nur Start-Reihenfolge vorgeben und den
# Prozess bei jedem Absturz (z.B. Bind-Fehler nach einem NM-Neustart) per
# Restart=always in kurzen Abstaenden neu versuchen lassen.
After=network.target NetworkManager.service
# Steht der Zugangspunkt nicht, kann server.py 192.168.8.1 nicht binden und
# stirbt sofort; Restart=always versucht es alle RestartSec=2 erneut. Damit
# das nicht endlos das Journal flutet, sondern nach kurzer Zeit ehrlich
# aufgibt (und ninlight-station dann mit einer klaren Fehlermeldung reagieren
# kann statt in einer stillen Neustartschleife zu haengen), hier explizit
# gedeckelt statt sich auf systemds Vorgabewerte zu verlassen.
StartLimitIntervalSec=30
StartLimitBurst=5
[Service]
Type=simple
EnvironmentFile=/etc/ninlight-station.conf
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode --imageset-file /var/lib/ninlight-station/imageset
Restart=always
RestartSec=2
# Laeuft als root: Port 80 und - fuer 4c - die DHCP-Leases in
# /var/lib/NetworkManager (Verzeichnis 0700 root:root) sind sonst nicht erreichbar.
# Gegengewicht:
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RuntimeDirectory=ninlight-station
# Ohne dies leert systemd /run/ninlight-station bei JEDEM Stop und Neustart, nicht
# nur beim allerersten Start - und ninlight-station (das CLI) schreibt mode/
# control.json immer VOR dem "systemctl restart", mit dem migrate/recovery on den
# Dienst (neu) starten. Ohne RuntimeDirectoryPreserve=restart wischt genau dieser
# Neustart die beiden gerade geschriebenen Dateien wieder weg, bevor der neue
# Serverprozess sie lesen kann - der Dienst faellt dann still auf den
# Recovery-Default zurueck (server.py resolve_mode()/read_control()), und ein
# ausdrueckliches "migrate on --listen" wird zu "Rettungsmodus" bzw. "auto"
# (Fixrunde 1, C1). "restart" (nicht "yes") reicht: das Verzeichnis muss nur einen
# Neustart ueberleben, nicht dauerhaft bestehen bleiben, wenn der Dienst regulaer
# gestoppt wird.
RuntimeDirectoryPreserve=restart
ReadWritePaths=/run/ninlight-station
RestrictAddressFamilies=AF_INET AF_UNIX
MemoryMax=128M
[Install]
WantedBy=multi-user.target
+113
View File
@@ -0,0 +1,113 @@
"""Produkt-IDs der alten Klyqa-Firmware und ihre Zuordnung zu ninlight-Zielen.
Vollstaendig uebernommen aus qcl_product_info.c der alten Firmware
(~/workspace/qcx-w600-firmware).
Die Variante (dev/prod) entscheidet ueber den Signierschluessel: Lampen mit
Entwicklungs-Bootloader akzeptieren nur den Entwicklungs-, Produktionslampen nur den
Produktionsschluessel. Sie ist aber KEINE Eigenschaft der Produkt-ID, sondern eine
Eigenschaft der ANFRAGE - das ist der Befund vom 2026-09-15 an echter Hardware:
/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img
Dieselbe Entwicklungs-G95, aber das "-dev" steht nur in der VERSION, nicht in der
Produkt-ID. Die Station hielt sie damit fuer eine Produktionslampe, lieferte das
produktionssignierte Image aus, und die Lampe lehnte es ab. Die Machbarkeitsprobe im
September hatte eine andere Entwicklungs-G95 mit "-dev" an BEIDEN Stellen gesehen, eine
Produktionslampe an keiner - alle drei Formen kommen also vor.
Regel seither: "-dev" an einer der beiden Stellen (Produkt-ID oder Version) bedeutet
Entwicklungsgeraet. PRODUCTS bildet nur noch auf Geraetetyp und Anzeigename ab, beide
Schreibweisen der Produkt-ID fuehren auf denselben Eintrag; die Variante ermittelt
parse_request() aus beiden Teilen und meldet in .variant_source mit, WORAUS sie stammt
("both", "product_id", "version", "none"), damit eine Fehlklassifikation vor der Freigabe
am Panel und in status.json sichtbar ist statt erst an der Lampe.
"""
import re
from collections import namedtuple
Product = namedtuple("Product", "product_id target name")
ParsedRequest = namedtuple(
"ParsedRequest", "product_id lamp_version product variant variant_source")
# (Produkt-ID ohne "@", Ziel, Anzeigename) - die "-dev"-Schreibweise wird darunter
# als zweiter Schluessel auf denselben Eintrag erzeugt.
_BASE = [
("klyqa.lighting.rgb-cw-ww.e27", "e27_rgb", "Klyqa E27 RGB CW/WW"),
("klyqa.lighting.rgb-cw-ww.e14", "e14_rgb", "Klyqa E14 RGB CW/WW"),
("klyqa.lighting.rgb-cw-ww.gu10", "gu10_rgb", "Klyqa GU10 RGB CW/WW"),
("klyqa.lighting.cw-ww.e27", "e27_cw", "Klyqa E27 CW/WW"),
("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"),
("klyqa.lighting.cw-ww.gu10", "gu10_cw", "Klyqa GU10 CW/WW"),
("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage CW/WW"),
("qcx.montana.black.ledshield.rgb", "ledshield_rgb", "Montana Black RGB LED Shield"),
]
_UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]")
# Die Produkt-ID darf kein "_" enthalten - daran trennen wir Version und ID.
_PATH = re.compile(r"^/qcx_firmware_([^/_]+)_([^/_]+)_ota_light\.img$")
# "-dev" in der Version, aber nicht als Anfang eines laengeren Wortes ("-devel", "-dev2"):
# erlaubt sind danach nur Ende oder ein Trennzeichen wie "." (z.B. "V1.1-dev.3").
_DEV_IN_VERSION = re.compile(r"-dev(?![0-9A-Za-z])")
def websafe(product_id):
"""Ersetzt jedes Zeichen ausserhalb [0-9A-Za-z+-_.] durch '-', wie BuildOTAURL()
in qc_ota.c der alten Firmware. Praktisch betrifft das nur das fuehrende '@'."""
return _UNSAFE.sub("-", product_id)
def _build():
t = {}
for pid, target, name in _BASE:
base_key = websafe("@" + pid)
product = Product(base_key, target, name)
# Beide Schreibweisen zeigen auf DENSELBEN Eintrag - "-dev" an der Produkt-ID
# sagt seit dem Hardwarebefund nichts mehr ueber das Ziel, nur noch (mit) etwas
# ueber die Variante, und die ermittelt parse_request().
t[base_key] = product
t[websafe("@" + pid + "-dev")] = product
# Das EVK hat nur eine Produktions-ID. Im Dev-Build meldet es sich laut
# qcl_product_info.c als "@klyqa.lighting.rgb-cw-ww.e27-dev" - die EVK-Zeile ist dort
# auskommentiert und durch die E27 ersetzt. Ein dev-signiertes EVK ist damit von einer
# dev-signierten E27 RGB NICHT unterscheidbar und bekommt deren Image. Das EVK wird
# ohnehin seriell geflasht; die Zeile steht nur der Vollstaendigkeit halber hier.
# Meldet es eine "-dev"-Version, gilt auch hier die Regel: dann wird evk_dev.img
# gesucht (und mangels Datei sauber mit 404 abgelehnt), nicht das falsch signierte.
key = websafe("@klyqa.lighting.w600.evk1.2")
t[key] = Product(key, "evk", "W600 EVK v1.2")
return t
PRODUCTS = _build()
def classify_variant(product_id, lamp_version):
"""(variant, variant_source) aus Produkt-ID UND Version.
"-dev" an einer der beiden Stellen bedeutet Entwicklungsgeraet; variant_source sagt,
an welcher es stand: "both", "product_id", "version" oder - bei prod - "none"."""
in_id = product_id.endswith("-dev")
in_version = bool(_DEV_IN_VERSION.search(lamp_version))
if in_id and in_version:
return "dev", "both"
if in_id:
return "dev", "product_id"
if in_version:
return "dev", "version"
return "prod", "none"
def parse_request(path):
"""Zerlegt einen angefragten Pfad. Gibt None zurueck, wenn er nicht dem Muster der
alten Firmware entspricht, sonst ParsedRequest - dessen .product ist None, wenn die
Produkt-ID unbekannt ist. Unbekannt heisst 404, nicht raten. .variant/.variant_source
stehen auch bei unbekannter Produkt-ID, sie haengen nicht an der Tabelle."""
m = _PATH.match(path)
if not m:
return None
pid, version = m.group(1), m.group(2)
if pid != websafe(pid) or "/" in pid or ".." in pid.split("."):
return None
variant, source = classify_variant(pid, version)
return ParsedRequest(pid, version, PRODUCTS.get(pid), variant, source)
+8
View File
@@ -0,0 +1,8 @@
[project]
name = "ninlight-station"
version = "0.1.0"
description = "recovery station HTTP server for ninlight lamps"
requires-python = ">=3.11"
[dependency-groups]
dev = ["pytest>=8"]
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env python3
"""ninlight recovery/migration station.
Serves firmware images to lamps that look for a rescue station while booting (mode
"recovery": ninlight_<target>_ota.img) or to old Klyqa lamps looking for their original
update server (mode "migration": qcx_firmware_<product-id>_<version>_ota_light.img).
Deliberately minimal: only files matching one of these two exact patterns ever leave the
machine, and only the pattern for the active mode is served.
Standard library only - the Pi gets no extra packages.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import re
import select
import socket
import sys
import tempfile
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
# products.py lives next to this file. sys.path[0] already covers that when server.py is
# run the normal way (as a script or via `python3 server.py ...`), but a test that loads
# this module through importlib.util.spec_from_file_location (see test_server.py's
# test_log_error_is_not_silenced_by_log_message) does not get that for free.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import products
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw",
"ledshield_rgb")
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
TRAILER_SIZE = 512
TRAILER_MAGIC = b"NINL"
VERSION_OFF = 21
VERSION_LEN = 16
RECENT_MAX = 8
CHUNK = 32 * 1024
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def trailer_version(path: Path) -> str:
"""Firmware version from the image trailer, or '?' if the file carries none."""
try:
with path.open("rb") as f:
if path.stat().st_size < TRAILER_SIZE:
return "?"
f.seek(-TRAILER_SIZE, os.SEEK_END)
tail = f.read(TRAILER_SIZE)
except OSError:
return "?"
if tail[0:4] != TRAILER_MAGIC:
return "?"
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
# --- Bildsatz: mit oder ohne einkompilierte WLAN-Zugangsdaten -----------------------
#
# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware (config.local.cmake). Es gibt
# deshalb zwei Abzuege je Ziel, und beide liegen auf dem Pi:
#
# "ohne-wlan" -> images/migrate-nocreds/ aus tools/build_nocreds.sh: die migrierte
# Lampe startet im eigenen Onboarding-Zugangspunkt (ninlight-<mac4>)
# und wartet, bis ihr jemand ein WLAN nennt.
# "mit-wlan" -> images/migrate-wlan/ aus build/: die Lampe haengt sofort im WLAN
# des Bedieners.
#
# Welcher ausgeliefert wird, entscheidet ein UMSCHALTER (ninlight-station bildsatz bzw.
# der Menueeintrag am Taster), nicht eine Abfrage je Migration: sein Zustand bleibt, bis
# ihn jemand aendert, und er steht dauerhaft auf dem Panel.
#
# Vorgabe ist "ohne-wlan", und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar,
# unbekannter Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in
# Funkreichweite migriert, auch fremde. Ein Abzug OHNE Zugangsdaten ist dann ein
# aergerlicher, aber harmloser Fehlgriff - die Lampe steht im Onboarding-AP und wartet.
# Ein Abzug MIT Zugangsdaten traegt das WLAN-Passwort des Bedieners unwiderruflich in
# ein fremdes Geraet. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
DEFAULT_IMAGE_SET = "ohne-wlan"
def read_image_set(path: Path | None) -> str:
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
ist oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei darf
den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
if path is None:
return DEFAULT_IMAGE_SET
try:
value = path.read_text().strip()
except OSError:
return DEFAULT_IMAGE_SET
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
class Station:
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None,
mode: str = "recovery", control_path: Path | None = None,
imageset_path: Path | None = None) -> None:
self.image_dir = image_dir
# Bei JEDER Anfrage neu gelesen, nicht einmal beim Start: der Umschalter soll
# wirken, ohne den Dienst neu zu starten - ein Neustart mitten in einer
# laufenden Auslieferung waere genau das, was die Station nicht tun darf.
self.imageset_path = imageset_path
self.state_path = state_path
self.stall_after = stall_after
self.mode = mode
self.control_path = control_path or Path("/run/ninlight-station/control.json")
self.since = now_iso()
self.served = 0
self.recent: list[dict] = []
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
self.last_seen: dict | None = None
self._manifest_mtime: float | None = None
# Zu welchem Bildsatz der Cache gehoert - siehe _load_manifest().
self._manifest_path: Path | None = None
# Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch
# geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt
# jeder Eintrag seine eigene Version, image_version() muss also den ganzen
# Eintragssatz vorhalten, nicht nur einen String.
self._manifest_cache: dict = {}
# Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
# dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf
# Platte geschrieben werden konnte (Fixrunde 1, I3). control_error landet in
# status.json, damit ein Bedienfehler nicht nur auf stdout verschwindet.
self._control_write_broken = False
self.control_error: str | None = None
# Cumulative bytes already delivered to each client while --stall-after is active,
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
# resumes with Range right where it left off, so a per-request counter would hand
# every reconnect a brand-new --stall-after allowance and the stall would never
# actually stick (see reserve_stall_bytes below).
self.stall_sent: dict[str, int] = {}
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
# can be downloading at once. Every mutation of served/recent/stall_sent and the
# snapshot that gets serialized to disk must happen under this same lock, or
# concurrent requests lose increments and stomp on each other's status.json writes.
self.lock = threading.RLock()
self.write_state()
def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img"
def image_set(self) -> str:
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
return read_image_set(self.imageset_path)
def migrate_dir(self) -> Path:
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes. Es kann fehlen (der Satz wurde
nie ausgeliefert) - dann wird NICHTS ausgeliefert, siehe Handler: auf den anderen
Satz auszuweichen waere genau der Fehlgriff, gegen den der Umschalter da ist."""
return self.image_dir / IMAGE_SET_DIRS[self.image_set()]
def migrate_image_path(self, target: str, variant: str) -> Path:
return self.migrate_dir() / f"{target}_{variant}.img"
def migrate_set_present(self) -> bool:
"""Liegt der eingestellte Bildsatz ueberhaupt auf dem Pi? "Vorhanden" heisst: das
Verzeichnis gibt es UND es enthaelt mindestens ein Image. Ein leeres Verzeichnis
zaehlt bewusst als nicht vorhanden - es entsteht genauso leicht (angelegt, aber nie
bestueckt) und unterscheidet sich fuer den Bediener in nichts; das Panel und das
CLI beurteilen es nach derselben Regel, sonst behaupten drei Stellen dreierlei."""
try:
return any(p.name.endswith(".img") for p in self.migrate_dir().iterdir())
except OSError:
return False
def _load_manifest(self) -> dict:
"""Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen
bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie
unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis
{} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit
eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem
AttributeError den Server abschiesst."""
path = self.migrate_dir() / "manifest.json"
try:
mtime = path.stat().st_mtime
except OSError:
self._manifest_mtime = None
self._manifest_path = path
self._manifest_cache = {}
return self._manifest_cache
# Der Pfad gehoert mit in den Vergleich: nach einem Umschalten zeigt er auf eine
# ANDERE Datei, die zufaellig dieselbe mtime tragen kann (beide entstehen im
# selben Deploy, oft in derselben Sekunde). Ohne ihn behielte der Cache die
# Versionen des vorigen Satzes - und die stuenden dann in status.json und am
# Panel neben einem Image, das gar nicht von dort kommt.
if mtime != self._manifest_mtime or path != getattr(self, "_manifest_path", None):
self._manifest_path = path
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
data = {}
if not isinstance(data, dict):
data = {}
self._manifest_cache = data
self._manifest_mtime = mtime
return self._manifest_cache
def image_version(self, target: str, variant: str) -> str:
"""Firmware-Version fuer genau das Migrationsimage, das gerade ausgeliefert
wird - die Migrationsimages selbst tragen keinen ninlight-Trailer, aus dem der
Server sie lesen koennte (Spec Abschnitt 4). Bevorzugt die Version aus dem
passenden Eintrag in manifest.json; nur wenn der Eintrag keine eigene traegt
(Bestandskompatibilitaet mit einem manifestweiten Feld), faellt es auf das
Feld auf oberster Ebene zurueck. Ein einzelnes manifestweites Feld allein
waere seit dem Zusammenfuehren mehrerer Deploys (deploy.sh, Fixrunde 1 I1)
schlicht nicht mehr wahr: uebernommene Alt-Eintraege koennen eine andere
Version tragen als die gerade neu signierten (Fixrunde 2, N2)."""
data = self._load_manifest()
images = data.get("images")
if isinstance(images, list):
for entry in images:
if not isinstance(entry, dict):
continue
if entry.get("target") == target and entry.get("variant") == variant:
version = entry.get("ninlight_version")
if isinstance(version, str) and version:
return version
break
top = data.get("ninlight_version")
return top if isinstance(top, str) and top else "unbekannt"
def read_control(self):
"""Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" -
eine fehlende Steuerdatei darf eine Migration nie blockieren, sondern hoechstens
bequemer machen als beabsichtigt. Bei jeder Anfrage frisch gelesen, damit
"ninlight-station arm" ohne Neustart des Dienstes wirkt."""
try:
d = json.loads(self.control_path.read_text())
except (OSError, ValueError):
return "auto", None
phase = d.get("phase")
if phase not in ("auto", "listen", "armed"):
return "auto", None
return phase, d.get("armed_target")
def write_control(self, phase: str, armed_target: str | None) -> None:
with self.lock:
try:
self.control_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.control_path.parent, prefix=".control-")
try:
with os.fdopen(fd, "w") as f:
json.dump({"phase": phase, "armed_target": armed_target}, f)
# Dasselbe Muster wie write_state() unten, aus demselben Grund: der
# Dienst laeuft als root, mkstemp() legt 0600 an, und ohne das
# explizite chmod() koennte das CLI die Steuerdatei nicht lesen.
os.chmod(tmp, 0o644)
os.replace(tmp, self.control_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
except OSError as e:
self._control_write_broken = True
self.control_error = f"cannot write {self.control_path}: {e}"
print(f"warning: {self.control_error}", flush=True)
else:
self._control_write_broken = False
self.control_error = None
def claim_delivery(self, target: str) -> tuple[str, str | None, bool]:
"""Entscheidet unter dem Lock, ob eine Anfrage fuer `target` gerade ausgeliefert
werden darf, und verbraucht - falls die Freigabe dafuer verantwortlich ist - diese
sofort per Compare-and-Set (lesen und auf "listen" setzen, ohne das Lock
dazwischen loszulassen). Ohne das koennten zwei gleichzeitige Anfragen (zwei
Lampen, oder Erst- und Range-Resume-Verbindung derselben Lampe) beide phase ==
"armed" sehen und beide ausgeliefert bekommen (Fixrunde 1, C1) - genau das Szenario,
fuer das die Freigabephase laut Spec 3.3 da ist.
Liefert (phase, armed_target) wie sie VOR dem Verbrauch galten, plus ob die
aktuelle Anfrage ausliefern darf. Bricht die Auslieferung anschliessend ab
(status == "aborted") oder findet der Aufrufer kein Image, muss er bei
phase == "armed" restore_armed(target) aufrufen, damit die Freigabe fuer den
naechsten Bootversuch stehen bleibt.
Schlaegt das Schreiben von "listen" fehl (I3), bekommt nur die eine Anfrage, die
den Fehler zuerst ausloest, noch ihre Auslieferung - jede weitere schlaegt hier
erneut fehl (_control_write_broken bleibt True), bis ein write_control()-Versuch
wieder klappt. Damit bleibt "armed" nicht dauerhaft scharf, nur weil /run gerade
nicht beschreibbar ist, heilt sich aber von selbst, sobald es das wieder ist."""
with self.lock:
phase, armed_target = self.read_control()
if phase == "listen" or (phase == "armed" and armed_target != target):
return phase, armed_target, False
if phase == "armed":
was_broken = self._control_write_broken
self.write_control("listen", None)
if self._control_write_broken:
return phase, armed_target, not was_broken
return phase, armed_target, True
def restore_armed(self, target: str) -> None:
"""Setzt eine per claim_delivery() verbrauchte Freigabe zurueck, wenn die
Auslieferung danach doch nicht (vollstaendig) stattgefunden hat - ein Abbruch darf
die Freigabe nicht verbrauchen, sonst muesste am Geraet erneut von Hand gearmt
werden, obwohl die Lampe es beim naechsten Bootversuch selbst nochmal versuchen
wuerde (Spec 3.3, Brief-Schritt 3.6)."""
self.write_control("armed", target)
def reserve_stall_bytes(self, ip: str, n: int) -> int:
"""How many of the next `n` bytes this client may still receive under --stall-after.
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
that the lamp abandons and resumes via Range does not get a fresh budget just
because it is a new TCP connection. Only called when stall_after is set.
"""
with self.lock:
sent_so_far = self.stall_sent.get(ip, 0)
allowed = max(0, min(n, self.stall_after - sent_so_far))
self.stall_sent[ip] = sent_so_far + allowed
return allowed
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
with self.lock:
if status == "ok":
self.served += 1
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
"version": version, "bytes": nbytes, "status": status})
del self.recent[:-RECENT_MAX]
self.write_state()
def record_migration(self, ip: str, target: str | None, variant: str | None,
product_id: str, lamp_version: str, product_name: str | None,
version: str, nbytes: int, status: str,
variant_source: str | None = None) -> None:
"""Protokolliert eine Migrationsanfrage. Anders als record() oben wird hier auch
dann ein Eintrag angelegt (Status "listen"/"unknown"/"notfound"), wenn nichts
ausgeliefert wurde - das ist der ganze Zweck der Horchphase. last_seen wird nur bei
einer erkannten Produkt-ID gesetzt (target is not None); eine unbekannte Produkt-ID
hat kein Modell, das die Anzeige zeigen koennte."""
with self.lock:
if status == "ok":
self.served += 1
entry = {"ts": now_iso(), "ip": ip, "target": target, "variant": variant,
"variant_source": variant_source,
"product_id": product_id, "lamp_version": lamp_version,
"version": version, "bytes": nbytes, "status": status}
self.recent.append(entry)
del self.recent[:-RECENT_MAX]
if target is not None:
self.last_seen = {"product_id": product_id, "target": target,
"variant": variant, "variant_source": variant_source,
"product_name": product_name,
"lamp_version": lamp_version, "ip": ip, "ts": entry["ts"]}
self.write_state()
def write_state(self) -> None:
with self.lock:
payload = {"since": self.since, "mode": self.mode, "served": self.served,
"recent": list(self.recent)}
if self.mode == "migration":
phase, armed_target = self.read_control()
payload["phase"] = phase
payload["armed_target"] = armed_target
payload["last_seen"] = self.last_seen
payload["control_error"] = self.control_error
try:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2)
# The service runs as root and mkstemp() creates the file 0600, so
# without this an operator reading status.json as a normal user (see
# tools/station/ninlight-station) gets "permission denied" - and since
# os.replace() preserves the mode it arrives with, that wrong mode
# would otherwise survive onto the live file on every single write,
# not just the first. os.chmod() ignores the process umask (unlike the
# mode argument to mkstemp/open), so a restrictive umask on the Pi
# can't quietly bring the 0600 back. Set it before the rename so
# there's never a window where the path holds a 0600 file.
os.chmod(tmp, 0o644)
os.replace(tmp, self.state_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
except OSError as e:
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "ninlight-station"
@property
def station(self) -> Station:
return self.server.station # type: ignore[attr-defined]
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
pass
def log_error(self, fmt: str, *args) -> None:
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
# silence above in favour of our own log() calls - but that also silences it for
# requests that never reach do_GET/do_POST (a malformed request line, a client
# timeout mid-request). The station is the only diagnostic tool available during a
# rescue, so those cases must still produce a line instead of nothing.
try:
client = self.client_address[0]
except (AttributeError, IndexError):
client = "?"
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
def log(self, status: int, nbytes: int, started: float) -> None:
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
def do_POST(self) -> None:
started = time.monotonic()
self.reject(405, "method not allowed")
self.log(405, 0, started)
do_PUT = do_DELETE = do_HEAD = do_POST
def reject(self, status: int, text: str) -> None:
body = (text + "\n").encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
started = time.monotonic()
if self.station.mode == "migration":
self._do_get_migration(started)
else:
self._do_get_recovery(started)
def _do_get_recovery(self, started: float) -> None:
m = PATH_RE.match(self.path)
if not m:
self.reject(404, "not found")
self.log(404, 0, started)
return
target = m.group(1)
path = self.station.image_path(target)
if not path.is_file():
# Record before responding: a client that sees the 404 complete may check
# status.json right away, so the entry must already be on disk by then.
self.station.record(self.client_address[0], target, "?", 0, "notfound")
self.reject(404, "no image for this device")
self.log(404, 0, started)
return
version = trailer_version(path)
sent, status = self._serve_file(path, started)
# Record only now that the transfer has actually finished (or failed): "ok" must
# never be written before the last byte is confirmed sent, or status.json can
# claim success for a device that never received the end of the image.
self.station.record(self.client_address[0], target, version, sent, status)
def _do_get_migration(self, started: float) -> None:
parsed = products.parse_request(self.path)
if parsed is None:
# Ein fremder Pfad, kein qcx_firmware_*-Muster - kein Protokolleintrag in
# status.json, das waere nur Rauschen (Brief Schritt 3.4).
self.reject(404, "not found")
self.log(404, 0, started)
return
pid, lamp_version, product, variant, variant_source = parsed
ip = self.client_address[0]
if product is None:
self.station.record_migration(ip, None, None, pid, lamp_version, None, "?", 0,
"unknown")
self.reject(404, "unknown product")
self.log(404, 0, started)
return
# variant/variant_source kommen aus parse_request(), nicht mehr aus der
# Produkttabelle: das "-dev" kann in der Produkt-ID stehen, in der Version, oder in
# beiden (Hardwarebefund 2026-09-15, siehe products.py). Das entscheidet, welches
# der beiden unterschiedlich signierten Images ausgeliefert wird.
target, name = product.target, product.name
version = self.station.image_version(target, variant)
# claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
phase, armed_target, allowed = self.station.claim_delivery(target)
if not allowed:
# last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase:
# die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht.
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
version, 0, "listen", variant_source)
self.reject(404, "listening only")
self.log(404, 0, started)
return
path = self.station.migrate_image_path(target, variant)
if not self.station.migrate_set_present():
# Der EINGESTELLTE Bildsatz liegt gar nicht auf dem Pi. Hier wird bewusst
# nichts ausgeliefert und auch nicht auf den anderen Satz ausgewichen: der
# Umschalter existiert genau dafuer, dass niemand versehentlich Lampen mit
# den eigenen WLAN-Zugangsdaten flasht. Ein eigener Status, damit der
# Bediener am Panel den Unterschied zu "dieses Modell fehlt" sieht.
if phase == "armed":
self.station.restore_armed(target)
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
version, 0, "noimageset", variant_source)
self.reject(404, "selected image set not available")
self.log(404, 0, started)
return
if not path.is_file():
if phase == "armed":
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten,
# dass es gar kein Image gibt - das darf nicht auf Kosten der Freigabe gehen.
self.station.restore_armed(target)
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
version, 0, "notfound", variant_source)
self.reject(404, "no image for this device")
self.log(404, 0, started)
return
sent, status = self._serve_file(path, started)
if phase == "armed" and status == "aborted":
# Bricht der Transfer ab, bleibt die Freigabe stehen, damit die Lampe es beim
# naechsten Boot noch einmal versuchen kann (Spec 3.3). claim_delivery() hat sie
# bereits auf "listen" gesetzt, bevor _serve_file() ueberhaupt anfing zu senden
# (das ist der Punkt von C1) - bei einem Abbruch wird das hier zurueckgenommen.
self.station.restore_armed(target)
self.station.record_migration(ip, target, variant, pid, lamp_version, name, version,
sent, status, variant_source)
def _serve_file(self, path: Path, started: float) -> tuple[int, str]:
"""Sendet `path` mit Range-Unterstuetzung; liefert (gesendete Bytes, Status).
Gemeinsam fuer Rettungs- und Migrationsroute - Uebertragungsmechanik (Range,
--stall-after, Abbruchbehandlung) ist identisch, nur was mit dem Ergebnis
geschieht, unterscheidet sich."""
size = path.stat().st_size
start = 0
rng = self.headers.get("Range", "")
if rng.startswith("bytes="):
first = rng.split("=", 1)[1].split("-", 1)[0]
if first.isdigit() and int(first) < size:
start = int(first)
self.send_response(206 if start else 200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(size - start))
if start:
self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Connection", "close")
self.end_headers()
sent = 0
status = "ok"
remaining = size - start
client_ip = self.client_address[0]
try:
with path.open("rb") as f:
f.seek(start)
while sent < remaining:
read_size = min(CHUNK, remaining - sent)
if self.station.stall_after is not None:
# Reserve against this client's cumulative budget, not just this
# request's byte count - see Station.reserve_stall_bytes.
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
if read_size == 0:
# test aid: hold the connection open and send nothing more, so
# the firmware's stall handling can be exercised on real
# hardware. We still notice if the peer gives up and closes its
# side, so a test (or an impatient client) that hangs up is
# recorded as "aborted" instead of leaking the thread forever.
while True:
readable, _, _ = select.select([self.connection], [], [], 1.0)
if readable:
try:
peek = self.connection.recv(1, socket.MSG_PEEK)
except OSError:
peek = b""
if not peek:
raise ConnectionResetError("peer closed during stall")
chunk = f.read(read_size)
if not chunk:
# Die Datei ist kuerzer als das eingangs per stat() ermittelte
# Content-Length (z.B. von deploy.sh waehrend einer laufenden
# Auslieferung durch eine kleinere ersetzt) - sent < remaining an
# dieser Stelle immer, denn die Schleifenbedingung oben haette sonst
# schon abgebrochen. Das ist ein abgebrochener, kein erfolgreicher
# Transfer (Fixrunde 1, I2): ohne dies wuerde eine im Migrationsmodus
# laufende "armed"-Freigabe faelschlich als verbraucht gelten, obwohl
# die Lampe nie das vollstaendige Image bekommen hat.
status = "aborted"
break
self.wfile.write(chunk)
sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
status = "aborted"
self.log(206 if start else 200, sent, started)
return sent, status
def resolve_mode(explicit_mode: str | None, mode_file: Path | None) -> str:
"""Bestimmt den Betriebsmodus. Vorrang: ein ausdrueckliches --mode schlaegt
--mode-file, --mode-file schlaegt die Vorgabe "recovery". Eine fehlende oder
unlesbare/unerwartete Modusdatei bedeutet ebenfalls "recovery" - Task 4 baut die
systemd-Unit darauf auf, dass der Modus ohne Neustart-Argumentaenderung aus einer
Datei kommt (/run/ninlight-station/mode), und ein fehlerhafter Zustand dieser Datei
darf den Dienst nicht zum Absturz bringen oder unbeabsichtigt scharf schalten."""
if explicit_mode is not None:
return explicit_mode
if mode_file is not None:
try:
text = mode_file.read_text().strip()
except OSError:
return "recovery"
if text in ("recovery", "migration"):
return text
return "recovery"
return "recovery"
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="ninlight recovery/migration station HTTP server")
p.add_argument("--image-dir", required=True, type=Path)
p.add_argument("--bind", default="192.168.8.1")
p.add_argument("--port", type=int, default=80)
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
p.add_argument("--mode", choices=("recovery", "migration"), default=None,
help="ausdruecklicher Modus - schlaegt --mode-file (Vorgabe: siehe dort)")
p.add_argument("--mode-file", type=Path, default=None,
help="Datei mit 'recovery' oder 'migration'; wird ohne --mode verwendet, "
"Vorgabe bei fehlender/kaputter Datei ist 'recovery'")
p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"),
help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus")
p.add_argument("--imageset-file", type=Path, default=None,
help="Datei mit 'ohne-wlan' oder 'mit-wlan' (Umschalter des "
"Migrations-Bildsatzes); Vorgabe bei fehlender/kaputter Datei "
"ist 'ohne-wlan'")
p.add_argument("--stall-after", type=int, default=None,
help="test aid: stop sending after N bytes but keep the connection open")
a = p.parse_args(argv)
if not a.image_dir.is_dir():
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
return 2
mode = resolve_mode(a.mode, a.mode_file)
station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control,
imageset_path=a.imageset_file)
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
httpd.daemon_threads = True
httpd.station = station # type: ignore[attr-defined]
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
f"{', '.join(images) or 'none'}", flush=True)
if mode == "migration":
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
selected = station.image_set()
present = "vorhanden" if station.migrate_set_present() else "FEHLT"
print(f"{now_iso()} image set: {selected} ({station.migrate_dir()}, {present})",
flush=True)
if a.stall_after is not None:
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
+583
View File
@@ -0,0 +1,583 @@
#!/usr/bin/env bash
# Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches
# Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem
# schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration"
# unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet).
#
# Aufruf: tools/station/setup_pi.sh <pi-host>
set -euo pipefail
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
HERE=$(cd "$(dirname "$0")" && pwd)
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und in deploy.sh), damit ein Test gegen
# Stubs statt einen echten Pi laufen kann.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
# Wo die per scp uebertragenen Dateien auf dem Zielsystem zwischenlanden, bevor
# sie an ihren endgueltigen Platz installiert werden. Ueberschreibbar wie die
# anderen NINLIGHT_*-Pfade: auf dem Pi bleibt es bei /tmp, ein Test kann hier in
# eine Sandbox statt in das echte /tmp des Entwicklungsrechners umleiten
# (Fixrunde 2, N4) - sowohl fuer die lokale scp-Zielangabe unten als auch fuer
# die REMOTE-Bloecke, die dieselbe Variable (mit demselben Default) referenzieren.
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
echo "== Voraussetzungen auf $HOST pruefen =="
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
echo " ok"
REMOTE
echo "== Konfiguration =="
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example"
# CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es
# beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte
# Systempfade laufen.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
TEMPLATE=${NINLIGHT_STAGE_DIR:-/tmp}/ninlight-station.conf.example
if [ -f "$CONF" ]; then
# Eine bestehende Konfiguration bleibt bei ihren Werten - nur Schluessel, die
# eine neuere Version des CLI zusaetzlich verlangt (z.B. die Migrations- und
# Anzeige-Schluessel aus Task 4), werden aus der Vorlage ergaenzt. Ohne das
# wuerde das Ausrollen eines neuen ninlight-station auf einen bereits
# eingerichteten Pi jeden Unterbefehl - "off" eingeschlossen - mit "Schluessel
# ... fehlt" blockieren, und ein bereits laufender Zugangspunkt bliebe an, bis
# jemand die 0600-Datei von Hand ergaenzt (Fixrunde 1, I4).
ADDED=""
for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
# Nicht nur "^KEY=": eine vorhandene Zuweisung mit fuehrendem Leerraum oder
# einem "export" davor (beides gueltiges ". datei"-Shell) wuerde sonst als
# fehlend gelten, die Vorlagenzeile wird angehaengt, und da das CLI die
# Datei per ". $CONF" sourct, gewinnt die LETZTE Zuweisung - ein eigenes
# Passwort wuerde so still durch den oeffentlichen Vorlagenwert ersetzt
# (Fixrunde 2, N3: genau das "vorhandene Werte nicht ueberschreiben" aus I4,
# nur eine Formatierungsvariante weiter).
if ! sudo grep -Eq "^[[:space:]]*(export[[:space:]]+)?${KEY}=" "$CONF"; then
LINE=$(grep -E "^${KEY}=" "$TEMPLATE" || true)
if [ -n "$LINE" ]; then
printf '%s\n' "$LINE" | sudo tee -a "$CONF" >/dev/null
ADDED="$ADDED $KEY"
fi
fi
done
if [ -n "$ADDED" ]; then
echo " $CONF: fehlende Schluessel ergaenzt:$ADDED (vorhandene Werte unveraendert)"
else
echo " $CONF existiert bereits, alle Schluessel vorhanden"
fi
else
sudo install -m 0600 "$TEMPLATE" "$CONF"
echo " $CONF aus der Vorlage angelegt"
fi
rm -f "$TEMPLATE"
REMOTE
echo "== Server, CLI und Units installieren =="
# Reststand der Ablage wegraeumen, BEVOR das scp laeuft (Task 6b F1, I1). "scp -r quelle
# ziel" kopiert das Quellverzeichnis IN ein bereits vorhandenes Ziel hinein - aus
# einem liegengebliebenen "…/ninlight-display/" wird dann "…/ninlight-display/display/",
# die Installationsschleife unten ueberspringt Verzeichnisse und findet gar nichts mehr.
# Ergebnis waere ein Lauf, der Erfolg meldet, den Anzeigedienst aktiviert und ein leeres
# $ROOT_DIR/display hinterlaesst: 203/EXEC im 5-Sekunden-Takt, Panel schwarz. Genau so
# ein Reststand entsteht, wenn ein frueherer Lauf nach dem scp abbricht (sudo-Timeout,
# Platte voll, Strg-C) - die Aufraeumzeile unten laeuft dann nicht mehr. Idempotent
# heisst, dass auch dieser Fall glattgeht (siehe Kopf dieser Datei).
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
rm -rf "$STAGE_DIR/ninlight-display" "$STAGE_DIR/ninlight-button"
REMOTE
"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
"$HOST:$STAGE_DIR/"
# Das Anzeigeverzeichnis als Ganzes: render.py, display.py, epdconfig.py, die beiden
# uebernommenen Panel-Treiber und HERKUNFT.md (der Herkunftsnachweis gehoert mit auf den
# Pi). "scp -r" statt rsync, weil setup_pi.sh - anders als deploy.sh - ausdruecklich nur
# ssh/scp voraussetzt; __pycache__ wird auf der Gegenseite weggeraeumt (unten).
"$SCP_BIN" -q -r "$HERE/display" "$HOST:$STAGE_DIR/ninlight-display"
# Das Tasterverzeichnis genauso: als Ganzes, mit demselben "vorher aufraeumen"-Schutz
# gegen ein "scp -r" auf ein bereits vorhandenes Ziel (siehe Kommentar oben zu
# ninlight-display).
"$SCP_BIN" -q -r "$HERE/button" "$HOST:$STAGE_DIR/ninlight-button"
# Pfade als Variablen mit Default (wie in deploy.sh): so kann ein Test die
# Installation gegen ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen
# lassen.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
# Die Ablage wird in JEDEM Fall abgeraeumt, auch wenn dieser Block vorzeitig aussteigt
# (Task 6b F2, N1). Vorher stand das "rm -rf" nur am Blockende und wurde beim Abbruch
# uebersprungen - die uebertragenen Dateien blieben unter /tmp liegen und waren beim
# naechsten Lauf genau der Reststand, gegen den der Block eigentlich absichert.
cleanup_stage() {
rm -rf "$STAGE_DIR/server.py" "$STAGE_DIR/products.py" "$STAGE_DIR/ninlight-station" \
"$STAGE_DIR/ninlight-station.service" "$STAGE_DIR/ninlight-display.service" \
"$STAGE_DIR/ninlight-display" "$STAGE_DIR/ninlight-button.service" \
"$STAGE_DIR/ninlight-button" "$STAGE_DIR/ninlight-poweroff.service" \
"$STAGE_DIR/ninlight-reboot.service"
}
trap cleanup_stage EXIT
rm -rf "$STAGE_DIR/ninlight-display/__pycache__" "$STAGE_DIR/ninlight-button/__pycache__"
# Harte Vorbedingung, und zwar VOR dem ersten "sudo install" (Task 6b F2, N1): ohne
# display.py in der Ablage hat das scp nicht geliefert, was es sollte - abgebrochen,
# falsch verschachtelt oder gar nicht gelaufen. Die Installationsschleife weiter unten
# wuerde das stillschweigend als "nichts zu tun" verbuchen, der Lauf meldete Erfolg und
# der aktivierte Anzeigedienst liefe gegen eine nicht existierende Datei.
#
# Die Pruefung stand zuerst NACH den vier "sudo install" darunter - die Meldung "Nichts
# installiert" war damit schlicht unwahr: server.py, products.py, das CLI und die
# Stations-Unit lagen dann bereits auf dem Pi. Ausgerechnet an dem Werkbank-Moment, fuer
# den diese Pruefung gedacht ist, log das Skript den Bediener an. Jetzt stimmt der Satz.
if [ ! -f "$STAGE_DIR/ninlight-display/display.py" ]; then
echo "Fehler: $STAGE_DIR/ninlight-display/display.py fehlt - die Uebertragung des Anzeigeverzeichnisses ist fehlgeschlagen." >&2
echo " Nichts installiert: $ROOT_DIR, $BIN_PATH und alle drei Units sind unveraendert. Ablage aufgeraeumt, Lauf einfach wiederholen." >&2
exit 1
fi
# Dieselbe harte Vorbedingung wie bei display.py, aus demselben Grund: ohne button.py in
# der Ablage hat das scp nicht geliefert, was es sollte, und die Installationsschleife
# unten wuerde das stillschweigend als "nichts zu tun" verbuchen.
if [ ! -f "$STAGE_DIR/ninlight-button/button.py" ]; then
echo "Fehler: $STAGE_DIR/ninlight-button/button.py fehlt - die Uebertragung des Tasterverzeichnisses ist fehlgeschlagen." >&2
echo " Nichts installiert: $ROOT_DIR, $BIN_PATH und alle drei Units sind unveraendert. Ablage aufgeraeumt, Lauf einfach wiederholen." >&2
exit 1
fi
sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" "$ROOT_DIR/display" "$ROOT_DIR/button"
# Dauerhafter Zustand, der einen Neustart ueberleben muss - bisher nur der Umschalter des
# Migrations-Bildsatzes (/var/lib/ninlight-station/imageset). BEWUSST NICHT in /run: der
# Umschalter ist ein Zustand, den der Bediener setzt und der bis zur naechsten Aenderung
# gilt; ein tmpfs wuerde ihn bei jedem Neustart stillschweigend zuruecksetzen.
#
# 0755, weil der Anzeigedienst die Datei lesen muss (er zeigt den Zustand dauerhaft auf
# dem Panel). Die Datei SELBST wird hier nicht angelegt: fehlt sie, gilt in CLI, Server
# und Anzeige derselbe sichere Vorgabewert "ohne-wlan" - eine hier vorgeschriebene Datei
# waere eine zweite Quelle fuer denselben Wert.
#
# Die beiden Bildsatzverzeichnisse werden hier ebenfalls NICHT angelegt: ein leeres
# Verzeichnis ist fuer den Bediener nicht von einem bestueckten zu unterscheiden, und
# genau das soll das Panel melden koennen ("SATZ FEHLT"). Sie entstehen in deploy.sh,
# sobald wirklich Images hineinkommen.
STATE_DIR=${NINLIGHT_STATION_STATE_DIR:-/var/lib/ninlight-station}
sudo install -d -m 0755 "$STATE_DIR"
sudo install -m 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py"
sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py"
sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH"
sudo install -m 0644 "$STAGE_DIR/ninlight-station.service" "$UNIT_PATH"
# Anzeige: display.py ist ausfuehrbar (die Unit ruft es zwar ueber /usr/bin/python3 auf,
# "ninlight-station display test" aber auch von Hand), der Rest ist importierter Code.
for f in "$STAGE_DIR"/ninlight-display/*; do
[ -f "$f" ] || continue
case "$(basename "$f")" in
display.py) sudo install -m 0755 "$f" "$ROOT_DIR/display/$(basename "$f")" ;;
*) sudo install -m 0644 "$f" "$ROOT_DIR/display/$(basename "$f")" ;;
esac
done
# Was im Repo geloescht wurde, muss auch hier verschwinden - dasselbe Argument wie in
# deploy.sh (dort per "rsync --delete"): display/ ist reiner Code aus dem Repo, es gibt
# keinen Bestand, der einen Lauf ueberleben muesste, und ein zurueckgebliebenes altes
# Modul wuerde weiter mit importiert. rsync steht hier nicht zur Verfuegung (setup_pi.sh
# setzt ausdruecklich nur ssh/scp voraus), also von Hand.
for f in "$ROOT_DIR"/display/*; do
[ -e "$f" ] || continue
if [ ! -e "$STAGE_DIR/ninlight-display/$(basename "$f")" ]; then
sudo rm -rf "$f"
echo " veraltete Datei entfernt: display/$(basename "$f")"
fi
done
sudo install -m 0644 "$STAGE_DIR/ninlight-display.service" "$DISPLAY_UNIT_PATH"
# Taster: dieselbe Behandlung wie die Anzeige - button.py ist ausfuehrbar (die Unit ruft
# es zwar ueber /usr/bin/python3 auf, von Hand pruefen laesst es sich trotzdem), der Rest
# (aktuell nichts weiter) waere importierter Code.
for f in "$STAGE_DIR"/ninlight-button/*; do
[ -f "$f" ] || continue
case "$(basename "$f")" in
button.py) sudo install -m 0755 "$f" "$ROOT_DIR/button/$(basename "$f")" ;;
*) sudo install -m 0644 "$f" "$ROOT_DIR/button/$(basename "$f")" ;;
esac
done
# Dasselbe Argument wie bei display/: button/ ist reiner Code aus dem Repo.
for f in "$ROOT_DIR"/button/*; do
[ -e "$f" ] || continue
if [ ! -e "$STAGE_DIR/ninlight-button/$(basename "$f")" ]; then
sudo rm -rf "$f"
echo " veraltete Datei entfernt: button/$(basename "$f")"
fi
done
sudo install -m 0644 "$STAGE_DIR/ninlight-button.service" "$BUTTON_UNIT_PATH"
# Abschlussbild beim Herunterfahren und beim Neustart (zwei Units, zwei Texte).
sudo install -m 0644 "$STAGE_DIR/ninlight-poweroff.service" "$POWEROFF_UNIT_PATH"
sudo install -m 0644 "$STAGE_DIR/ninlight-reboot.service" "$REBOOT_UNIT_PATH"
# Das Aufraeumen der Ablage macht der EXIT-Trap oben - auf beiden Wegen aus diesem Block.
sudo systemctl daemon-reload
echo " installiert"
REMOTE
echo "== Pakete =="
# Pillow kommt auf dem Pi bewusst aus dem Distributionspaket (python3-pil), nicht per
# pip: tools/station/ laeuft dort unter dem System-Python 3.13, und Debian 13 laesst ein
# "pip install" ins System-Python gar nicht mehr zu (PEP 668). Ohne Pillow zeichnet die
# Anzeige nicht - display.py faengt den ImportError ab und beendet sich still mit 0
# (Absicht, damit ein fehlendes Panel nie eine Migration verhindert). Genau deshalb muss
# das Paket ueberhaupt hier kommen: sonst faellt es nirgends auf.
#
# Aber NACH der Installation und nur als Warnung (Task 6b F1, I4): eine Migrationsstation
# steht typischerweise nicht am Netz, und mit veralteten Paketlisten oder ohne Internet
# scheitert apt. Unter "set -e" vor der Installation haette das den Lauf abgebrochen,
# bevor server.py, das CLI, beide Units und beide NetworkManager-Profile installiert sind
# - man haette dann GAR KEINE Station statt einer Station ohne Panel. Das waere die
# einzige harte Stelle in einer sonst durchgehend weichen Kette gewesen (display.py
# beendet sich mit 0, deploy.sh warnt nur, enable/restart unten warnen nur).
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
if python3 -c 'import PIL' 2>/dev/null; then
echo " python3-pil ist bereits vorhanden"
else
# "update" zuerst: auf einem laenger nicht gepflegten Pi zeigen die Paketlisten auf
# Versionen, die es im Archiv nicht mehr gibt, und "install" scheitert mit 404.
#
# Aber "|| true", und "install" laeuft UNABHAENGIG davon (Task 6b F2, N2): "apt-get
# update" liefert schon dann einen Fehler, wenn eine einzige Quelle nicht erreichbar
# ist - eine veraltete Drittanbieter-Liste, teilweises Netz. Mit "update && install"
# bekaeme ein Pi, auf dem "install" aus dem Paketcache oder den erreichbaren Quellen
# muehelos durchgelaufen waere, still kein Pillow. Das Auffrischen ist eine
# Verbesserung der Chancen, keine Voraussetzung.
sudo apt-get update >/dev/null 2>&1 || true
if sudo apt-get install -y python3-pil; then
echo " python3-pil installiert"
else
echo " Warnung: python3-pil liess sich nicht installieren (kein Netz? veraltete Paketlisten?)." >&2
echo " Die Station ist vollstaendig eingerichtet, das E-Paper bleibt aber leer," >&2
echo " bis 'sudo apt install python3-pil' durchlaeuft und der Anzeigedienst neu startet." >&2
fi
fi
REMOTE
echo "== SPI aktivieren =="
# Auf einem frischen Raspberry Pi OS ist SPI ausgeschaltet: dtparam=spi=on steht
# auskommentiert in der Boot-Konfiguration, /dev/spidev0.0 fehlt, und der Anzeigedienst
# meldet "E-Paper-Anzeige nicht verfuegbar" - das sieht nach fehlender Hardware aus,
# ist aber nur ein abgeschalteter Bus. Ohne diesen Schritt haette jede frische Station
# ein schwarzes Panel, bis jemand von Hand nachhilft (wie hier gestern geschehen).
#
# Die Aenderung wirkt erst nach einem Neustart der Boot-Konfiguration - das Skript
# startet den Pi NICHT selbst neu (ein unerwarteter Reboot mitten in einer Fernsitzung
# waere ueberraschender als ein Hinweis am Ende), meldet ueber den Exit-Code des
# Remote-Blocks aber, ob einer noetig war (10 = geaendert, 0 = schon an, 20 = keine
# Boot-Konfiguration gefunden). $SPI_REBOOT_NEEDED steuert unten, ob der Hinweis in den
# naechsten Schritten erscheint - nur dann, wenn wirklich etwas geaendert wurde.
SPI_REBOOT_NEEDED=0
set +e
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
# Ueberschreibbar wie die anderen NINLIGHT_*-Pfade oben: auf dem Pi bleibt es bei den
# echten Pfaden, ein Test kann hier gegen Sandbox-Dateien statt gegen echte
# Systempfade laufen.
SPIDEV=${NINLIGHT_SPIDEV_PATH:-/dev/spidev0.0}
CONFIG_NEW=${NINLIGHT_BOOT_CONFIG_NEW:-/boot/firmware/config.txt}
CONFIG_OLD=${NINLIGHT_BOOT_CONFIG_OLD:-/boot/config.txt}
# Existiert das Geraeteknoten schon, ist SPI aktiv - unabhaengig davon, was in der
# Boot-Konfiguration steht (es kann auch per Overlay oder einem anderen Mechanismus
# kommen). Dann kein Eingriff und kein Neustart-Hinweis.
if [ -e "$SPIDEV" ]; then
echo " SPI ist bereits aktiv ($SPIDEV vorhanden)"
exit 0
fi
# Aktuelle Raspberry-Pi-OS-Images nutzen /boot/firmware/config.txt, aeltere
# /boot/config.txt - beide werden geprueft, die neuere zuerst. Gibt es keinen von
# beiden, ist das kein Grund abzubrechen: ein anderes Boot-Verfahren (oder gar kein
# Raspberry Pi OS) ist moeglich, und die Anzeige ist nirgends Voraussetzung fuer eine
# funktionierende Station (dasselbe Prinzip wie beim apt-Schritt und beim
# Anzeigedienst oben).
CONFIG=""
if sudo test -f "$CONFIG_NEW"; then
CONFIG="$CONFIG_NEW"
elif sudo test -f "$CONFIG_OLD"; then
CONFIG="$CONFIG_OLD"
fi
if [ -z "$CONFIG" ]; then
echo "Warnung: weder $CONFIG_NEW noch $CONFIG_OLD gefunden - vermutlich kein" >&2
echo " Raspberry Pi OS oder ein anderes Boot-Verfahren. SPI konnte nicht" >&2
echo " geprueft werden - bleibt das E-Paper schwarz, ist das der" >&2
echo " wahrscheinlichste Grund." >&2
exit 20
fi
# Eine aktive (nicht auskommentierte) "dtparam=spi=on"-Zeile heisst: schon an - dann
# nichts tun und keinen Neustart verlangen, den es nicht braucht.
if sudo grep -Eq '^[[:space:]]*dtparam=spi=on[[:space:]]*$' "$CONFIG"; then
echo " SPI ist in $CONFIG bereits eingeschaltet"
exit 0
fi
BACKUP="${CONFIG}.bak-ninlight"
if sudo test -f "$BACKUP"; then
# Nicht ueberschreiben: sonst waere beim zweiten Lauf (z.B. weil zwischendurch von
# Hand wieder auf "off" gestellt wurde) der urspruengliche Originalzustand weg.
echo " Sicherung $BACKUP existiert bereits - wird nicht ueberschrieben"
else
sudo cp -p "$CONFIG" "$BACKUP"
echo " Sicherung angelegt: $BACKUP"
fi
# Drei Faelle bleiben: die Zeile fehlt ganz, sie steht auskommentiert da, oder sie
# steht aktiv auf "off" (z.B. von Hand deaktiviert). In allen dreien wird auf
# "dtparam=spi=on" normalisiert - eine vorhandene Zeile wird ERSETZT statt eine
# zweite anzuhaengen, sonst stuenden am Ende zwei widerspruechliche
# dtparam=spi-Zeilen in der Datei und es waere Zufall, welche NetworkManager beim
# naechsten Boot zuletzt liest.
#
# Kein "sed -i": dieser Block laeuft zwar auf dem Pi (GNU) unter "REMOTE", aber der
# Stub-Test fuehrt ihn woertlich auf dem Entwicklungsrechner (macOS/BSD) aus - "sed
# -i" braucht dort ein Suffix-Argument, GNU-sed keins, und die falsche Reihenfolge
# war in diesem Projekt schon zweimal ein Fehler. Deshalb sed nur als Filter
# (Eingabe->Ausgabe), das Ergebnis geht ueber eine Temp-Datei und "sudo install" an
# den Zielpfad - das funktioniert auf beiden Plattformen identisch.
if sudo grep -Eq '^[[:space:]]*#?[[:space:]]*dtparam=spi=(on|off)[[:space:]]*$' "$CONFIG"; then
TMP_CONFIG=$(mktemp "${TMPDIR:-/tmp}/ninlight-spi-config.XXXXXX")
sudo sed -E 's/^[[:space:]]*#?[[:space:]]*dtparam=spi=(on|off)[[:space:]]*$/dtparam=spi=on/' "$CONFIG" >"$TMP_CONFIG"
sudo install -m 0644 -o root -g root "$TMP_CONFIG" "$CONFIG"
rm -f "$TMP_CONFIG"
echo " $CONFIG: dtparam=spi=on gesetzt (vorhandene Zeile aktiviert)"
else
printf '%s\n' 'dtparam=spi=on' | sudo tee -a "$CONFIG" >/dev/null
echo " $CONFIG: dtparam=spi=on ergaenzt"
fi
# Signalisiert dem Aufrufer, dass ein Neustart noetig ist (siehe oben).
exit 10
REMOTE
SPI_RC=$?
set -e
case "$SPI_RC" in
0) ;;
10) SPI_REBOOT_NEEDED=1 ;;
20) ;;
*) echo "Warnung: SPI-Aktivierung ergab einen unerwarteten Fehler (rc=$SPI_RC) - /boot/firmware/config.txt bzw. /boot/config.txt von Hand pruefen." >&2 ;;
esac
echo "== Anzeigedienst aktivieren =="
# Die Anzeige wird aktiviert (startet also beim Booten mit) UND jetzt gestartet - anders
# als die Station selbst, die absichtlich aus bleibt: die Anzeige sendet nichts, sie
# zeigt nur an, und "Station aus" ist genau das, was nach dem Einrichten auf dem Panel
# stehen soll. Erst hier, nach dem Paketschritt: so startet der Dienst bereits mit
# vorhandenem Pillow, statt sich beim ersten Zeichnen still mit 0 zu beenden und bis zum
# naechsten manuellen Neustart nichts anzuzeigen.
#
# Fehlt Panel oder Pillow, beendet sich der Dienst still mit 0 (siehe display.py) -
# deshalb nur Warnungen: ein fehlendes Panel darf setup_pi.sh nicht abbrechen.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
sudo systemctl enable ninlight-display.service >/dev/null 2>&1 || \
echo " Warnung: 'systemctl enable ninlight-display' fehlgeschlagen - die Anzeige startet nach einem Neustart des Pi nicht von allein mit" >&2
sudo systemctl restart ninlight-display.service >/dev/null 2>&1 || \
echo " Warnung: 'systemctl restart ninlight-display' fehlgeschlagen - journalctl -u ninlight-display" >&2
echo " Anzeigedienst aktiviert"
REMOTE
echo "== Tasterdienst aktivieren =="
# Dieselbe weiche Behandlung wie die Anzeige, aus demselben Grund: der Taster ist
# nirgends Voraussetzung. Fehlt gpiozero, fehlt der Taster oder ist GPIO23 belegt,
# beendet sich button.py still mit 0 (siehe dessen Modul-Docstring) - ein
# fehlgeschlagenes enable/start darf setup_pi.sh trotzdem nicht abbrechen, denn eine
# Station ohne Taster muss vollstaendig benutzbar bleiben.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
sudo systemctl enable ninlight-button.service >/dev/null 2>&1 || \
echo " Warnung: 'systemctl enable ninlight-button' fehlgeschlagen - der Taster startet nach einem Neustart des Pi nicht von allein mit" >&2
sudo systemctl restart ninlight-button.service >/dev/null 2>&1 || \
echo " Warnung: 'systemctl restart ninlight-button' fehlgeschlagen - journalctl -u ninlight-button" >&2
echo " Tasterdienst aktiviert"
REMOTE
echo "== Abschlussbild aktivieren =="
# Zwei Units, zwei Texte: beim Herunterfahren "Strom kann getrennt werden", beim Neustart
# "Strom NICHT trennen!". Ohne sie bliebe auf dem E-Paper fuer immer das letzte Bild
# stehen - bei der Abnahme war das die Rueckfrage des Tastermenues.
#
# Dieselbe weiche Behandlung wie Anzeige und Taster: ein fehlgeschlagenes "enable" ist
# eine Warnung, kein Abbruch. Eine Station, deren Panel nach dem Ausschalten das falsche
# Bild zeigt, ist aergerlich; eine Station, die sich gar nicht einrichten laesst, ist
# unbrauchbar. "enable" allein, kein "start": diese Units tun beim Systemstart NICHTS,
# sie gehoeren zur Abschalt-Transaktion.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
for unit in ninlight-poweroff.service ninlight-reboot.service; do
sudo systemctl enable "$unit" >/dev/null 2>&1 || \
echo " Warnung: 'systemctl enable $unit' fehlgeschlagen - das E-Paper behaelt nach dem Herunterfahren sein letztes Bild" >&2
done
echo " Abschlussbild aktiviert (Herunterfahren und Neustart)"
REMOTE
echo "== NetworkManager-Profile =="
# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
# ". /etc/ninlight-station.conf" wuerde also mit "Permission denied"
# abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der
# Datei aufzuweichen.
# - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen-
# argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per
# "ps"/"/proc/<pid>/cmdline" sichtbar. Deshalb wird das Verbindungsprofil
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
# Prozessliste.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections}
eval "$(sudo cat "$CONF")"
# Die Keyfiles werden erst in einer Temp-Datei aufgebaut und dann per "install"
# an ihren Platz gelegt - dazwischen liegt das WLAN-Passwort in einer Datei, die
# noch nicht 0600 root:root ist. Ein Abbruch (set -e) genau dort darf sie nicht
# liegen lassen. Ein EXIT-Trap in write_profile() selbst waere dafuer untauglich:
# die Funktion laeuft zweimal, der zweite trap ueberschriebe den ersten, und
# "$tmp" ist "local" - beim Auswerten des Traps am Skriptende gaebe es die
# Variable gar nicht mehr. Deshalb ein gemeinsames Verzeichnis mit genau einem
# Trap darum herum (0700, mktemp -d legt es so an).
#
# Zur Reichweite des Traps (Task 6b F2, N3 - die Begruendung hier war vorher falsch):
# der EXIT-Trap allein WUERDE bei einem Strg-C schon reichen, bash fuehrt ihn auch beim
# Abbruch durch ein nicht abgefangenes SIGINT aus (Exit 130). Der Grund fuer die
# Signalhandler ist ein anderer: sie sollen den Exit-Code ehrlich halten. Ein Handler
# fuer INT/TERM, der nicht selbst beendet, laesst bash naemlich NACH dem Handler
# weiterlaufen - ein Strg-C mitten im Schreiben der Profile koennte dann in "fertig,
# Exit 0" muenden, obwohl der Lauf abgebrochen wurde. Deshalb raeumt jeder Handler auf
# und beendet danach ausdruecklich mit dem ueblichen Signal-Code (128+n).
#
# Mit ausdruecklicher Vorlage unter ${TMPDIR:-/tmp}, nicht als blankes "mktemp -d":
# BSD-mktemp (macOS, wo diese Suite auch laeuft) beachtet TMPDIR ohne Vorlage gar nicht
# und legt unabhaengig davon unter /var/folders/... an, GNU-mktemp (der Pi) dagegen
# schon. Ohne Vorlage landet das Verzeichnis also je nach Plattform woanders - ein Test,
# der nachsieht, ob wirklich nichts liegengeblieben ist, sucht dann auf macOS an der
# falschen Stelle und ist gruen, ohne etwas geprueft zu haben.
PROFILE_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/ninlight-profile.XXXXXX")
trap 'rm -rf "$PROFILE_TMPDIR"' EXIT
trap 'rm -rf "$PROFILE_TMPDIR"; exit 130' INT
trap 'rm -rf "$PROFILE_TMPDIR"; exit 143' TERM
trap 'rm -rf "$PROFILE_TMPDIR"; exit 129' HUP
# $1 = Profilname, $2 = SSID, $3 = PSK, $4 = "update" (bestehendes Profil neu schreiben,
# UUID behalten) oder "keep" (bestehendes Profil unangetastet lassen).
#
# Beide Profile bekommen dieselbe Adresse 192.168.8.1/24 auf wlan0 - genau deshalb
# schliessen sie sich gegenseitig aus, und genau deshalb prueft ninlight-station's
# ap_switch() nach jedem Umschalten nach, dass wirklich nur eines aktiv ist.
write_profile() {
local id=$1 ssid=$2 psk=$3 mode=$4
# Ein leerer Profilname wuerde eine Datei ".nmconnection" anlegen, eine leere SSID
# oder ein leeres PSK ein Profil, das NetworkManager beim Hochfahren stumm ablehnt -
# in beiden Faellen faellt es erst auf, wenn jemand mit einer toten Lampe vor der
# Station steht. Der Konfigurationsschritt oben ergaenzt fehlende Schluessel aus der
# Vorlage; leer sein koennen sie trotzdem (von Hand geleert).
if [ -z "$id" ] || [ -z "$ssid" ] || [ -z "$psk" ]; then
echo "Fehler: Profilname, SSID oder PSK ist leer - $CONF pruefen (Profil '$id')" >&2
return 1
fi
local conn_file="$NM_DIR/${id}.nmconnection"
local uuid action tmp
if sudo test -f "$conn_file"; then
if [ "$mode" = "keep" ]; then
# Der Migrations-AP ist auf manchen Pis von Hand angelegt worden (SSID/PSK sind
# von der alten Klyqa-Firmware fest vorgegeben, es gibt also nichts zu variieren).
# Ein bestehendes Profil wird deshalb nicht ueberschrieben - es koennte
# handjustierte Felder (Kanal, Band, Regulierungsdomaene) tragen, die dieses
# Skript gar nicht kennt.
echo " Profil $id existiert bereits - unveraendert gelassen"
return 0
fi
uuid=$(sudo sed -n 's/^uuid=//p' "$conn_file" | head -n1)
action="aktualisiert"
else
# /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback
# auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt
# ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst.
uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]')
action="angelegt"
fi
tmp=$(mktemp "$PROFILE_TMPDIR/profile.XXXXXX")
{
printf '%s\n' '[connection]'
printf 'id=%s\n' "$id"
printf 'uuid=%s\n' "$uuid"
printf '%s\n' 'type=wifi'
printf '%s\n' 'interface-name=wlan0'
printf '%s\n' 'autoconnect=false'
printf '\n%s\n' '[wifi]'
printf '%s\n' 'mode=ap'
printf 'ssid=%s\n' "$ssid"
printf '%s\n' 'band=bg'
printf '%s\n' 'channel=6'
printf '\n%s\n' '[wifi-security]'
printf '%s\n' 'key-mgmt=wpa-psk'
printf '%s\n' 'proto=rsn'
printf '%s\n' 'pairwise=ccmp;'
printf '%s\n' 'group=ccmp;'
printf 'psk=%s\n' "$psk"
printf '\n%s\n' '[ipv4]'
printf '%s\n' 'method=shared'
printf '%s\n' 'address1=192.168.8.1/24'
printf '\n%s\n' '[ipv6]'
printf '%s\n' 'method=disabled'
} >"$tmp"
sudo install -o root -g root -m 0600 "$tmp" "$conn_file"
rm -f "$tmp"
sudo nmcli connection load "$conn_file" >/dev/null
echo " Profil $id $action"
}
write_profile "$AP_CONNECTION" "$SSID" "$PSK" update
# Ohne dieses zweite Profil scheitert "ninlight-station migrate on" auf einem frisch
# eingerichteten Pi an need_profile "$MIGRATE_AP_CONNECTION".
write_profile "$MIGRATE_AP_CONNECTION" "$MIGRATE_SSID" "$MIGRATE_PSK" keep
REMOTE
# Der Neustart-Hinweis steht an eigener, auffaelliger Stelle VOR den "Naechsten
# Schritten" - nicht dazwischengemischt - und nur, wenn oben wirklich etwas an der
# Boot-Konfiguration geaendert wurde (SPI_REBOOT_NEEDED). Ein Hinweis bei jedem Lauf,
# auch wenn SPI laengst an ist, wuerde nach ein paar Durchlaeufen ignoriert.
SPI_NOTE=""
if [ "$SPI_REBOOT_NEEDED" -eq 1 ]; then
SPI_NOTE='
WICHTIG: SPI wurde soeben erst eingeschaltet (Aenderung an der Boot-Konfiguration) -
das wirkt erst nach einem Neustart. Bis dahin bleibt das E-Paper schwarz,
auch wenn sonst alles richtig eingerichtet ist.
ssh <pi-host> sudo reboot
'
fi
cat <<EOF
Fertig. Die Station ist absichtlich noch AUS.
$SPI_NOTE
Naechste Schritte:
ssh <pi-host> ninlight-station display test
Testbild aufs E-Paper - bleibt es leer oder
verzerrt, steht EPD_REVISION in
/etc/ninlight-station.conf auf der falschen
Panel-Revision (V2 statt V3 oder umgekehrt)
tools/station/deploy.sh <pi-host> Images ausliefern
ssh <pi-host> ninlight-station on Station einschalten
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
Der Taster auf GPIO23 ist mit eingerichtet: kurzer Druck oeffnet das Menue am Geraet,
3 s gehalten brechen eine laufende Migration/Rettung ab.
EOF
+38
View File
@@ -0,0 +1,38 @@
# /etc/ninlight-station.conf - von tools/station/setup_pi.sh angelegt.
# Muss zu den Build-Parametern der Firmware passen (NINLIGHT_RECOVERY_SSID,
# NINLIGHT_RECOVERY_PASSWORD in config.local.cmake). Weicht etwas ab, findet
# die Lampe die Station nicht.
#
# Werte immer in einfachen Anführungszeichen: ninlight-station liest diese
# Datei per POSIX ". datei" ein, und dabei wertet die Shell auch in doppelten
# Anführungszeichen $ und ` aus - ein Passwort mit "$1" wuerde so lautlos
# durch ein Shell-Argument ersetzt. Einfache Anführungszeichen unterbinden
# das; systemds EnvironmentFile= entfernt sie ebenso wie doppelte und fuehrt
# ohnehin nie eine Shell aus.
SSID='ninlight-recovery'
# Oeffentlicher Default. Fuer den echten Einsatz hier UND in config.local.cmake
# einen eigenen Wert setzen - wer das Passwort kennt und in Funkreichweite ist,
# kann jeder Lampe beim naechsten Boot beliebige Firmware unterschieben.
PSK='ninlight-recovery'
IMAGE_DIR='/opt/ninlight-station/images'
# NetworkManager-Profil, in das "ninlight-station off" zurueckschaltet.
HOME_CONNECTION='preconfigured'
# NetworkManager-Profil der Station selbst.
AP_CONNECTION='ninlight-recovery'
# Von der alten Klyqa-Firmware fest vorgegeben (qcx-w600-firmware) - nicht
# aenderbar, ohne dass alte Lampen die Migrationsstation nicht mehr finden.
MIGRATE_SSID='qconnex-firmware'
MIGRATE_PSK='qconnex-firmware'
# NetworkManager-Profil der Migrations-Station.
MIGRATE_AP_CONNECTION='qconnex-firmware'
# Panel-Revision der E-Paper-Anzeige (V2 oder V3) - am Geraet nicht auslesbar,
# siehe README.md.
EPD_REVISION='V3'
+553
View File
@@ -0,0 +1,553 @@
"""Tests fuer den Tasterdienst (tools/station/button/button.py).
Die Bedienlogik ist reine Zustandsfuehrung und wird hier OHNE Hardware geprueft - aber
bewusst NICHT, indem der Ablauf im Test nachgebaut wird: getrieben wird die ECHTE
Hauptschleife button.run_daemon() mit einer Fake-Uhr, einem nach Zeitplan gedrueckten
Taster-Doppel, dem ECHTEN CliActions (nur dessen subprocess.run ist ersetzt) und einem
protokollierenden Schreiber.
Der Grund steht in display.py/test_render.py: bei der Zeitsteuerung der Anzeige trieben
die Tests eine KOPIE der Verdrahtung zwischen Zustandsmaschine und Ausfuehrung, und man
konnte die eigentliche Aktion durch eine falsche ersetzen, ohne dass ein Test rot wurde.
Deshalb laeuft hier alles ueber dieselbe Schleife, die auch auf dem Pi laeuft."""
import json
import os
import subprocess
import sys
import types
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "button"))
import button # noqa: E402
# --- Zustaende, von denen die Menueeintraege abhaengen -------------------------------
STATE_OFF = {"running": False, "mode": "recovery", "target": None,
"imageset": "ohne-wlan"}
STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None,
"imageset": "ohne-wlan"}
STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb",
"imageset": "ohne-wlan"}
STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None,
"imageset": "ohne-wlan"}
# --- Fake-Uhr, Taster-Doppel, Protokoll ---------------------------------------------
class _StopDaemon(Exception):
"""Bricht run_daemon()s Endlosschleife ab, sobald die simulierte Zeit abgelaufen ist."""
class _FakeClock:
def __init__(self, until_s: float) -> None:
self.t = 0.0
self.until_s = until_s
def monotonic(self) -> float:
return self.t
def advance(self, seconds: float) -> None:
self.t += seconds
def sleep(self, seconds: float) -> None:
self.t += seconds
if self.t > self.until_s:
raise _StopDaemon
class _Recorder:
"""Gemeinsame Zeitleiste von Schreiber und Ausfuehrung - nur so faellt auf, wenn die
Reihenfolge in run_daemon() kippt (z.B. das "Bitte warten" erst NACH dem
sekundenlangen CLI-Aufruf geschrieben wuerde)."""
def __init__(self, clock: _FakeClock) -> None:
self.clock = clock
self.events: list[tuple[float, str, object]] = []
def note(self, kind: str, payload: object) -> None:
self.events.append((self.clock.monotonic(), kind, payload))
def views(self) -> list[tuple[float, dict]]:
return [(t, p) for t, k, p in self.events if k == "view"]
def runs(self) -> list[tuple[float, list]]:
return [(t, p) for t, k, p in self.events if k == "run"]
def argvs(self) -> list[list]:
return [p for _t, p in self.runs()]
def kinds(self) -> list[str]:
return [v.get("view") for _t, v in self.views()]
def first_view(self, name: str):
for t, v in self.views():
if v.get("view") == name:
return t, v
return None
class _ScriptedButton:
"""Taster-Doppel: gedrueckt genau waehrend der angegebenen Zeitfenster. `is_pressed`
ist wie bei gpiozero ein PEGEL (die Entprellung macht dort bounce_time), keine
Flanke - genau das, was run_daemon() abfragt."""
def __init__(self, clock: _FakeClock, presses) -> None:
self.clock = clock
self.presses = list(presses)
@property
def is_pressed(self) -> bool:
t = self.clock.monotonic()
return any(start <= t < end for start, end in self.presses)
class _RecordingWriter:
"""Schreiber-Doppel: protokolliert jeden tatsaechlich geschriebenen Bedienzustand.
Dedupliziert wie der echte UiWriter, damit die Zeitleiste die ANZEIGEWECHSEL zeigt
und nicht 50 identische Zeilen je Sekunde."""
def __init__(self, rec: _Recorder) -> None:
self.rec = rec
self.path = Path("/dev/null")
self._last = None
def write(self, view: dict) -> bool:
if view == self._last:
return False
self._last = dict(view)
self.rec.note("view", dict(view))
return True
def _fake_runner(clock: _FakeClock, rec: _Recorder, duration_s: float = 0.0, rc: int = 0):
"""Ersetzt subprocess.run in CliActions - der Rest von CliActions (und damit die
ZUSAMMENSETZUNG der Kommandozeile, auf die es sicherheitsrelevant ankommt) ist echt."""
def run(argv, check=False, timeout=None):
rec.note("run", list(argv))
# Ein CLI-Aufruf dauert Sekunden (Profilumschaltung) - die simulierte Uhr muss
# das mitmachen, sonst pruefen die Tests eine Schleife, die es so nicht gibt.
clock.advance(duration_s)
return types.SimpleNamespace(returncode=rc)
return run
def _drive(presses, duration_s: float, state=STATE_OFF, action_duration_s: float = 0.0,
rc: int = 0) -> _Recorder:
"""Laesst das echte button.run_daemon() `duration_s` simulierte Sekunden laufen."""
clock = _FakeClock(duration_s)
rec = _Recorder(clock)
actions = button.CliActions(cli="/usr/local/bin/ninlight-station",
runner=_fake_runner(clock, rec, action_duration_s, rc))
try:
button.run_daemon(button=_ScriptedButton(clock, presses),
writer=_RecordingWriter(rec), actions=actions, clock=clock,
read_state=lambda: dict(state))
except _StopDaemon:
pass
return rec
def _short(at: float):
"""Ein kurzer Druck (deutlich unter SHORT_PRESS_MAX_S, deutlich ueber DEBOUNCE_S)."""
return (at, at + 0.2)
# --- Menueaufbau --------------------------------------------------------------------
def _labels(state):
return [a.label for a in button.menu_actions(state)]
def test_menu_off_offers_starting_and_shutdown():
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen",
"Migration: AUTOMATIK", "Umschalten: mit WLAN",
"Herunterfahren"]
def test_the_listening_migration_comes_before_the_automatic_one():
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
irgendwo loslaesst, soll nicht im Automatikmodus landen."""
labels = _labels(STATE_OFF)
assert labels.index("Migration: horchen") < labels.index("Migration: AUTOMATIK")
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN",
"Anzeige auffrischen", "Herunterfahren"]
def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus",
"Umschalten: mit WLAN", "Anzeige auffrischen",
"Herunterfahren"]
def test_menu_in_migration_without_a_detected_target_offers_no_release():
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN",
"Anzeige auffrischen", "Herunterfahren"]
def test_only_the_refresh_entry_skips_the_confirmation():
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Anzeige auffrischen"
veraendert nichts und braucht keine."""
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
for action in button.menu_actions(state):
assert action.confirm is (action.label != "Anzeige auffrischen"), action.label
def test_actions_run_through_the_station_cli():
"""Die Aktionen gehen ueber das vorhandene CLI, nicht durch nachgebaute Logik - und
das Herunterfahren als einzige an systemd selbst."""
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
assert actions.argv_for(button.menu_actions(STATE_OFF)[0]) == [
"/usr/local/bin/ninlight-station", "recovery", "on"]
assert actions.argv_for(button.action_shutdown()) == ["systemctl", "poweroff"]
# --- Bedienung, getrieben durch die echte Hauptschleife ------------------------------
def test_a_short_press_opens_the_menu():
rec = _drive([_short(1.0)], 4.0)
assert rec.argvs() == []
t, view = rec.first_view("menu")
assert view["hero"] == "> Rettung starten"
assert view["active"] is True
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
# display.UI_POLL_INTERVAL_S).
assert 1.2 <= t <= 1.3
def test_a_short_press_steps_to_the_next_entry():
rec = _drive([_short(1.0), _short(2.0)], 4.0)
assert rec.argvs() == []
assert rec.views()[-1][1]["hero"] == "> Migration: horchen"
def test_a_short_press_wraps_around_the_last_entry():
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
_short(6.0)], 8.0)
assert rec.views()[-1][1]["hero"] == "> Rettung starten"
def test_holding_one_and_a_half_seconds_selects_the_entry():
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
t, view = rec.first_view("confirm")
assert view["hero"] == "Rettung starten"
assert 3.5 <= t <= 3.6
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
assert rec.argvs() == []
def test_holding_in_the_confirmation_executes_the_entry():
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "recovery", "on"]]
assert 6.5 <= rec.runs()[0][0] <= 6.6
def test_a_short_press_in_the_confirmation_cancels_and_returns_to_the_menu():
rec = _drive([_short(1.0), (2.0, 4.0), _short(5.0)], 8.0)
assert rec.argvs() == []
assert rec.views()[-1][1]["view"] == "menu"
def test_the_refresh_entry_runs_without_a_confirmation():
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0,
state=STATE_RECOVERY)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "display", "test"]]
assert "confirm" not in rec.kinds()
def test_the_listening_entry_calls_the_cli_with_listen():
"""Der zweite Eintrag ("Migration: horchen") ruft das CLI MIT --listen: der
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
freigibt."""
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on",
"--listen"]]
def test_the_automatic_entry_calls_the_cli_without_listen():
"""Der dritte Eintrag ("Migration: AUTOMATIK") ruft dasselbe CLI OHNE --listen - das
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
fremde Lampen ungefragt migriert werden."""
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on"]]
assert "--listen" not in rec.argvs()[0]
def test_the_automatic_entry_asks_a_louder_question_than_the_others():
"""Zusage: die Rueckfrage des Automatikmodus benennt die Folge, statt nur "Wirklich?"
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege."""
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
_t, view = rec.first_view("confirm")
assert view["title"] == "ACHTUNG"
assert view["hero"] == "Migration: AUTOMATIK"
text = " ".join(view["details"]).lower()
assert "jede" in text and "fremde" in text
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
assert view["details"][-2:] == ["halten (1,5 s): ja", "kurz: nein"]
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
_t, plain = rec.first_view("confirm")
assert plain["title"] == "RUECKFRAGE"
assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"]
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
"""Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen - der Bediener soll die
Wirkung am Panel sehen, waehrend er noch haelt."""
rec = _drive([(1.0, 8.0)], 10.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"]]
t = rec.runs()[0][0]
assert 4.0 <= t <= 4.1
# ... und danach laeuft am Panel der Countdown der zweiten Stufe.
later = [v for vt, v in rec.views() if vt > t]
assert any("Herunterfahren in" in v.get("hero", "") for v in later)
def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
rec = _drive([(1.0, 7.0)], 12.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"]]
assert ["systemctl", "poweroff"] not in rec.argvs()
def test_holding_ten_seconds_shuts_down():
rec = _drive([(1.0, 12.0)], 14.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"],
["systemctl", "poweroff"]]
assert 11.0 <= rec.runs()[1][0] <= 11.1
def test_the_shutdown_countdown_is_visible_before_it_happens():
"""Das Herunterfahren ist nur ueber eine SICHTBARE Zustandsaenderung hinweg
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
rec = _drive([(1.0, 12.0)], 14.0)
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
assert "Herunterfahren in 6 s" in heroes
assert "Herunterfahren in 1 s" in heroes
def test_a_blocking_action_does_not_break_the_hold_gesture():
"""Der Abbruch-Aufruf blockiert die Schleife sekundenlang (Profilumschaltung). Danach
wird derselbe PEGEL gegen dieselbe Uhr gemessen - die zweite Stufe muss trotzdem
erreichbar bleiben."""
rec = _drive([(1.0, 12.0)], 14.0, action_duration_s=2.0)
assert rec.argvs()[-1] == ["systemctl", "poweroff"]
def test_the_waiting_screen_is_written_before_the_slow_call():
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 9.0,
state=STATE_RECOVERY, action_duration_s=4.0)
kinds = [k for _t, k, _p in rec.events]
run_at = kinds.index("run")
assert rec.events[run_at - 1][2].get("view") == "busy"
def test_the_menu_closes_after_twenty_seconds_without_input():
rec = _drive([_short(1.0)], 26.0)
assert rec.kinds()[-1] == "idle"
closed_at = [t for t, v in rec.views() if v.get("view") == "idle" and t > 2.0][0]
assert 21.2 <= closed_at <= 21.4
def test_the_confirmation_expires_after_ten_seconds_as_a_cancel():
rec = _drive([_short(1.0), (2.0, 4.0)], 18.0)
assert rec.argvs() == []
assert rec.kinds()[-1] == "idle"
expired_at = [t for t, v in rec.views() if v.get("view") == "idle" and t > 4.0][0]
assert 13.5 <= expired_at <= 13.7
def test_an_expired_confirmation_leaves_nothing_pending():
"""Der Abbruch muss die vorgemerkte Aktion auch wirklich vergessen - eine Aktion, die
nach dem Verfall noch in der Maschine liegt, ist genau das, was eine Rueckfrage
verhindern soll."""
ui = button.ButtonUI(0.0, read_state=lambda: dict(STATE_OFF))
ui.update(1.0, True)
ui.update(1.2, False) # kurzer Druck: Menue auf
ui.update(2.0, True)
ui.update(3.6, True) # 1,6 s gehalten: Eintrag gewaehlt -> Rueckfrage
ui.update(3.8, False)
assert ui.screen == "confirm" and ui.pending is not None
ui.update(3.6 + button.CONFIRM_TIMEOUT_S + 0.1, False)
assert ui.screen == "idle"
assert ui.pending is None
def test_a_failed_action_says_so_on_the_panel():
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
assert any(v.get("view") == "result" and v.get("title") == "FEHLER"
for _t, v in rec.views())
def test_nothing_happens_without_any_press():
rec = _drive([], 30.0)
assert rec.argvs() == []
assert rec.kinds() == ["idle"]
# --- Bedienzustandsdatei ------------------------------------------------------------
def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
"""Dasselbe Muster und dieselben Rechte wie status.json/control.json - ohne 0644
kann der Anzeigedienst die Datei nicht lesen, ohne das Umbenennen sieht er sie halb
geschrieben."""
path = tmp_path / "run" / "ui.json"
writer = button.UiWriter(path)
view = {"view": "menu", "title": "MENUE", "hero": "> Station aus", "details": [],
"active": True}
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
# "der Anzeigedienst kann die Datei lesen" an einer Umgebungsvariablen des Systems.
old_umask = os.umask(0o077)
try:
assert writer.write(view) is True
finally:
os.umask(old_umask)
assert json.loads(path.read_text()) == view
assert oct(os.stat(path).st_mode & 0o777) == "0o644"
# Keine Temp-Datei zurueckgelassen (sie wuerde sonst mit jedem Neustart ein weiteres
# Bruchstueck im RuntimeDirectory hinterlassen).
assert [p.name for p in path.parent.iterdir()] == ["ui.json"]
def test_the_ui_file_is_only_written_on_change(tmp_path):
writer = button.UiWriter(tmp_path / "ui.json")
view = {"view": "idle", "active": False}
assert writer.write(view) is True
assert writer.write(dict(view)) is False
def test_an_unwritable_ui_file_is_survived_and_logged_once(tmp_path, caplog):
"""Ein Fehler am Taster darf niemals eine laufende Migration stoeren - und eine Zeile
je Schleifentakt wuerde das Journal fluten."""
blocker = tmp_path / "blocker"
blocker.write_text("kein Verzeichnis")
writer = button.UiWriter(blocker / "ui.json")
with caplog.at_level("WARNING", logger="ninlight-button"):
assert writer.write({"view": "menu", "active": True}) is False
assert writer.write({"view": "confirm", "active": True}) is False
assert len(caplog.records) == 1
# --- Der Taster ist nirgends Voraussetzung ------------------------------------------
def test_missing_gpiozero_logs_exactly_one_line_and_exits_zero(monkeypatch, caplog):
"""Dieselbe Zusage wie bei der Anzeige (dort seit fuenf Fixrunden): kein Taster ->
GENAU EINE Protokollzeile und sys.exit(0), kein Fehler - sonst startet systemd
(Restart=on-failure) den Dienst endlos neu und flutet das Journal."""
monkeypatch.setitem(sys.modules, "gpiozero", None)
with caplog.at_level("INFO", logger="ninlight-button"):
with pytest.raises(SystemExit) as excinfo:
button.main()
assert excinfo.value.code == 0
assert len(caplog.records) == 1
assert "gpiozero" in caplog.records[0].getMessage()
def test_a_busy_pin_is_treated_like_a_missing_button(monkeypatch, caplog):
""""GPIO busy" kommt je nach Pin-Factory als gpiozero-Fehler, als roher lgpio.error
oder als OSError - alles davon heisst "kein Taster", nicht "abstuerzen"."""
class _Busy(Exception):
pass
fake = types.SimpleNamespace(
Button=lambda *a, **k: (_ for _ in ()).throw(_Busy("GPIO busy")))
monkeypatch.setitem(sys.modules, "gpiozero", fake)
with caplog.at_level("INFO", logger="ninlight-button"):
with pytest.raises(SystemExit) as excinfo:
button.main()
assert excinfo.value.code == 0
assert len(caplog.records) == 1
def test_a_failing_cli_call_never_escapes(monkeypatch):
"""subprocess-Fehler (Binary fehlt, Timeout) duerfen den Dienst nicht beenden."""
def boom(argv, check=False, timeout=None):
raise OSError("kein CLI da")
actions = button.CliActions(runner=boom)
assert actions.run(button.action_cancel()) is False
def slow(argv, check=False, timeout=None):
raise subprocess.TimeoutExpired(argv, timeout)
assert button.CliActions(runner=slow).run(button.action_cancel()) is False
def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
"""Menueeintraege haengen am Stationszustand. Ist der nicht zu ermitteln, bleibt das
Menue zu, statt einen falschen Zustand zu behaupten - und der Dienst laeuft weiter."""
ui = button.ButtonUI(0.0, read_state=lambda: (_ for _ in ()).throw(RuntimeError("weg")))
with caplog.at_level("WARNING", logger="ninlight-button"):
ui.update(0.0, True)
assert ui.update(0.2, False) is None
assert ui.view(0.3) == {"view": "idle", "active": False}
# --- Bildsatz-Umschalter im Menue ----------------------------------------------------
def test_the_image_set_entry_names_where_it_switches_to():
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
assert "Umschalten: mit WLAN" in _labels(STATE_OFF)
ohne = dict(STATE_OFF, imageset="mit-wlan")
assert "Umschalten: ohne WLAN" in _labels(ohne)
assert "Umschalten: mit WLAN" not in _labels(ohne)
def test_the_image_set_entry_switches_through_the_cli():
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
assert actions.argv_for(button.action_imageset("ohne-wlan")) == [
"/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]
assert actions.argv_for(button.action_imageset("mit-wlan")) == [
"/usr/local/bin/ninlight-station", "bildsatz", "ohne-wlan"]
def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
loud = button.action_imageset("ohne-wlan")
back = button.action_imageset("mit-wlan")
assert loud.confirm and back.confirm
assert loud.confirm_title == "ACHTUNG"
assert back.confirm_title == "RUECKFRAGE"
assert "WLAN-Passwort" in " ".join(loud.confirm_details)
assert back.confirm_details is None
def test_the_image_set_entry_really_runs_the_switch(tmp_path):
"""Durch die echte Hauptschleife, nicht nur ueber menu_actions(): vierter Eintrag im
ausgeschalteten Zustand, Auswahl halten, Rueckfrage halten."""
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0),
(5.0, 7.0), (8.0, 10.0)], 12.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]]
def test_station_state_reads_the_same_switch_file_as_the_cli(tmp_path, monkeypatch):
monkeypatch.setattr(button, "_service_active", lambda service_name: False)
switch = tmp_path / "imageset"
state = lambda: button.station_state(run_dir=tmp_path, service_name="x",
imageset_path=switch)
assert state()["imageset"] == "ohne-wlan" # Datei fehlt -> sichere Vorgabe
switch.write_text("mit-wlan\n")
assert state()["imageset"] == "mit-wlan"
switch.write_text("quatsch")
assert state()["imageset"] == "ohne-wlan"
+1493
View File
File diff suppressed because it is too large Load Diff
+1428
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import products # noqa: E402
def test_table_is_complete():
"""Acht Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine
Produktions-ID hat."""
assert len(products.PRODUCTS) == 17
targets = {p.target for p in products.PRODUCTS.values()}
assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb",
"e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"}
def test_table_carries_no_variant():
"""Die Variante ist eine Eigenschaft der Anfrage, nicht der Produkt-ID - sie darf gar
nicht mehr aus der Tabelle zu holen sein (Hardwarebefund 2026-09-15)."""
assert not hasattr(products.PRODUCTS["-klyqa.lighting.cw-ww.g95"], "variant")
assert "variant" not in products.Product._fields
def test_both_spellings_map_to_the_same_device():
with_dev = products.PRODUCTS["-klyqa.lighting.cw-ww.g95-dev"]
without = products.PRODUCTS["-klyqa.lighting.cw-ww.g95"]
assert with_dev.target == without.target == "g95_cw"
assert with_dev.name == without.name == "Klyqa G95 Vintage CW/WW"
def test_production_id_maps_to_device_and_name():
p = products.PRODUCTS["-klyqa.lighting.rgb-cw-ww.gu10"]
assert p.target == "gu10_rgb"
assert p.name == "Klyqa GU10 RGB CW/WW"
def test_parses_real_request_from_g95_dev_in_both_places():
"""Am 2026-09-13 an einer echten Entwicklungs-G95 protokolliert: "-dev" in Produkt-ID
UND Version."""
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
assert r is not None
assert r.product_id == "-klyqa.lighting.cw-ww.g95-dev"
assert r.lamp_version == "V1.1.1.1800-dev"
assert r.product.target == "g95_cw"
assert (r.variant, r.variant_source) == ("dev", "both")
def test_parses_real_request_from_g95_dev_only_in_version():
"""Am 2026-09-15 an einer echten Entwicklungs-G95 protokolliert - die Anfrage, an der
die Station bisher scheiterte: Produkt-ID OHNE "-dev", Version MIT. Sie muss trotzdem
als Entwicklungsgeraet gelten, sonst geht das produktionssignierte Image raus."""
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
assert r is not None
assert r.product_id == "-klyqa.lighting.cw-ww.g95"
assert r.lamp_version == "V1.1.1.2357-dev"
assert r.product.target == "g95_cw"
assert (r.variant, r.variant_source) == ("dev", "version")
def test_parses_real_request_from_gu10_without_any_dev():
"""Die erfolgreich migrierte Produktionslampe: nirgends ein "-dev"."""
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
assert r.product.target == "gu10_rgb"
assert (r.variant, r.variant_source) == ("prod", "none")
assert r.lamp_version == "V1.1.0.1138"
def test_dev_only_in_product_id_is_dev():
"""Die vierte denkbare Kombination - so noch nicht am Geraet gesehen, aber die
Machbarkeitsprobe zeigt, dass die Produkt-ID das "-dev" tragen kann, und nichts
garantiert, dass die Version es dann auch tut."""
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800_ota_light.img")
assert r.product.target == "g95_cw"
assert (r.variant, r.variant_source) == ("dev", "product_id")
def test_dev_must_be_a_whole_word_in_the_version():
""""-devel"/"-dev2" sind kein Entwicklungskennzeichen - sonst wuerde eine
Produktionslampe mit so einer Version das dev-signierte Image bekommen."""
for version in ("V1.1.1.2357-devel", "V1.1.1.2357-dev2"):
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_" + version + "_ota_light.img")
assert (r.variant, r.variant_source) == ("prod", "none"), version
# ... ein "-dev" vor einem Trennzeichen dagegen schon.
r = products.parse_request(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1-dev.3_ota_light.img")
assert (r.variant, r.variant_source) == ("dev", "version")
def test_variant_is_classified_even_for_unknown_product_ids():
r = products.parse_request(
"/qcx_firmware_-acme.lightbulb.xxl_V9.9-dev_ota_light.img")
assert r.product is None
assert (r.variant, r.variant_source) == ("dev", "version")
def test_unknown_product_id_parses_but_has_no_product():
r = products.parse_request(
"/qcx_firmware_-acme.lightbulb.xxl_V9.9_ota_light.img")
assert r is not None
assert r.product is None
assert r.product_id == "-acme.lightbulb.xxl"
def test_non_matching_paths():
for p in ("/", "/ninlight_evk_ota.img", "/qcx_firmware_.img",
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_ota_light.img",
"/x/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img"):
assert products.parse_request(p) is None, p
def test_path_traversal_is_not_parseable():
assert products.parse_request(
"/qcx_firmware_../../etc/passwd_V1_ota_light.img") is None
def test_websafe_transform():
assert products.websafe("@klyqa.lighting.cw-ww.g95") == "-klyqa.lighting.cw-ww.g95"
assert products.websafe("a b/c") == "a-b-c"
assert products.websafe("keep+all-_.09Az") == "keep+all-_.09Az"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1142
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "ninlight-station"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
+38
View File
@@ -5,3 +5,41 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd)
cmake -S "$ROOT/test" -B "$ROOT/build/host" -G Ninja >/dev/null cmake -S "$ROOT/test" -B "$ROOT/build/host" -G Ninja >/dev/null
cmake --build "$ROOT/build/host" cmake --build "$ROOT/build/host"
ctest --test-dir "$ROOT/build/host" --output-on-failure "$@" ctest --test-dir "$ROOT/build/host" --output-on-failure "$@"
if python3 -c 'import pytest' 2>/dev/null; then
# tools/mkimage als GANZES Verzeichnis, nicht nur tools/mkimage/tests: die 19 Tests in
# tools/mkimage/test_nl_mkimage.py liegen direkt daneben und liefen sonst in keiner
# Suite mit - darunter test_stays_python38_compatible, die Wache fuer die bindende
# 3.8-Randbedingung des Docker-Build-Containers.
#
# Diese Wache braucht das Paket "vermin" und schlaegt ohne es HART fehl (kein Skip):
# eine Wache, die sich beim Fehlen ihres Werkzeugs stillschweigend abschaltet, haette
# den Build-Bruch von damals genauso durchgelassen wie gar kein Test. Fehlt vermin,
# nachinstallieren:
# pip install vermin (oder: pip3 install --user vermin)
python3 -m pytest "$ROOT/tools/mkimage" "$ROOT/tools/station/tests" -q
else
echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2
echo " Installation: pip install pytest vermin (vermin braucht" >&2
echo " tools/mkimage/test_nl_mkimage.py::test_stays_python38_compatible)" >&2
fi
# Alle Shell-Suiten - test_deploy.sh fehlte hier und lief damit nur bei Handaufruf
# (Task 6b F1, I3): dieselbe Klasse von Luecke wie die aus keiner Suite gefallene
# test_nl_mkimage.py, nur eine Ebene hoeher. Mit ihr liefen 95 Zusagen nirgends mit,
# darunter die komplette Deploy-Haelfte der Anzeige-Auslieferung.
#
# Als ausdrueckliche Liste, nicht als lose Folge von Aufrufen (Task 6b F2): so gibt es
# EINE Stelle, die vollstaendig sein muss, und
# tools/mkimage/tests/test_suite_coverage.py kann sie gegen das Dateisystem pruefen,
# statt im Skripttext nach Dateinamen zu suchen - eine Textsuche haelt schon ein
# nachgestellter Kommentar zum Narren.
SHELL_SUITES=(
"$ROOT/tools/station/tests/test_cli.sh"
"$ROOT/tools/station/tests/test_deploy.sh"
"$ROOT/tools/station/tests/test_setup_pi.sh"
"$ROOT/tools/tests/test_release.sh"
)
for suite in "${SHELL_SUITES[@]}"; do
"$suite"
done
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env bash
# Stub-Tests fuer tools/release.sh (und tools/build_nocreds.sh) - ohne Docker, ohne
# echten Firmwarebau ausfuehrbar.
#
# Gestubbt wird nur die UNTERSTE Ebene: NINLIGHT_DOCKER zeigt auf ein Skript, das sich
# wie der Bau im Container verhaelt (relative Pfade ab Repo-Wurzel, legt _gz.img und
# _ota.img ab). Alles darueber - das Beiseitelegen von config.local.cmake, das
# Signieren, README/SHA256SUMS, das Packen - laeuft echt. Wichtig dabei: der Stub
# verhaelt sich wie ein Compiler und SCHREIBT die Werte aus config.local.cmake in die
# Images, wenn die Datei zum Bauzeitpunkt noch da ist. Nur so pruefen die Faelle 5/6
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug.
#
# Aufruf: tools/tests/test_release.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
TOOLS_DIR=$(cd "$HERE/.." && pwd)
REPO=$(cd "$TOOLS_DIR/.." && pwd)
# Sicherheitsnetz (wie in tools/station/tests/test_deploy.sh): NINLIGHT_SIGN_DIR zeigt
# auf diesem Rechner auf ECHTE Signierschluessel. Sie darf nicht aus der Umgebung in
# diese Suite durchsickern - jedes Szenario setzt sie hart, Testschluessel kommen ueber
# die separate Variable TEST_SIGN_DIR.
unset NINLIGHT_SIGN_DIR
unset TEST_SIGN_DIR
unset NINLIGHT_NOCREDS_BUILD_REL
unset NINLIGHT_DOCKER
# GNU vor BSD: auf dem Pi/Debian gibt es sha256sum, auf dem Mac nur shasum.
if command -v sha256sum >/dev/null 2>&1; then
SHA_CHECK=(sha256sum -c)
else
SHA_CHECK=(shasum -a 256 -c)
fi
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
FAILED=0
pass() { echo "ok - $1"; }
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
# Wegwerf-Zugangsdaten - niemals die echten aus dem Repo.
SECRET_SSID="TestWLAN-4b7f-geheim"
SECRET_PASS="testpasswort-4b7f"
SECRET_TOKEN="0badc0de4b7f0badc0de4b7f"
# --- Sandbox-Repo: release.sh, build_nocreds.sh, nl_sign.py, bruno/, config.local.cmake ---
setup_sandbox() {
local sandbox=$1
rm -rf "$sandbox"
mkdir -p "$sandbox/tools/mkimage" "$sandbox/bruno" "$sandbox/dist"
cp "$TOOLS_DIR/release.sh" "$sandbox/tools/release.sh"
cp "$TOOLS_DIR/build_nocreds.sh" "$sandbox/tools/build_nocreds.sh"
chmod +x "$sandbox/tools/release.sh" "$sandbox/tools/build_nocreds.sh"
# Das ECHTE Signierwerkzeug, nur die Schluessel sind Wegwerf-Testschluessel.
cp "$TOOLS_DIR/mkimage/nl_sign.py" "$sandbox/tools/mkimage/nl_sign.py"
# Die ECHTE Bruno-Collection: die Zusage ist, dass sie im Archiv liegt - mit einer
# erfundenen Attrappe wuerde der Test nur sich selbst pruefen.
cp "$REPO/bruno/ninlight.yml" "$sandbox/bruno/ninlight.yml"
cat >"$sandbox/config.local.cmake" <<EOF
set(NINLIGHT_WIFI_SSID "$SECRET_SSID")
set(NINLIGHT_WIFI_PASSWORD "$SECRET_PASS")
set(NINLIGHT_TOKEN "$SECRET_TOKEN")
EOF
}
# --- Docker-Stub: verhaelt sich wie der Bau im Container ---
# Steuerbar ueber:
# STUB_FAIL_TARGET bricht beim Bau dieses Ziels mit Exit 1 ab
# STUB_TERM_TARGET schickt beim Bau dieses Ziels SIGTERM an den Aufrufer
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
# config.local.cmake beiseite liegt (prueft den doppelten Boden)
setup_stub_docker() {
local stub=$1 sandbox=$2
cat >"$stub" <<'STUB'
#!/usr/bin/env bash
# Erwartet "cmake -S . -B <rel>/<ziel> ..." bzw. "cmake --build <rel>/<ziel>".
set -euo pipefail
cd "$NINLIGHT_STUB_ROOT"
mode=""
outdir=""
args=("$@")
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[$i]}" in
--build) mode=build; outdir=${args[$((i + 1))]} ;;
-B) mode=${mode:-configure}; outdir=${args[$((i + 1))]} ;;
esac
done
[ -n "$outdir" ] || { echo "Stub: kein Build-Verzeichnis in '$*'" >&2; exit 1; }
target=$(basename "$outdir")
mkdir -p "$outdir"
[ "$mode" = "build" ] || exit 0
if [ "${STUB_TERM_TARGET:-}" = "$target" ]; then
kill -TERM "$PPID"
sleep 5
exit 1
fi
if [ "${STUB_FAIL_TARGET:-}" = "$target" ]; then
echo "Stub: Bau von $target fehlgeschlagen" >&2
exit 1
fi
# "Compiler": die Zugangsdaten aus config.local.cmake landen im Abzug - genau wie beim
# echten Bau, wo CMakeLists.txt sie per set() einkompiliert.
creds=""
if [ -f config.local.cmake ]; then
creds=$(sed -n 's/.*set(NINLIGHT_[A-Z_]* "\(.*\)").*/\1/p' config.local.cmake | tr '\n' ' ')
fi
if [ "${STUB_LEAK:-}" = "1" ]; then
creds="$STUB_LEAK_VALUE"
fi
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" python3 - <<'PY'
import os
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
body = bytearray(("firmware-%s-" % target).encode() * 200)
body = body[:4096] + b"\0" * (4096 - len(body[:4096]))
blob = creds.encode()
if blob:
body[100:100 + len(blob)] = blob
gz = bytes(body)
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
fh.write(gz) # roh, ohne NINL-Trailer - das signiert nl_sign.py
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
fh.write(gz + bytes(trailer))
PY
STUB
chmod +x "$stub"
}
# Wegwerf-Testschluessel ueber nl_sign.generate_test_key() - nie echte Schluessel.
make_test_keys() {
local keydir=$1 with_prod=$2 with_dev=$3
mkdir -p "$keydir/QCX_Device_CA"
PYTHONPATH="$TOOLS_DIR/mkimage" python3 - "$keydir" "$with_prod" "$with_dev" <<'PY'
import sys
import nl_sign
keydir, with_prod, with_dev = sys.argv[1], sys.argv[2] == "1", sys.argv[3] == "1"
if with_prod:
k = nl_sign.generate_test_key()
with open(keydir + "/QCX_Device_CA/qcx_firmware_signing_key.pem", "wb") as fh:
fh.write(k.private_pem)
with open(keydir + "/prod_pub.der", "wb") as fh:
fh.write(k.public_der)
if with_dev:
k = nl_sign.generate_test_key()
with open(keydir + "/fw_sign.pem", "wb") as fh:
fh.write(k.private_pem)
with open(keydir + "/dev_pub.der", "wb") as fh:
fh.write(k.public_der)
PY
}
# Ruft release.sh in der Sandbox auf. NINLIGHT_SIGN_DIR wird hier HART gesetzt, nie aus
# der geerbten Umgebung uebernommen (siehe Sicherheitsnetz oben).
run_release() {
local sandbox=$1 version=$2
local sign_dir="${TEST_SIGN_DIR:-$sandbox/kein-schluesselverzeichnis}"
env NINLIGHT_SIGN_DIR="$sign_dir" \
NINLIGHT_DOCKER="$sandbox/stub-docker" \
NINLIGHT_STUB_ROOT="$sandbox" \
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
STUB_LEAK="${STUB_LEAK:-}" \
STUB_LEAK_VALUE="$SECRET_SSID" \
"$sandbox/tools/release.sh" "$version"
}
# Sucht die Zugangsdaten BINAER in jeder Datei unterhalb eines Verzeichnisses.
grep_secrets() {
local dir=$1
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 - "$dir" <<'PY'
import os
import sys
root = sys.argv[1]
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
for dirpath, _dirs, files in os.walk(root):
for fn in sorted(files):
p = os.path.join(dirpath, fn)
if os.path.islink(p):
continue
with open(p, "rb") as fh:
data = fh.read()
for name, needle in needles.items():
if needle in data:
print("%s in %s" % (name, os.path.relpath(p, root)))
PY
}
sha_of() { python3 -c "
import hashlib,sys
print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; }
echo "=== Szenario 1: Erfolgslauf mit beiden Schluesseln ==="
SANDBOX="$TMPROOT/s1"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
KEYS_FULL="$TMPROOT/keys-full"
make_test_keys "$KEYS_FULL" 1 1
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
set +e
OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC: $OUT"; fi
TGZ="$SANDBOX/dist/ninlight-0.9.9.tar.gz"
if [ -f "$TGZ" ]; then pass "Archiv entstanden"; else fail "kein Archiv unter $TGZ"; fi
EXTRACT="$TMPROOT/x1"
mkdir -p "$EXTRACT"
tar xzf "$TGZ" -C "$EXTRACT"
A="$EXTRACT/ninlight-0.9.9"
if [ -d "$A" ]; then pass "Archiv entpackt nach ninlight-0.9.9/"; else fail "Verzeichnis ninlight-0.9.9/ fehlt"; fi
echo "--- Fall 1/2/3: Verzeichnisse und Dateizahlen ---"
for d in ota migrate flash bruno; do
if [ -d "$A/$d" ]; then pass "$d/ vorhanden"; else fail "$d/ fehlt"; fi
done
for f in SHA256SUMS README.md onboard.sh bruno/ninlight.yml; do
if [ -f "$A/$f" ]; then pass "$f vorhanden"; else fail "$f fehlt"; fi
done
if [ -x "$A/onboard.sh" ]; then pass "onboard.sh ist ausfuehrbar"; else fail "onboard.sh ist nicht ausfuehrbar"; fi
N_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ')
N_FLASH=$(find "$A/flash" -type f | wc -l | tr -d ' ')
N_MIG=$(find "$A/migrate" -type f | wc -l | tr -d ' ')
[ "$N_OTA" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}"
[ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}"
[ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))"
MISS=""
for t in "${TARGETS[@]}"; do
[ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t"
[ -f "$A/flash/ninlight_${t}_gz.img" ] || MISS="$MISS flash/$t"
[ -f "$A/migrate/${t}_prod.img" ] || MISS="$MISS migrate/${t}_prod"
[ -f "$A/migrate/${t}_dev.img" ] || MISS="$MISS migrate/${t}_dev"
done
[ -z "$MISS" ] && pass "je Ziel genau die erwarteten Dateinamen" || fail "fehlende Dateien:$MISS"
echo "--- Migrationsimages sind echt signiert (und zwar das _gz.img) ---"
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/prod_pub.der" \
--in "$A/migrate/evk_prod.img" >/dev/null 2>&1; then
pass "evk_prod.img traegt eine gueltige Produktionssignatur"
else
fail "evk_prod.img besteht die Signaturpruefung nicht"
fi
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/dev_pub.der" \
--in "$A/migrate/g95_cw_dev.img" >/dev/null 2>&1; then
pass "g95_cw_dev.img traegt eine gueltige Entwicklungssignatur"
else
fail "g95_cw_dev.img besteht die Signaturpruefung nicht"
fi
# Die Signatur belegt denselben 512-Byte-Block wie der Trailer: signiert werden muss das
# rohe _gz.img. Ein versehentlich signiertes _ota.img waere 512 Byte laenger.
GZ_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/flash/ninlight_evk_gz.img")
MIG_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/migrate/evk_prod.img")
if [ "$MIG_SIZE" -eq $((GZ_SIZE + 512)) ]; then
pass "Migrationsimage = _gz.img + 512 Byte Signatur (nicht das _ota.img signiert)"
else
fail "Migrationsimage hat $MIG_SIZE Byte, erwartet $((GZ_SIZE + 512))"
fi
echo "--- Fall 4: SHA256SUMS deckt jede Datei ab und stimmt ---"
LISTED=$(awk '{print $2}' "$A/SHA256SUMS" | sed 's|^\*||' | sort)
PRESENT=$( (cd "$A" && find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort) )
if [ "$LISTED" = "$PRESENT" ]; then
pass "SHA256SUMS listet genau die Dateien des Archivs ($(echo "$PRESENT" | wc -l | tr -d ' '))"
else
fail "SHA256SUMS und Archivinhalt weichen ab:
$(diff <(echo "$LISTED") <(echo "$PRESENT") || true)"
fi
if (cd "$A" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
pass "Pruefsummen stimmen"
else
fail "Pruefsummenpruefung fehlgeschlagen"
fi
echo "--- Fall 5: keine Zugangsdaten im Archiv (binaer gesucht) ---"
HITS=$(grep_secrets "$A")
if [ -z "$HITS" ]; then
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
else
fail "Zugangsdaten im Archiv gefunden:
$HITS"
fi
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
# Verschleierung - aber eine unkomprimierbare Kopie wuerde hier auffallen).
if python3 -c "
import gzip,sys
data = gzip.open(sys.argv[1],'rb').read()
sys.exit(0 if not any(s.encode() in data for s in sys.argv[2:]) else 1)" \
"$TGZ" "$SECRET_SSID" "$SECRET_PASS" "$SECRET_TOKEN"; then
pass "auch im entpackten tar-Strom keine Zugangsdaten"
else
fail "Zugangsdaten im tar-Strom des Archivs"
fi
# Der Stub verhaelt sich nur dann wie ein Compiler, wenn er die Werte ueberhaupt
# einbauen KANN - sonst waere Fall 5 wertlos. Gegenprobe: mit vorhandener
# config.local.cmake landen sie im Abzug.
mkdir -p "$TMPROOT/probe"
env NINLIGHT_STUB_ROOT="$SANDBOX" "$SANDBOX/stub-docker" cmake --build "$TMPROOT/probe/evk" >/dev/null
if grep_secrets "$TMPROOT/probe" | grep -q SSID; then
pass "Gegenprobe: der Stub baut die Zugangsdaten ein, solange config.local.cmake da ist"
else
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
fi
echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---"
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
pass "config.local.cmake liegt unveraendert an seinem Platz"
else
fail "config.local.cmake fehlt oder wurde veraendert"
fi
echo "--- README nennt die Vertrauensgrenze und die Verzeichnisse ---"
for phrase in "kein kryptografisches Signaturverfahren" "ohne TLS" "onboard.sh" "migrate/" "flash/"; do
if grep -qF "$phrase" "$A/README.md"; then pass "README nennt '$phrase'"; else fail "README nennt '$phrase' nicht"; fi
done
echo "=== Szenario 2: Bau bricht mittendrin ab (Fall 6) ==="
SANDBOX="$TMPROOT/s2"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
set +e
OUT=$(STUB_FAIL_TARGET=e27_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein halbes Archiv zurueckgelassen"; else fail "Archiv trotz Abbruch entstanden"; fi
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
pass "config.local.cmake nach dem Abbruch unveraendert an seinem Platz"
else
fail "config.local.cmake nach dem Abbruch verschwunden oder veraendert"
fi
echo "=== Szenario 3: Abbruch per Signal (SIGTERM mitten im Bau) ==="
SANDBOX="$TMPROOT/s3"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
set +e
OUT=$(STUB_TERM_TARGET=gu10_rgb TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
pass "config.local.cmake nach dem Signalabbruch unveraendert an seinem Platz"
else
fail "config.local.cmake nach dem Signalabbruch verschwunden oder veraendert"
fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv nach dem Signalabbruch"; else fail "Archiv trotz Signalabbruch entstanden"; fi
echo "=== Szenario 4: ohne Schluesselverzeichnis (Fall 3) ==="
SANDBOX="$TMPROOT/s4"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
set +e
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1) # kein TEST_SIGN_DIR -> Verzeichnis gibt es nicht
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0 - das Archiv entsteht trotzdem"; else fail "Exit-Code $RC: $OUT"; fi
if echo "$OUT" | grep -q "Schluesselverzeichnis"; then pass "deutliche Meldung zum fehlenden Schluesselverzeichnis"; else fail "keine Meldung zum fehlenden Schluesselverzeichnis"; fi
EXTRACT="$TMPROOT/x4"; mkdir -p "$EXTRACT"
tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT"
A4="$EXTRACT/ninlight-0.9.9"
if [ ! -d "$A4/migrate" ]; then pass "migrate/ liegt nicht vor"; else fail "migrate/ trotzdem vorhanden"; fi
if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "${#TARGETS[@]}" ]; then
pass "ota/ trotzdem vollstaendig"
else
fail "ota/ unvollstaendig"
fi
if grep -q "migrate/\` fehlt in diesem Archiv" "$A4/README.md"; then
pass "README.md sagt, warum migrate/ fehlt"
else
fail "README.md erklaert das fehlende migrate/ nicht"
fi
if (cd "$A4" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
pass "SHA256SUMS stimmt auch ohne migrate/"
else
fail "SHA256SUMS stimmt ohne migrate/ nicht"
fi
echo "=== Szenario 5: zweiter Lauf mit derselben Version bricht ab (Fall 7) ==="
BEFORE=$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")
set +e
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "zweiter Lauf war erfolgreich"; fi
if echo "$OUT" | grep -q "existiert bereits"; then pass "Meldung 'existiert bereits'"; else fail "keine Meldung zum vorhandenen Archiv: $OUT"; fi
if [ "$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")" = "$BEFORE" ]; then
pass "vorhandenes Archiv unveraendert"
else
fail "vorhandenes Archiv wurde ueberschrieben"
fi
# Und eine andere Version laeuft weiterhin durch.
set +e
OUT=$(run_release "$SANDBOX" 0.9.10 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ] && [ -f "$SANDBOX/dist/ninlight-0.9.10.tar.gz" ]; then
pass "andere Version laeuft normal durch"
else
fail "andere Version schlaegt fehl: $OUT"
fi
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im Abzug brechen den Lauf ab ==="
SANDBOX="$TMPROOT/s6"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
set +e
OUT=$(STUB_LEAK=1 TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Zugangsdaten im Abzug erfolgreich"; fi
if echo "$OUT" | grep -q "Zugangsdaten aus config.local.cmake im Archiv gefunden"; then
pass "Meldung benennt den Fund"
else
fail "keine Meldung zum Fund: $OUT"
fi
if echo "$OUT" | grep -qF "$SECRET_SSID"; then
fail "die Meldung druckt den geheimen Wert selbst"
else
pass "die Meldung druckt den Wert nicht, nur den Namen"
fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"
STUBBIN="$TMPROOT/onb-bin"
mkdir -p "$STUBBIN"
CURLLOG="$TMPROOT/curl.log"
cat >"$STUBBIN/curl" <<'CURL'
#!/usr/bin/env bash
{ for a in "$@"; do printf '%s\n' "$a"; done; echo "--"; } >>"$CURL_LOG"
echo '{}'
CURL
chmod +x "$STUBBIN/curl"
: >"$CURLLOG"
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" "MeinWLAN" "geheim123" >/dev/null
if grep -q "^http://192.168.4.1:3333/api/v1/system/settings$" "$CURLLOG"; then
pass "PUT geht an system/settings im Onboarding-AP"
else
fail "falsche URL: $(cat "$CURLLOG")"
fi
if grep -q '^PUT$' "$CURLLOG"; then pass "Methode PUT"; else fail "keine PUT-Methode"; fi
if grep -qF '{"type": "wifi", "ssid": "MeinWLAN", "password": "geheim123"}' "$CURLLOG"; then
pass "Rumpf entspricht der Bruno-Anfrage 'Set WiFi'"
else
fail "falscher Rumpf: $(cat "$CURLLOG")"
fi
if grep -q "^http://192.168.4.1:3333/api/v1/system/info$" "$CURLLOG"; then
pass "danach wird system/info abgefragt"
else
fail "system/info wird nicht abgefragt"
fi
if grep -q "^Authorization:" "$CURLLOG"; then
fail "ohne --token wird trotzdem ein Authorization-Header geschickt"
else
pass "ohne --token kein Authorization-Header (offener Onboarding-AP)"
fi
: >"$CURLLOG"
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" --token abc12345 "MeinWLAN" "geheim123" >/dev/null
if grep -q "^Authorization: abc12345$" "$CURLLOG"; then
pass "mit --token wird der Header geschickt (Fallback-AP)"
else
fail "--token kommt nicht als Authorization-Header an"
fi
HELP=$("$ONB" --help)
if echo "$HELP" | grep -qi "von hand" && echo "$HELP" | grep -q "ninlight-<mac4>"; then
pass "Hilfetext nennt die manuellen Schritte und den AP-Namen"
else
fail "Hilfetext erklaert die manuellen Schritte nicht: $HELP"
fi
set +e
"$ONB" >/dev/null 2>&1
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "ohne SSID bricht onboard.sh ab"; else fail "ohne SSID lief onboard.sh durch"; fi
echo
if [ "$FAILED" -eq 0 ]; then
echo "alle Zusagen erfuellt"
else
echo "es gab Fehler" >&2
fi
exit "$FAILED"