136 Commits
Author SHA1 Message Date
Michael Sauer e0379445e8 docs(bruno): Standalone-Anfragen und die neuen Felder in system/info 2026-10-02 23:50:15 +02:00
Michael Sauer 58cdc73afc feat(station): ausgelieferte Firmware-Version auf dem Panel
Welche Version die Station gerade ausliefert, war am Geraet nur per SSH zu
beantworten. Jetzt sagt es die Anzeige selbst, je nach Bild an der Stelle, wo
Platz ist:

- Aus-Zustand: rechtsbuendig neben der Adresse (neue Rolle ROLE_DETAIL_RIGHT,
  nach dem Muster der Phase neben dem Modus)
- Rettung: rechtsbuendig in der Kopfzeile, dort steht keine Phase im Weg
- Migration: als beschriftete Zeile "liefert: <version>" direkt hinter "FW:",
  NICHT in der Kopfzeile - daneben bleiben nur rund 112 px, daran ist schon
  "FREIGEGEBEN: <ziel>" gescheitert, und zwei nackte Versionsnummern
  nebeneinander waeren eine Verwechslungsfalle (FW: ist die der Lampe)

Quelle ist der GEWAEHLTE Bildsatz: im Migrationsmodus manifest.json (die
Migrationsabzuege tragen keinen ninlight-Anhaenger), im Rettungsmodus der
Anhaenger der _ota.img. Weichen die Eintraege voneinander ab, steht da
"gemischt" - ein zusammengefuehrter Deploy kann Alt-Eintraege mit anderer
Version enthalten, und eine davon herauszugreifen waere geraten. Fehlt die
Quelle, bleibt die Angabe weg statt eine Version zu erfinden.
2026-10-02 23:44:37 +02:00
Michael Sauer c53a23daba docs: Standalone als Alternative beim Onboarding, Tokenschutz am Geraet belegt 2026-10-02 22:06:29 +02:00
Michael Sauer 6fe5efd2c0 merge: Standalone-AP-Modus (eigener Dauer-AP statt WLAN-Einwahl)
Die Lampe kann dauerhaft einen eigenen Zugangspunkt aufspannen, mit Passwort,
optional eigenem Namen und Funkkanal. Nach einem Neustart geht sie direkt
dorthin, ohne Onboarding-Pulsen. Steuerung weiter per REST und sACN ueber
diesen AP, mDNS inklusive. Am EVK vollstaendig abgenommen - siehe
docs/hardware-checklist.md.

* standalone-ap:
  docs: Standalone-AP am EVK abgenommen, alle neun Punkte belegt
  Doku: AP-Name in system/settings (gewuenscht) und system/info (laufend) unterschieden, Abnahmepunkt fuer wifi.ssid im AP-Betrieb
  fix(wifi): kurze Pause vor dem Standalone-Wechsel, Abnahmepunkt dazu
  fix(mdns): im eigenen AP bis zum Erfolg wiederholen, netif-Zeiger raus
  fix(api): system/info meldet im AP-Betrieb den AP-Namen
  fix(wifi): Zugriffsschutz und AP-Daten beim Verlassen des Standalone-Modus
  docs: Standalone-AP, ssid/pulse-Angaben korrigiert, Begriffe geschaerft
  docs: Standalone-AP-Modus in Abnahmeliste und README
  fix(mdns): im Standalone-Modus am AP-Netif anmelden, genutztes Netif protokollieren
  fix(mdns): Bekanntmachung am Zustand statt am Erzeugungsereignis
  fix(mdns): Bekanntmachung auch im eigenen AP
  fix(wifi): AP-Daten nach dem Onboarding zuruecksetzen, Netz trotz abgelehntem AP-Aufbau
  feat(wifi): Standalone-AP im Treiber und im Startpfad
  fix(api): standalone rollt bei 503 zurueck, Tests fuer Grenzen und behaltene Felder
  feat(api): Settings-Typ standalone und wifi.standalone in system/info
  fix(wifi): Standalone beendet laufenden Puls, schaerfere Tests fuer den Fehlschlagzweig
  feat(wifi): Wechsel in und aus dem Standalone-Modus
  feat(wifi): Standalone-Zustand mit begrenztem Startpuls
  feat(settings): Felder fuer den Standalone-AP-Modus
2026-10-02 21:30:35 +02:00
Michael Sauer b198fead77 docs: Standalone-AP am EVK abgenommen, alle neun Punkte belegt 2026-10-02 21:17:35 +02:00
Michael Sauer 71cede19a4 Doku: AP-Name in system/settings (gewuenscht) und system/info (laufend) unterschieden, Abnahmepunkt fuer wifi.ssid im AP-Betrieb 2026-10-02 19:45:39 +02:00
Michael Sauer ba8f2a10a0 fix(wifi): kurze Pause vor dem Standalone-Wechsel, Abnahmepunkt dazu
I6: Der HTTP-Task schreibt die 202 erst nach nl_api_handle(); die
Warteschlangennachricht geht aber schon darin raus, und net_task hat die
hoehere Prioritaet (35 gegen 42, im WM-SDK ist die kleinere Zahl hoeher). Er
verdraengt also sofort und reisst das Netz ab, bevor send_all() laeuft. Beim
Wechsel AUS dem WLAN kam die 202 trotzdem an, weil tls_wifi_disconnect()
asynchron arbeitet - ueber den eigenen AP ist tls_wifi_softap_destroy() der
haertere Abriss, und dieser Fall ist ungemessen.

Deshalb beides: rund 250 ms Pause im MSG_STANDALONE-Zweig vor dem Wechsel als
billige Versicherung, und der Fall als offener Punkt in der Abnahmeliste - die
Messung am Geraet bleibt noetig.
2026-10-02 19:32:56 +02:00
Michael Sauer bc3eff1a41 fix(mdns): im eigenen AP bis zum Erfolg wiederholen, netif-Zeiger raus
I5: s_ap_mdns_done wurde VOR nl_mdns_on_ip_up() gesetzt und merkte damit den
Versuch, nicht den Erfolg. Die Arbeit laeuft asynchron im tcpip-Thread; liefert
pick_netif() dort NULL, weil das AP-Netif noch keine Adresse hat, protokolliert
announce() nur "no usable netif yet" - und niemand versucht es je wieder,
solange der AP steht. mDNS ist laut Spec Pflichtteil, die Lampe waere im
eigenen AP dauerhaft nicht auffindbar.

Neu meldet nl_mdns_service_up() den tatsaechlichen Zustand aus s_service_added,
und der Netz-Task stoesst alle 5 s erneut an, solange die Lampe standalone ist,
der AP steht und der Dienst noch nicht gemeldet ist. s_announce_pending deckt
das Fenster zwischen dem Anstossen und dem Lauf von announce() ab: ohne das
haette die Abfrage beim Wechsel Station -> eigener AP noch den Dienst des alten
Netifs als Erfolg gewertet.

Dazu der netif-Zeiger (%p) aus der mdns-Protokollzeile: waehrend der
Fehlersuche war er wertvoll, in einer Release-Firmware ist er Rauschen.
Namenskuerzel, Nummer und IPv4 unterscheiden Station und eigenen AP bereits
eindeutig, eine Speicheradresse in einem Protokoll, das Nutzer weiterreichen,
hilft niemandem - und die Abnahmeliste zitiert die Zeile ohnehin ohne ihn.
2026-10-02 19:32:47 +02:00
Michael Sauer 08ea01f944 fix(api): system/info meldet im AP-Betrieb den AP-Namen
nl_wifi_status() fuellte out->ssid immer aus der Stations-SSID. Eine
Standalone-Lampe mit noch gespeicherten Heimnetzdaten meldete damit
mode: "ap", standalone: true UND den Namen des Heim-WLANs: App und HA zeigten
den falschen Netznamen, und der Heimnetzname stand in einer tokenfrei lesbaren
Antwort. Die Spec verlangt dort den AP-Namen.

Gemeldet wird jetzt im Modus "ap" (eigener AP oder Onboarding-AP) der
WIRKSAME AP-Name: die Daten des laufenden AP, sonst der gewuenschte, sonst der
Hostname. "apsta" bleibt bei der Station - dort ist sie die Hauptsache.
Erwuenschter Nebeneffekt: ein Rueckfall auf andere AP-Daten wird ueber die API
sichtbar, ohne ein neues Feld.

Beruehrt den API-Vertrag, deshalb ein eigener Commit. test/test_api.c ist
nicht betroffen: die Tests fahren ueber den Fake nl_sys_info_t, nl_wifi_status()
selbst gibt es im Hostbau nicht.
2026-10-02 19:32:33 +02:00
Michael Sauer ef321b37ef fix(wifi): Zugriffsschutz und AP-Daten beim Verlassen des Standalone-Modus
C1 (Merge-Blocker): Schaltet jemand den Standalone-Modus ab, waehrend keine
WLAN-Daten gespeichert sind, geht die Zustandsmaschine in den Onboarding-AP -
nl_wifi_sm_open_access() liefert dann true, obwohl der AP noch mit Name und
Passwort des Nutzers steht. Wer das AP-Passwort kennt, koennte system/settings
und system/command ohne Token benutzen: Token setzen, OTA anstossen,
Werksreset. Der Netz-Task reisst den AP in diesem Fall jetzt ausdruecklich ab
und baut ihn mit den Vorgabedaten neu auf - vor execute(ev) und nicht ueber die
Aktionsstruktur, weil execute() start_ap vor stop_ap ausfuehrt. Damit stimmt
auch die README-Zusage wieder, der AP heisse danach "ninlight-<mac4>".

I1: Der zweite Rueckfall nach drei abgelehnten tls_wifi_softap_create() setzte
ueber reset_ap_defaults() auch das Passwort auf das einkompilierte, das in der
README oeffentlich dokumentiert ist - eine Standalone-Lampe stuende dann mit
einem allgemein bekannten Passwort im Funkfeld, und sACN ist unauthentifiziert.
reset_ap_defaults() ist dafuer in reset_ap_identity() (Name und Kanal) und die
vollstaendige Rueckstellung aufgeteilt; der Rueckfall nimmt nur noch die
Identitaet. Kanal und SSID sind ohnehin die plausiblen Gruende, die
Passwortlaenge prueft die API selbst.

I2: destroy_ap() liess s_ap_live_ssid/-pass/-channel stehen. Nach dem Verlassen
des Standalone-Modus trugen diese Puffer weiter die Daten des Nutzers, und der
erste Rueckfallzweig holte sie Minuten spaeter zurueck - die Lampe pendelte
dann im 15-s-Takt zwischen Nutzerdaten und Vorgaben. Neu: forget_ap_live() in
beiden Abschaltwegen (clear_standalone und enabled:false), nicht in
destroy_ap() selbst, weil der Rueckfall auf zuletzt tragende Daten sonst auch
im Neuaufbau-Fall verlorenginge, fuer den er gedacht ist.

I3: s_ap_create_fails wurde nur bei Erfolg genullt und summierte damit ueber
verschiedene Datensaetze - nach einer Korrektur von Kanal 13 auf 6 loeste schon
ein einziger Fehlschlag den Rueckfall aus. Jetzt auch in destroy_ap() und bei
jedem neuen Wunsch aus MSG_STANDALONE.

Kleinigkeiten: nl_app_clear_standalone() wird auf seinen Rueckgabewert geprueft
und ein Schreibfehler protokolliert (sonst steht die Lampe im WLAN und nach dem
Neustart wieder im eigenen AP, ohne jede Spur). enabled:false auf einer Lampe,
die gar nicht standalone war, erzwingt keine Neuverbindung mehr, sondern tut
nichts und antwortet weiter mit 202.

Abnahmeliste: der Gerätebeleg für C1 als offener Punkt.
2026-10-02 19:32:19 +02:00
Michael Sauer 813d861cd1 docs: Standalone-AP, ssid/pulse-Angaben korrigiert, Begriffe geschaerft 2026-10-02 18:54:26 +02:00
Michael Sauer a022d8ae3e docs: Standalone-AP-Modus in Abnahmeliste und README 2026-10-02 18:51:50 +02:00
Michael Sauer 20c26d16d7 fix(mdns): im Standalone-Modus am AP-Netif anmelden, genutztes Netif protokollieren 2026-10-02 15:03:07 +02:00
Michael Sauer fda2552e50 fix(mdns): Bekanntmachung am Zustand statt am Erzeugungsereignis 2026-10-02 14:17:09 +02:00
Michael Sauer ded09a3516 fix(mdns): Bekanntmachung auch im eigenen AP 2026-10-02 10:47:10 +02:00
Michael Sauer 24c4e38e4e fix(wifi): AP-Daten nach dem Onboarding zuruecksetzen, Netz trotz abgelehntem AP-Aufbau 2026-10-02 01:17:00 +02:00
Michael Sauer 18eb7b5c01 feat(wifi): Standalone-AP im Treiber und im Startpfad 2026-10-02 00:52:52 +02:00
Michael Sauer e36add5030 fix(api): standalone rollt bei 503 zurueck, Tests fuer Grenzen und behaltene Felder 2026-10-02 00:21:40 +02:00
Michael Sauer 416307083f feat(api): Settings-Typ standalone und wifi.standalone in system/info 2026-10-02 00:10:38 +02:00
Michael Sauer 8469b92338 fix(wifi): Standalone beendet laufenden Puls, schaerfere Tests fuer den Fehlschlagzweig 2026-10-01 23:52:01 +02:00
Michael Sauer 906a6d55f2 feat(wifi): Wechsel in und aus dem Standalone-Modus 2026-10-01 23:41:10 +02:00
Michael Sauer 1c29735eaf feat(wifi): Standalone-Zustand mit begrenztem Startpuls
open_access() fordert zusaetzlich !standalone, damit der Tokenschutz auch
waehrend eines Onboarding-Tests aus dem Standalone-Modus erhalten bleibt.
2026-10-01 23:18:43 +02:00
Michael Sauer d4b683de8f feat(settings): Felder fuer den Standalone-AP-Modus 2026-10-01 23:01:02 +02:00
Michael Sauer 543123a60d feat(api): system/info sagt, ob der sACN-Empfaenger laeuft
Ein toter Empfangs-Task war von aussen nicht von "es sendet gerade niemand" zu
unterscheiden: beides meldete nur active=false. "multicast" half nicht weiter -
es zeigt den gemerkten Erfolg des LETZTEN Beitritts und bleibt true, auch wenn
der Socket laengst zu ist.

Genau dieser Fall trat am Montana-Black-Shield auf (2026-09-26, direkt nach
Migration und Onboarding) und hat eine halbe Stunde Suche gekostet, an deren
Ende die Ursache immer noch offen war. nl_sacn_running() gab es da schon, es
wurde nur nirgends ausgeliefert.

Zwei Tests: der bestehende prueft running=true mit, ein neuer haelt den toten
Task fest.
2026-09-27 02:48:48 +02:00
Michael Sauer 7fd279721f chore: HA-Kopie und die von der Station abgedeckten Werkzeuge wieder heraus
Der Shield-Zweig brachte zwei Verzeichnisse mit, die nichts mit dem Board zu
tun haben. Nach Ruecksprache mit Michael bleibt davon nur, was es sonst
nirgends gibt.

ha-integration/ faellt ganz weg: die Home-Assistant-Integration hat ihr
eigenes Repo (ha-ninlight, privat im Gitea) und laeuft dort in Michaels
Produktivinstanz. Eine zweite Kopie hier wuerde unweigerlich auseinanderlaufen,
und dann waere unklar, welche gilt.

Aus qconnex-listen/ fliegen die Teile, die tools/station betriebsfertig kann:
hostapd.conf, dnsmasq-qcx.conf, up.sh, down.sh und otaserve.py bauen von Hand
den Zugangspunkt "qconnex-firmware" und liefern Abbilder aus - genau der
Migrationsmodus der Pi-Station, dort als Dienst, mit Produkt-ID-Tabelle und
Signaturpruefung. Ihre eigene hostapd.conf nennt sich im Kopf selbst
"temporary experiment, do not enable as a service".

Es bleibt, wofuer es bei uns nichts gibt: onboard.py spricht die ALTE Firmware
ueber ihren TLS-Socket auf Port 2223 an (unser onboard.sh richtet dagegen eine
bereits migrierte ninlight-Lampe ein - anderer Zweck), dazu die
Analysewerkzeuge tlssniff.py, raw3333.py und logserver.py sowie bulb-map.txt.

Ausserdem in diesem Commit: die erwarteten Zahlen in test_release.sh von 35/32
auf 39/36. Der Merge lief konfliktfrei durch und liess sie trotzdem falsch
stehen - der Zweig zweigte vor der Reparatur der Zugangsdatenpruefung ab und
konnte ihre neue Meldung nicht kennen, waehrend sie sein neuntes Ziel nicht
kennen konnte. Woran die Zahlen haengen, steht jetzt im Kommentar daneben.
2026-09-26 18:01:56 +02:00
Michael Sauer e10147b70f Merge remote-tracking branch 'origin/montana-black-shield' into merge-probe
* origin/montana-black-shield:
  docs(bulb-map): Montana-Black-Shield (f960) ergaenzt
  chore(tools): qconnex-Migrationswerkzeug und HA-Integration ins Repo
  feat(station): Montana-Black-Produkt-ID -> Ziel ledshield_rgb
  feat(board): ledshield_rgb — Montana Black RGB-LED-Shield (analoge RGB-Streifen)
  build: tools/podman.sh — docker.sh-Pendant für podman (gleiches Image, gleicher Mount)
  Überträgt lokale Änderungen (nl_crc8 unsigned-shift, docker.sh cd -P) in neuen Top-Level-Tree und entfernt altes ninlight-w600/
  Initial commit
2026-09-26 17:18:27 +02:00
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
Michael Sauer 5cbf425e49 fix(release): Zugangsdatenpruefung entpackt die Abbilder, Teststub baut echte
Die Pruefung vor dem Packen suchte die Werte aus config.local.cmake in den
Rohbytes jeder Archivdatei. Ein Abbild ist aber 56 Byte Kopf, dann ein
gzip-Strom, dann - je nach Bildart - 512 Byte Trailer bzw. Signatur; im
komprimierten Strom steht kein Klartext. Derselbe Pruefkoerper meldete deshalb
auch fuer einen Bildsatz aus build/, der die Zugangsdaten nachweislich traegt,
"sauber (2 geprueft)". Er haette nie Alarm geschlagen.

Jetzt wird jede Datei weiterhin roh geprueft (Kopf, Trailer und die
Nicht-Abbilder wie README.md, onboard.sh und bruno/ sind unkomprimiert) und
jedes Abbild zusaetzlich entpackt. Ein Abbild, das sich nicht entpacken laesst,
bricht den Release ab, statt ungeprueft durchzugehen; das gilt auch fuer einen
abgeschnittenen gzip-Strom, den decompressobj sonst stillschweigend annimmt.

Entpackt wird ab Byte 56 ohne festes Abschneiden des Dateiendes. Anders als in
tools/station/deploy.sh liegen hier drei Bildarten nebeneinander: flash/_gz.img
traegt gar keinen Trailer, ota/_ota.img einen 512-Byte-Trailer, migrate/*.img
eine 512-Byte-Signatur. Ein raw[56:-512] wie dort haette jedes _gz.img fuer
unentpackbar gehalten und jeden Release abgebrochen.

Der Teststub war der zweite Fehler und verdeckte den ersten: er legte die
Zugangsdaten als Klartext in eine Datei, die er nur "..._ota.img" nannte. Damit
fand die rohe Suche sie, und der Fall war gruen, obwohl er eine Welt prueft, die
es nicht gibt. Der Stub baut jetzt Abbilder wie der echte Bau - 56 Byte Kopf,
gzip des Rumpfes mit den Zugangsdaten INNERHALB des komprimierten Rumpfes, beim
_ota.img 512 Byte Trailer mit NINL - und kontrolliert selbst, dass der Klartext
nicht roh in der Datei steht. Der Rumpf ist gross und gleichfoermig genug, dass
deflate ihn wirklich komprimiert statt ihn als "stored block" durchzureichen.

Neu sind Szenarien fuer Klartext in einer Nicht-Abbild-Datei, ein nicht
entpackbares Abbild und den Meldungstext; die Gegenprobe prueft jetzt auch den
Aufbau des Stub-Abbilds. Szenario 6 (Zugangsdaten im komprimierten Rumpf) und 6c
sind gegen den alten Pruefkoerper rot.

Die Meldung nennt jetzt die Zahl der Dateien und der entpackten Abbilder statt
der Zahl der Suchbegriffe. Gefundene Werte werden weiterhin nie ausgegeben, nur
ihr Name und die Datei.
2026-09-24 00:25:49 +02:00
Michael Sauer d7810c6cff fix(station): Zugangsdatenpruefung entpackt die Abbilder und prueft beide Rettungssaetze
Die Pruefung des Bildsatzes "ohne-wlan" suchte den Klartext der Werte aus
config.local.cmake in der Abbilddatei, wie sie auf der Platte liegt. Der Rumpf
eines Abbilds ist aber gzip (ab Byte 56, die letzten 512 Byte sind Trailer bzw.
Signatur) - im komprimierten Strom steht der Klartext nicht. Derselbe
Pruefkoerper meldete deshalb auch fuer den Satz aus build/, der die
Zugangsdaten nachweislich traegt, "sauber". Er haette nie Alarm geschlagen,
egal was im Bildsatz lag.

Jetzt wird jedes Abbild entpackt und erst dann durchsucht - im Rumpf wie in den
Rohbytes, denn Kopf, Trailer und Beidateien wie manifest.json sind
unkomprimiert. Ein Abbild, das sich nicht entpacken laesst, ist ein Fehler und
bricht den Deploy ab, statt ungeprueft durchzugehen; das gilt auch fuer einen
abgeschnittenen gzip-Strom, den decompressobj sonst stillschweigend annimmt.

Zweitens hing der ganze Block an der Existenz von migrate-nocreds/. Der
Rettungssatz rescue-nocreds/ lief ungeprueft durch, obwohl er der gefaehrlichere
von beiden ist: er geht ohne Signatur an die Lampe. Geprueft werden jetzt beide
Saetze "ohne-wlan"; migrate-wlan/ und rescue-wlan/ bleiben ungeprueft, die
sollen die Daten enthalten.

Die Meldung nennt jetzt Zahl der Abbilder und der Saetze statt der Zahl der
Suchbegriffe. Gefundene Werte werden weiterhin nie ausgegeben, nur ihr Name und
die Datei.

Die Testattrappen haben jetzt den echten Aufbau (56 Byte Kopf, gzip-Rumpf,
512 Byte Trailer) und tragen die Zugangsdaten im komprimierten Rumpf; die
Gegenproben-Hilfe tree_contains entpackt mit. Neu sind Szenarien fuer
Zugangsdaten im Rettungssatz, die Abgrenzung zu rescue-wlan, ein nicht
entpackbares Abbild und den Meldungstext. Szenario 33, 39, 41 und 42 sind gegen
den alten Pruefkoerper rot.
2026-09-23 23:55:23 +02:00
Michael Sauer bd467b5e52 fix(station): Panel ermittelt die Adresse per Netlink und nennt das Geraet
Am echten Pi stand im Aus-Zustand "LAN: not connected", obwohl er unter
192.168.2.96 erreichbar war - derselbe build_state()-Aufruf von Hand lieferte die
Adresse dagegen richtig. Ursache ist die Absicherung des Anzeigedienstes:
ninlight-display.service setzt RestrictAddressFamilies=AF_UNIX AF_NETLINK, der
UDP-Kniff in local_ip() legt aber ein AF_INET-Socket an. Das scheitert dort mit
EAFNOSUPPORT, und die Zeile fiel auf "not connected" zurueck. Kein Test hat das
gefunden, weil alle ohne diese Beschraenkung liefen.

Statt die Absicherung zu lockern ermittelt local_address() die Adresse jetzt mit
"ip -4 route get" - das spricht Netlink und bleibt damit innerhalb der Erlaubnis.
Der Unterprozess faellt nur im Aus-Zustand an, dem einzigen Statusbild mit dieser
Zeile.

Dazu die Beschriftung: "LAN" war eine Falschauskunft, dieser Pi haengt per WLAN
im Netz und hat gar kein Kabel. Der Kernel nennt in derselben Auskunft beides,
also steht dort jetzt "wlan0: 192.168.2.96" (bzw. "eth0: ..." am Kabel) und ohne
Netz "no home network" statt eines nackten "no network", das schon einmal auf den
Rettungs-Zugangspunkt bezogen wurde.

Der neue Test sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem -
genau die Luecke, die den Befund ueberhaupt durchgelassen hat.
2026-09-23 22:35:50 +02:00
Michael Sauer f31ef879ee fix(station): CLI prueft Rettungs- und Migrationsbildsatz getrennt, meldet Modus dauerhaft
Fixrunde 1, Befund 1: imageset_dir()/migrate_images()/imageset_status_lines()
pruefen bislang IMMER die Migrationsverzeichnisse, unabhaengig vom Modus - im
Rettungsbetrieb meldete "status" so faelschlich "MISSING", waehrend
rescue-nocreds voll war, oder umgekehrt "vorhanden", waehrend die Rettung
nichts hatte. Ein falsches "MISSING" dort verleitet dazu, auf mit-wlan
umzuschalten - und dann liefert die Rettung tatsaechlich Abbilder mit dem
WLAN-Passwort aus. imageset_dir() nimmt jetzt den Modus als Parameter
(Vorgabe: current_mode()), dieselbe Unterscheidung wie im Panel.

Befund 2: images() griff weiter flach auf $IMAGE_DIR zu, wo seit dem
Deploy-Umbau nichts mehr liegt - "recovery on" warnte deshalb bei JEDEM Start
"answers everything with 404". Ersetzt durch imageset_images(), das dem
Bildsatz-Umschalter UND dem Modus folgt.

Befund 3 (CLI-Seite): current_mode() liest die neue dauerhafte
LAST_MODE_FILE statt der ephemeren RUN_DIR/mode - write_mode() schreibt jetzt
beide (siehe display.py-Commit fuer die Panel-Seite derselben Ursache).

tests/test_cli.sh legte weiterhin die alte flache Rettungsablage an
(images/ninlight_foo_ota.img) - die Sandbox hat jetzt eigene
rescue-wlan/rescue-nocreds-Verzeichnisse wie ein echter Pi nach diesem
Umschalter, und run_cli() setzt NINLIGHT_LAST_MODE_FILE auf einen
Sandbox-Pfad. Mehrere bestehende Bildsatz-Szenarien liefen unbemerkt gegen
den Migrationssatz, obwohl kein Modus gesetzt war (Vorgabe: recovery) -
angepasst, damit sie den tatsaechlich geltenden Satz pruefen.
2026-09-23 21:59:25 +02:00
Michael Sauer 1562a7983a fix(station): Panel liest den Modus aus einer dauerhaften Datei, nicht aus RUN_DIR
Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart"
(RuntimeDirectoryPreserve=restart) - "ninlight-station off" fuehrt aber einen
echten "systemctl stop" aus, der das RuntimeDirectory leert. build_state()
fiel danach unbemerkt auf "recovery" zurueck, auch wenn tatsaechlich eine
Migration anstand - die Fusszeile haette dann "in Ordnung" gezeigt, obwohl der
Migrationssatz leer ist. Ein Docstring behauptete ausdruecklich das Gegenteil.

_read_mode_file() liest jetzt eine neue, dauerhafte LAST_MODE_FILE (unter
/var/lib/ninlight-station, wie der Bildsatz-Umschalter selbst) statt
RUN_DIR/mode; write_mode() im CLI schreibt beide. Ausserdem: _read_imageset()
faengt jetzt auch UnicodeDecodeError ab (Fixrunde 1, Kleinigkeit).

Ein neuer Test bildet den echten Ablauf nach (Modus schreiben, RUN_DIR wie bei
einem echten Stop leeren, pruefen dass der Modus trotzdem stimmt) - der alte
Test fuer den Rettungs-Bildsatz liess die Modusdatei ganz weg und pruefte
damit nur den zufaellig gleichen Rueckfallwert, nicht die Wirklichkeit.
2026-09-23 21:59:09 +02:00
Michael Sauer e67fc53b20 fix(station): Server nennt Rettungs-Bildsatz im Journal, haertet Umschalter-Lesen ab
main() glob'te die Startzeile weiter flach ueber image_dir und nannte den
geltenden Rettungs-Bildsatz nie - seit image_path() aus rescue_dir() liest,
zeigte das im Rettungsmodus immer "0 image(s)". rescue_set_present() (bisher
toter Code) wird jetzt fuer eine eigene Journalzeile verwendet, analog zur
bestehenden Migrationszeile.

read_image_set() fing nur OSError - eine Umschalterdatei mit ungueltigem
UTF-8 schlug durch, statt auf die sichere Vorgabe zu fallen.
2026-09-23 21:58:54 +02:00
Michael Sauer 6d0f79a6ac feat(station): Panel zeigt Rettungs-Bildsatz, Lebenszeichen und klarere LAN-Zeile
display.py/_read_imageset() prueft jetzt das zum Modus passende Verzeichnispaar
(rescue-* im Rettungsmodus, migrate-* in der Migration) - vorher pruefte die
Fusszeile immer nur den Migrationssatz, auch im Rettungsmodus.

render.py haengt einen kleinen Punkt an die Servierzeile, solange
display.py:_recent_request() eine Anfrage der letzten ACTIVITY_WINDOW_S
Sekunden meldet - faehrt ueber den vorhandenen Textvergleich mit, kein
zusaetzlicher Zeichentakt fuer das E-Paper.

Aus-Zustand: "no network" hiess fuer einen Bediener wie "der Migrations-/
Rettungs-Zugangspunkt ist aus" - gemeint ist das Gegenteil, das Heimnetz.
Jetzt "LAN: <ip>" bzw. "LAN: not connected".

run_daemon() faengt jetzt jeden Fehler beim Erheben des Zustands ab
(_safe_read_state) statt die Schleife zu beenden - sonst friert das Panel
nach einem einzigen unerwarteten Lesefehler fuer immer auf dem letzten Bild
ein, ohne dass das am Geraet zu erkennen waere.
2026-09-23 21:22:42 +02:00
Michael Sauer ac8dfb1077 feat(station): Rettung folgt dem Bildsatz-Umschalter, Server meldet Lebenszeichen
server.py: image_path() liefert jetzt aus rescue_dir() (nach dem Umschalter
ohne-wlan/mit-wlan), genau wie migrate_dir() es fuer die Migration schon tut.
Bewusst kein Fallback auf den alten flachen Bestand in images/ - ein Pi mit
altem Bestand muss einmal per deploy.sh neu bestueckt werden, sonst wuerde
"ohne-wlan" den alten Zustand (immer mit Zugangsdaten) stillschweigend
fortsetzen.

Station.last_activity/"last_request_ts" in status.json: Zeitpunkt jeder
Anfrage (auch erfolgloser), damit die Anzeige ein Lebenszeichen zeigen kann,
statt nur am Servierzaehler abzulesen, ob ueberhaupt etwas ankommt.
2026-09-23 21:22:22 +02:00
Michael Sauer eec9fa5a21 feat(station): Rettungsabbilder folgen dem Bildsatz-Umschalter beim Deploy
deploy.sh legt Rettungsabzuege jetzt in images/rescue-wlan/ (aus build/) und
images/rescue-nocreds/ (aus build-nocreds/) ab, statt sie flach in images/
abzulegen - dasselbe Muster wie migrate-wlan/migrate-nocreds, inklusive
derselben --delete-Vorsicht (nur bei vollstaendigem Satz). Alter flacher
Bestand wird bei jedem Deploy aufgeraeumt, auch bei einem gezielten.

README beschreibt den Umschalter jetzt fuer beide Betriebsarten.
2026-09-23 21:22:06 +02:00
Michael Sauer 9ee1983dff feat: sACN-Belegung rgb als Vorgabe
Pixelorientierte Werkzeuge wie LedFx schreiben drei Kanaele je Pixel; mit
dem Dimmer voran landet deren erstes Pixel auf Dimmer, R, G. rgb ist damit
die Belegung, die out of the box das Richtige tut - der Dimmer bleibt fuer
DMX-Pulte eine Umschaltung entfernt.
2026-09-22 23:20:12 +02:00
Michael Sauer 363aee12ef docs(sacn): Multicast-Ursache gefunden - nicht geklammertes htonl-Makro 2026-09-21 23:22:03 +02:00
Michael Sauer 4231444a93 merge: Multicast-Fehler behoben - htonl() zerriss die Gruppenadresse
* sacn-multicast-fix:
  fix(sacn): Multicast kam nie an - htonl() zerriss die Gruppenadresse
2026-09-21 23:10:19 +02:00
Michael Sauer fdded43ab9 fix(sacn): Multicast kam nie an - htonl() zerriss die Gruppenadresse
Die Lampe trat seit jeher der falschen Gruppe bei. Aus Universe 1 wurde
239.255.239.255 statt 239.255.0.1, und damit lauschte sie auf einer Adresse,
auf die niemand sendet: Multicast kam nie an, Unicast lief, und der Beitritt
meldete trotzdem Erfolg - er gelang ja, nur eben fuer die falsche Gruppe.

Ursache ist ein nicht geklammertes Makro im SDK:

  #define htonl(n) (((n & 0xff) << 24) | ((n & 0xff00) << 8) | ...)

Stand die Adressrechnung direkt im Aufruf, traf die Maske nach der
Textersetzung nur den letzten Term, weil & staerker bindet als |. Nachgerechnet
ergibt das fuer Universe 1 exakt die 0xefffefff, die am EVK auf der seriellen
Konsole stand.

Die Rechnung liegt jetzt in nl_e131_group_addr() - im reinen, host-testbaren
Teil, mit Testfaellen fuer beide Byte-Haelften. Der Aufrufer reicht eine
Variable an htonl(), keinen Ausdruck mehr.

Gefunden am EVK ueber die serielle Konsole, nachdem vier Netz-Konfigurationen
und fuenf andere Theorien nichts ergeben hatten.
2026-09-21 23:06:01 +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
Michael Sauer 4400ea5b42 docs(sacn): rgb-Belegung an der Hardware abgenommen 2026-09-20 03:40:38 +02:00
Michael Sauer fbdf0c40ef feat(sacn): zweite Slot-Belegung "rgb" fuer pixelorientierte Werkzeuge
LedFx und Verwandte denken in RGB-Pixeln: Ihr e131-Geraet schreibt je Pixel
genau drei Kanaele. Unsere Belegung beginnt mit einem Master-Dimmer, also landet
deren erstes Pixel auf Dimmer, R, G - ein rotes Effektbild (255,0,0) hiesse bei
uns "Dimmer 255, Rot 0, Gruen 0" und damit Dunkelheit. Von Hand eintragen liess
sich eine Lampe schon, sie zeigte aber Unsinn.

Der Dimmer bleibt fuer DMX-Pulte richtig, deshalb eine zweite Belegung statt
einer anderen: layout = "dimmer" (Vorgabe, unveraendert) oder "rgb" (R,G,B, drei
Slots auf jedem Board). Im rgb-Layout ist eine Lampe genau ein Pixel.

Ein Board ohne Farbkanaele bekommt trotzdem ein Farbtripel. Genommen wird der
groesste der drei Werte auf beide Weisskanaele: die Lampe folgt sichtbar der
Helligkeit des Effekts, ohne dass ein Farbmodell in den DMX-Pfad kommt - genau
das wird dort sonst bewusst vermieden.

Die Obergrenze fuer start_slot haengt jetzt am Layout (512 - slots + 1). Ein
Wechsel, der einen bisher zulaessigen start_slot unzulaessig macht, wird
abgelehnt statt still verschoben: eine Lampe, die nach einer Einstellung
woanders im Universe liegt als angegeben, findet niemand wieder.

Das neue Settings-Feld beginnt bei Offset 216, dem sizeof davor, und ist durch
eine eigene Pruefung zur Uebersetzungszeit und einen Migrationstest abgesichert -
derselbe Fallstrick, der beim Schlussreview fuer sacn_enabled gefunden wurde.
2026-09-20 03:35:19 +02:00
Michael Sauer 2c7ebac6d1 docs(sacn): Multicast weiter eingekreist - vier Theorien ausgeschlossen
Nicht die Ursache: der netif-Ausstieg im SDK-Filter (tls_get_netif liefert das
STA-Interface), fehlender Multicast-Empfang (mDNS kommt an), fehlendes
Multicast-Senden (die mDNS-Ankuendigung der Lampe ist ueber die Luft sichtbar),
die zweite Filterebene tls_filter_mcast_mac (gebaut, aufgespielt, getestet,
zurueckgenommen) und Stromsparen/DTIM. Uebrig bleibt das Indiz, dass die Lampe
keine IGMP-Meldung sendet - beweisbar nur per serieller Konsole oder
Monitor-Mitschnitt.
2026-09-20 03:15:17 +02:00
Michael Sauer d82df96655 docs(sacn): Netzseite durchprobiert - Multicast bleibt aus, Verdacht liegt bei der Lampe
Vier UniFi-Varianten getestet (Multicast-zu-Unicast, IGMP-Snooping, Querier), in
jeder kam Multicast nicht an und Unicast sofort. Dazu die Einschraenkung, die
beim ersten Freispruch der Firmware unterging: sacn.multicast meldet nur den
Rueckgabewert von IP_ADD_MEMBERSHIP, und die SDK-Funktion fuer den
Hardware-Filter kann ERR_OK liefern, ohne den Filter gesetzt zu haben.
2026-09-20 02:58:04 +02:00
Michael Sauer 60b662ff20 merge: sACN/E1.31-Fernsteuerung
Die Lampe laesst sich per E1.31 ueber UDP fernsteuern, ohne dass der von Home
Assistant gesetzte Zustand angetastet wird. Abgenommen an einer E27 RGB, siehe
docs/acceptance-sacn.md - dort steht auch, dass Multicast derzeit am WLAN
haengenbleibt und nicht an der Firmware.

* feat/sacn:
  docs(sacn): Multicast-Ursache geklaert - die Lampe tritt bei, das WLAN filtert
  feat(sacn): Ergebnis des Multicast-Beitritts sichtbar machen
  docs(sacn): Abnahmeprotokoll der Hardwarepruefung
  docs(light,e131): zwei Stellen benennen, die sonst wie Versehen aussehen
  fix(sacn): ein nicht gestarteter Empfangs-Task darf die Lampe nicht unbrauchbar machen
  fix(console): Licht-Befehl gibt den DMX-Override frei - die Konsole ist der Rettungsweg
  fix(settings): neue Felder erst ab dem alten sizeof - sonst war sACN nach dem Update aus
  docs(sacn): Sender fuer die Abnahme, README und Bruno-Anfrage
  feat(sacn): UDP-Empfangs-Task und Verdrahtung in App und API
  fix(sacn): validate universe/start_slot via get_int(), reject fractional input
  feat(sacn): Endpunkt zum Einstellen und Statusfeld in system/info
  fix(sacn): sequence handling on priority change and start_slot clamp
  feat(sacn): Uebernahme-Schicht mit Timeout, Prioritaet und Sequenz
  feat(sacn): Override-Zweig in der Licht-Engine
  feat(sacn): Slotzahl je Board und vier Settings-Felder
  fix(sacn): validate universe range (1..63999), add boundary tests
  feat(sacn): E1.31-Pakete pruefen und zerlegen
2026-09-20 02:33:49 +02:00
Michael Sauer 15074fb58f merge: Panel und CLI der Station auf Englisch, IP-Zeile im Off-Bild
* station-english:
  feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
2026-09-20 02:33:49 +02:00
Michael Sauer 8f877f18d0 docs(sacn): Multicast-Ursache geklaert - die Lampe tritt bei, das WLAN filtert
Die Diagnose-Firmware meldet multicast: true, die Pakete kommen trotzdem nicht
an. Damit ist die Firmware raus und die Suche hat eine Richtung: IGMP Snooping
ohne Querier bzw. Multicast-Sperre auf dem IoT-WLAN. Dass mDNS laeuft, ist kein
Gegenbeweis - 224.0.0.0/24 ist von der Filterung per Norm ausgenommen.
2026-09-20 02:33:21 +02:00
Michael Sauer fd61f151d7 feat(sacn): Ergebnis des Multicast-Beitritts sichtbar machen
Bei der Abnahme kam Multicast nicht an, waehrend Unicast lief. Der Rueckgabewert
von IP_ADD_MEMBERSHIP wurde bis jetzt verworfen - absichtlich, weil ein
gescheiterter Beitritt den Unicast-Empfang nicht verhindern soll. Die Folge war
aber, dass sich die entscheidende Frage nicht beantworten liess: tritt die Lampe
nicht bei, oder reicht das WLAN die Gruppe nicht weiter?

system/info traegt deshalb jetzt "sacn.multicast", und der Beitritt schreibt beim
Socket-Aufbau eine Zeile auf die Konsole. Am Verhalten aendert sich nichts: ein
Fehlschlag bleibt folgenlos fuer Unicast.
2026-09-20 02:23:33 +02:00
Michael Sauer aa0ed770eb docs(sacn): Abnahmeprotokoll der Hardwarepruefung
Sieben Pruefpunkte an einer E27 RGB, dazu das OTA aus Home Assistant. Zwei
Befunde stehen mit drin, weil sie sonst verlorengehen: Multicast erreicht die
Lampe nicht (Unicast schon), und eine sACN-Quelle aus einem anderen Netz liefert
sichtbar ruckelige Rampen. Beim Multicast ist auch festgehalten, was NICHT
geprueft werden konnte und warum - der Beitritt wird derzeit bewusst ignoriert,
also weiss niemand, ob er scheitert.
2026-09-20 02:21:19 +02:00
Michael Sauer 9b80ab0e0d docs(light,e131): zwei Stellen benennen, die sonst wie Versehen aussehen
nl_engine_override_active() hat keinen Aufrufer im src/-Baum - die Firmware fragt
nl_dmx_active(). Die Funktion bleibt, weil die Engine-Tests sonst keinen Blick auf den
Override-Zustand haben; der Header sagt das jetzt, damit niemand nach dem Aufrufer sucht.

Der E1.31-Parser ueberliest Synchronisationsadresse und das Bit "force synchronization".
Fuer eine einzelne Lampe ist sofortige Ausgabe richtig - auf ein Sync-Paket zu warten
hiesse, bei einem Sender mit gesetzter Adresse dunkel zu bleiben. Der Kommentar nennt es
als bewusste Abweichung von E1.31-2018 Abschnitt 6.2.4.1.
2026-09-20 01:16:42 +02:00
Michael Sauer c70b6db4b8 fix(sacn): ein nicht gestarteter Empfangs-Task darf die Lampe nicht unbrauchbar machen
nl_sacn_beat ist ein Pflichtterm in der Wachhund-Bedingung. Schlug tls_os_task_create()
fehl, bewegte sich der Herzschlag nie, der Wachhund wurde nie gefuettert und die Lampe
setzte sich alle 20 s zurueck - ohne Weg hinein. Ein Zusatzdienst muss zu "kein sACN"
degradieren, nicht zu "keine Lampe".

nl_sacn_running() meldet den Fehlschlag, app_main merkt ihn sich einmal als
sacn_required, und eine Zeile auf der Konsole macht ihn sichtbar.
2026-09-20 01:16:42 +02:00
Michael Sauer 5c17c94dbc fix(console): Licht-Befehl gibt den DMX-Override frei - die Konsole ist der Rettungsweg
nl_app_api.c loest den Override vor nl_ctrl_apply() auf, die serielle Konsole nicht.
Waehrend eines Streams - im Modus "hold" dauerhaft - aenderte ein Licht-Befehl den
Zustand unter der Haube, die Lampe tat sichtbar nichts und die Konsole meldete trotzdem
"ok". Wer zur seriellen Leitung greift, hat meist genau diesen Fall: dunkel geparkt,
kein Controller da.

nl_dmx_release() liegt in der schon gehaltenen Lock-Klammer, nl_app_lock() ist nicht
rekursiv.
2026-09-20 01:16:42 +02:00
Michael Sauer 993b7c1575 fix(settings): neue Felder erst ab dem alten sizeof - sonst war sACN nach dem Update aus
Ein Datensatz aus der Firmware vor diesem Zweig ist 208 Byte lang und traegt hinter
wake_on_change (206) noch ein Fuellbyte. sacn_enabled lag auf genau diesem Byte (207),
und read_rec() kopiert hdr.length Bytes ueber die Vorgaben - jede Lampe im Feld waere
nach der OTA mit sacn_enabled == 0 hochgekommen und haette keinen Socket gebunden,
genau das Gegenteil dessen, was Spezifikation und README versprechen.

_legacy_pad schiebt das erste neue Feld auf 208; _padding schrumpft auf 1 Byte, damit
sizeof ein Vielfaches von 4 bleibt (216). nl_settings_legacy_check pinnt den Offset zur
Uebersetzungszeit fest, denn die bisherige Pruefung sah nur die Teilbarkeit durch 4 und
liess diesen Fall durch. Der Test kuerzte auf offsetof(sacn_enabled) - eine Laenge, die
es auf keiner Lampe je gab - und ging deshalb durch; er kuerzt jetzt auf die Literalzahl
208 und stellt das Fuellbyte nach.
2026-09-20 01:16:42 +02:00
Michael Sauer f2cae9b7ce docs(sacn): Sender fuer die Abnahme, README und Bruno-Anfrage 2026-09-20 00:57:16 +02:00
Michael Sauer c547a9f2b2 feat(sacn): UDP-Empfangs-Task und Verdrahtung in App und API 2026-09-20 00:51:33 +02:00
Michael Sauer 545ca30699 fix(sacn): validate universe/start_slot via get_int(), reject fractional input 2026-09-20 00:29:47 +02:00
Michael Sauer 021089a68b feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
Die deutschen Texte sahen auf dem 2,13"-Panel merkwuerdig aus - jetzt sind
alle Texte, die der Bediener am Geraet oder auf der Konsole sieht, englisch:
Modus-, Phasen-, Bildsatz- und Abschlussbeschriftungen in render.py, die
Bedientexte des Tasterdienstes und saemtliche Ausgaben von ninlight-station
inklusive des Hilfetexts. Die Testbeschreibungen der Suiten bleiben deutsch -
die liest nur, wer die Tests laufen laesst.

Neu im Off-Bild: die Adresse, unter der der Pi gerade erreichbar ist. Ist die
Station aus, ist der Pi ein gewoehnlicher Rechner im Heimnetz, und die
einzige Auskunft, die man dann am Panel braucht, ist die fuer SSH. Die
Adresse kommt aus einem UDP-Socket, der nur verbunden und nie benutzt wird
(kein Datenverkehr, kein DNS, kein Unterprozess in einer 5-s-Schleife) und
liefert genau die Adresse der Standardroute. Ohne Netz sagt die Zeile
ausdruecklich "no network": eine fehlende Zeile wuerde offen lassen, warum
SSH gerade scheitert.

Die Zeile gibt es NUR im Off-Bild - im Betrieb traegt wlan0 die Adresse des
Zugangspunkts, die ohnehin auf jedem Statusbild steht.
2026-09-20 00:28:33 +02:00
Michael Sauer e892b441ce feat(sacn): Endpunkt zum Einstellen und Statusfeld in system/info 2026-09-20 00:24:09 +02:00
Michael Sauer 882cb55297 fix(sacn): sequence handling on priority change and start_slot clamp 2026-09-20 00:16:39 +02:00
Michael Sauer fae22f8c64 feat(sacn): Uebernahme-Schicht mit Timeout, Prioritaet und Sequenz 2026-09-20 00:08:49 +02:00
Michael Sauer 6be0473f18 feat(sacn): Override-Zweig in der Licht-Engine 2026-09-20 00:02:16 +02:00
Michael Sauer 33d085cbed feat(sacn): Slotzahl je Board und vier Settings-Felder 2026-09-19 23:56:00 +02:00
Michael Sauer f09a51088a fix(sacn): validate universe range (1..63999), add boundary tests
Universe 0 and values >63999 are reserved per E1.31 spec and must be rejected.
Priority is deliberately not validated (compared relatively in later tasks only).

Test coverage:
- test_universe_zero_rejected: rejects universe 0
- test_universe_too_high_rejected: rejects universe 64000
- test_universe_is_read: continues to accept universe 63999
2026-09-19 23:49:47 +02:00
Michael Sauer cdbbe31831 feat(sacn): E1.31-Pakete pruefen und zerlegen 2026-09-19 23:44:55 +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
97 changed files with 21289 additions and 309 deletions
+4 -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}>")
@@ -74,6 +74,7 @@ set(NL_FW_SOURCES
src/board/nl_board.c src/board/nl_board.c
src/light/nl_color.c src/light/nl_color.c
src/light/nl_engine.c src/light/nl_engine.c
src/light/nl_dmx.c
src/light/nl_control.c src/light/nl_control.c
src/light/nl_boot_state.c src/light/nl_boot_state.c
src/config/nl_settings.c src/config/nl_settings.c
@@ -89,12 +90,14 @@ set(NL_FW_SOURCES
src/sys/sdk_stubs.c src/sys/sdk_stubs.c
src/net/nl_names.c src/net/nl_names.c
src/net/nl_http.c src/net/nl_http.c
src/net/nl_e131.c
src/net/nl_api.c src/net/nl_api.c
src/net/nl_wifi_sm.c src/net/nl_wifi_sm.c
src/net/nl_wifi_w600.c src/net/nl_wifi_w600.c
src/net/nl_recovery.c src/net/nl_recovery.c
src/net/nl_mdns.c src/net/nl_mdns.c
src/net/nl_http_server.c src/net/nl_http_server.c
src/net/nl_sacn.c
src/sys/nl_app_api.c src/sys/nl_app_api.c
third_party/cJSON/cJSON.c) third_party/cJSON/cJSON.c)
+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
+111 -8
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`.
@@ -44,7 +44,7 @@ Firmware flashen (Standard: App-Image):
tools/flash.sh <device> <port> tools/flash.sh <device> <port>
Das Skript druckt `Bitte bei "please manually reset the device" die Reset-Taste drücken.` — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Für blank/erased Chips: `tools/flash.sh <device> <port> full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet). Das Skript druckt `Bitte bei "please manually reset the device" die Reset-Taste drücken.` — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Das Skript ruft `wm_tool` mit `-rs none` auf, es löst also **keinen** Reset aus: Der Reset muss von Hand in dem Zeitfenster kommen, in dem `wm_tool` auf den Sync wartet. Ein automatischer Reset über die Steuerleitungen (`-rs rts` o. ä.) funktioniert an diesem Board nicht. Für blank/erased Chips: `tools/flash.sh <device> <port> full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet).
Serielle Konsole (115200 8N1, lokales Echo): Serielle Konsole (115200 8N1, lokales Echo):
@@ -65,12 +65,107 @@ Verfügbare Befehle in der Konsole: `help`.
gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits
Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich. Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich.
Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback). Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback).
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
**Alternative beim Einrichten:** Statt ein WLAN anzugeben, kann man die Lampe im selben Schritt
dauerhaft auf ihren eigenen Zugangspunkt festlegen — dann braucht sie überhaupt keinen Router.
Im unkonfigurierten AP geht das wie das WLAN-Onboarding ohne Token:
curl -X PUT http://192.168.4.1:3333/api/v1/system/settings \
-d '{"type":"standalone","enabled":true,"password":"geheim123"}'
Einzelheiten unter [Standalone-AP](#standalone-ap).
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. die Typen `sacn` und `standalone`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'` - Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`. - Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen. - Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler. - Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
### sACN / E1.31
Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf
`239.255.<universe_hi>.<universe_lo>` und per Unicast direkt an ihre IP. Nach einem
Update ist der Empfang aktiv: Universe 1, Startslot 1, Belegung `rgb`.
Es gibt zwei Slot-Belegungen, umschaltbar über `layout`:
| `layout` | Slots ab `start_slot` | wofür |
|---|---|---|
| `dimmer` | Dimmer, R, G, B, CW, WW — ohne RGB: Dimmer, CW, WW | DMX-Pulte, die einen Master-Dimmer erwarten |
| `rgb` (Vorgabe) | R, G, B (3 Slots auf jedem Board) | pixelorientierte Werkzeuge wie LedFx, die je Pixel drei Kanäle schreiben |
Im `rgb`-Layout ist jede Lampe genau ein Pixel: Lampe 1 auf Offset 0, Lampe 2 auf Offset 3,
und so weiter. Ein Board ohne Farbkanäle nimmt den größten der drei Werte auf beide
Weißkanäle — es folgt damit der Helligkeit des Effekts.
Wieviele es sind, steht als `slots` in `GET system/settings`.
Während Pakete fließen, hat sACN Vorrang; die Werte gehen ohne Fade direkt an die
Kanäle, und der per API gesetzte Zustand bleibt unangetastet. 2,5 s nach dem letzten
Paket endet die Übernahme — je nach `on_timeout` fällt die Lampe auf den per API
gesetzten Zustand zurück (`release`, Vorgabe) oder behält die letzten Werte (`hold`).
Ein Befehl an `device/state` holt die Kontrolle in beiden Fällen sofort zurück.
Umstellen:
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
-H 'Authorization: <token>' \
-d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}'
Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im
WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts
speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`.
Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`.
### Standalone-AP
Statt sich in ein WLAN einzuwählen, kann die Lampe dauerhaft einen eigenen Zugangspunkt aufspannen
(z. B. für Bühne oder Gartenhaus ohne Router). Der Modus überlebt Neustarts: die Lampe startet
direkt im eigenen AP, ohne Onboarding und ohne Onboarding-Pulsen. Die Lampe ist dann unter
`192.168.4.1` erreichbar, per mDNS als `<hostname>.local`.
Einschalten:
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
-H 'Authorization: <token>' \
-d '{"type":"standalone","enabled":true,"ssid":"Buehne","password":"geheim123","channel":6}'
Antwort `202 {"status":"standalone"}`. Der Aufruf braucht den Token, sobald die Lampe konfiguriert
ist; im unkonfigurierten Onboarding-AP geht er ohne. Anders als dieser ist der Standalone-AP
anschließend **nicht** token-frei: der Tokenschutz gilt dort wie im Normalbetrieb. Am Gerät
gemessen (2026-10-02, EVK und eine E27 RGB): `GET system/settings` ohne Token → 401 im
Standalone-AP, 200 im unkonfigurierten Onboarding-AP. Felder:
| Feld | Pflicht | Bedeutung |
|---|---|---|
| `enabled` | ja | `true` schaltet den Modus ein, `false` aus |
| `password` | beim Einschalten, solange noch keines gespeichert ist | AP-Passwort, 8..63 Zeichen |
| `ssid` | nein | AP-Name, 1..32 Zeichen; ein leerer String wird mit 400 `invalid ssid` abgelehnt. Weggelassen bleibt der gespeicherte Name erhalten; `ninlight-<mac4>` gilt nur, solange nie ein eigener gesetzt wurde — über die API führt kein Weg zurück zum Vorgabenamen |
| `channel` | nein | Funkkanal 1..13, Vorgabe 6 |
| `pulse` | nein | Startpuls beim Hochfahren der Lampe an/aus, Vorgabe an (läuft rund 5 s, danach gilt das normale Einschaltverhalten) |
Ausschalten braucht kein Passwort: `{"type":"standalone","enabled":false}`. Hat die Lampe gespeicherte
WLAN-Daten, wählt sie sich damit wieder ins WLAN ein; ohne solche öffnet sie den unkonfigurierten
Onboarding-AP `ninlight-<mac4>`.
`GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **gewünschten** AP-Namen
(`ssid`: der gespeicherte Name, ohne eigenen Namen der Hostname), `channel` und `pulse` — das
Passwort steht nie darin. `GET system/info` meldet `wifi.standalone` und im AP-Betrieb (`mode: "ap"`,
also eigener AP oder Onboarding-AP) unter `wifi.ssid` den **laufenden AP-Namen** statt der
gespeicherten Stations-SSID — der Name des Heimnetzes steht damit nicht in einer tokenfrei lesbaren
Antwort.
Die beiden Namen können auseinanderlaufen, und das ist Absicht. Scheitert der AP-Aufbau mehrfach,
fällt der Treiber auf die zuletzt funktionierenden Daten zurück, und gibt es die nicht, auf Name und
Kanal der Vorgaben (das Nutzerpasswort bleibt); die gespeicherte Einstellung behält dabei den
Wunschnamen. Nach `{"enabled":false}` behält sie `ap_ssid` ebenfalls, der laufende AP heißt dann
wieder `ninlight-<mac4>`. Genau diese Abweichung zwischen `system/settings` (gewünscht) und
`system/info` (läuft) ist das Erkennungsmerkmal für einen Rückfall.
Der Startpuls übermalt als Überlagerung die sACN-Ausgabe: solange er läuft, ist von sACN nichts
zu sehen. Beim Ausprobieren von sACN über den eigenen AP den Puls mit `pulse: false` abschalten oder
abwarten. Außerdem muss der Sender bei zwei Default-Routen (LAN und AP) das Interface für Multicast
festlegen (`IP_MULTICAST_IF`), sonst sendet er ins eigene LAN statt in den AP.
## Firmware-Update ## Firmware-Update
Der Build erzeugt pro Gerät zwei Images: Der Build erzeugt pro Gerät zwei Images:
@@ -95,11 +190,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):
+136 -5
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
@@ -213,6 +234,16 @@ items:
Enthält `last_ota_error`: `null`, solange kein Update fehlgeschlagen ist, sonst einer von Enthält `last_ota_error`: `null`, solange kein Update fehlgeschlagen ist, sonst einer von
`download`, `crc`, `trailer`, `device_mismatch`, `hash`, `busy` (`busy` heißt: es läuft bereits `download`, `crc`, `trailer`, `device_mismatch`, `hash`, `busy` (`busy` heißt: es läuft bereits
ein Update). Kein `reset_reason` mehr im Feld-Satz. ein Update). Kein `reset_reason` mehr im Feld-Satz.
`wifi.standalone` unterscheidet den dauerhaften eigenen Zugangspunkt vom
Onboarding-AP - `wifi.mode` ist bei beiden `"ap"`. Im AP-Betrieb steht unter
`wifi.ssid` der **laufende** AP-Name, nicht die gespeicherte Stations-SSID; der
Name des Heimnetzes steht damit nicht in dieser tokenfrei lesbaren Antwort.
`sacn.running` sagt, ob der Empfangs-Task lebt und einen Socket haelt - zusammen
mit `sacn.active` (es kommt gerade ein gueltiger Strom an) und `sacn.multicast`
(der letzte Gruppenbeitritt hat geklappt) ist damit unterscheidbar, ob niemand
sendet oder ob der Empfang tot ist.
- info: - info:
name: Get System Settings name: Get System Settings
type: http type: http
@@ -225,6 +256,17 @@ 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.
Der `standalone`-Block zeigt `enabled`, den **gewuenschten** AP-Namen (`ssid`: der
gespeicherte, ohne eigenen der Hostname), `channel` und `pulse`. Das AP-Passwort
steht nie darin. Der gewuenschte Name kann vom laufenden abweichen, den
`GET system/info` meldet - genau das ist das Erkennungsmerkmal fuer einen Rueckfall
auf die Vorgaben nach mehrfach gescheitertem AP-Aufbau.
- info: - info:
name: Set WiFi name: Set WiFi
type: http type: http
@@ -302,10 +344,99 @@ items:
timeout: 0 timeout: 0
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
- info:
name: Set sACN
type: http
seq: 7
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "sacn",
"enabled": true,
"universe": 1,
"start_slot": 1,
"on_timeout": "release"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
- info:
name: Standalone einschalten
type: http
seq: 8
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "standalone",
"enabled": true,
"password": "geheim123",
"ssid": "Buehne",
"channel": 6,
"pulse": true
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Die Lampe spannt dauerhaft einen eigenen Zugangspunkt auf, statt sich in ein WLAN
einzuwaehlen - danach ist sie unter `192.168.4.1` erreichbar, per mDNS als
`<hostname>.local`. Antwort **202** `{"status":"standalone"}`, nicht 200: die
Antwort muss das Geraet verlassen haben, bevor das Netz unter der Verbindung
weggezogen wird.
`password` ist beim Einschalten Pflicht, solange keines gespeichert ist (8..63
Zeichen). `ssid` (1..32), `channel` (1..13, Vorgabe 6) und `pulse` sind optional
und behalten sonst ihren gespeicherten Wert; ein leerer `ssid`-String wird mit 400
`invalid ssid` abgelehnt.
**Achtung beim Ausprobieren:** Aenderst du Name oder Passwort, waehrend du ueber
genau diesen AP verbunden bist, baut die Lampe ihn neu auf und wirft dich hinaus -
die 202 kommt noch an, danach musst du dem neuen Netz beitreten.
Anders als der unkonfigurierte Onboarding-AP ist der Standalone-AP **nicht**
token-frei: ab da gilt der Tokenschutz wie im Normalbetrieb.
- info:
name: Standalone ausschalten
type: http
seq: 9
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "standalone",
"enabled": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Braucht kein Passwort. Hat die Lampe gespeicherte WLAN-Daten, waehlt sie sich damit
wieder ein; ohne solche oeffnet sie den unkonfigurierten Onboarding-AP
`ninlight-<mac4>` - der ist dann wieder token-frei.
Der gespeicherte AP-Name bleibt dabei in den Einstellungen stehen und greift beim
naechsten Einschalten des Modus erneut.
- info: - info:
name: Commands name: Commands
type: folder type: folder
seq: 7 seq: 10
items: items:
- info: - info:
name: Ping name: Ping
+189
View File
@@ -0,0 +1,189 @@
# Abnahme sACN/E1.31
Datum: 2026-09-20, nachts
Lampe: Klyqa E27 RGB CW/WW, `192.168.2.204`, MAC `28:6d:cd:5c:7f:5b`, Board `e27_rgb` (6 Slots)
Firmware vorher: `0.3.8-36-gca9bb` — nachher: `0.3.8-61-g9b80a`
Sender: `tools/sacn_send.py`, ausgefuehrt vom Pi `ninlight-station` (192.168.2.96), also aus
demselben Netz wie die Lampen.
## Ergebnis
Bestanden, mit einer benannten Einschraenkung: **Multicast erreicht die Lampe nicht**,
Unicast funktioniert vollstaendig. Siehe den eigenen Abschnitt unten.
## Einzelne Schritte
| # | Pruefpunkt | Wie geprueft | Ergebnis |
|---|---|---|---|
| 0 | OTA aus Home Assistant | `update.install` auf der Update-Entitaet, Abbild aus dem Firmware-Ordner der Produktiv-HA | bestanden; Lampe nach dem Neustart mit Uptime 12 s zurueck, WLAN unveraendert |
| 0a | Versionsvergleich der Integration | HA meldete `0.3.8-36-gca9bb` -> `0.3.8-61-g9b80a` als Update | bestanden; HAs eingebauter Vergleich haette diese Git-Versionen falsch sortiert, der eigene greift |
| 0b | Vorgaben nach dem Update | `GET system/settings` | bestanden: `enabled true`, Universe 1, Startslot 1, `release`, `slots 6` |
| 1 | Uebernahme, Farbe | Gruen per Unicast, 60 s | bestanden (Sichtpruefung Michael: "leuchtet gruen") |
| 2 | Dimmer-Rampe | 0..255 und zurueck, 25 fps | bestanden nach Wechsel des Senders auf den Pi, siehe "Ruckeln" unten |
| 3 | Timeout, Modus `release` | 30 s Blau, dann Strom beenden | bestanden: nach 2,5 s zurueck auf den HA-Zustand (rot, 21 %), Sicht + `device/state` |
| 4 | Timeout, Modus `hold` | Nullen senden, dann Strom beenden | bestanden: Lampe blieb dunkel, obwohl HA "an, rot" hielt |
| 5 | HA holt die Kontrolle zurueck | `light.turn_on` waehrend die Lampe im `hold` dunkel festhing | bestanden: sofort gelb/80 %, `sacn.active` aus |
| 6 | Abbruch-Bit | Zweimal gemessen, mit und ohne `--terminate` | bestanden: **0,1 s** mit Abbruch-Bit gegen **2,5 s** ueber den Timeout |
| 7 | Universe im Betrieb wechseln | auf 12 umgestellt, dann auf 1 und auf 12 gesendet | bestanden: altes Universe wirkungslos, neues uebernimmt, kein Neustart |
Nicht auf Hardware geprueft: ein Board **ohne RGB** (3 Slots, Dimmer/CW/WW). Dafuer haette
eine zweite Lampe geflasht werden muessen. Abgedeckt ist der Fall durch die Host-Tests
(`test_dmx.c`, `test_board_without_rgb_uses_three_slots`) und die board-abhaengige
Slotgrenze in `test_api.c`.
## Befund: Multicast erreicht die Lampe nicht - Ursache liegt im WLAN
Unicast an die Lampen-IP funktioniert durchgaengig. Multicast auf `239.255.0.1` kommt nicht
an, auch nicht von einem Sender im selben Netz.
**Geklaert am 2026-09-20 mit einer Diagnose-Firmware** (`0.3.8-63-gfd61f`, Commit fd61f15):
`system/info` meldet seitdem `sacn.multicast`, also das Ergebnis von `IP_ADD_MEMBERSHIP`.
Die Lampe liefert **`multicast: true`** - der Beitritt zur Gruppe gelingt. Die Pakete kommen
trotzdem nicht an. Damit liegt es nicht an der Firmware, sondern am Netz: das WLAN reicht
die Gruppe `239.255.0.1` nicht an die Lampe weiter.
Dass mDNS funktioniert, widerspricht dem nicht: `224.0.0.251` liegt im link-lokalen Bereich
`224.0.0.0/24`, den Switches und Access Points per Norm von der IGMP-Filterung ausnehmen und
immer fluten. `239.255.0.1` unterliegt dagegen der Filterung.
**Netzseite in derselben Nacht durchprobiert - ohne Erfolg:**
| Zustand im UniFi | Multicast kommt an? |
|---|---|
| Snooping aus, Multicast-zu-Unicast aus, kein Blocker (Ausgangslage) | nein |
| Multicast-zu-Unicast an | nein |
| zusaetzlich IGMP-Snooping an, Querier "Aus (Drittanbieter)" | nein |
| zusaetzlich Querier "Primaer/Failover" | nein |
| dasselbe, danach frischer Beitritt der Lampe erzwungen | nein (`multicast: true` gemeldet) |
Unicast funktionierte in jeder dieser Varianten sofort. Der Multicast- und Broadcast-Blocker
war durchgehend aus, Geraete- und L3-Isolierung ebenfalls.
**Damit zeigt der Verdacht auf die Lampe.** Wichtig fuer den naechsten Versuch: Das Feld
`sacn.multicast` ist NICHT so aussagekraeftig, wie es aussieht. Es meldet nur, dass
`IP_ADD_MEMBERSHIP` erfolgreich zurueckkam. Die SDK-Funktion, die den Hardware-Filter des
W600 programmiert (`ethernetif_igmp_mac_filter` in
`sdk/Src/Network/lwip2.0.3/netif/ethernetif.c`), steigt jedoch ganz oben mit
```c
if (netif != tls_get_netif())
return ERR_OK;
```
aus - sie meldet also Erfolg, OHNE den Filter gesetzt zu haben, wenn die Schnittstelle nicht
die vom SDK als aktuell gefuehrte ist. Dann nimmt der Chip die Frames nie an.
### Was in derselben Nacht noch geprueft und AUSGESCHLOSSEN wurde
- **Der Ausstieg in `ethernetif_igmp_mac_filter` greift nicht.** `tls_get_netif()`
(`sdk/Src/Network/lwip2.0.3/netif/wm_ethernet.c:473`) liefert `nif`, und das ist das
STA-Interface - genau das, auf dem der Beitritt laeuft. `netif != tls_get_netif()` ist
also falsch, der Hardware-Schluessel wird gesetzt. Meine erste Theorie war damit falsch.
- **Die Lampe empfaengt Multicast.** Sie beantwortet eine mDNS-Anfrage, die ausschliesslich
als Multicast an 224.0.0.251 ging.
- **Die Lampe sendet Multicast.** Nach einem Neustart sah der Pi ihre mDNS-Ankuendigung auf
224.0.0.251 - Multicast von WLAN-Geraet zu WLAN-Geraet ist also sichtbar und die Messung
taugt.
- **Im IGMP-Mitschnitt schweigt die Lampe.** Querier-Abfragen (vom Gateway) und Meldungen
anderer Geraete sind zu sehen, von der Lampe kommt weder eine Beitrittsmeldung noch eine
Antwort auf die Abfrage. ABER: Bei aktivem Snooping leiten Switches Meldungen nur Richtung
Router weiter - das Schweigen ist damit ein Indiz, kein Beweis.
- **`tls_filter_mcast_mac(gruppen_mac, 1)` nach dem Beitritt aendert nichts.** Die zweite,
im SDK-Header dokumentierte Filterebene wird aus dem Quelltext nie aufgerufen; sie
ausdruecklich zu oeffnen wurde gebaut, aufgespielt und getestet - Multicast kam weiterhin
nicht an. Der Versuch wurde danach zurueckgenommen, die Lampe laeuft wieder auf `main`.
- **Stromsparen/DTIM scheidet aus**: Waere das die Ursache, kaeme auch mDNS nicht an.
### Naechste Schritte, wenn es weitergeht
1. Serielle Konsole an eine Lampe haengen: sieht man beim Beitritt einen Sendeversuch der
IGMP-Meldung? Das ist die einzige Messung, die das verbleibende Indiz zum Beweis macht.
2. Mitschnitt im Monitor-Modus (Laptop auf demselben Kanal) - zeigt, was wirklich ueber die
Luft geht, unabhaengig davon, was der AP weiterreicht.
3. IGMP-Snooping einmal ganz aus und dabei mitschneiden: dann fluten Switches auch
IGMP-Meldungen, und das Schweigen der Lampe waere eindeutig zu belegen oder zu widerlegen.
4. `sacn.multicast` ehrlicher benennen: es meldet den Rueckgabewert von
`IP_ADD_MEMBERSHIP`, nicht die tatsaechliche Empfangsbereitschaft.
Zwei Fehlversuche auf dem Weg dorthin, damit sie niemand wiederholt:
- Der erste Multicast-Test lief vom Mac (192.168.1.131) gegen eine Lampe in 192.168.2.x.
Multicast wird zwischen den Netzen nicht geroutet - der Test konnte gar nicht gelingen.
- Der zweite liess den Pi in die Gruppe eintreten und an sie senden. Ein Access Point
schickt einem Sender seine eigenen Multicast-Frames nicht zurueck; das Ergebnis war
aussagelos, nicht negativ.
## Befund: Ruckeln bei der Dimmer-Rampe
Die erste Rampe kam sichtbar ruckelig an ("ruckelt sehr"). Gesendet wurde vom Mac, der in
einem anderen Netz haengt als die Lampen; jedes Paket lief ueber den Router. Dieselbe Rampe
vom Pi aus, im selben Netz wie die Lampe, lief deutlich glatter ("viel besser").
Fuer den Einsatz heisst das: Die sACN-Quelle gehoert in dasselbe Netz wie die Lampen.
## Nicht geprueft, weil dafuer eine alte Firmware noetig waere
Der Migrationspfad aus dem kritischen Befund des Schlussreviews (ein vor sACN geschriebener
208-Byte-Datensatz darf `sacn_enabled` nicht ueberschreiben) wurde auf dieser Lampe nicht
durchlaufen: Sie trug keinen gespeicherten Einstellungssatz, alle Werte standen auf Vorgabe.
Abgedeckt ist der Fall durch den Host-Test in `test_settings.c`, der vor dem Fix fehlschlug
und danach besteht. Wer ihn auf Hardware sehen will, nimmt eine Lampe, deren
Einschaltverhalten vom Vorgabewert abweicht — die traegt einen echten alten Datensatz.
## Nachtrag 2026-09-20, 03:40 - Belegung "rgb"
Umgesetzt und an derselben E27 RGB geprueft (Firmware `0.3.8-70-gfbdf0`):
| Pruefpunkt | Ergebnis |
|---|---|
| `PUT {"type":"sacn","layout":"rgb"}` | bestanden; `GET` meldet `layout: rgb`, `slots: 3` |
| Paket mit NUR drei Slots | bestanden: wird angenommen (mit Dimmer-Belegung waere es verworfen worden) |
| Reines Rot (255,0,0) | bestanden (Sichtpruefung Michael: "leuchtet rot") - genau der Fall, der mit der Dimmer-Belegung Dunkelheit ergeben haette |
Die Lampe bleibt im `rgb`-Layout stehen, damit sie fuer LedFx vorbereitet ist. Die anderen
Lampen tragen noch die alte Firmware und damit nur die Dimmer-Belegung.
Nicht auf Hardware geprueft: das `rgb`-Layout auf einem Board OHNE Farbkanaele (groesster
der drei Werte auf beide Weisskanaele). Abgedeckt durch `test_dmx.c`.
## Nachtrag 2026-09-21 - Multicast GEFUNDEN und behoben
Die Ursache lag die ganze Zeit bei uns, nicht im Netz: Die Lampe trat der **falschen Gruppe**
bei. Aus Universe 1 wurde `239.255.239.255` statt `239.255.0.1`.
Schuld ist ein nicht geklammertes Makro im SDK
(`sdk/Include/Net/wm_sockets2.0.3.h`):
```c
#define htonl(n) (((n & 0xff) << 24) | ((n & 0xff00) << 8) | ...)
```
Stand die Adressrechnung direkt im Aufruf, traf die Maske nach der Textersetzung nur den
letzten Term, weil `&` staerker bindet als `|`. Fuer Universe 1 ergibt das exakt `0xefffefff` -
genau der Wert, der am EVK auf der seriellen Konsole stand.
Damit erklaert sich alles, was vorher widerspruechlich aussah: Der Beitritt gelang (deshalb
`multicast: true`), die Lampe meldete ihn auch brav per IGMP - nur eben fuer eine Gruppe, auf
die niemand sendete. Unicast war davon nicht betroffen, mDNS auch nicht (das joint das SDK
selbst mit einer festen Adresse).
**Gefunden am EVK ueber die serielle Konsole**, nachdem vier Netz-Konfigurationen und fuenf
andere Theorien nichts ergeben hatten. LwIPs `igmp.c` liegt als Quelltext im Baum und laesst
sich instrumentieren - das war der Hebel, der zwei Naechte lang fehlte.
### Abnahme des Fixes
| Pruefpunkt | Ergebnis |
|---|---|
| EVK, Gruppe laut Konsole | `join efff0001`, MAC-Filter `01:00:5e:7f:00:01`, Report `type=0x16` |
| EVK, Multicast-Uebernahme | bestanden |
| EVK, Universe-Wechsel ueber Multicast | bestanden: Sender auf 1 wirkungslos, Sender auf 12 uebernimmt |
| E27 RGB (Produktivlampe), Multicast im `rgb`-Layout | bestanden |
| Farbzuordnung: `255,0,255` | lila (Sichtpruefung) - R und B an, G aus |
| Farbzuordnung: `0,255,0` | gruen (Sichtpruefung) - nur der mittlere Kanal |
### Fuer die Netzseite
Die vier UniFi-Umstellungen vom Vortag (Multicast-zu-Unicast, IGMP-Snooping, Querier) waren
fuer dieses Problem nicht noetig. Der Querier auf "Primaer/Failover" gehoert trotzdem so -
Snooping ohne Querier ist unabhaengig davon eine schlechte Kombination.
+83 -5
View File
@@ -1,6 +1,6 @@
# Hardware-Checkliste (EVK) # Hardware-Checkliste (EVK)
Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.sh evk <port>`. Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.sh evk <port>` — das Skript nutzt `-rs none`, die Lampe braucht also einen **manuellen** Reset in dem Zeitfenster, in dem `wm_tool` auf den Sync wartet; ein automatischer Reset über die Steuerleitungen funktioniert an diesem Board nicht.
## Licht (Plan 1, Task 11) ## Licht (Plan 1, Task 11)
- [x] Nach dem Boot blendet warmweiß 100 % in 0,5 s ein - [x] Nach dem Boot blendet warmweiß 100 % in 0,5 s ein
@@ -59,11 +59,29 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
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.*
## Standalone-AP-Modus (Abnahme am EVK, MAC 28:6d:cd:5c:68:1f)
- [x] `type: standalone` per `PUT system/settings` einschalten → 202 `{"status":"standalone"}`, `system/info` meldet `wifi.standalone: true` — 2026-10-02
- [x] Neustart → Lampe startet direkt im eigenen AP: Konsole `wifi: standalone AP, hostname ninlight-681f`, keine Onboarding-Zeile, kein Onboarding-Pulsen (den gibt es nur im Zustand `AP_ONBOARDING`, aus dem Code ableitbar; der Standalone-Startpuls ist davon getrennt und unten offen) — 2026-10-02
- [x] Geänderte AP-Daten → `wifi: AP data changed, rebuilding`, danach `wifi: AP ninlight-681f (ch 6) up` — 2026-10-02
- [x] Standalone mit exakt den Vorgabedaten auf einem bereits stehenden Onboarding-AP (kein Neuaufbau) löst die mDNS-Bekanntmachung ebenfalls aus — 2026-10-02
- [x] mDNS im eigenen AP: Konsole `mdns: ninlight-681f.local _ninlight._tcp up on en1 192.168.4.1`, `dns-sd -G v4 ninlight-681f.local` löst auf `192.168.4.1` auf, `ping` 0 % Verlust — 2026-10-02
- [x] REST über den AP antwortet auf `192.168.4.1:3333` — 2026-10-02
- [x] Startpuls als Sichtprüfung: Michael hat zugesehen — der Puls lief nach dem Start, hörte nach rund 5 s auf, danach stand die voreingestellte Farbe wieder. Das Einschaltverhalten greift vor dem Netz-Task (`app_main.c:224` vs. `:250`), die Lampe zeigt deshalb erst ihre Farbe und der Puls legt sich darüber — 2026-10-02
- [x] REST über den AP verlangt das Token: im Standalone-AP `GET system/settings` ohne Token → 401 `unauthorized`, mit Werkstoken → 200; `GET system/info` ohne Token → 200. Gegenprobe nach dem Werksreset im unkonfigurierten Onboarding-AP: derselbe Aufruf ohne Token → **200** — 2026-10-02
- [x] `{"enabled":false}` **ohne** gespeicherte WLAN-Daten: 202, danach heißt der AP wieder `ninlight-681f` (Beitritt mit dem Vorgabepasswort gelang, Name **und** Passwort sind also umgestellt), `system/info` meldet `wifi.ssid: ninlight-681f` und `standalone: false`, `GET system/settings` ohne Token → 200. Der gespeicherte Wunschname bleibt in `system/settings` als `standalone.ssid: Buehne` stehen — 2026-10-02
- [x] Standalone-Daten ändern, während der Aufrufer **über den AP** verbunden ist: die 202 kommt an. Direkter Vorher/Nachher-Beleg — derselbe Aufruf lieferte auf dem Stand **vor** der I6-Pause `HTTP 000` (Verbindung riss ab, bevor die Antwort raus war), mit der Pause `{"status":"standalone"} HTTP 202` — 2026-10-02
- [x] WLAN eingeben, falsches Passwort → Gerät bleibt im eigenen AP mit eigenen Daten: 202 `connecting`, nach dem Fehlschlag `wifi.error: auth`, `standalone: true`, AP weiterhin `Buehne` mit dem Nutzerpasswort, `GET system/settings` ohne Token weiterhin **401** — 2026-10-02
- [x] WLAN eingeben, richtiges Passwort → Gerät im Heimnetz (192.168.2.58, `standalone: false`), nach einem Neustart `mode: sta` und **nicht** wieder im AP — 2026-10-02
- [x] Im Standalone-Betrieb meldet `GET system/info` unter `wifi.ssid` den AP-Namen: an einer Lampe mit gespeichertem Heimnetz (`settings.wifi.ssid: ninIOT, source: stored`) meldete die **tokenfreie** Antwort `wifi.ssid: Buehne`, nicht `ninIOT`. Vor dem Wechsel in den Standalone-Modus stand dort korrekt die Stations-SSID — 2026-10-02
- [x] Werksreset über 5× ein/aus → wieder unkonfigurierter Onboarding-AP: alte Heimnetz-IP antwortet nicht mehr, AP `ninlight-681f` mit Vorgabepasswort, `settings` ohne Token → 200, alle Werte auf Vorgabe (`name: ''`, `standalone.enabled: false`) — 2026-10-02
- [x] sACN über den Standalone-AP, sichtbar an der Lampe: Michael hat grün (unicast an 192.168.4.1) und blau (multicast 239.255.0.1, Sender mit `IP_MULTICAST_IF` auf 192.168.4.2) gesehen, `sacn.active` jeweils true — 2026-10-02
## Offene Beobachtung aus der EVK-Session vom 2026-09-12 ## Offene Beobachtung aus der EVK-Session vom 2026-09-12
@@ -73,3 +91,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.
+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)
+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()
+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()
+59 -11
View File
@@ -13,10 +13,12 @@
#include "drivers/nl_led.h" #include "drivers/nl_led.h"
#include "light/nl_boot_state.h" #include "light/nl_boot_state.h"
#include "light/nl_control.h" #include "light/nl_control.h"
#include "light/nl_dmx.h"
#include "light/nl_engine.h" #include "light/nl_engine.h"
#include "net/nl_http_server.h" #include "net/nl_http_server.h"
#include "net/nl_mdns.h" #include "net/nl_mdns.h"
#include "net/nl_recovery.h" #include "net/nl_recovery.h"
#include "net/nl_sacn.h"
#include "net/nl_wifi.h" #include "net/nl_wifi.h"
#include "sys/nl_app.h" #include "sys/nl_app.h"
#include "sys/nl_app_api.h" #include "sys/nl_app_api.h"
@@ -109,6 +111,19 @@ int nl_app_set_wifi_credentials(const char *ssid, const char *pass)
return nl_app_save_settings(); return nl_app_save_settings();
} }
int nl_app_clear_standalone(void)
{
bool was_set;
nl_app_lock();
was_set = s_settings.standalone != 0;
s_settings.standalone = 0;
nl_app_unlock();
if (!was_set)
return 0; /* nichts zu schreiben - der Flash-Satz steht schon richtig */
return nl_app_save_settings();
}
void nl_app_factory_reset(void) void nl_app_factory_reset(void)
{ {
printf("factory reset\r\n"); printf("factory reset\r\n");
@@ -147,6 +162,7 @@ static void light_task(void *arg)
now = nl_app_now_ms(); now = nl_app_now_ms();
nl_app_lock(); nl_app_lock();
nl_engine_tick(now - last); nl_engine_tick(now - last);
nl_dmx_tick(now);
nl_app_unlock(); nl_app_unlock();
last = now; last = now;
s_light_beat++; s_light_beat++;
@@ -160,9 +176,10 @@ static void app_task(void *arg)
nl_engine_cfg_t cfg; nl_engine_cfg_t cfg;
nl_light_state_t last, initial, pending; nl_light_state_t last, initial, pending;
bool have_last, after_ota; bool have_last, after_ota;
bool light_alive = false, net_alive = false, http_alive = false; bool light_alive = false, net_alive = false, http_alive = false, sacn_alive = false;
bool sacn_required;
uint8_t boots; uint8_t boots;
uint32_t boot_ms, light_seen, net_seen, http_seen; uint32_t boot_ms, light_seen, net_seen, http_seen, sacn_seen;
(void)arg; (void)arg;
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery /* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
@@ -202,8 +219,10 @@ 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_dmx_init(board);
nl_app_unlock(); nl_app_unlock();
tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0); tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0);
@@ -222,17 +241,41 @@ static void app_task(void *arg)
boot = s_settings; boot = s_settings;
nl_app_unlock(); nl_app_unlock();
nl_mdns_set_name(boot.name); nl_mdns_set_name(boot.name);
if (boot.wifi_ssid[0]) if (boot.standalone)
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD); /* Kein Verbindungsversuch: die Lampe steht ausdruecklich allein. Die gespeicherten
* Zugangsdaten gehen trotzdem mit - der Standalone-Zweig der Zustandsmaschine
* verbindet nicht, aber sie zaehlen wieder, sobald der Modus ueber die API
* zurueckgenommen wird; ohne sie landete die Lampe dann im offenen
* Onboarding-AP, obwohl ihr WLAN bekannt ist. */
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass,
boot.ap_pass[0] ? boot.ap_pass : NINLIGHT_AP_PASSWORD,
boot.ap_ssid, boot.ap_channel, true, boot.standalone_pulse != 0);
else if (boot.wifi_ssid[0])
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD, "", 0, false, false);
else else
nl_wifi_start(NINLIGHT_WIFI_SSID, NINLIGHT_WIFI_PASSWORD, NINLIGHT_AP_PASSWORD); nl_wifi_start(NINLIGHT_WIFI_SSID, NINLIGHT_WIFI_PASSWORD, NINLIGHT_AP_PASSWORD,
"", 0, false, false);
} }
nl_http_server_start(nl_app_api_ctx()); nl_http_server_start(nl_app_api_ctx());
{
static nl_settings_t sacn_boot; /* copy: the network task may change s_settings later */
nl_app_lock();
sacn_boot = s_settings;
nl_app_unlock();
nl_sacn_apply_settings(&sacn_boot);
}
nl_sacn_start();
/* Nur ein tatsaechlich gestarteter Task darf den Wachhund mitbestimmen: ohne Task
* bewegt sich nl_sacn_beat nie, die Bedingung waere nie erfuellt und die Lampe liefe
* in eine endlose Reset-Schleife. Ein Zusatzdienst faellt dann eben aus. */
sacn_required = nl_sacn_running();
light_seen = s_light_beat; light_seen = s_light_beat;
net_seen = nl_net_beat; net_seen = nl_net_beat;
http_seen = nl_http_beat; http_seen = nl_http_beat;
sacn_seen = nl_sacn_beat;
for (;;) { for (;;) {
uint32_t now; uint32_t now;
bool due; bool due;
@@ -261,9 +304,10 @@ static void app_task(void *arg)
} }
} }
/* Feed the watchdog only once the light, network and HTTP tasks have each made /* Feed the watchdog only once the light, network, HTTP and sACN tasks have each
* progress since the last feed - a single stuck task (e.g. nl_app_lock() held * made progress since the last feed - a single stuck task (e.g. nl_app_lock() held
* forever) must still starve the watchdog, not just the light task. */ * forever) must still starve the watchdog, not just the light task. Ausnahme: der
* sACN-Task, wenn er gar nicht erst angelegt werden konnte (sacn_required). */
if (s_light_beat != light_seen) { if (s_light_beat != light_seen) {
light_seen = s_light_beat; light_seen = s_light_beat;
light_alive = true; light_alive = true;
@@ -276,8 +320,12 @@ static void app_task(void *arg)
http_seen = nl_http_beat; http_seen = nl_http_beat;
http_alive = true; http_alive = true;
} }
if (light_alive && net_alive && http_alive) { if (nl_sacn_beat != sacn_seen) {
light_alive = net_alive = http_alive = false; sacn_seen = nl_sacn_beat;
sacn_alive = true;
}
if (light_alive && net_alive && http_alive && (sacn_alive || !sacn_required)) {
light_alive = net_alive = http_alive = sacn_alive = false;
tls_watchdog_clr(); tls_watchdog_clr();
} }
} }
+19 -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]);
@@ -49,3 +57,10 @@ const nl_board_t *nl_board_get(void)
return nl_board_find(NINLIGHT_DEVICE_ID); return nl_board_find(NINLIGHT_DEVICE_ID);
} }
#endif #endif
uint8_t nl_board_dmx_slots(const nl_board_t *b, bool rgb_layout)
{
if (rgb_layout)
return 3u; /* R,G,B - unabhaengig vom Board, damit eine Lampe ein Pixel ist */
return (b != NULL && b->has_rgb) ? 6u : 3u;
}
+7
View File
@@ -41,4 +41,11 @@ const nl_board_t *nl_board_find(const char *id);
/* Board selected at build time via NINLIGHT_DEVICE_ID. */ /* Board selected at build time via NINLIGHT_DEVICE_ID. */
const nl_board_t *nl_board_get(void); const nl_board_t *nl_board_get(void);
/* Slots, die eine Lampe im DMX-Universe belegt. Steht hier, damit API, Uebernahme-Schicht
* und Doku dieselbe Zahl benutzen.
* rgb_layout = false: Dimmer + R,G,B,CW,WW (6) bzw. Dimmer + CW,WW (3)
* rgb_layout = true: R,G,B (3) auf JEDEM Board - so ist eine Lampe genau ein Pixel
* fuer pixelorientierte Werkzeuge wie LedFx. */
uint8_t nl_board_dmx_slots(const nl_board_t *b, bool rgb_layout);
#endif #endif
+41 -3
View File
@@ -29,15 +29,45 @@ typedef struct {
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly /* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal * behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
* while sizeof(nl_settings_t) is a multiple of 4 (204 today). Adding a single uint16_t would move * while sizeof(nl_settings_t) is a multiple of 4 (216 today, 208 before sACN). Adding a single
* offsetof(rec_t, crc) two bytes further than the reader looks and every stored record would * uint16_t would move offsetof(rec_t, crc) two bytes further than the reader looks and every
* silently fail its CRC on the next boot - Wi-Fi, token and name gone on every lamp. */ * stored record would silently fail its CRC on the next boot - Wi-Fi, token and name gone on
* every lamp. Aus demselben Grund gilt fuer neue Felder: sie beginnen bei oder hinter dem
* vorherigen sizeof (also nicht unterhalb von 208), denn ein alter Datensatz ist genau
* sizeof lang und traegt seine Fuellbytes mit - ein Feld darunter wuerde beim Lesen von
* diesen Fuellbytes (Null) ueberschrieben statt seine Vorgabe zu behalten. */
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len)) #define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload /* The CRC runs over header and payload as one contiguous block on both sides, so the payload
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */ * really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1]; typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
/* Die Satzlaenge muss ein Vielfaches von 4 bleiben - sonst zeigt NL_SETTINGS_CRC_OFF() woanders
* hin als offsetof(rec_t, crc), und jeder gespeicherte Datensatz faellt beim naechsten Boot
* durch die CRC-Pruefung. Wer hier anstoesst, fuegt ein Fuellbyte hinzu, statt die Pruefung
* zu entfernen. */
typedef char nl_settings_size_check[(sizeof(nl_settings_t) % 4u == 0u) ? 1 : -1];
/* Das erste mit sACN angehaengte Feld muss genau dort beginnen, wo der alte Satz aufhoerte:
* bei 208, dem sizeof(nl_settings_t) der Firmware vor sACN. Saetze, die eine solche Firmware
* geschrieben hat, sind 208 Byte lang und enthalten hinter wake_on_change (Offset 206) noch
* ein Fuellbyte. read_rec() kopiert hdr.length Bytes ueber die Vorgaben - ein Feld unterhalb
* von 208 wuerde also von diesem Fuellbyte (Null) ueberschrieben und seine Vorgabe verlieren.
* Bei sacn_enabled hiesse das: sACN nach dem Update auf jeder Lampe aus. */
typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 208 ? 1 : -1];
/* Dasselbe fuer das naechste angehaengte Feld: sacn_layout muss bei 216 beginnen, dem
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
* eingefuegt - dann kaeme es aus einem aelteren Satz als Fuellbyte zurueck, nicht als
* Vorgabe. */
typedef char nl_settings_layout_offset_check[offsetof(nl_settings_t, sacn_layout) == 216 ? 1 : -1];
/* Dasselbe fuer die Standalone-Felder: standalone muss bei 220 beginnen, dem
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
* eingefuegt - dann kaeme standalone_pulse aus einem aelteren Satz als Fuellbyte (0)
* zurueck statt als Vorgabe (1), und der Startpuls waere still aus. */
typedef char nl_settings_standalone_check[offsetof(nl_settings_t, standalone) == 220 ? 1 : -1];
static uint32_t s_addr[2]; static uint32_t s_addr[2];
void nl_settings_defaults(nl_settings_t *s) void nl_settings_defaults(nl_settings_t *s)
@@ -47,6 +77,14 @@ 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;
s->sacn_enabled = 1;
s->sacn_universe = 1;
s->sacn_start_slot = 1;
s->sacn_hold = 0;
s->sacn_layout = 1;
s->standalone_pulse = 1; /* standalone, ap_channel, ap_ssid, ap_pass bleiben auf 0/leer */
} }
void nl_settings_init(uint32_t addr_a, uint32_t addr_b) void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
+33
View File
@@ -19,6 +19,39 @@ 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. */
uint8_t _legacy_pad; /* war Fuellbyte im 208-Byte-Satz: neue Felder muessen
* bei oder hinter dem vorherigen sizeof beginnen, sonst
* ueberschreibt das Fuellbyte eines alten Datensatzes sie */
uint8_t sacn_enabled; /* Offset 208 - sACN/E1.31-Empfang aktiv (Vorgabe 1) */
uint16_t sacn_universe; /* 1..63999 (Vorgabe 1) */
uint16_t sacn_start_slot; /* 1..512 (Vorgabe 1) */
uint8_t sacn_hold; /* 0 = nach dem Timeout zurueck zum HA-Zustand, 1 = halten */
uint8_t _padding[1]; /* haelt sizeof auf einem Vielfachen von 4 */
uint8_t sacn_layout; /* Offset 216 - 0 = Dimmer voran, 1 = nur R,G,B (Vorgabe 1).
* Dieselbe Regel wie oben: neue Felder beginnen bei oder hinter
* dem vorherigen sizeof (216), sonst ueberschreibt das Fuellbyte
* eines aelteren Satzes sie. */
uint8_t _padding2[3]; /* haelt sizeof auf einem Vielfachen von 4 */
uint8_t standalone; /* Offset 220 - 1 = eigener AP statt Station. Dieselbe Regel
* wie oben: neue Felder beginnen bei oder hinter dem
* vorherigen sizeof (220), sonst ueberschreibt das
* Fuellbyte eines aelteren Satzes sie. */
uint8_t ap_channel; /* 0 = Vorgabe (6), sonst 1..13 */
uint8_t standalone_pulse; /* 1 = beim Start kurz pulsen (Vorgabe) */
uint8_t _padding3;
char ap_ssid[NL_SSID_LEN]; /* leer = "ninlight-<mac4>" */
char ap_pass[NL_PASS_LEN]; /* leer = einkompiliertes AP-Passwort. Taucht in keiner
* Antwort und in keiner Protokollzeile auf. */
uint8_t _padding4[2]; /* haelt sizeof auf einem Vielfachen von 4 (324) */
} 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);
+155
View File
@@ -0,0 +1,155 @@
#include "light/nl_dmx.h"
#include <string.h>
#include "light/nl_engine.h"
static const nl_board_t *s_board;
static uint8_t s_slots; /* 3 oder 6 */
static nl_dmx_cfg_t s_cfg;
static bool s_streaming; /* es fliessen Pakete */
static bool s_override; /* die Engine haelt unsere Kanaele */
static uint8_t s_prio;
static uint8_t s_seq;
static uint32_t s_last_ms;
void nl_dmx_init(const nl_board_t *board)
{
s_board = board;
s_cfg.start_slot = 1;
s_cfg.hold = false;
s_cfg.release_fade_ms = 0;
s_cfg.rgb_layout = false;
s_slots = nl_board_dmx_slots(board, false);
s_streaming = false;
s_override = false;
s_prio = 0;
s_seq = 0;
s_last_ms = 0;
}
void nl_dmx_configure(const nl_dmx_cfg_t *cfg)
{
s_cfg = *cfg;
/* Sicherheitsnetz: start_slot=0 wuerde unten vor den Puffer lesen. Die REST-Schicht
* validiert 1..512, aber dieses Modul soll dafuer nicht auf einen Aufrufer angewiesen
* sein, den es nicht sehen kann. */
if (s_cfg.start_slot < 1u)
s_cfg.start_slot = 1u;
/* Die Slotzahl haengt am Layout, nicht nur am Board - hier nachziehen, sonst liest
* nl_dmx_feed() beim Umschalten die falsche Anzahl. */
s_slots = nl_board_dmx_slots(s_board, s_cfg.rgb_layout);
}
bool nl_dmx_active(void) { return s_override; }
void nl_dmx_release(void)
{
if (s_override)
nl_engine_override_clear(s_cfg.release_fade_ms);
s_override = false;
s_streaming = false;
s_prio = 0;
}
/* Der Strom ist zu Ende - entweder durch das terminated-Bit oder durch Timeout. Im
* "release"-Modus geben wir den Override frei und blenden zurueck zum Zustand von
* nl_ctrl; im "hold"-Modus bleiben die zuletzt gesetzten Kanalwerte einfach stehen. */
static void stream_ended(void)
{
s_streaming = false;
s_prio = 0;
if (!s_cfg.hold && s_override) {
nl_engine_override_clear(s_cfg.release_fade_ms);
s_override = false;
}
}
static uint8_t scale(uint8_t v, uint8_t dim)
{
return (uint8_t)(((uint16_t)v * (uint16_t)dim + 127u) / 255u);
}
void nl_dmx_feed(const nl_e131_packet_t *p, uint32_t now_ms)
{
const uint8_t *s;
uint8_t ch[NL_CH_COUNT];
uint8_t dim;
if (s_board == NULL)
return;
/* Reicht das Paket bis zu unserem letzten Slot? Ein halb belegter Satz ergaebe auf den
* restlichen Kanaelen willkuerliche Werte - also gar nicht erst anwenden. */
if ((uint32_t)s_cfg.start_slot + s_slots - 1u > (uint32_t)p->slot_count)
return;
if (s_streaming) {
if (p->priority < s_prio)
return;
if (p->priority == s_prio) {
/* E1.31-2018 Section 6.7.2: verwerfen, wenn die Differenz <= 0 und > -20 ist.
* Das Fenster ist bei -20 exklusiv - eine Sequenznummer genau 20 hinter der
* zuletzt angenommenen gilt bereits wieder als neu und wird angenommen. Nicht
* "korrigieren": ein Reviewer hat genau das schon einmal versucht. */
int8_t d = (int8_t)(p->sequence - s_seq);
if (d <= 0 && d > -NL_DMX_SEQ_WINDOW)
return; /* veraltet oder Wiederholung */
}
/* p->priority > s_prio: eine neue, hoeherpriore Quelle loest die laufende ab. Sie
* fuehrt ihre eigene Sequenzzaehlung - ein Abgleich gegen s_seq der abgeloesten
* Quelle waere Zufall und koennte die neue Quelle bis zu NL_DMX_SEQ_WINDOW Pakete
* lang verwerfen. Also: wie das erste Paket eines frischen Streams behandeln. */
}
s_prio = p->priority;
s_seq = p->sequence;
s_last_ms = now_ms;
if (p->terminated) { /* die Quelle sagt selbst, dass sie fertig ist */
stream_ended();
return;
}
s_streaming = true;
s = p->slots + (s_cfg.start_slot - 1u);
memset(ch, 0, sizeof ch);
if (s_cfg.rgb_layout) {
/* Drei Kanaele, kein Dimmer: so schreiben pixelorientierte Werkzeuge (LedFx und
* Verwandte) je Pixel genau R,G,B. Eine Lampe ist damit ein Pixel. */
if (s_board->has_rgb) {
ch[NL_CH_R] = s[0];
ch[NL_CH_G] = s[1];
ch[NL_CH_B] = s[2];
} else {
/* Ein Board ohne Farbkanaele bekommt trotzdem ein Farbtripel. Genommen wird der
* groesste der drei Werte auf beide Weisskanaele: die Lampe folgt damit sichtbar
* der Helligkeit des Effekts, ohne dass ein Farbmodell in den DMX-Pfad kommt -
* genau das wird hier sonst bewusst vermieden. */
uint8_t v = s[0] > s[1] ? s[0] : s[1];
if (s[2] > v)
v = s[2];
ch[NL_CH_CW] = v;
ch[NL_CH_WW] = v;
}
} else {
dim = s[0];
if (s_board->has_rgb) {
ch[NL_CH_R] = scale(s[1], dim);
ch[NL_CH_G] = scale(s[2], dim);
ch[NL_CH_B] = scale(s[3], dim);
ch[NL_CH_CW] = scale(s[4], dim);
ch[NL_CH_WW] = scale(s[5], dim);
} else {
ch[NL_CH_CW] = scale(s[1], dim);
ch[NL_CH_WW] = scale(s[2], dim);
}
}
nl_engine_override(ch);
s_override = true;
}
void nl_dmx_tick(uint32_t now_ms)
{
if (!s_streaming)
return;
if (now_ms - s_last_ms >= NL_DMX_TIMEOUT_MS)
stream_ended();
}
+35
View File
@@ -0,0 +1,35 @@
#ifndef NL_DMX_H
#define NL_DMX_H
#include <stdbool.h>
#include <stdint.h>
#include "board/nl_board.h"
#include "net/nl_e131.h"
#define NL_DMX_TIMEOUT_MS 2500u /* E1.31, Abschnitt 6.7.1 */
#define NL_DMX_SEQ_WINDOW 20 /* E1.31, Abschnitt 6.7.2 */
typedef struct {
uint16_t start_slot; /* 1..512 */
bool hold; /* false = nach dem Timeout zurueck zum Zustand von nl_ctrl */
uint16_t release_fade_ms; /* Dauer der Rueckblende (aus settings.fade_in_ms) */
/* false: Dimmer voran (DMX-Pulte erwarten das). true: nur R,G,B - dann ist eine Lampe
* genau ein Pixel fuer Werkzeuge wie LedFx, die je Pixel drei Kanaele schreiben. */
bool rgb_layout;
} nl_dmx_cfg_t;
/* Die Engine muss initialisiert sein. Nicht threadsicher: Aufrufer serialisieren. */
void nl_dmx_init(const nl_board_t *board);
void nl_dmx_configure(const nl_dmx_cfg_t *cfg);
/* Ein vom Parser angenommenes Paket. Prueft Sequenz, Prioritaet und Slotbereich und setzt
* bei Annahme den Override. */
void nl_dmx_feed(const nl_e131_packet_t *p, uint32_t now_ms);
/* Aus dem Licht-Task (alle ~10 ms): prueft den Timeout. */
void nl_dmx_tick(uint32_t now_ms);
/* Ein HA-/REST-Befehl holt die Kontrolle zurueck - in beiden Timeout-Modi. */
void nl_dmx_release(void);
/* true, solange die Lampe ferngesteuert ist, einschliesslich der Haltephase. */
bool nl_dmx_active(void);
#endif
+48
View File
@@ -1,5 +1,7 @@
#include "light/nl_engine.h" #include "light/nl_engine.h"
#include <string.h>
#include "light/nl_color.h" #include "light/nl_color.h"
typedef struct { typedef struct {
@@ -19,6 +21,11 @@ static uint32_t s_blink_t;
static bool s_pulse; static bool s_pulse;
static uint32_t s_pulse_t; static uint32_t s_pulse_t;
static bool s_ov; /* Override haelt die Ausgabe */
static uint8_t s_ov_ch[NL_CH_COUNT]; /* zuletzt gesetzte Override-Kanaele */
static uint32_t s_ov_fade_dur; /* > 0: Rueckblende laeuft */
static uint32_t s_ov_fade_el;
static nl_visual_t to_visual(const nl_light_state_t *s) static nl_visual_t to_visual(const nl_light_state_t *s)
{ {
nl_visual_t v; nl_visual_t v;
@@ -69,6 +76,18 @@ static void emit(void)
v = s_cur; v = s_cur;
} }
nl_visual_to_channels(&v, s_cfg.ct_min, s_cfg.ct_max, ch); nl_visual_to_channels(&v, s_cfg.ct_min, s_cfg.ct_max, ch);
/* Blink und Pulse sind Rueckmeldungen des Geraets an den Menschen (Werksreset,
* Onboarding) - die muessen auch waehrend einer laufenden Show sichtbar bleiben. */
if (!s_blink_left && !s_pulse) {
if (s_ov) {
memcpy(ch, s_ov_ch, sizeof ch);
} else if (s_ov_fade_dur) {
int i;
for (i = 0; i < NL_CH_COUNT; i++)
ch[i] = (uint8_t)lerp(s_ov_ch[i], ch[i], s_ov_fade_el, s_ov_fade_dur);
}
}
s_cfg.output(ch, s_cfg.ctx); s_cfg.output(ch, s_cfg.ctx);
} }
@@ -84,6 +103,8 @@ void nl_engine_init(const nl_engine_cfg_t *cfg)
s_blink_t = 0; s_blink_t = 0;
s_pulse = false; s_pulse = false;
s_pulse_t = 0; s_pulse_t = 0;
s_ov = false;
s_ov_fade_dur = s_ov_fade_el = 0;
emit(); emit();
} }
@@ -123,6 +144,12 @@ void nl_engine_tick(uint32_t elapsed_ms)
{ {
uint32_t left = elapsed_ms; uint32_t left = elapsed_ms;
if (s_ov_fade_dur) {
s_ov_fade_el += elapsed_ms;
if (s_ov_fade_el >= s_ov_fade_dur)
s_ov_fade_dur = s_ov_fade_el = 0;
}
while (s_iseg < s_nseg) { while (s_iseg < s_nseg) {
seg_t *s = &s_seg[s_iseg]; seg_t *s = &s_seg[s_iseg];
if (s->elapsed + left >= s->dur) { if (s->elapsed + left >= s->dur) {
@@ -180,3 +207,24 @@ void nl_engine_pulse(bool enable)
s_pulse_t = 0; s_pulse_t = 0;
emit(); emit();
} }
void nl_engine_override(const uint8_t ch[NL_CH_COUNT])
{
memcpy(s_ov_ch, ch, sizeof s_ov_ch);
s_ov = true;
s_ov_fade_dur = s_ov_fade_el = 0;
}
void nl_engine_override_clear(uint32_t fade_ms)
{
if (!s_ov)
return;
s_ov = false;
s_ov_fade_dur = fade_ms;
s_ov_fade_el = 0;
}
bool nl_engine_override_active(void)
{
return s_ov;
}
+12
View File
@@ -29,4 +29,16 @@ void nl_engine_current(nl_visual_t *out);
void nl_engine_blink(uint8_t count); void nl_engine_blink(uint8_t count);
void nl_engine_pulse(bool enable); void nl_engine_pulse(bool enable);
/* DMX-Override: die Kanalwerte gehen unveraendert an die Ausgabe, ohne Fade und ohne
* Farbmischung. Rangfolge in emit(): Blink > Pulse > Override > Fade-Engine. Der
* darunterliegende Zustand laeuft unberuehrt weiter. */
void nl_engine_override(const uint8_t ch[NL_CH_COUNT]);
/* Beendet den Override. fade_ms > 0 blendet im Kanalraum auf das zurueck, was die
* Fade-Engine gerade ausgeben wuerde; 0 schaltet sofort um. */
void nl_engine_override_clear(uint32_t fade_ms);
/* Nur fuer die Tests: sie haben sonst keinen Blick auf den Override-Zustand der Engine.
* Die Firmware fragt stattdessen nl_dmx_active() - wer hier nach einem Aufrufer im
* src/-Baum sucht, findet zu Recht keinen. */
bool nl_engine_override_active(void);
#endif #endif
+185
View File
@@ -10,6 +10,9 @@
#define P_SETTINGS "/api/v1/system/settings" #define P_SETTINGS "/api/v1/system/settings"
#define P_COMMAND "/api/v1/system/command" #define P_COMMAND "/api/v1/system/command"
/* Obergrenze fuer das AP-Passwort des Standalone-Modus (WPA2-ASCII-Passphrase). */
#define NL_AP_PASS_MAX 63
static const char *const POB_NAMES[] = {"last", "on", "off"}; static const char *const POB_NAMES[] = {"last", "on", "off"};
static void reply(nl_api_response_t *r, int status, const char *json) static void reply(nl_api_response_t *r, int status, const char *json)
@@ -115,6 +118,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 +201,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)) {
@@ -243,12 +262,33 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
cJSON_AddStringToObject(w, "error", in.wifi_error); cJSON_AddStringToObject(w, "error", in.wifi_error);
else else
cJSON_AddNullToObject(w, "error"); cJSON_AddNullToObject(w, "error");
/* "mode" allein reicht nicht: im Standalone-Betrieb ist es "ap", genau wie beim offenen
* Onboarding-AP. Ohne dieses Feld boete die App einer fertig eingerichteten Lampe ein
* Onboarding an. */
cJSON_AddBoolToObject(w, "standalone", in.wifi_standalone);
cJSON_AddNumberToObject(o, "uptime", in.uptime_s); cJSON_AddNumberToObject(o, "uptime", in.uptime_s);
cJSON_AddNumberToObject(o, "free_heap", in.free_heap); cJSON_AddNumberToObject(o, "free_heap", in.free_heap);
if (in.last_ota_error) if (in.last_ota_error)
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error); cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
else else
cJSON_AddNullToObject(o, "last_ota_error"); cJSON_AddNullToObject(o, "last_ota_error");
{
nl_settings_t s;
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
ctx->get_settings(&s);
cJSON_AddBoolToObject(sa, "active", in.sacn_active);
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
/* Sagt, ob der Beitritt zur Multicast-Gruppe gelungen ist. Ohne dieses Feld war bei
* der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die
* Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */
cJSON_AddBoolToObject(sa, "multicast", in.sacn_joined);
/* Ob der Empfangs-Task ueberhaupt laeuft. Ohne dieses Feld war ein toter Task von
* "es sendet gerade niemand" nicht zu unterscheiden - beides meldete nur
* active=false, und "multicast" bleibt vom letzten gelungenen Beitritt true, auch
* wenn der Socket laengst zu ist. Das hat beim Montana-Black-Shield eine halbe
* Stunde Suche gekostet. */
cJSON_AddBoolToObject(sa, "running", in.sacn_running);
}
reply_json(resp, 200, o); reply_json(resp, 200, o);
} }
@@ -262,7 +302,35 @@ 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);
{
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
cJSON_AddBoolToObject(sa, "enabled", s.sacn_enabled != 0);
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
cJSON_AddNumberToObject(sa, "start_slot", s.sacn_start_slot);
cJSON_AddStringToObject(sa, "on_timeout", s.sacn_hold ? "hold" : "release");
/* Informativ und bewusst aus dem Board, nicht aus dem Datensatz: so steht beim
* Adressieren direkt da, wieviel Platz diese Lampe im Universe belegt. */
cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer");
cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board, s.sacn_layout != 0));
}
{
cJSON *sa = cJSON_AddObjectToObject(o, "standalone");
cJSON_AddBoolToObject(sa, "enabled", s.standalone != 0);
/* Der WIRKSAME Name, nicht der gespeicherte: ohne eigenen Namen heisst der AP
* wie der Hostname. Wer hier einen leeren String saehe, muesste die Vorgabe
* selbst nachbilden. Das AP-Passwort steht bewusst nicht hier. */
cJSON_AddStringToObject(sa, "ssid", s.ap_ssid[0] ? s.ap_ssid : ctx->hostname);
cJSON_AddNumberToObject(sa, "channel", s.ap_channel ? s.ap_channel : 6);
cJSON_AddBoolToObject(sa, "pulse", s.standalone_pulse != 0);
}
reply_json(resp, 200, o); reply_json(resp, 200, o);
} }
@@ -294,6 +362,67 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
cJSON_Delete(j); cJSON_Delete(j);
return; return;
} }
} else if (strcmp(type, "standalone") == 0) {
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
const cJSON *pu = cJSON_GetObjectItemCaseSensitive(j, "pulse");
const char *as = NULL, *ap = NULL;
long ch = 0;
int asp, app, chp;
bool will_enable;
nl_settings_t prev = s; /* Stand vor der Aenderung, zum Zurueckrollen bei 503 */
will_enable = cJSON_IsBool(en) && cJSON_IsTrue(en);
asp = get_string(j, "ssid", 1, NL_SSID_LEN - 1, &as);
/* 63, nicht NL_PASS_LEN - 1 (64): eine WPA2-ASCII-Passphrase darf hoechstens 63
* Zeichen haben. Bei 64 schriebe der Treiber keyinfo.key[64] randvoll ohne
* Abschlussbyte, und ob libwlan.a das annimmt, sieht man dem Binaerobjekt nicht an -
* ein abgelehnter AP-Aufbau ist hier aber eine ueber die API erreichbare Sackgasse.
* Der "wifi"-Zweig bleibt bewusst bei 64: dort haengt kein eigener AP daran. */
app = get_string(j, "password", 8, NL_AP_PASS_MAX, &ap);
chp = get_int(j, "channel", 1, 13, &ch);
if (!cJSON_IsBool(en)) {
err = "invalid enabled";
} else if (asp < 0) {
err = "invalid ssid";
} else if (app < 0) {
/* Die Meldung nennt das Feld, nie den Wert: ein zu kurzes Passwort darf
* nicht im Protokoll des Aufrufers landen. */
err = "invalid password";
} else if (chp < 0) {
err = "invalid channel";
} else if (will_enable && app != 1 && s.ap_pass[0] == '\0') {
err = "password required";
} else if (pu && !cJSON_IsBool(pu)) {
err = "invalid pulse";
} else {
if (asp == 1)
strcpy(s.ap_ssid, as);
if (app == 1)
strcpy(s.ap_pass, ap);
if (chp == 1)
s.ap_channel = (uint8_t)ch;
if (pu)
s.standalone_pulse = cJSON_IsTrue(pu) ? 1 : 0;
s.standalone = will_enable ? 1 : 0;
/* Wie bei "wifi" kehrt dieser Zweig hier zurueck statt ans gemeinsame Ende zu
* laufen (das antwortet 200): der Wechsel zieht der Verbindung das Netz weg, die
* Antwort ist 202 und muss vorher ausgefuellt sein. */
if (ctx->save_settings(&s) != 0) {
reply_error(resp, 500, "save failed");
} else if (ctx->standalone_configure(will_enable, s.ap_ssid, s.ap_pass,
s.ap_channel, s.standalone_pulse != 0) != 0) {
/* 503 heisst "nicht uebernommen": was schon im Flash steht, muss zurueck,
* sonst startet die Lampe nach einem Neustart doch im neuen Modus. Best
* effort - scheitert auch das, bleibt nur die Meldung. */
(void)ctx->save_settings(&prev);
reply_error(resp, 503, "wifi busy");
} else {
reply(resp, 202, "{\"status\":\"standalone\"}");
}
cJSON_Delete(j);
return;
}
} else if (strcmp(type, "device_name") == 0) { } else if (strcmp(type, "device_name") == 0) {
if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1) if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1)
err = "invalid name"; err = "invalid name";
@@ -310,6 +439,62 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
err = "invalid all"; err = "invalid all";
else else
s.auth_all = cJSON_IsTrue(all) ? 1 : 0; s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
} else if (strcmp(type, "sacn") == 0) {
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
const char *ot = NULL, *lo = NULL;
long un = 0, st = 0;
int unp, stp, otp, lop;
bool rgb;
uint8_t slots;
uint16_t want_start;
/* Das Layout wird ZUERST ausgewertet: es bestimmt die Slotzahl und damit die
* Obergrenze fuer start_slot. Wer in derselben Anfrage beides setzt, soll gegen das
* neue Layout geprueft werden, nicht gegen das alte. */
lop = get_string(j, "layout", 1, 16, &lo);
if (lop < 0 || (lop == 1 && strcmp(lo, "dimmer") != 0 && strcmp(lo, "rgb") != 0)) {
err = "invalid layout";
lop = 0; /* die Zweige unten duerfen den Wert dann nicht mehr benutzen */
rgb = s.sacn_layout != 0;
} else {
rgb = (lop == 1) ? (strcmp(lo, "rgb") == 0) : (s.sacn_layout != 0);
}
slots = nl_board_dmx_slots(ctx->board, rgb);
if (err) {
/* invalid layout steht schon fest */
} else if (en && !cJSON_IsBool(en)) {
err = "invalid enabled";
} else if ((unp = get_int(j, "universe", 1, 63999, &un)) < 0) {
err = "invalid universe";
/* Upper bound folds the board's slot count in: the highest start_slot that still
* leaves room for all of them is 512 - slots + 1. */
} else if ((stp = get_int(j, "start_slot", 1, 512 - slots + 1, &st)) < 0) {
err = "invalid start_slot";
} else if ((otp = get_string(j, "on_timeout", 1, 16, &ot)) < 0 ||
(otp == 1 && strcmp(ot, "release") != 0 && strcmp(ot, "hold") != 0)) {
err = "invalid on_timeout";
} else {
/* Ein Wechsel auf ein breiteres Layout kann einen bisher zulaessigen start_slot
* unzulaessig machen. Das wird abgelehnt statt still verschoben: eine Lampe, die
* nach einer Einstellung woanders im Universe liegt als angegeben, findet niemand
* wieder. */
want_start = (stp == 1) ? (uint16_t)st : s.sacn_start_slot;
if ((uint32_t)want_start + slots - 1u > 512u) {
err = "invalid start_slot";
} else {
if (en)
s.sacn_enabled = cJSON_IsTrue(en) ? 1 : 0;
if (unp == 1)
s.sacn_universe = (uint16_t)un;
if (stp == 1)
s.sacn_start_slot = (uint16_t)st;
if (otp == 1)
s.sacn_hold = strcmp(ot, "hold") == 0 ? 1 : 0;
if (lop == 1)
s.sacn_layout = rgb ? 1 : 0;
}
}
} else { } else {
err = "invalid type"; err = "invalid type";
} }
+9
View File
@@ -24,18 +24,27 @@ typedef struct {
uint32_t uptime_s; uint32_t uptime_s;
uint32_t free_heap; uint32_t free_heap;
const char *last_ota_error; /* NULL until OTA exists */ const char *last_ota_error; /* NULL until OTA exists */
bool sacn_active; /* true, solange die Lampe ferngesteuert wird */
bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */
bool sacn_running; /* Empfangs-Task lebt und haelt einen Socket */
bool wifi_standalone; /* eigener Dauer-AP, nicht der offene Onboarding-AP */
} nl_sys_info_t; } nl_sys_info_t;
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */ /* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
typedef struct { typedef struct {
const nl_board_t *board; const nl_board_t *board;
const char *fw_version; const char *fw_version;
const char *hostname; /* "ninlight-<mac4>", Vorgabe fuer den AP-Namen */
const char *build_date; const char *build_date;
void (*get_state)(nl_light_state_t *out); void (*get_state)(nl_light_state_t *out);
nl_ctrl_err_t (*apply)(const nl_light_change_t *c); nl_ctrl_err_t (*apply)(const nl_light_change_t *c);
void (*get_settings)(nl_settings_t *out); void (*get_settings)(nl_settings_t *out);
int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */ int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */
int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */ int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */
/* 0 = uebernommen (Antwort 202), sonst 503. Wie wifi_configure wirkt der Wechsel
erst nach der Antwort - die Verbindung bricht sonst vor dem Senden weg. */
int (*standalone_configure)(bool enabled, const char *ssid, const char *pass,
uint8_t channel, bool pulse);
bool (*open_access)(void); /* unconfigured onboarding AP */ bool (*open_access)(void); /* unconfigured onboarding AP */
void (*get_info)(nl_sys_info_t *out); void (*get_info)(nl_sys_info_t *out);
void (*factory_reset)(void); /* request only */ void (*factory_reset)(void); /* request only */
+76
View File
@@ -0,0 +1,76 @@
#include "net/nl_e131.h"
#include <string.h>
static uint16_t be16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); }
static uint32_t be32(const uint8_t *p)
{
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
}
/* Flags (obere 4 Bit) muessen 0x7 sein, die Laenge (untere 12 Bit) muss exakt den Rest des
* Datagramms ab `from` beschreiben. Ein Paket, dessen Laengenfelder nicht zueinander passen,
* ist entweder verstuemmelt oder ein Angriffsversuch - in beiden Faellen: weg damit. */
static bool pdu_len_ok(const uint8_t *p, size_t len, size_t from)
{
uint16_t fl = be16(p + from);
if ((fl & 0xf000u) != 0x7000u)
return false;
return (size_t)(fl & 0x0fffu) == len - from;
}
/* Bewusste Abweichung von E1.31-2018 Abschnitt 6.2.4.1: die Synchronisationsadresse
* (Offset 109-110) und das Options-Bit "force synchronization" werden ueberlesen, das Paket
* wird sofort ausgegeben. Eine einzelne Lampe hat nichts zu synchronisieren, und auf ein
* Sync-Paket zu warten hiesse, bei einem Sender mit gesetzter Adresse dunkel zu bleiben.
* Das ist kein Versehen - fuer mehrere gemeinsam getaktete Empfaenger muesste es nachgeruestet
* werden. */
bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out)
{
static const uint8_t acn_pid[12] = { 'A', 'S', 'C', '-', 'E', '1', '.', '1', '7', 0, 0, 0 };
uint16_t pvc;
if (len < NL_E131_MIN_PACKET || len > NL_E131_MIN_PACKET + 512u)
return false;
if (be16(buf) != 0x0010u || be16(buf + 2) != 0x0000u)
return false;
if (memcmp(buf + 4, acn_pid, sizeof acn_pid) != 0)
return false;
if (!pdu_len_ok(buf, len, 16) || be32(buf + 18) != 0x00000004u)
return false;
if (!pdu_len_ok(buf, len, 38) || be32(buf + 40) != 0x00000002u)
return false;
if (!pdu_len_ok(buf, len, 115) || buf[117] != 0x02u)
return false;
if (buf[118] != 0xa1u || be16(buf + 119) != 0x0000u || be16(buf + 121) != 0x0001u)
return false;
pvc = be16(buf + 123);
if (pvc < 1u || (size_t)(pvc - 1u) != len - NL_E131_MIN_PACKET)
return false;
if (buf[125] != 0x00u) /* nur Start-Code 0 ist Lichtinhalt (0xdd waere Kanalprioritaet) */
return false;
if (buf[112] & 0x80u) /* Preview-Daten duerfen laut E1.31 nicht ausgegeben werden */
return false;
/* Universe must be in range 1..63999; 0 and values >63999 are reserved. */
uint16_t universe = be16(buf + 113);
if (universe == 0u || universe > 63999u)
return false;
out->universe = universe;
out->priority = buf[108]; /* taken as-is without range check; priority is only compared
* relatively in later tasks, never against absolute bounds.
* strict validation would silently drop light data from slightly
* non-conformant senders, worse than accepting 0..255 */
out->sequence = buf[111];
out->terminated = (buf[112] & 0x40u) != 0u;
out->slot_count = (uint16_t)(pvc - 1u);
out->slots = buf + NL_E131_MIN_PACKET;
return true;
}
uint32_t nl_e131_group_addr(uint16_t universe)
{
return 0xefff0000u | ((uint32_t)(universe >> 8) << 8) | (uint32_t)(universe & 0xffu);
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef NL_E131_H
#define NL_E131_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define NL_E131_PORT 5568u
#define NL_E131_MAX_PACKET 640u /* ein volles Paket ist 638 Byte; 640 laesst Luft zum Erkennen
* ueberlanger Datagramme, ohne den Puffer unnoetig aufzublaehen */
#define NL_E131_MIN_PACKET 126u
typedef struct {
uint16_t universe;
uint8_t priority;
uint8_t sequence;
bool terminated; /* Options-Bit "stream terminated" */
uint16_t slot_count; /* Nutzdaten ohne Start-Code, 0..512 */
const uint8_t *slots; /* zeigt in den uebergebenen Puffer, wird nicht kopiert */
} nl_e131_packet_t;
/* true = gueltiges DMX-Datenpaket (Start-Code 0, kein Preview). Alles andere: false.
* `out` wird nur bei true beschrieben. */
bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out);
/* Die Multicast-Gruppe eines Universe nach E1.31 Anhang A: 239.255.<hi>.<lo>, als Zahl in
* HOST-Byte-Reihenfolge. Steht hier und nicht im Empfangs-Task, damit sie im Host-Test
* nachgerechnet werden kann - der Wert war schon einmal falsch, und man sieht es einer
* Lampe nicht an: sie tritt dann sauber einer Gruppe bei, auf die niemand sendet. */
uint32_t nl_e131_group_addr(uint16_t universe);
#endif
+65 -6
View File
@@ -3,6 +3,7 @@
#include "wm_include.h" #include "wm_include.h"
#include "lwip/apps/mdns.h" #include "lwip/apps/mdns.h"
#include "lwip/netif.h"
#include "lwip/tcpip.h" #include "lwip/tcpip.h"
#include "config/nl_settings.h" #include "config/nl_settings.h"
@@ -16,8 +17,13 @@
#define NL_MDNS_TTL 120 #define NL_MDNS_TTL 120
static bool s_inited; static bool s_inited;
static struct netif *s_nif; /* Netif, an dem Antwortdienst und Dienst haengen */
static bool s_netif_added; static bool s_netif_added;
static bool s_service_added; /* Vom Netz-Task gelesen, im tcpip-Thread geschrieben: volatile, und s_announce_pending deckt
* das Fenster zwischen dem Anstossen und dem Lauf von announce() ab. Ohne das meldete
* nl_mdns_service_up() beim Wechsel Station -> eigener AP noch den Dienst des alten Netifs. */
static volatile bool s_service_added;
static volatile bool s_announce_pending;
static char s_name[NL_NAME_LEN]; static char s_name[NL_NAME_LEN];
static char s_mac[18]; static char s_mac[18];
@@ -45,15 +51,53 @@ static void txt_cb(struct mdns_service *service, void *data)
add_txt(service, "name", name); add_txt(service, "name", name);
} }
/* Das SDK fuehrt zwei Netifs (wm_ethernet.c, Tcpip_stack_init): tls_get_netif() liefert das
* der STATION; das des Softap wurde zuerst angelegt und haengt als ->next dahinter (dieselbe
* Annahme treffen DHCPS_Start/DNSS_Start im SDK mit nif->next, ethernetif_input() mit
* netif->next). Im Standalone-Modus gibt es keine Station, die Antworten muessen ueber das
* AP-Netif gehen, sonst haengt der Dienst an einem Netif ohne Adresse und Funkstrecke. */
static struct netif *pick_netif(void)
{
struct netif *sta = tls_get_netif();
struct netif *n;
if (!sta)
return NULL;
if (!nl_wifi_is_standalone())
return sta;
/* Erst der bekannte Platz, dann zur Sicherheit die lwip-Kette: das erste Netif mit
* IPv4-Adresse, das nicht die Station ist (Loopback gibt es in diesem SDK nicht). */
if (sta->next && !ip4_addr_isany_val(*netif_ip4_addr(sta->next)))
return sta->next;
for (n = netif_list; n != NULL; n = n->next)
if (n != sta && !ip4_addr_isany_val(*netif_ip4_addr(n)))
return n;
return NULL; /* AP-Netif noch ohne Adresse: lieber nichts melden als am falschen Netif */
}
/* tcpip thread */ /* tcpip thread */
static void announce(void *arg) static void announce(void *arg)
{ {
struct netif *nif = tls_get_netif(); struct netif *nif = pick_netif();
bool attempted = false; bool attempted = false;
(void)arg; (void)arg;
if (!nif) if (!nif) {
printf("mdns: no usable netif yet\r\n");
s_announce_pending = false;
return; return;
}
/* Anderes Netif als bisher (Station <-> eigener AP): ein blosses
* mdns_resp_netif_settings_changed() wuerde am alten haengen bleiben, denn Antwortdienst
* und Dienst sind je Netif angelegt. Also am alten Netif abmelden - remove_netif gibt auch
* dessen Dienste und die Multicast-Gruppe frei - und am neuen frisch anmelden. */
if (s_netif_added && s_nif != nif) {
mdns_resp_remove_netif(s_nif);
s_netif_added = false;
s_service_added = false;
}
s_nif = nif;
if (!s_inited) { if (!s_inited) {
mdns_resp_init(); mdns_resp_init();
@@ -69,10 +113,19 @@ static void announce(void *arg)
NL_MDNS_TTL, txt_cb, NULL) == ERR_OK; NL_MDNS_TTL, txt_cb, NULL) == ERR_OK;
} }
if (attempted) if (attempted) {
printf("mdns: %s.local _ninlight._tcp %s\r\n", nl_wifi_hostname(), s_service_added ? "up" : "failed"); /* Welches Netif benutzt wurde, steht in der Zeile selbst: Namenskuerzel, Nummer, IPv4.
else * Der rohe netif-Zeiger stand hier frueher mit (%p) dabei; waehrend der Fehlersuche war
* er nuetzlich, in einer Release-Firmware ist er Rauschen - Kuerzel und Adresse
* unterscheiden Station und eigenen AP bereits eindeutig, und eine Speicheradresse in
* einem Protokoll, das Nutzer weiterreichen, hat keinen Nutzen. */
const ip4_addr_t *ip = netif_ip4_addr(nif);
printf("mdns: %s.local _ninlight._tcp %s on %c%c%u %u.%u.%u.%u\r\n", nl_wifi_hostname(),
s_service_added ? "up" : "failed", nif->name[0], nif->name[1], (unsigned)nif->num,
ip4_addr1(ip), ip4_addr2(ip), ip4_addr3(ip), ip4_addr4(ip));
} else
mdns_resp_netif_settings_changed(nif); /* re-announce: new IP or TXT */ mdns_resp_netif_settings_changed(nif); /* re-announce: new IP or TXT */
s_announce_pending = false;
} }
void nl_mdns_on_ip_up(void) void nl_mdns_on_ip_up(void)
@@ -80,9 +133,15 @@ void nl_mdns_on_ip_up(void)
uint8_t mac[6]; uint8_t mac[6];
nl_wifi_mac(mac); nl_wifi_mac(mac);
nl_names_mac_str(mac, s_mac); nl_names_mac_str(mac, s_mac);
s_announce_pending = true;
tcpip_callback(announce, NULL); tcpip_callback(announce, NULL);
} }
bool nl_mdns_service_up(void)
{
return !s_announce_pending && s_service_added;
}
void nl_mdns_set_name(const char *name) void nl_mdns_set_name(const char *name)
{ {
u32 sr = tls_os_set_critical(); u32 sr = tls_os_set_critical();
+6
View File
@@ -1,8 +1,14 @@
#ifndef NL_MDNS_H #ifndef NL_MDNS_H
#define NL_MDNS_H #define NL_MDNS_H
#include <stdbool.h>
/* Called by the network task once the station got an IP (again). */ /* Called by the network task once the station got an IP (again). */
void nl_mdns_on_ip_up(void); void nl_mdns_on_ip_up(void);
/* True, sobald der Dienst wirklich angemeldet ist - und erst, nachdem die zuletzt
* angestossene Bekanntmachung im tcpip-Thread auch gelaufen ist. Der Netz-Task stoesst
* im eigenen AP so lange erneut an, bis das hier wahr ist (I5). */
bool nl_mdns_service_up(void);
/* Device name for the TXT record; re-announces when mDNS is running. */ /* Device name for the TXT record; re-announces when mDNS is running. */
void nl_mdns_set_name(const char *name); void nl_mdns_set_name(const char *name);
+199
View File
@@ -0,0 +1,199 @@
#include <stdio.h>
#include <string.h>
#include "wm_include.h"
#include "wm_sockets.h"
#include "light/nl_dmx.h"
#include "net/nl_e131.h"
#include "net/nl_sacn.h"
#include "net/nl_wifi.h"
#include "sys/nl_app.h"
#define NL_SACN_PRIO 41 /* zwischen Licht (30) und HTTP (42) */
#define NL_SACN_RECV_TIMEOUT_S 2
static u32 s_stack[512]; /* 2 KB: Parser ohne Heap, Puffer liegt statisch daneben */
static uint8_t s_buf[NL_E131_MAX_PACKET];
/* Vom HTTP-Task geschrieben, vom Empfangs-Task gelesen. Jedes Feld passt in einen
* atomaren Zugriff; s_dirty wird zuletzt gesetzt und zuerst geloescht, damit der Task
* keine halb uebernommene Konfiguration sieht. */
static volatile uint8_t s_enabled = 1;
static volatile uint16_t s_universe = 1;
static volatile bool s_dirty = true;
volatile uint32_t nl_sacn_beat;
/* Faellt nur aus, wenn der Task gar nicht erst entsteht. app_main fragt das ab: ohne Task
* bewegt sich nl_sacn_beat nie, und eine Wachhund-Bedingung, die trotzdem darauf besteht,
* wuerde die Lampe im 20-s-Takt endlos zuruecksetzen - ein Zusatzdienst darf sie nicht
* unbrauchbar machen. */
static volatile bool s_running = true;
/* Ergebnis des letzten Multicast-Beitritts; ueber system/info sichtbar. */
static volatile bool s_joined;
void nl_sacn_apply_settings(const nl_settings_t *s)
{
nl_dmx_cfg_t c;
c.start_slot = s->sacn_start_slot;
c.hold = s->sacn_hold != 0;
c.release_fade_ms = s->fade_in_ms;
c.rgb_layout = s->sacn_layout != 0;
nl_app_lock();
nl_dmx_configure(&c);
nl_app_unlock();
s_enabled = s->sacn_enabled;
s_universe = s->sacn_universe;
s_dirty = true;
}
/* Legt den Socket an und tritt der Universe-Gruppe bei. -1 bei Fehlschlag. */
static int open_socket(uint16_t universe, char ip_seen[16])
{
struct sockaddr_in addr;
struct ip_mreq mreq;
struct timeval tv;
nl_wifi_status_t w;
uint32_t group;
int on = 1;
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0)
return -1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(NL_E131_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0) {
closesocket(fd);
return -1;
}
/* Ohne Verkehr wuerde recvfrom() sonst ewig blockieren; dieser Timeout haelt den
* Herzschlag am Leben - derselbe Grund, aus dem der HTTP-Server accept() begrenzt. */
tv.tv_sec = NL_SACN_RECV_TIMEOUT_S;
tv.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
nl_wifi_status(&w);
strncpy(ip_seen, w.sta_ip, 15);
ip_seen[15] = '\0';
/* 239.255.<hi>.<lo> gemaess E1.31 Anhang A. Ein fehlgeschlagener Beitritt ist kein
* Abbruchgrund: Unicast an die Lampen-IP kommt auf demselben Socket weiterhin an.
*
* Das Ergebnis wird aber festgehalten und ueber system/info sichtbar gemacht. Bei der
* Abnahme am 2026-09-20 kam Multicast nicht an, waehrend Unicast lief - und weil der
* Rueckgabewert damals verworfen wurde, liess sich nicht unterscheiden, ob die Lampe
* nicht beitritt oder das WLAN die Gruppe nicht weiterreicht. Genau diese Frage
* beantwortet das Feld. */
memset(&mreq, 0, sizeof mreq);
/* Die Gruppenadresse wird ZUERST in eine Variable gerechnet und erst dann umgedreht.
* Das ist kein Schoenheitsfehler: htonl() ist in diesem SDK ein Makro, das sein
* Argument NICHT klammert (sdk/Include/Net/wm_sockets2.0.3.h):
*
* #define htonl(n) (((n & 0xff) << 24) | ((n & 0xff00) << 8) | ...)
*
* Ein Ausdruck mit | wird dabei zerrissen, weil & staerker bindet als | - die Maske
* trifft nur den letzten Term. Frueher stand der ganze Ausdruck direkt im Aufruf, und
* fuer Universe 1 kam 239.255.239.255 statt 239.255.0.1 heraus. Die Lampe trat also
* sauber einer Gruppe bei, auf die niemand sendete: Multicast kam nie an, waehrend
* Unicast lief, und der Beitritt meldete trotzdem Erfolg. Gefunden am 2026-09-21 am
* EVK ueber die serielle Konsole. */
group = nl_e131_group_addr(universe);
mreq.imr_multiaddr.s_addr = htonl(group);
mreq.imr_interface.s_addr = ip_seen[0] ? inet_addr(ip_seen) : htonl(INADDR_ANY);
s_joined = setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) == 0;
printf("sacn: universe %u, multicast join %s\r\n", (unsigned)universe,
s_joined ? "ok" : "FEHLGESCHLAGEN");
return fd;
}
static void sacn_task(void *arg)
{
int fd = -1;
char ip_seen[16] = "";
uint16_t bound_universe = 0;
(void)arg;
for (;;) {
nl_wifi_status_t w;
nl_e131_packet_t p;
uint32_t started_ms;
int n;
nl_sacn_beat++;
if (!s_enabled) {
if (fd >= 0) {
closesocket(fd);
fd = -1;
}
s_dirty = false;
tls_os_time_delay(HZ / 2);
continue;
}
/* Neu aufbauen, wenn die Konfiguration sich geaendert hat oder die Station eine
* andere IP bekommen hat - nach einem WLAN-Wechsel gilt die alte Mitgliedschaft
* nicht mehr, und ohne Neubeitritt kaeme nie wieder ein Multicast-Paket an. */
nl_wifi_status(&w);
if (fd >= 0 && (s_dirty || bound_universe != s_universe ||
strncmp(ip_seen, w.sta_ip, sizeof ip_seen) != 0)) {
closesocket(fd);
fd = -1;
}
if (fd < 0) {
s_dirty = false;
bound_universe = s_universe;
fd = open_socket(bound_universe, ip_seen);
if (fd < 0) {
tls_os_time_delay(HZ); /* z.B. noch kein netif: spaeter erneut versuchen */
continue;
}
}
started_ms = nl_app_now_ms();
n = recvfrom(fd, s_buf, sizeof s_buf, 0, NULL, NULL);
if (n < 0) {
/* Die Zeitueberschreitung ist der Normalfall ohne Verkehr und laeuft die vollen
* zwei Sekunden. Kommt der Fehler deutlich frueher, ist der Socket hart kaputt
* (das lwIP dieser Version reicht kein errno heraus, die Laufzeit ist das
* einzige Unterscheidungsmerkmal): neu aufbauen statt heiss zu drehen. */
if (nl_app_now_ms() - started_ms < NL_SACN_RECV_TIMEOUT_S * 1000u / 2u) {
closesocket(fd);
fd = -1;
tls_os_time_delay(HZ);
}
continue;
}
if (!nl_e131_parse(s_buf, (size_t)n, &p))
continue;
if (p.universe != bound_universe)
continue;
/* nl_dmx teilt sich seinen Zustand mit der Licht-Engine und ist nicht threadsicher. */
nl_app_lock();
nl_dmx_feed(&p, nl_app_now_ms());
nl_app_unlock();
}
}
void nl_sacn_start(void)
{
if (tls_os_task_create(NULL, "nl_sacn", sacn_task, NULL, (u8 *)s_stack, sizeof(s_stack),
NL_SACN_PRIO, 0) != TLS_OS_SUCCESS) {
s_running = false;
printf("sacn: task create failed - receiver off\r\n");
}
}
bool nl_sacn_running(void)
{
return s_running;
}
bool nl_sacn_joined(void) { return s_joined; }
+25
View File
@@ -0,0 +1,25 @@
#ifndef NL_SACN_H
#define NL_SACN_H
#include <stdbool.h>
#include <stdint.h>
#include "config/nl_settings.h"
/* Wie nl_net_beat und nl_http_beat: app_main fuettert den Watchdog erst, wenn sich jeder
* Herzschlag bewegt hat - ein haengender Empfangs-Task muss den Chip zuruecksetzen. */
extern volatile uint32_t nl_sacn_beat;
/* Uebernimmt enabled/universe/start_slot/hold und fade_in_ms. Darf jederzeit aufgerufen
* werden; der Task baut seinen Socket beim naechsten Durchlauf neu auf. */
void nl_sacn_apply_settings(const nl_settings_t *s);
void nl_sacn_start(void);
/* false, wenn der Empfangs-Task nicht angelegt werden konnte. Dann bewegt sich
* nl_sacn_beat nie und der Wachhund darf ihn nicht mehr verlangen. */
bool nl_sacn_running(void);
/* true, wenn der Beitritt zur Multicast-Gruppe des eingestellten Universe gelungen ist.
* Unicast an die Lampen-IP funktioniert unabhaengig davon. */
bool nl_sacn_joined(void);
#endif
+13 -2
View File
@@ -19,8 +19,12 @@ typedef struct {
* sure the network task is still alive, not just the light task. */ * sure the network task is still alive, not just the light task. */
extern volatile uint32_t nl_net_beat; extern volatile uint32_t nl_net_beat;
/* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied. */ /* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied.
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass); * Ein leerer ap_ssid heisst "wie der Hostname", ap_channel 0 heisst Kanal 6. Mit
* standalone == true wird gar nicht erst verbunden: die Lampe oeffnet ihren eigenen,
* passwortgeschuetzten AP, pulse laesst sie dabei kurz als Lebenszeichen pulsen. */
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass,
const char *ap_ssid, uint8_t ap_channel, bool standalone, bool pulse);
/* Switches the SDK's own auto-connect off and drops any connection it may already have brought /* Switches the SDK's own auto-connect off and drops any connection it may already have brought
* up on its own. nl_wifi_start() does this too, but the boot recovery path runs before that and * up on its own. nl_wifi_start() does this too, but the boot recovery path runs before that and
* must not have the SDK joining its stored AP in parallel (I3, final review). Safe to call any * must not have the SDK joining its stored AP in parallel (I3, final review). Safe to call any
@@ -34,6 +38,13 @@ void nl_wifi_disable_sdk_auto_connect(void);
bool nl_wifi_join_blocking(const char *ssid, const char *pass, uint32_t timeout_ms); bool nl_wifi_join_blocking(const char *ssid, const char *pass, uint32_t timeout_ms);
/* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */ /* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */
int nl_wifi_request_onboard(const char *ssid, const char *pass); int nl_wifi_request_onboard(const char *ssid, const char *pass);
/* Schaltet den Standalone-Modus zur Laufzeit ein oder aus (aus dem HTTP-Task). Die Werte
* werden kopiert und erst im Netz-Task wirksam, nachdem die Antwort raus ist; ein leeres
* ssid/pass bzw. channel == 0 bedeutet jeweils die Vorgabe. 0 = uebernommen, sonst 503. */
int nl_wifi_request_standalone(bool enabled, const char *ssid, const char *pass,
uint8_t channel, bool pulse);
/* True, solange die Lampe in ihrem eigenen Dauer-AP steht. */
bool nl_wifi_is_standalone(void);
/* True while the unconfigured onboarding AP is open (API without token). */ /* True while the unconfigured onboarding AP is open (API without token). */
bool nl_wifi_open_access(void); bool nl_wifi_open_access(void);
void nl_wifi_status(nl_wifi_status_t *out); void nl_wifi_status(nl_wifi_status_t *out);
+109 -5
View File
@@ -40,7 +40,15 @@ static void arm_ap_stop(nl_wifi_sm_t *sm, uint32_t at)
static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wifi_actions_t *a) static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wifi_actions_t *a)
{ {
sm->error = err; sm->error = err;
if (sm->have_creds) { if (sm->standalone) {
/* Zurueck in den EIGENEN AP. Der offene Onboarding-AP waere hier doppelt falsch:
* er verloere Name und Passwort des Nutzers, und open_access() machte die Lampe
* ohne Token flashbar. Ein Tippfehler im WLAN-Passwort darf sie nicht entsichern. */
sm->state = NL_WIFI_ST_STANDALONE;
sm->ap_up = true;
a->disconnect = true;
a->start_ap = true;
} else if (sm->have_creds) {
sm->state = NL_WIFI_ST_APSTA_FALLBACK; sm->state = NL_WIFI_ST_APSTA_FALLBACK;
sm->since = now; sm->since = now;
sm->backoff = NL_WIFI_BACKOFF_MIN_MS; sm->backoff = NL_WIFI_BACKOFF_MIN_MS;
@@ -54,13 +62,26 @@ static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wi
} }
} }
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, uint32_t now) nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, bool standalone, bool pulse, uint32_t now)
{ {
nl_wifi_actions_t a = none(); nl_wifi_actions_t a = none();
memset(sm, 0, sizeof *sm); memset(sm, 0, sizeof *sm);
sm->have_creds = have_creds; sm->have_creds = have_creds;
if (have_creds) { sm->standalone = standalone;
if (standalone) {
/* No connect attempt, not even with stored credentials: the mode is the
* user's explicit choice. */
sm->state = NL_WIFI_ST_STANDALONE;
sm->ap_up = true;
a.start_ap = true;
if (pulse) {
sm->pulsing = true;
sm->pulse_armed = true;
sm->pulse_stop_at = now + NL_WIFI_STANDALONE_PULSE_MS;
a.pulse_on = true;
}
} else if (have_creds) {
begin_connecting(sm, now); begin_connecting(sm, now);
a.connect = true; a.connect = true;
sm->awaiting = true; sm->awaiting = true;
@@ -101,6 +122,10 @@ nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi
case NL_WIFI_ST_ONBOARD_TEST: case NL_WIFI_ST_ONBOARD_TEST:
if (ev == NL_WIFI_EV_CONNECTED) { if (ev == NL_WIFI_EV_CONNECTED) {
a.save_test_creds = true; a.save_test_creds = true;
if (sm->standalone) {
sm->standalone = false;
a.clear_standalone = true;
}
sm->have_creds = true; sm->have_creds = true;
sm->error = NL_WIFI_ERR_NONE; sm->error = NL_WIFI_ERR_NONE;
sm->state = NL_WIFI_ST_STA_CONNECTED; sm->state = NL_WIFI_ST_STA_CONNECTED;
@@ -118,6 +143,10 @@ nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi
if (ev == NL_WIFI_EV_CONNECTED) if (ev == NL_WIFI_EV_CONNECTED)
a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */ a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */
break; break;
case NL_WIFI_ST_STANDALONE:
if (ev == NL_WIFI_EV_CONNECTED)
a.disconnect = true; /* stray success of an old attempt */
break;
default: default:
break; break;
} }
@@ -128,6 +157,11 @@ nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now)
{ {
nl_wifi_actions_t a = none(); nl_wifi_actions_t a = none();
if (sm->pulse_armed && due(now, sm->pulse_stop_at)) {
sm->pulse_armed = false;
sm->pulsing = false;
a.pulse_off = true;
}
if (sm->ap_stop_armed && due(now, sm->ap_stop_at)) { if (sm->ap_stop_armed && due(now, sm->ap_stop_at)) {
sm->ap_stop_armed = false; sm->ap_stop_armed = false;
sm->ap_up = false; sm->ap_up = false;
@@ -173,20 +207,90 @@ nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now)
sm->deadline = now + NL_WIFI_ONBOARD_TIMEOUT_MS; sm->deadline = now + NL_WIFI_ONBOARD_TIMEOUT_MS;
sm->retry_armed = false; sm->retry_armed = false;
sm->ap_stop_armed = false; sm->ap_stop_armed = false;
if (sm->pulse_armed) {
/* Der Startpuls gehoert nicht in den Test: sonst feuerte spaeter ein pulse_off, das zu
* keinem Puls mehr gehoert. Hier abraeumen; pulsing auch, damit er nicht haengen bleibt. */
sm->pulse_armed = false;
sm->pulsing = false;
a.pulse_off = true;
}
a.disconnect = true; /* M1: drop any stale station attempt so its events don't count */ a.disconnect = true; /* M1: drop any stale station attempt so its events don't count */
a.connect = true; a.connect = true;
a.use_test_creds = true; a.use_test_creds = true;
return a; return a;
} }
nl_wifi_actions_t nl_wifi_sm_standalone(nl_wifi_sm_t *sm, bool pulse, uint32_t now)
{
nl_wifi_actions_t a = none();
sm->standalone = true;
sm->state = NL_WIFI_ST_STANDALONE;
sm->error = NL_WIFI_ERR_NONE;
sm->retry_armed = false;
sm->ap_stop_armed = false;
sm->awaiting = false;
a.disconnect = true; /* eine laufende Stationsverbindung aufgeben */
if (!sm->ap_up) {
sm->ap_up = true;
a.start_ap = true;
}
if (pulse) {
sm->pulsing = true;
sm->pulse_armed = true;
sm->pulse_stop_at = now + NL_WIFI_STANDALONE_PULSE_MS;
a.pulse_on = true;
} else if (sm->pulsing) {
/* Ein laufender unbefristeter Puls (aus dem Onboarding-AP) hat im eigenen AP keinen
* Timer mehr, der ihn beendet; er uebermalte dauerhaft die normale Ausgabe. */
sm->pulsing = false;
sm->pulse_armed = false;
a.pulse_off = true;
}
return a;
}
nl_wifi_actions_t nl_wifi_sm_leave_standalone(nl_wifi_sm_t *sm, bool have_creds, uint32_t now)
{
nl_wifi_actions_t a = none();
sm->standalone = false;
sm->have_creds = have_creds;
sm->pulse_armed = false;
if (have_creds) {
begin_connecting(sm, now);
a.connect = true;
sm->awaiting = true;
sm->connect_at = now;
if (sm->pulsing) {
sm->pulsing = false;
a.pulse_off = true;
}
} else {
/* Ohne Zugangsdaten bliebe die Lampe sonst unerreichbar: der offene
* Onboarding-AP ist hier der gewollte Ausweg. */
sm->state = NL_WIFI_ST_AP_ONBOARDING;
sm->ap_up = true;
sm->pulsing = true;
a.start_ap = true;
a.pulse_on = true;
}
return a;
}
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm) bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm)
{ {
return sm->state == NL_WIFI_ST_AP_ONBOARDING || (sm->state == NL_WIFI_ST_ONBOARD_TEST && !sm->have_creds); /* !standalone is NOT redundant: during an onboarding test started from standalone mode
* the state is ONBOARD_TEST with have_creds == false, which the second clause alone would
* call open. The user's password-protected AP is up then, so the token check must stay on
* (up to NL_WIFI_ONBOARD_TIMEOUT_MS), same as in normal operation. */
return !sm->standalone &&
(sm->state == NL_WIFI_ST_AP_ONBOARDING || (sm->state == NL_WIFI_ST_ONBOARD_TEST && !sm->have_creds));
} }
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm) const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm)
{ {
if (sm->state == NL_WIFI_ST_AP_ONBOARDING) if (sm->state == NL_WIFI_ST_AP_ONBOARDING || sm->state == NL_WIFI_ST_STANDALONE)
return "ap"; return "ap";
return sm->ap_up ? "apsta" : "sta"; return sm->ap_up ? "apsta" : "sta";
} }
+13 -2
View File
@@ -11,13 +11,15 @@
#define NL_WIFI_BACKOFF_MIN_MS 1000u #define NL_WIFI_BACKOFF_MIN_MS 1000u
#define NL_WIFI_BACKOFF_MAX_MS 60000u #define NL_WIFI_BACKOFF_MAX_MS 60000u
#define NL_WIFI_CONNECT_TIMEOUT_MS 30000u /* no event after a connect -> treat as failed */ #define NL_WIFI_CONNECT_TIMEOUT_MS 30000u /* no event after a connect -> treat as failed */
#define NL_WIFI_STANDALONE_PULSE_MS 5000u /* short sign-of-life pulse, then normal light */
typedef enum { typedef enum {
NL_WIFI_ST_AP_ONBOARDING = 0, NL_WIFI_ST_AP_ONBOARDING = 0,
NL_WIFI_ST_STA_CONNECTING, NL_WIFI_ST_STA_CONNECTING,
NL_WIFI_ST_STA_CONNECTED, NL_WIFI_ST_STA_CONNECTED,
NL_WIFI_ST_APSTA_FALLBACK, NL_WIFI_ST_APSTA_FALLBACK,
NL_WIFI_ST_ONBOARD_TEST NL_WIFI_ST_ONBOARD_TEST,
NL_WIFI_ST_STANDALONE
} nl_wifi_state_t; } nl_wifi_state_t;
typedef enum { NL_WIFI_ERR_NONE = 0, NL_WIFI_ERR_AUTH, NL_WIFI_ERR_NOT_FOUND, NL_WIFI_ERR_TIMEOUT } nl_wifi_error_t; typedef enum { NL_WIFI_ERR_NONE = 0, NL_WIFI_ERR_AUTH, NL_WIFI_ERR_NOT_FOUND, NL_WIFI_ERR_TIMEOUT } nl_wifi_error_t;
@@ -33,6 +35,7 @@ typedef struct {
bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */ bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */
bool pulse_on; bool pulse_on;
bool pulse_off; bool pulse_off;
bool clear_standalone; /* Onboarding gelungen: den Standalone-Modus dauerhaft loeschen */
} nl_wifi_actions_t; } nl_wifi_actions_t;
typedef struct { typedef struct {
@@ -44,18 +47,26 @@ typedef struct {
bool retry_armed; bool retry_armed;
bool ap_stop_armed; bool ap_stop_armed;
bool awaiting; /* waiting for response to a connect */ bool awaiting; /* waiting for response to a connect */
bool standalone; /* stays set across an onboarding test */
bool pulse_armed; /* start pulse still running */
uint32_t since; /* start of the current connecting phase */ uint32_t since; /* start of the current connecting phase */
uint32_t retry_at; uint32_t retry_at;
uint32_t backoff; uint32_t backoff;
uint32_t deadline; /* onboarding test */ uint32_t deadline; /* onboarding test */
uint32_t ap_stop_at; uint32_t ap_stop_at;
uint32_t connect_at; /* when the pending connect was issued */ uint32_t connect_at; /* when the pending connect was issued */
uint32_t pulse_stop_at;
} nl_wifi_sm_t; } nl_wifi_sm_t;
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, uint32_t now); nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, bool standalone, bool pulse, uint32_t now);
nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi_error_t err, uint32_t now); nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi_error_t err, uint32_t now);
nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now); nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now);
nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now); nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now);
/* Schaltet zur Laufzeit in den Standalone-Modus (aus der API). */
nl_wifi_actions_t nl_wifi_sm_standalone(nl_wifi_sm_t *sm, bool pulse, uint32_t now);
/* Nimmt ihn zurueck: mit gespeicherten Zugangsdaten ins WLAN, sonst in den
offenen Onboarding-AP. */
nl_wifi_actions_t nl_wifi_sm_leave_standalone(nl_wifi_sm_t *sm, bool have_creds, uint32_t now);
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm); bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm);
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm); const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm);
const char *nl_wifi_error_name(nl_wifi_error_t e); const char *nl_wifi_error_name(nl_wifi_error_t e);
+306 -15
View File
@@ -18,6 +18,10 @@
#define NL_NET_QUEUE 8 #define NL_NET_QUEUE 8
#define MSG_NETIF 0x100u /* | netif status */ #define MSG_NETIF 0x100u /* | netif status */
#define MSG_ONBOARD 0x200u #define MSG_ONBOARD 0x200u
/* 0x400, nicht 0x300: die netif-Meldungen werden weiter unten mit (m & MSG_NETIF) erkannt,
* und 0x300 traegt das 0x100-Bit mit - die Standalone-Meldung waere als netif-Ereignis
* protokolliert worden. */
#define MSG_STANDALONE 0x400u
static u32 s_stack[768]; static u32 s_stack[768];
static tls_os_queue_t *s_queue; static tls_os_queue_t *s_queue;
@@ -29,6 +33,34 @@ static char s_hostname[24];
static char s_ssid[NL_SSID_LEN], s_pass[NL_PASS_LEN]; static char s_ssid[NL_SSID_LEN], s_pass[NL_PASS_LEN];
static char s_test_ssid[NL_SSID_LEN], s_test_pass[NL_PASS_LEN]; static char s_test_ssid[NL_SSID_LEN], s_test_pass[NL_PASS_LEN];
static char s_ap_pass[NL_PASS_LEN]; static char s_ap_pass[NL_PASS_LEN];
static char s_ap_ssid[NL_SSID_LEN]; /* leer = Hostname */
static uint8_t s_ap_channel = 6;
/* Die Daten, mit denen der laufende AP tatsaechlich aufgebaut wurde. Nur so ist zu sehen,
* ob ein Wunsch aus der API den AP wirklich veraendert - die Zustandsmaschine liefert kein
* start_ap, wenn der AP schon steht, und die alten Daten blieben sonst still in Kraft. */
static char s_ap_live_ssid[NL_SSID_LEN], s_ap_live_pass[NL_PASS_LEN];
static uint8_t s_ap_live_channel;
static bool s_ap_live;
/* mDNS im eigenen AP haengt am ZUSTAND (Standalone + AP steht), nicht am Erzeugungsereignis:
* betritt die Lampe Standalone auf einem schon laufenden, unveraenderten AP, entsteht kein
* SOFTAP_SUCCESS mehr. Beides nur im Netz-Task angefasst. */
static bool s_ap_event_seen; /* zum aktuell stehenden AP kam SOFTAP_SUCCESS an */
static bool s_ap_mdns_done; /* fuer den aktuell stehenden AP steht der mDNS-Dienst wirklich */
/* I5: s_ap_mdns_done haengt am ERFOLG, nicht am Versuch. nl_mdns_on_ip_up() reicht die Arbeit
* nur an den tcpip-Thread weiter; findet announce() dort noch kein Netif mit Adresse, gibt es
* auf. Bis der Dienst steht, wird alle NL_AP_MDNS_RETRY_MS erneut angestossen. */
#define NL_AP_MDNS_RETRY_MS 5000u
static uint32_t s_ap_mdns_retry_at;
/* Drei Versuche, dann der Rueckfall auf Daten, die schon einmal getragen haben: einer koennte
* ein Zufall sein (das Funkteil raeumt gerade den alten AP ab), aber drei hintereinander
* heissen, dass das Funkteil genau diese Daten nicht annimmt. Und 3 x 5 s hoechstens
* 15 s ohne AP ist kuerzer als jede Fehlersuche vor Ort. */
#define NL_AP_CREATE_TRIES 3
static uint8_t s_ap_create_fails;
/* Vom HTTP-Task gefuellt, vom Netz-Task uebernommen; beides unter s_lock. */
static char s_req_ap_ssid[NL_SSID_LEN], s_req_ap_pass[NL_PASS_LEN];
static uint8_t s_req_ap_channel;
static bool s_req_enabled, s_req_pulse, s_req_pending;
static bool s_ap_create_pending; /* M3: tls_wifi_softap_create() failed, retry it */ static bool s_ap_create_pending; /* M3: tls_wifi_softap_create() failed, retry it */
static uint32_t s_ap_create_retry_at; static uint32_t s_ap_create_retry_at;
@@ -88,18 +120,77 @@ void nl_wifi_disable_sdk_auto_connect(void)
tls_wifi_disconnect(); tls_wifi_disconnect();
} }
/* Setzt NUR Name und Kanal des naechsten AP auf die Vorgaben zurueck: "ninlight-<mac4>" auf
* Kanal 6. Das Passwort bleibt stehen - I1: ein Rueckfall darf das Passwort des Nutzers nicht
* durch das einkompilierte ersetzen, das in der README oeffentlich dokumentiert ist. Beruehrt
* einen LAUFENDEN AP nicht - der behaelt seine Daten, bis er abgebaut und neu aufgebaut wird. */
static void reset_ap_identity(void)
{
s_ap_ssid[0] = '\0';
s_ap_channel = 6;
}
/* I2: Vergisst, mit welchen Daten der zuletzt stehende AP aufgebaut wurde. Verlaesst die Lampe
* den Standalone-Modus, duerfen Name und Passwort des Nutzers nicht als "hat schon einmal
* getragen" liegenbleiben: der erste Rueckfallzweig in start_ap() holte sie sonst Minuten
* spaeter zurueck (etwa am Rueckfall-AP nach fuenf Minuten ohne Router), und die Lampe boete
* periodisch einen AP an, den der Nutzer laengst abgeschaltet hat. */
static void forget_ap_live(void)
{
s_ap_live_ssid[0] = s_ap_live_pass[0] = '\0';
s_ap_live_channel = 0;
}
/* Vollstaendige Rueckstellung auf die Vorgaben, einschliesslich des einkompilierten Passworts.
* Nur fuer den Weg AUS dem Standalone-Modus heraus, wo der Wunsch des Nutzers endet. */
static void reset_ap_defaults(void)
{
reset_ap_identity();
copy(s_ap_pass, NINLIGHT_AP_PASSWORD, sizeof s_ap_pass);
}
/* Reisst den AP ab und raeumt dabei auch die Buchfuehrung auf. Laeuft nur im Netz-Task
* (oder vor dessen Start) und nie unter s_lock - clear_softap_param() fasst SDK-Zustand an. */
static void destroy_ap(void)
{
tls_wifi_softap_destroy();
s_ap_create_pending = false;
s_ap_live = false;
s_ap_event_seen = s_ap_mdns_done = false;
/* I3: Der Zaehler gehoert zu EINEM Datensatz. Ohne diese Nullung addieren sich die
* Fehlschlaege mit den alten Daten zu denen mit den neuen, und nach einer Korrektur
* (Kanal 13 -> Kanal 6) loeste schon ein einziger Fehlschlag den Rueckfall aus. */
s_ap_create_fails = 0;
clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */
}
static void start_ap(void) static void start_ap(void)
{ {
struct tls_softap_info_t ap; struct tls_softap_info_t ap;
struct tls_ip_info_t ip; struct tls_ip_info_t ip;
const char *ssid = s_ap_ssid[0] ? s_ap_ssid : s_hostname;
size_t n = strlen(s_ap_pass); size_t n = strlen(s_ap_pass);
int r; int r;
/* Steht derselbe AP schon, bleibt er stehen: die Zustandsmaschine verlangt start_ap
* auch dann, wenn sie den AP gar nicht erst abgebaut hat (fehlgeschlagener
* Onboarding-Versuch aus dem Standalone-Modus), und ein Neuaufbau wuerfe dort alle
* Clients ohne Not heraus. */
if (s_ap_live && strcmp(s_ap_live_ssid, ssid) == 0 && strcmp(s_ap_live_pass, s_ap_pass) == 0 &&
s_ap_live_channel == s_ap_channel)
return;
/* Umgekehrt: Name, Passwort oder Kanal haben sich geaendert, waehrend der AP steht.
* Ohne eigenen Abriss liefe er mit den alten Daten weiter. */
if (s_ap_live) {
printf("wifi: AP data changed, rebuilding\r\n");
destroy_ap();
}
memset(&ap, 0, sizeof ap); memset(&ap, 0, sizeof ap);
memset(&ip, 0, sizeof ip); memset(&ip, 0, sizeof ip);
copy((char *)ap.ssid, s_hostname, sizeof ap.ssid); copy((char *)ap.ssid, ssid, sizeof ap.ssid);
ap.encrypt = IEEE80211_ENCRYT_CCMP_WPA2; ap.encrypt = IEEE80211_ENCRYT_CCMP_WPA2;
ap.channel = 6; ap.channel = s_ap_channel;
ap.keyinfo.format = 1; /* ascii */ ap.keyinfo.format = 1; /* ascii */
ap.keyinfo.index = 1; ap.keyinfo.index = 1;
ap.keyinfo.key_len = (u8)n; ap.keyinfo.key_len = (u8)n;
@@ -108,10 +199,45 @@ static void start_ap(void)
ip.netmask[0] = 255; ip.netmask[1] = 255; ip.netmask[2] = 255; ip.netmask[3] = 0; ip.netmask[0] = 255; ip.netmask[1] = 255; ip.netmask[2] = 255; ip.netmask[3] = 0;
memcpy(ip.dnsname, "local", 6); memcpy(ip.dnsname, "local", 6);
r = tls_wifi_softap_create(&ap, &ip); r = tls_wifi_softap_create(&ap, &ip);
printf("wifi: AP %s %s\r\n", s_hostname, r == WM_SUCCESS ? "up" : "failed"); printf("wifi: AP %s (ch %u) %s\r\n", ssid, (unsigned)s_ap_channel,
r == WM_SUCCESS ? "up" : "failed");
/* M3: retry a failed AP create instead of leaving onboarding/fallback without an AP. */ /* M3: retry a failed AP create instead of leaving onboarding/fallback without an AP. */
s_ap_create_pending = r != WM_SUCCESS; s_ap_create_pending = r != WM_SUCCESS;
s_ap_create_retry_at = nl_app_now_ms() + 5000u; s_ap_create_retry_at = nl_app_now_ms() + 5000u;
s_ap_live = r == WM_SUCCESS;
if (s_ap_live) {
/* Neuer AP: erst das Ereignis abwarten, dann bekanntmachen. */
s_ap_event_seen = s_ap_mdns_done = false;
copy(s_ap_live_ssid, ssid, sizeof s_ap_live_ssid);
copy(s_ap_live_pass, s_ap_pass, sizeof s_ap_live_pass);
s_ap_live_channel = s_ap_channel;
s_ap_create_fails = 0;
return;
}
/* Nimmt das Funkteil diese Daten dauerhaft nicht an, steht die Lampe ohne AP da - und mit
* in den Einstellungen gespeicherten Wunschdaten auch nach einem Neustart wieder. Dann
* lieber ein AP unter unerwartetem Namen: der ist sichtbar und reparierbar. */
if (++s_ap_create_fails < NL_AP_CREATE_TRIES)
return;
s_ap_create_fails = 0;
if (s_ap_live_ssid[0] && (strcmp(s_ap_live_ssid, ssid) != 0 ||
strcmp(s_ap_live_pass, s_ap_pass) != 0 ||
s_ap_live_channel != s_ap_channel)) {
printf("wifi: AP create keeps failing, falling back to the last working data (%s, ch %u)\r\n",
s_ap_live_ssid, (unsigned)s_ap_live_channel);
copy(s_ap_ssid, s_ap_live_ssid, sizeof s_ap_ssid);
copy(s_ap_pass, s_ap_live_pass, sizeof s_ap_pass);
s_ap_channel = s_ap_live_channel;
} else if (s_ap_ssid[0] || s_ap_channel != 6) {
/* I1: nur Name und Kanal. Das Passwort ist der unwahrscheinlichste Grund fuer ein
* abgelehntes tls_wifi_softap_create() - die Laenge prueft die API selbst, Kanal und
* SSID sind die plausiblen Kandidaten. Ein Rueckfall auf NINLIGHT_AP_PASSWORD stellte
* eine Standalone-Lampe mit einem oeffentlich dokumentierten Passwort ins Funkfeld,
* und sACN hoert dort unauthentifiziert mit. */
printf("wifi: AP create keeps failing, falling back to %s on ch 6\r\n", s_hostname);
reset_ap_identity();
}
/* Sonst scheitern schon die Vorgaben - dann bleibt nur, es weiter zu versuchen. */
} }
static void connect_station(bool test) static void connect_station(bool test)
@@ -149,6 +275,24 @@ static void execute(nl_wifi_actions_t a)
printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid); printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid);
nl_app_set_wifi_credentials(ssid, pass); nl_app_set_wifi_credentials(ssid, pass);
} }
if (a.clear_standalone) {
/* Sonst stuende die Lampe nach dem naechsten Neustart wieder in ihrem eigenen AP,
* obwohl sie gerade erfolgreich ins WLAN gewechselt ist. */
printf("wifi: standalone mode cleared\r\n");
/* Dieselbe Rueckstellung wie beim Zurueckschalten ueber die API: sonst kaeme ein
* spaeterer Rueckfall-AP (5 min ohne Station) mit Namen und Passwort des Nutzers
* hoch, obwohl standalone in den Einstellungen schon 0 ist. forget_ap_live() raeumt
* dazu die Buchfuehrung ueber den zuletzt tragenden AP weg (I2) - sonst holte ihn
* der erste Rueckfallzweig in start_ap() nach drei abgelehnten Aufbauten zurueck.
* Der noch laufende Standalone-AP bleibt stehen, bis die Zustandsmaschine ihn
* abbaut: dieses Aktionsbuendel enthaelt kein start_ap. */
reset_ap_defaults();
forget_ap_live();
/* Schlaegt das Speichern fehl, steht die Lampe jetzt im WLAN, nach dem naechsten
* Neustart aber wieder in ihrem eigenen AP - ohne jede Spur. Wenigstens melden. */
if (nl_app_clear_standalone() != 0)
printf("wifi: could not persist the cleared standalone flag\r\n");
}
if (a.pulse_on) if (a.pulse_on)
pulse(true); pulse(true);
if (a.pulse_off) if (a.pulse_off)
@@ -157,9 +301,7 @@ static void execute(nl_wifi_actions_t a)
start_ap(); start_ap();
if (a.stop_ap) { if (a.stop_ap) {
printf("wifi: AP down\r\n"); printf("wifi: AP down\r\n");
tls_wifi_softap_destroy(); destroy_ap();
s_ap_create_pending = false;
clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */
} }
if (a.disconnect) { if (a.disconnect) {
printf("wifi: dropping stray/failed station attempt\r\n"); printf("wifi: dropping stray/failed station attempt\r\n");
@@ -185,7 +327,9 @@ static void net_task(void *arg)
void *msg = NULL; void *msg = NULL;
nl_wifi_actions_t ev, tick; nl_wifi_actions_t ev, tick;
uint32_t m = 0, now; uint32_t m = 0, now;
bool got_ip_event = false, connected_now, ap_wanted, unexpected_softap; bool got_ip_event = false, connected_now, ap_wanted, unexpected_softap,
standalone_now, open_now;
bool recheck_ap = false, rebuild_ap = false, ignored_off = false;
memset(&ev, 0, sizeof ev); memset(&ev, 0, sizeof ev);
if (tls_os_queue_receive(s_queue, &msg, 0, HZ / 10) == TLS_OS_SUCCESS) if (tls_os_queue_receive(s_queue, &msg, 0, HZ / 10) == TLS_OS_SUCCESS)
@@ -202,6 +346,38 @@ static void net_task(void *arg)
ev = nl_wifi_sm_event(&s_sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, now); ev = nl_wifi_sm_event(&s_sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, now);
} else if (m == MSG_ONBOARD) { } else if (m == MSG_ONBOARD) {
ev = nl_wifi_sm_onboard(&s_sm, now); ev = nl_wifi_sm_onboard(&s_sm, now);
} else if (m == MSG_STANDALONE) {
/* Die Wunschwerte erst hier uebernehmen: der HTTP-Task hat sie abgelegt, der
* Netz-Task ist der einzige, der s_ap_* schreibt und start_ap() aufruft. */
if (s_req_enabled) {
copy(s_ap_ssid, s_req_ap_ssid, sizeof s_ap_ssid);
copy(s_ap_pass, s_req_ap_pass[0] ? s_req_ap_pass : NINLIGHT_AP_PASSWORD,
sizeof s_ap_pass);
s_ap_channel = s_req_ap_channel ? s_req_ap_channel : 6;
ev = nl_wifi_sm_standalone(&s_sm, s_req_pulse, now);
/* Steht der AP schon, liefert die Zustandsmaschine kein start_ap. Ob die
* neuen Daten einen Neuaufbau verlangen, entscheidet start_ap() selbst. */
recheck_ap = true;
} else if (!s_sm.standalone) {
/* War die Lampe gar nicht standalone, gibt es nichts abzuschalten. Ohne diese
* Vorpruefung riefe leave_standalone() auf einer gewoehnlichen Stationslampe
* begin_connecting() und erzwaenge eine Neuverbindung ohne Anlass. Die Antwort
* bleibt 202: der gewuenschte Zustand gilt ja bereits. (standalone bleibt auch
* waehrend eines Onboarding-Tests gesetzt, der Test faellt also nicht hierher.) */
ignored_off = true;
} else {
/* Zurueck auf die Vorgaben: ein spaeterer Rueckfall- oder Onboarding-AP ist
* wieder der gewohnte "ninlight-<mac4>" mit dem einkompilierten Passwort,
* genau wie bei einer Lampe, die nie standalone war. */
reset_ap_defaults();
forget_ap_live();
ev = nl_wifi_sm_leave_standalone(&s_sm, s_ssid[0] != '\0', now);
rebuild_ap = true;
}
/* I3: Ein neuer Wunsch ist ein neuer Datensatz - die Fehlschlaege des alten
* duerfen nicht mitzaehlen. */
s_ap_create_fails = 0;
s_req_pending = false;
} }
tick = nl_wifi_sm_tick(&s_sm, now); tick = nl_wifi_sm_tick(&s_sm, now);
/* I1: a late/stray IP-up must not announce mDNS unless the SM actually settled on STA_CONNECTED. */ /* I1: a late/stray IP-up must not announce mDNS unless the SM actually settled on STA_CONNECTED. */
@@ -213,20 +389,77 @@ static void net_task(void *arg)
* clear_softap_param() touch the SDK's own state and must not run while s_lock * clear_softap_param() touch the SDK's own state and must not run while s_lock
* (which only guards s_sm and the credential buffers) is held. */ * (which only guards s_sm and the credential buffers) is held. */
unexpected_softap = m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS) && !ap_wanted; unexpected_softap = m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS) && !ap_wanted;
/* Nur die Lage merken; der Aufruf nach aussen folgt hinter der Entsperrung. */
standalone_now = s_sm.state == NL_WIFI_ST_STANDALONE;
open_now = nl_wifi_sm_open_access(&s_sm);
unlock(); unlock();
if (unexpected_softap) { if (unexpected_softap) {
printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n"); printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n");
tls_wifi_softap_destroy(); destroy_ap();
clear_softap_param();
} }
if (m & MSG_NETIF) if (m & MSG_NETIF)
printf("wifi: netif event %u\r\n", (unsigned)(m & 0xFFu)); printf("wifi: netif event %u\r\n", (unsigned)(m & 0xFFu));
/* I6: Die 202 auf den Standalone-Wunsch schreibt der HTTP-Task erst, nachdem
* nl_api_handle() zurueck ist - die Warteschlangennachricht geht aber schon darin
* raus, und dieser Task hat die hoehere Prioritaet (35 gegen 42, kleinere Zahl heisst
* hoeher). Er verdraengt also sofort und risse das Netz ab, bevor send_all() lief.
* Beim Wechsel AUS dem WLAN ging das bisher gut, weil tls_wifi_disconnect() asynchron
* arbeitet; ueber den eigenen AP ist tls_wifi_softap_destroy() der haertere Abriss.
* Die Pause ist billige Versicherung - gemessen ist der AP-Fall damit nicht, er steht
* als offener Punkt in der Abnahmeliste. */
if (m == MSG_STANDALONE)
tls_os_time_delay(HZ / 4); /* ~250 ms */
if (ignored_off)
printf("wifi: standalone was already off, nothing to do\r\n");
/* C1: Beim Verlassen des Standalone-Modus OHNE gespeicherte WLAN-Daten geht die
* Zustandsmaschine in den Onboarding-AP - dort liefert open_access() true, waehrend
* der AP noch unveraendert mit Name und Passwort des Nutzers steht. Wer das
* AP-Passwort kennt, koennte dann ohne Token system/settings und system/command
* benutzen. Also hier hart abreissen und mit den Vorgabedaten neu aufbauen; damit
* stimmt auch die README-Zusage wieder, der AP heisse danach "ninlight-<mac4>".
* Nicht ueber die Aktionsstruktur: execute() fuehrt start_ap VOR stop_ap aus, ein
* Buendel mit beiden Flags baute erst auf und risse dann ab. Und VOR execute(ev),
* damit dessen start_ap in die Gleichheitspruefung von start_ap() faellt, statt den
* eben gebauten AP ein zweites Mal abzureissen. */
if (rebuild_ap && ap_wanted && open_now) {
printf("wifi: left standalone without credentials, rebuilding the AP with the defaults\r\n");
destroy_ap();
start_ap();
}
execute(ev); execute(ev);
/* Nach einem Wechsel in den Standalone-Modus: start_ap() baut den AP neu auf, wenn
* Name, Passwort oder Kanal sich geaendert haben, und tut sonst nichts. */
if (recheck_ap && ap_wanted)
start_ap();
execute(tick); execute(tick);
if (got_ip_event && connected_now) if (got_ip_event && connected_now)
nl_mdns_on_ip_up(); nl_mdns_on_ip_up();
/* Im eigenen AP gibt es kein Stationsereignis, an dem mDNS haengen koennte.
* Ohne diesen Aufruf findet die App ein Standalone-Geraet nicht - am
* 2026-10-01 am EVK gemessen: REST auf der AP-IP lief, ninlight-681f.local
* war weder aufloesbar noch sichtbar. */
if (m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS))
s_ap_event_seen = true;
/* Zustandsgebunden: auch ein Wechsel auf einen unveraendert stehenden AP loest es aus.
* Mehrfaches Rufen waere harmlos, s_ap_mdns_done spart es trotzdem. Faellt der Zustand
* weg, gilt die Bekanntmachung beim naechsten Eintritt neu. */
if (!standalone_now) {
s_ap_mdns_done = false;
s_ap_mdns_retry_at = now; /* beim Eintritt sofort anstossen */
} else if (s_ap_live && s_ap_event_seen && !s_ap_mdns_done) {
/* I5: Erst der Erfolg beendet die Sache. Wer den blossen Versuch als erledigt
* verbucht, laesst die Lampe fuer die ganze Standzeit des AP unauffindbar,
* sobald announce() einmal "no usable netif yet" gemeldet hat. Deshalb immer
* erst anstossen und dann den gemeldeten Zustand abfragen - nl_mdns_service_up()
* ist erst wahr, wenn die Bekanntmachung im tcpip-Thread auch gelaufen ist. */
if (due_now(now, s_ap_mdns_retry_at)) {
s_ap_mdns_retry_at = now + NL_AP_MDNS_RETRY_MS;
nl_mdns_on_ip_up();
} else
s_ap_mdns_done = nl_mdns_service_up();
}
/* M3: retry a softap create that failed, as long as the SM still wants the AP up. */ /* M3: retry a softap create that failed, as long as the SM still wants the AP up. */
if (s_ap_create_pending && ap_wanted && due_now(now, s_ap_create_retry_at)) { if (s_ap_create_pending && ap_wanted && due_now(now, s_ap_create_retry_at)) {
@@ -237,7 +470,8 @@ static void net_task(void *arg)
} }
} }
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass) void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass,
const char *ap_ssid, uint8_t ap_channel, bool standalone, bool pulse)
{ {
nl_wifi_actions_t a; nl_wifi_actions_t a;
struct netif *nif; struct netif *nif;
@@ -248,7 +482,11 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
nl_names_hostname(s_mac, s_hostname, sizeof s_hostname); nl_names_hostname(s_mac, s_hostname, sizeof s_hostname);
copy(s_ssid, ssid, sizeof s_ssid); copy(s_ssid, ssid, sizeof s_ssid);
copy(s_pass, pass, sizeof s_pass); copy(s_pass, pass, sizeof s_pass);
copy(s_ap_pass, ap_pass, sizeof s_ap_pass); /* Wie in nl_wifi_request_standalone(): NULL ist hier zwar von keinem Aufrufer zu
* erwarten, aber copy() wuerde darueber stolpern. */
copy(s_ap_pass, ap_pass && ap_pass[0] ? ap_pass : NINLIGHT_AP_PASSWORD, sizeof s_ap_pass);
copy(s_ap_ssid, ap_ssid ? ap_ssid : "", sizeof s_ap_ssid); /* leer = Hostname */
s_ap_channel = ap_channel ? ap_channel : 6;
/* The SDK would otherwise reconnect on its own with credentials from its parameter area. */ /* The SDK would otherwise reconnect on its own with credentials from its parameter area. */
nl_wifi_disable_sdk_auto_connect(); nl_wifi_disable_sdk_auto_connect();
@@ -256,8 +494,7 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
/* C2: the SDK already brought up its own softap (e.g. from NETIF_IP_NET_UP before /* C2: the SDK already brought up its own softap (e.g. from NETIF_IP_NET_UP before
* we got a chance to run) - tear it down, our state machine owns AP lifecycle. */ * we got a chance to run) - tear it down, our state machine owns AP lifecycle. */
printf("wifi: destroying SDK-created softap left over from boot\r\n"); printf("wifi: destroying SDK-created softap left over from boot\r\n");
tls_wifi_softap_destroy(); destroy_ap();
clear_softap_param();
} }
nif = tls_get_netif(); nif = tls_get_netif();
if (nif) if (nif)
@@ -268,10 +505,11 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
tls_netif_add_status_event(netif_event); tls_netif_add_status_event(netif_event);
lock(); lock();
a = nl_wifi_sm_start(&s_sm, s_ssid[0] != '\0', nl_app_now_ms()); a = nl_wifi_sm_start(&s_sm, s_ssid[0] != '\0', standalone, pulse, nl_app_now_ms());
unlock(); unlock();
s_started = true; s_started = true;
printf("wifi: %s, hostname %s\r\n", s_ssid[0] ? "station" : "onboarding AP", s_hostname); printf("wifi: %s, hostname %s\r\n",
standalone ? "standalone AP" : (s_ssid[0] ? "station" : "onboarding AP"), s_hostname);
execute(a); execute(a);
tls_os_task_create(NULL, "nl_net", net_task, NULL, (u8 *)s_stack, sizeof(s_stack), NL_NET_PRIO, 0); tls_os_task_create(NULL, "nl_net", net_task, NULL, (u8 *)s_stack, sizeof(s_stack), NL_NET_PRIO, 0);
} }
@@ -294,6 +532,50 @@ int nl_wifi_request_onboard(const char *ssid, const char *pass)
return tls_os_queue_send(s_queue, (void *)MSG_ONBOARD, 0) == TLS_OS_SUCCESS ? 0 : -1; return tls_os_queue_send(s_queue, (void *)MSG_ONBOARD, 0) == TLS_OS_SUCCESS ? 0 : -1;
} }
int nl_wifi_request_standalone(bool enabled, const char *ssid, const char *pass,
uint8_t channel, bool pulse)
{
bool busy;
if (!s_started)
return -1;
lock();
/* Wie beim Onboarding: nicht auf einen laufenden Test draufsatteln, und keinen zweiten
* Wunsch annehmen, solange der erste noch in der Warteschlange steht - er ueberschriebe
* dessen Werte, die Antwort haette aber schon 202 gesagt. */
busy = s_sm.state == NL_WIFI_ST_ONBOARD_TEST || s_req_pending;
if (!busy) {
copy(s_req_ap_ssid, ssid ? ssid : "", sizeof s_req_ap_ssid);
copy(s_req_ap_pass, pass ? pass : "", sizeof s_req_ap_pass);
s_req_ap_channel = channel;
s_req_enabled = enabled;
s_req_pulse = pulse;
s_req_pending = true;
}
unlock();
if (busy)
return -1;
if (tls_os_queue_send(s_queue, (void *)MSG_STANDALONE, 0) != TLS_OS_SUCCESS) {
lock();
s_req_pending = false;
unlock();
return -1;
}
return 0;
}
bool nl_wifi_is_standalone(void)
{
bool r;
if (!s_started)
return false;
lock();
r = s_sm.state == NL_WIFI_ST_STANDALONE;
unlock();
return r;
}
bool nl_wifi_open_access(void) bool nl_wifi_open_access(void)
{ {
bool r; bool r;
@@ -316,6 +598,15 @@ void nl_wifi_status(nl_wifi_status_t *out)
lock(); lock();
out->mode = nl_wifi_sm_mode(&s_sm); out->mode = nl_wifi_sm_mode(&s_sm);
out->error = nl_wifi_error_name(s_sm.error); out->error = nl_wifi_error_name(s_sm.error);
/* I4: Im eigenen AP (Standalone oder Onboarding) ist die Stations-SSID nicht der Name
* des Netzes, auf dem die Lampe antwortet - und ein Heimnetzname hat in einer
* tokenfrei lesbaren Antwort ohnehin nichts verloren. Gemeldet wird der WIRKSAME
* AP-Name; damit wird auch ein Rueckfall auf andere AP-Daten ueber die API sichtbar,
* ohne ein neues Feld. "apsta" bleibt bei der Station: dort ist sie die Hauptsache. */
if (strcmp(nl_wifi_sm_mode(&s_sm), "ap") == 0)
copy(out->ssid, s_ap_live_ssid[0] ? s_ap_live_ssid : (s_ap_ssid[0] ? s_ap_ssid : s_hostname),
sizeof out->ssid);
else
copy(out->ssid, s_ssid, sizeof out->ssid); copy(out->ssid, s_ssid, sizeof out->ssid);
ap_up = s_sm.ap_up || s_sm.state == NL_WIFI_ST_AP_ONBOARDING; ap_up = s_sm.ap_up || s_sm.state == NL_WIFI_ST_AP_ONBOARDING;
unlock(); unlock();
+5
View File
@@ -28,6 +28,11 @@ void nl_app_request_factory_reset(void);
/* Stores new station credentials (from a successful onboarding test) and persists them. */ /* Stores new station credentials (from a successful onboarding test) and persists them. */
int nl_app_set_wifi_credentials(const char *ssid, const char *pass); int nl_app_set_wifi_credentials(const char *ssid, const char *pass);
/* Loescht den Standalone-Modus dauerhaft (gelungenes Onboarding aus dem eigenen AP heraus).
* Name, Passwort und Kanal des AP bleiben stehen - sie zaehlen wieder, sobald der Modus
* erneut eingeschaltet wird. */
int nl_app_clear_standalone(void);
/* Clears the power-cycle counter ahead of the regular 5 s deadline (spec 5.5), idempotent. /* Clears the power-cycle counter ahead of the regular 5 s deadline (spec 5.5), idempotent.
* Used by the boot recovery path once it knows this boot is a recovery boot: a successful * Used by the boot recovery path once it knows this boot is a recovery boot: a successful
* recovery never returns to the app task, so without this the counter would keep growing * recovery never returns to the app task, so without this the counter would keep growing
+34 -2
View File
@@ -5,9 +5,11 @@
#include "cJSON.h" #include "cJSON.h"
#include "light/nl_control.h" #include "light/nl_control.h"
#include "light/nl_dmx.h"
#include "light/nl_engine.h" #include "light/nl_engine.h"
#include "net/nl_mdns.h" #include "net/nl_mdns.h"
#include "net/nl_names.h" #include "net/nl_names.h"
#include "net/nl_sacn.h"
#include "net/nl_wifi.h" #include "net/nl_wifi.h"
#include "sys/nl_app.h" #include "sys/nl_app.h"
#include "sys/nl_app_api.h" #include "sys/nl_app_api.h"
@@ -29,6 +31,9 @@ static nl_ctrl_err_t apply(const nl_light_change_t *c)
{ {
nl_ctrl_err_t e; nl_ctrl_err_t e;
nl_app_lock(); nl_app_lock();
/* Ein Befehl aus HA oder per curl holt die Kontrolle zurueck - auch im Modus "hold",
* in dem der Override sonst stehen bliebe. */
nl_dmx_release();
e = nl_ctrl_apply(c, nl_app_now_ms()); e = nl_ctrl_apply(c, nl_app_now_ms());
nl_app_unlock(); nl_app_unlock();
return e; return e;
@@ -46,8 +51,10 @@ static void get_settings(nl_settings_t *out)
static int save_settings(const nl_settings_t *s) static int save_settings(const nl_settings_t *s)
{ {
nl_settings_t *cur; nl_settings_t *cur;
nl_settings_t applied; /* copy taken under the lock: nl_sacn_apply_settings() takes it itself */
char name[NL_NAME_LEN]; char name[NL_NAME_LEN];
bool name_changed; bool name_changed;
int r;
nl_app_lock(); nl_app_lock();
cur = nl_app_settings(); cur = nl_app_settings();
@@ -58,17 +65,29 @@ 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);
applied = *cur;
nl_app_unlock(); nl_app_unlock();
if (name_changed) if (name_changed)
nl_mdns_set_name(name); nl_mdns_set_name(name);
return nl_app_save_settings(); r = nl_app_save_settings();
if (r == 0)
nl_sacn_apply_settings(&applied); /* neues Universe/Startslot/hold sofort wirksam */
return r;
} }
static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); } static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); }
static bool open_access(void) { return nl_wifi_open_access(); } static bool open_access(void) { return nl_wifi_open_access(); }
/* Der Treiber nimmt den Wunsch nur entgegen; wirksam wird er im Netz-Task, nachdem die
* Antwort raus ist. 0 = uebernommen, sonst 503. */
static int standalone_configure(bool enabled, const char *ssid, const char *pass, uint8_t channel,
bool pulse)
{
return nl_wifi_request_standalone(enabled, ssid, pass, channel, pulse);
}
static void get_info(nl_sys_info_t *out) static void get_info(nl_sys_info_t *out)
{ {
nl_wifi_status_t w; nl_wifi_status_t w;
@@ -79,7 +98,14 @@ static void get_info(nl_sys_info_t *out)
nl_names_mac_str(mac, out->mac); nl_names_mac_str(mac, out->mac);
nl_app_lock(); nl_app_lock();
memcpy(out->name, nl_app_settings()->name, sizeof out->name); memcpy(out->name, nl_app_settings()->name, sizeof out->name);
/* Gleicher Sperrabschnitt wie der Name: nl_dmx ist nicht threadsicher, und
* nl_app_lock() ist nicht rekursiv - also hier mit erledigen statt ein zweites Mal
* zu sperren. */
out->sacn_active = nl_dmx_active();
nl_app_unlock(); nl_app_unlock();
/* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */
out->sacn_joined = nl_sacn_joined();
out->sacn_running = nl_sacn_running();
nl_wifi_status(&w); nl_wifi_status(&w);
out->wifi_mode = w.mode; out->wifi_mode = w.mode;
memcpy(out->ssid, w.ssid, sizeof out->ssid); memcpy(out->ssid, w.ssid, sizeof out->ssid);
@@ -90,6 +116,7 @@ static void get_info(nl_sys_info_t *out)
out->uptime_s = nl_app_now_ms() / 1000u; out->uptime_s = nl_app_now_ms() / 1000u;
out->free_heap = tls_mem_get_avail_heapsize(); out->free_heap = tls_mem_get_avail_heapsize();
out->last_ota_error = nl_ota_last_error(); out->last_ota_error = nl_ota_last_error();
out->wifi_standalone = nl_wifi_is_standalone();
} }
static void factory_reset(void) { nl_app_request_factory_reset(); } static void factory_reset(void) { nl_app_request_factory_reset(); }
@@ -113,12 +140,17 @@ const nl_api_ctx_t *nl_app_api_ctx(void)
cJSON_InitHooks(&hooks); cJSON_InitHooks(&hooks);
ctx.board = nl_board_get(); ctx.board = nl_board_get();
ctx.fw_version = NINLIGHT_FW_VERSION; ctx.fw_version = NINLIGHT_FW_VERSION;
/* Zeigerziel ist ein statischer Puffer im Treiber und gilt ab nl_wifi_start(), das im
* Startpfad vor dem HTTP-Server laeuft. Ohne diese Zeile dereferenziert der
* Einstellungs-Handler NULL, wenn kein eigener AP-Name gesetzt ist. */
ctx.hostname = nl_wifi_hostname();
ctx.build_date = NINLIGHT_BUILD_DATE; ctx.build_date = NINLIGHT_BUILD_DATE;
ctx.get_state = get_state; ctx.get_state = get_state;
ctx.apply = apply; ctx.apply = apply;
ctx.get_settings = get_settings; ctx.get_settings = get_settings;
ctx.save_settings = save_settings; ctx.save_settings = save_settings;
ctx.wifi_configure = wifi_configure; ctx.wifi_configure = wifi_configure;
ctx.standalone_configure = standalone_configure;
ctx.open_access = open_access; ctx.open_access = open_access;
ctx.get_info = get_info; ctx.get_info = get_info;
ctx.factory_reset = factory_reset; ctx.factory_reset = factory_reset;
+6 -1
View File
@@ -5,6 +5,7 @@
#include "wm_watchdog.h" #include "wm_watchdog.h"
#include "light/nl_control.h" #include "light/nl_control.h"
#include "light/nl_dmx.h"
#include "light/nl_engine.h" #include "light/nl_engine.h"
#include "net/nl_wifi.h" #include "net/nl_wifi.h"
#include "sys/nl_app.h" #include "sys/nl_app.h"
@@ -78,7 +79,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();
@@ -123,6 +124,10 @@ static void exec(const nl_cmd_t *c)
} }
nl_app_lock(); nl_app_lock();
/* Wie in nl_app_api.c: ein Befehl von Hand holt die Kontrolle vom sACN-Sender zurueck.
* Die serielle Konsole ist der Rettungsweg - ohne das Freigeben bliebe eine im Modus
* "hold" dunkel geparkte Lampe dunkel und meldete trotzdem "ok". */
nl_dmx_release();
err = nl_ctrl_apply(&ch, nl_app_now_ms()); err = nl_ctrl_apply(&ch, nl_app_now_ms());
nl_app_unlock(); nl_app_unlock();
if (err == NL_CTRL_OK) if (err == NL_CTRL_OK)
+11 -2
View File
@@ -94,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
if (s_error == NULL && !s_skipped) * 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)
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();
+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;
} }
+3
View File
@@ -44,6 +44,9 @@ nl_test(test_reset_counter test_reset_counter.c fake_flash.c ${NL_SRC}/config/nl
nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c) nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c)
nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c) nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c)
nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c) nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c)
nl_test(test_e131 test_e131.c ${NL_SRC}/net/nl_e131.c)
nl_test(test_dmx test_dmx.c ${NL_SRC}/light/nl_dmx.c ${NL_SRC}/light/nl_engine.c
${NL_SRC}/light/nl_color.c ${NL_SRC}/board/nl_board.c ${NL_SRC}/net/nl_e131.c)
nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c
${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c) ${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c)
target_link_libraries(test_api PRIVATE nl_cjson) target_link_libraries(test_api PRIVATE nl_cjson)
+23
View File
@@ -1,5 +1,6 @@
#include "fake_api_ctx.h" #include "fake_api_ctx.h"
#include <stdio.h>
#include <string.h> #include <string.h>
#include "light/nl_boot_state.h" #include "light/nl_boot_state.h"
@@ -12,6 +13,10 @@ nl_ctrl_err_t fake_apply_result;
int fake_apply_calls, fake_save_calls, fake_save_result; int fake_apply_calls, fake_save_calls, fake_save_result;
int fake_wifi_calls, fake_wifi_result; int fake_wifi_calls, fake_wifi_result;
char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN]; char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
int fake_standalone_calls, fake_standalone_result;
bool fake_standalone_enabled, fake_standalone_pulse;
char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
uint8_t fake_standalone_channel;
int fake_factory_calls, fake_ping_calls, fake_fw_result; int fake_factory_calls, fake_ping_calls, fake_fw_result;
char fake_fw_url[256]; char fake_fw_url[256];
bool fake_open; bool fake_open;
@@ -49,6 +54,18 @@ static int wifi_configure(const char *ssid, const char *pass)
return fake_wifi_result; return fake_wifi_result;
} }
static int fake_standalone_configure(bool enabled, const char *ssid, const char *pass,
uint8_t channel, bool pulse)
{
fake_standalone_calls++;
fake_standalone_enabled = enabled;
fake_standalone_pulse = pulse;
fake_standalone_channel = channel;
snprintf(fake_standalone_ssid, sizeof fake_standalone_ssid, "%s", ssid ? ssid : "");
snprintf(fake_standalone_pass, sizeof fake_standalone_pass, "%s", pass ? pass : "");
return fake_standalone_result;
}
static bool open_access(void) { return fake_open; } static bool open_access(void) { return fake_open; }
static void get_info(nl_sys_info_t *out) { *out = fake_info; } static void get_info(nl_sys_info_t *out) { *out = fake_info; }
static void factory_reset(void) { fake_factory_calls++; } static void factory_reset(void) { fake_factory_calls++; }
@@ -83,6 +100,10 @@ void fake_api_reset(const char *board_id)
fake_apply_calls = fake_save_calls = fake_save_result = 0; fake_apply_calls = fake_save_calls = fake_save_result = 0;
fake_wifi_calls = fake_wifi_result = 0; fake_wifi_calls = fake_wifi_result = 0;
fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0'; fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0';
fake_standalone_calls = fake_standalone_result = 0;
fake_standalone_enabled = fake_standalone_pulse = false;
fake_standalone_ssid[0] = fake_standalone_pass[0] = '\0';
fake_standalone_channel = 0;
fake_factory_calls = fake_ping_calls = 0; fake_factory_calls = fake_ping_calls = 0;
fake_fw_result = -1; fake_fw_result = -1;
fake_fw_url[0] = '\0'; fake_fw_url[0] = '\0';
@@ -96,6 +117,8 @@ void fake_api_reset(const char *board_id)
fake_ctx.get_settings = get_settings; fake_ctx.get_settings = get_settings;
fake_ctx.save_settings = save_settings; fake_ctx.save_settings = save_settings;
fake_ctx.wifi_configure = wifi_configure; fake_ctx.wifi_configure = wifi_configure;
fake_ctx.standalone_configure = fake_standalone_configure;
fake_ctx.hostname = "ninlight-681f";
fake_ctx.open_access = open_access; fake_ctx.open_access = open_access;
fake_ctx.get_info = get_info; fake_ctx.get_info = get_info;
fake_ctx.factory_reset = factory_reset; fake_ctx.factory_reset = factory_reset;
+4
View File
@@ -12,6 +12,10 @@ extern nl_ctrl_err_t fake_apply_result;
extern int fake_apply_calls, fake_save_calls, fake_save_result; extern int fake_apply_calls, fake_save_calls, fake_save_result;
extern int fake_wifi_calls, fake_wifi_result; extern int fake_wifi_calls, fake_wifi_result;
extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN]; extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
extern int fake_standalone_calls, fake_standalone_result;
extern bool fake_standalone_enabled, fake_standalone_pulse;
extern char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
extern uint8_t fake_standalone_channel;
extern int fake_factory_calls, fake_ping_calls, fake_fw_result; extern int fake_factory_calls, fake_ping_calls, fake_fw_result;
extern char fake_fw_url[256]; extern char fake_fw_url[256];
extern bool fake_open; extern bool fake_open;
+538
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\"}");
@@ -301,6 +346,149 @@ static void test_settings_name_token_auth(void)
TEST_ASSERT_EQUAL_INT(500, resp.status); TEST_ASSERT_EQUAL_INT(500, resp.status);
} }
/* --- system/settings: sacn --- */
static const cJSON *sacn_field(const char *name)
{
const cJSON *s = cJSON_GetObjectItemCaseSensitive(doc, "sacn");
return cJSON_GetObjectItemCaseSensitive(s, name);
}
static void test_settings_exposes_sacn_block(void)
{
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("enabled")));
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("universe")));
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
TEST_ASSERT_EQUAL_STRING("release", cJSON_GetStringValue(sacn_field("on_timeout")));
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
}
static void test_settings_sacn_block_reflects_stored_values(void)
{
fake_settings.sacn_enabled = 0;
fake_settings.sacn_universe = 12;
fake_settings.sacn_start_slot = 7;
fake_settings.sacn_hold = 1;
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("enabled")));
TEST_ASSERT_EQUAL_INT(12, (int)cJSON_GetNumberValue(sacn_field("universe")));
TEST_ASSERT_EQUAL_INT(7, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
TEST_ASSERT_EQUAL_STRING("hold", cJSON_GetStringValue(sacn_field("on_timeout")));
}
static void test_put_sacn_all_fields(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"sacn\",\"enabled\":false,\"universe\":300,"
"\"start_slot\":100,\"on_timeout\":\"hold\"}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_enabled);
TEST_ASSERT_EQUAL_UINT16(300, fake_settings.sacn_universe);
TEST_ASSERT_EQUAL_UINT16(100, fake_settings.sacn_start_slot);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
}
static void test_put_sacn_single_field_leaves_rest(void)
{
fake_settings.sacn_universe = 9;
fake_settings.sacn_hold = 1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"sacn\",\"start_slot\":50}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_UINT16(50, fake_settings.sacn_start_slot);
TEST_ASSERT_EQUAL_UINT16(9, fake_settings.sacn_universe);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
}
static void test_put_sacn_rejects_bad_values(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"enabled\":\"ja\"}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":0}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":64000}");
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":0}");
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"on_timeout\":\"halten\"}");
TEST_ASSERT_EQUAL_STRING("invalid on_timeout", error_text());
TEST_ASSERT_EQUAL_INT(0, fake_save_calls); /* nichts davon darf gespeichert werden */
}
/* universe/start_slot muessen ueber get_int() laufen wie jedes andere Zahlenfeld in dieser
* Datei - ein Bruchwert darf nicht klammheimlich auf einen Integer abgeschnitten werden. */
static void test_put_sacn_rejects_fractional_numbers(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":12.5}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":2.5}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
}
/* Ein Board mit RGB braucht 6 Slots: ab 508 passt der Satz nicht mehr ins Universe. */
static void test_put_sacn_start_slot_must_leave_room_for_all_slots(void)
{
fake_settings.sacn_layout = 0; /* die Vorgabe ist rgb mit 3 Slots - hier geht es um 6 */
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":507}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":508}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
}
static void test_put_sacn_start_slot_limit_follows_the_board(void)
{
fake_api_reset("g95_cw");
fake_settings.sacn_layout = 0; /* im dimmer-Layout haengt die Slotzahl am Board */
/* Ein Board ohne Farbe hat auch mit Dimmer nur drei Slots: 510 passt noch. */
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":511}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
}
static void test_info_exposes_sacn_state(void)
{
fake_info.sacn_active = true;
fake_info.sacn_joined = true;
fake_info.sacn_running = true;
fake_settings.sacn_universe = 5;
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active")));
TEST_ASSERT_EQUAL_INT(5, (int)cJSON_GetNumberValue(sacn_field("universe")));
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("multicast")));
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("running")));
}
/* Ein toter Empfangs-Task sah von aussen genauso aus wie "es sendet gerade niemand":
* beides meldete nur active=false. Beim Montana-Black-Shield (2026-09-26) hat das eine
* halbe Stunde Suche gekostet - "multicast" half nicht weiter, weil es den gemerkten
* Erfolg des LETZTEN Beitritts zeigt und auch dann true bleibt, wenn der Socket laengst
* zu ist. */
static void test_info_reports_a_dead_sacn_task(void)
{
fake_info.sacn_running = false;
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("running")));
}
/* --- system/command --- */ /* --- system/command --- */
static void test_commands(void) static void test_commands(void)
{ {
@@ -339,9 +527,330 @@ static void test_commands(void)
TEST_ASSERT_EQUAL_STRING("invalid command", error_text()); TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
} }
/* --- sacn: Belegung --- */
static void test_settings_reports_the_layout_and_matching_slots(void)
{
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
/* Vorgabe: rgb - und darin belegt auch ein Board mit Farbe nur drei Slots. */
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
fake_settings.sacn_layout = 0;
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
TEST_ASSERT_EQUAL_STRING("dimmer", cJSON_GetStringValue(sacn_field("layout")));
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(sacn_field("slots")));
}
static void test_put_sacn_layout(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"rgb\"}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_layout);
}
static void test_put_sacn_rejects_an_unknown_layout(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"pixel\"}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid layout", error_text());
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
}
/* Die Obergrenze fuer start_slot haengt am Layout. Im rgb-Layout (3 Slots) ist 510 erlaubt,
* mit Dimmer (6 Slots) nicht mehr - ein Wechsel darf einen unzulaessigen Zustand also nicht
* still herstellen. */
static void test_put_sacn_start_slot_limit_follows_the_layout(void)
{
fake_settings.sacn_layout = 1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
/* Zurueck auf dimmer wuerde 510 + 6 - 1 = 515 bedeuten: abgelehnt. */
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout); /* nichts uebernommen */
}
static void test_put_sacn_layout_and_start_slot_together(void)
{
/* Beides in einer Anfrage: geprueft wird gegen das NEUE Layout. */
fake_settings.sacn_layout = 0;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"sacn\",\"layout\":\"rgb\",\"start_slot\":510}");
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
}
/* --- standalone --- */
static void test_standalone_requires_a_password(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("password required", error_text());
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
}
/* Ein gespeichertes Passwort genuegt - man soll den Kanal aendern koennen, ohne das
* Passwort erneut zu schicken. */
static void test_standalone_keeps_a_stored_password(void)
{
strcpy(fake_settings.ap_pass, "geheim123");
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":11}");
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_INT(11, fake_standalone_channel);
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
}
static void test_standalone_rejects_a_short_password(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"1234567\"}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_STRING("invalid password", error_text());
/* Die Fehlermeldung nennt das Feld, nie den Wert. */
TEST_ASSERT_NULL(strstr(resp.body, "1234567"));
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
}
static void test_standalone_rejects_a_channel_outside_the_band(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":14}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":0}");
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
}
static void test_standalone_accepts_a_full_request(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\","
"\"ssid\":\"Wohnzimmerlampe\",\"channel\":6,\"pulse\":false}");
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_STRING("standalone", cJSON_GetStringValue(field("status")));
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
TEST_ASSERT_TRUE(fake_standalone_enabled);
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", fake_standalone_ssid);
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
TEST_ASSERT_EQUAL_INT(6, fake_standalone_channel);
TEST_ASSERT_FALSE(fake_standalone_pulse);
/* Gespeichert wird auch: sonst ist der Modus nach dem Neustart weg. */
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone);
TEST_ASSERT_EQUAL_STRING("geheim123", fake_settings.ap_pass);
}
/* Abschalten braucht kein Passwort. */
static void test_standalone_can_be_switched_off(void)
{
fake_settings.standalone = 1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":false}");
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
TEST_ASSERT_FALSE(fake_standalone_enabled);
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
}
static void test_standalone_busy_radio_answers_503_and_rolls_back(void)
{
strcpy(fake_settings.ap_pass, "geheim123");
fake_settings.standalone = 0;
fake_standalone_result = -1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
TEST_ASSERT_EQUAL_INT(503, resp.status);
TEST_ASSERT_EQUAL_STRING("wifi busy", error_text());
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
/* "nicht uebernommen" gilt auch im Flash. */
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
TEST_ASSERT_EQUAL_INT(2, fake_save_calls);
}
static void test_standalone_rollback_restores_every_changed_field(void)
{
strcpy(fake_settings.ap_pass, "altpass123");
strcpy(fake_settings.ap_ssid, "alt");
fake_settings.ap_channel = 3;
fake_settings.standalone_pulse = 1;
fake_standalone_result = -1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"neupass123\","
"\"ssid\":\"neu\",\"channel\":11,\"pulse\":false}");
TEST_ASSERT_EQUAL_INT(503, resp.status);
TEST_ASSERT_EQUAL_STRING("altpass123", fake_settings.ap_pass);
TEST_ASSERT_EQUAL_STRING("alt", fake_settings.ap_ssid);
TEST_ASSERT_EQUAL_UINT8(3, fake_settings.ap_channel);
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone_pulse);
}
static void test_standalone_failed_save_answers_500_without_switching(void)
{
strcpy(fake_settings.ap_pass, "geheim123");
fake_save_result = -1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
TEST_ASSERT_EQUAL_INT(500, resp.status);
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
}
/* Nicht angegebene Felder behalten ihren gespeicherten Wert. */
static void test_standalone_unspecified_fields_keep_their_stored_values(void)
{
strcpy(fake_settings.ap_pass, "geheim123");
strcpy(fake_settings.ap_ssid, "Gespeichert");
fake_settings.ap_channel = 11;
fake_settings.standalone_pulse = 0;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
TEST_ASSERT_EQUAL_STRING("Gespeichert", fake_standalone_ssid);
TEST_ASSERT_EQUAL_UINT8(11, fake_standalone_channel);
TEST_ASSERT_FALSE(fake_standalone_pulse);
fake_settings.standalone_pulse = 1;
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
TEST_ASSERT_TRUE(fake_standalone_pulse);
}
static void test_standalone_length_limits(void)
{
char body[256], pw[80], ss[48];
/* 63 ist die Obergrenze, nicht 64: eine WPA2-ASCII-Passphrase ist hoechstens 63 Zeichen
* lang, und ein vom Funkteil abgelehnter AP-Aufbau waere hier eine Sackgasse. */
memset(pw, 'p', 63); pw[63] = '\0';
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_STRING(pw, fake_standalone_pass);
memset(pw, 'p', 64); pw[64] = '\0';
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_NULL(strstr(resp.body, pw));
memset(pw, 'p', 65); pw[65] = '\0';
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
TEST_ASSERT_EQUAL_INT(400, resp.status);
TEST_ASSERT_NULL(strstr(resp.body, pw));
memset(ss, 's', 32); ss[32] = '\0';
snprintf(body, sizeof body,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"ssid\":\"%s\"}", ss);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_STRING(ss, fake_standalone_ssid);
memset(ss, 's', 33); ss[33] = '\0';
snprintf(body, sizeof body,
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"ssid\":\"%s\"}", ss);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
TEST_ASSERT_EQUAL_INT(400, resp.status);
}
static void test_standalone_accepts_the_band_edges(void)
{
strcpy(fake_settings.ap_pass, "geheim123");
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":13}");
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_UINT8(13, fake_standalone_channel);
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":1}");
TEST_ASSERT_EQUAL_INT(202, resp.status);
TEST_ASSERT_EQUAL_UINT8(1, fake_standalone_channel);
}
static void test_standalone_rejects_bad_types_of_the_other_fields(void)
{
strcpy(fake_settings.ap_pass, "geheim123");
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":\"yes\"}");
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"pulse\":1}");
TEST_ASSERT_EQUAL_STRING("invalid pulse", error_text());
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"ssid\":\"\"}");
TEST_ASSERT_EQUAL_STRING("invalid ssid", error_text());
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
}
/* Das AP-Passwort darf nirgends in der Antwort auftauchen. */
static void test_settings_show_standalone_without_the_password(void)
{
cJSON *o, *sa;
fake_settings.standalone = 1;
fake_settings.ap_channel = 11;
fake_settings.standalone_pulse = 0;
strcpy(fake_settings.ap_ssid, "Wohnzimmerlampe");
strcpy(fake_settings.ap_pass, "geheim123");
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
o = cJSON_Parse(resp.body);
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "enabled")));
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe",
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
TEST_ASSERT_EQUAL_INT(11, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "pulse")));
cJSON_Delete(o);
}
/* Ohne eigenen Namen zeigt die Antwort den WIRKSAMEN, nicht einen leeren String -
* sonst muesste jeder Aufrufer die Vorgabe selbst nachbilden. */
static void test_settings_show_the_effective_ap_name(void)
{
cJSON *o, *sa;
fake_settings.ap_ssid[0] = '\0';
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
o = cJSON_Parse(resp.body);
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
TEST_ASSERT_EQUAL_STRING("ninlight-681f",
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
/* Kanal 0 im Datensatz heisst 6. */
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
cJSON_Delete(o);
}
/* "ap" allein reicht nicht: denselben Wert meldet der Onboarding-AP. Ohne dieses Feld
* boete die App einer fertig eingerichteten Lampe ein Onboarding an. */
static void test_info_tells_standalone_from_onboarding(void)
{
cJSON *o, *w;
fake_info.wifi_mode = "ap";
fake_info.wifi_standalone = true;
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
o = cJSON_Parse(resp.body);
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
cJSON_Delete(o);
fake_info.wifi_standalone = false;
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
o = cJSON_Parse(resp.body);
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
cJSON_Delete(o);
}
int main(void) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_settings_reports_the_layout_and_matching_slots);
RUN_TEST(test_put_sacn_layout);
RUN_TEST(test_put_sacn_rejects_an_unknown_layout);
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_layout);
RUN_TEST(test_put_sacn_layout_and_start_slot_together);
RUN_TEST(test_unknown_path_and_method); RUN_TEST(test_unknown_path_and_method);
RUN_TEST(test_get_state); RUN_TEST(test_get_state);
RUN_TEST(test_white_board_capabilities); RUN_TEST(test_white_board_capabilities);
@@ -350,14 +859,43 @@ 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_settings_exposes_sacn_block);
RUN_TEST(test_settings_sacn_block_reflects_stored_values);
RUN_TEST(test_put_sacn_all_fields);
RUN_TEST(test_put_sacn_single_field_leaves_rest);
RUN_TEST(test_put_sacn_rejects_bad_values);
RUN_TEST(test_put_sacn_rejects_fractional_numbers);
RUN_TEST(test_put_sacn_start_slot_must_leave_room_for_all_slots);
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_board);
RUN_TEST(test_info_exposes_sacn_state);
RUN_TEST(test_info_reports_a_dead_sacn_task);
RUN_TEST(test_commands); RUN_TEST(test_commands);
RUN_TEST(test_standalone_requires_a_password);
RUN_TEST(test_standalone_keeps_a_stored_password);
RUN_TEST(test_standalone_rejects_a_short_password);
RUN_TEST(test_standalone_rejects_a_channel_outside_the_band);
RUN_TEST(test_standalone_accepts_a_full_request);
RUN_TEST(test_standalone_can_be_switched_off);
RUN_TEST(test_standalone_busy_radio_answers_503_and_rolls_back);
RUN_TEST(test_standalone_rollback_restores_every_changed_field);
RUN_TEST(test_standalone_failed_save_answers_500_without_switching);
RUN_TEST(test_standalone_unspecified_fields_keep_their_stored_values);
RUN_TEST(test_standalone_length_limits);
RUN_TEST(test_standalone_accepts_the_band_edges);
RUN_TEST(test_standalone_rejects_bad_types_of_the_other_fields);
RUN_TEST(test_settings_show_standalone_without_the_password);
RUN_TEST(test_settings_show_the_effective_ap_name);
RUN_TEST(test_info_tells_standalone_from_onboarding);
return UNITY_END(); return UNITY_END();
} }
+31 -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++) {
@@ -101,6 +116,16 @@ static void test_known_values_from_old_firmware(void)
TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch); TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch);
} }
static void test_dmx_slots(void)
{
TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("gu10_rgb"), false));
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"), false));
TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("evk"), false));
/* Im rgb-Layout belegt JEDES Board drei Slots - eine Lampe ist ein Pixel. */
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("gu10_rgb"), true));
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"), true));
}
int main(void) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -110,5 +135,6 @@ int main(void)
RUN_TEST(test_pwm_boards_consistent); RUN_TEST(test_pwm_boards_consistent);
RUN_TEST(test_sm_boards_consistent); RUN_TEST(test_sm_boards_consistent);
RUN_TEST(test_known_values_from_old_firmware); RUN_TEST(test_known_values_from_old_firmware);
RUN_TEST(test_dmx_slots);
return UNITY_END(); return UNITY_END();
} }
+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();
} }
+412
View File
@@ -0,0 +1,412 @@
#include <string.h>
#include "unity.h"
#include "board/nl_board.h"
#include "light/nl_dmx.h"
#include "light/nl_engine.h"
static uint8_t last_ch[NL_CH_COUNT];
static void sink(const uint8_t ch[NL_CH_COUNT], void *ctx)
{
(void)ctx;
memcpy(last_ch, ch, sizeof last_ch);
}
static uint8_t slot_data[16];
/* Baut ein Paket aus slot_data und speist es ein - als Funktion, damit kein Aufrufer
* die Adresse eines Rueckgabewerts nehmen muss. */
static void feed(uint8_t prio, uint8_t seq, bool terminated, uint16_t count, uint32_t now_ms)
{
nl_e131_packet_t p;
p.universe = 1;
p.priority = prio;
p.sequence = seq;
p.terminated = terminated;
p.slot_count = count;
p.slots = slot_data;
nl_dmx_feed(&p, now_ms);
}
static void start(const char *board_id, uint16_t start_slot, bool hold)
{
const nl_board_t *b = nl_board_find(board_id);
nl_engine_cfg_t cfg;
nl_dmx_cfg_t dc;
nl_light_state_t off = { false, 100, NL_MODE_WHITE, 0, 0, 0, 3000 };
cfg.ct_min = b->ct_min;
cfg.ct_max = b->ct_max;
cfg.output = sink;
cfg.ctx = NULL;
nl_engine_init(&cfg);
nl_engine_set(&off, 0);
nl_dmx_init(b);
dc.start_slot = start_slot;
dc.hold = hold;
dc.release_fade_ms = 0;
dc.rgb_layout = false;
nl_dmx_configure(&dc);
}
/* Wie start(), aber mit der Belegung ohne Dimmer (drei Kanaele je Lampe). */
static void start_rgb(const char *board_id, uint16_t start_slot)
{
nl_dmx_cfg_t dc;
start(board_id, start_slot, false);
dc.start_slot = start_slot;
dc.hold = false;
dc.release_fade_ms = 0;
dc.rgb_layout = true;
nl_dmx_configure(&dc);
}
void setUp(void)
{
memset(slot_data, 0, sizeof slot_data);
memset(last_ch, 0, sizeof last_ch);
}
void tearDown(void) {}
static void test_first_packet_takes_over(void)
{
/* Dimmer voll, R=10 G=20 B=30 CW=40 WW=50 */
uint8_t d[6] = { 255, 10, 20, 30, 40, 50 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_dmx_active());
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(20, last_ch[NL_CH_G]);
TEST_ASSERT_EQUAL_UINT8(30, last_ch[NL_CH_B]);
TEST_ASSERT_EQUAL_UINT8(40, last_ch[NL_CH_CW]);
TEST_ASSERT_EQUAL_UINT8(50, last_ch[NL_CH_WW]);
}
static void test_master_dimmer_scales(void)
{
uint8_t d[6] = { 128, 200, 200, 200, 200, 200 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_UINT8_WITHIN(1, 100, last_ch[NL_CH_R]);
}
static void test_dimmer_zero_is_dark(void)
{
uint8_t d[6] = { 0, 255, 255, 255, 255, 255 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_dmx_active()); /* dunkel, aber sehr wohl ferngesteuert */
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_WW]);
}
static void test_board_without_rgb_uses_three_slots(void)
{
uint8_t d[3] = { 255, 77, 88 };
start("g95_cw", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 3, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(77, last_ch[NL_CH_CW]);
TEST_ASSERT_EQUAL_UINT8(88, last_ch[NL_CH_WW]);
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]);
}
static void test_start_slot_offset(void)
{
uint8_t d[9] = { 9, 9, 9, 255, 11, 22, 33, 44, 55 };
start("gu10_rgb", 4, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 9, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(11, last_ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(55, last_ch[NL_CH_WW]);
}
static void test_packet_too_short_for_our_slots_is_dropped(void)
{
uint8_t d[4] = { 255, 1, 2, 3 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 4, 1000); /* braucht 6, bekommt 4 */
nl_engine_tick(10);
TEST_ASSERT_FALSE(nl_dmx_active());
}
static void test_stale_sequence_is_dropped(void)
{
uint8_t d[6] = { 255, 10, 0, 0, 0, 0 };
uint8_t older[6] = { 255, 99, 0, 0, 0, 0 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 50, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
memcpy(slot_data, older, sizeof older);
feed(100, 40, false, 6, 1010); /* 10 zurueck: im Fenster, verwerfen */
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
feed(100, 20, false, 6, 1020); /* 30 zurueck: ausserhalb, annehmen */
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(99, last_ch[NL_CH_R]);
}
static void test_a_packet_exactly_twenty_behind_is_accepted(void)
{
/* E1.31-2018 6.7.2 verwirft "<= 0 und > -20" - bei d == -20 ist das Fenster bereits
* verlassen, das Paket gilt als neu. Ein Reviewer hat das schon einmal fuer einen
* Off-by-one gehalten; dieser Test legt fest, auf welcher Seite die Grenze liegt. */
uint8_t d[6] = { 255, 10, 0, 0, 0, 0 };
uint8_t twenty_behind[6] = { 255, 77, 0, 0, 0, 0 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 20, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
memcpy(slot_data, twenty_behind, sizeof twenty_behind);
feed(100, 0, false, 6, 1010); /* d = 0 - 20 = -20: ausserhalb des Fensters, annehmen */
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(77, last_ch[NL_CH_R]);
}
static void test_higher_priority_source_takes_over_despite_stale_looking_sequence(void)
{
/* Quelle A laeuft mit hoher Sequenznummer. Quelle B mit hoeherer Prioritaet startet mit
* einer Sequenz, die - gegen A's Zaehler geprueft - wie ein alter Wiederholer aussaehe.
* B fuehrt aber ihre eigene Zaehlung und muss trotzdem sofort uebernehmen. */
uint8_t a[6] = { 255, 10, 0, 0, 0, 0 };
uint8_t b[6] = { 255, 222, 0, 0, 0, 0 };
start("gu10_rgb", 1, false);
memcpy(slot_data, a, sizeof a);
feed(100, 50, false, 6, 1000); /* Quelle A, Prioritaet 100, Sequenz 50 */
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
memcpy(slot_data, b, sizeof b);
/* Quelle B, Prioritaet 150, Sequenz 45: gegen A's Zaehler (50) waere d = -5, also
* innerhalb des Verwerfungsfensters - trotzdem muss B sofort uebernehmen. */
feed(150, 45, false, 6, 1010);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(222, last_ch[NL_CH_R]);
}
static void test_lower_priority_is_ignored_while_streaming(void)
{
uint8_t high[6] = { 255, 10, 0, 0, 0, 0 };
uint8_t low[6] = { 255, 200, 0, 0, 0, 0 };
start("gu10_rgb", 1, false);
memcpy(slot_data, high, sizeof high);
feed(150, 1, false, 6, 1000);
nl_engine_tick(10);
memcpy(slot_data, low, sizeof low);
feed(100, 2, false, 6, 1010);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
/* Nach dem Timeout ist wieder jede Prioritaet willkommen. */
nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS + 1);
feed(100, 3, false, 6, 5000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(200, last_ch[NL_CH_R]);
}
static void test_timeout_release_returns_to_engine(void)
{
uint8_t d[6] = { 255, 255, 255, 255, 255, 255 };
start("gu10_rgb", 1, false); /* die Lampe steht auf "aus" */
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(255, last_ch[NL_CH_R]);
nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS - 1);
TEST_ASSERT_TRUE(nl_dmx_active());
nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS);
nl_engine_tick(10);
TEST_ASSERT_FALSE(nl_dmx_active());
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]); /* wieder aus, wie von nl_ctrl gesetzt */
}
static void test_timeout_hold_keeps_last_values(void)
{
uint8_t d[6] = { 255, 123, 0, 0, 0, 0 };
start("gu10_rgb", 1, true);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS + 500);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_dmx_active());
TEST_ASSERT_EQUAL_UINT8(123, last_ch[NL_CH_R]);
}
static void test_hold_keeps_darkness(void)
{
uint8_t dark[6] = { 0, 0, 0, 0, 0, 0 };
nl_light_state_t on = { true, 100, NL_MODE_WHITE, 0, 0, 0, 3000 };
start("gu10_rgb", 1, true);
nl_engine_set(&on, 0); /* HA haette die Lampe gern an */
memcpy(slot_data, dark, sizeof dark);
feed(100, 1, false, 6, 1000);
nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS + 1);
nl_engine_tick(10);
/* Genau der Fall, fuer den "hold" existiert: es bleibt dunkel. */
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_WW]);
}
static void test_terminated_bit_ends_immediately(void)
{
uint8_t d[6] = { 255, 255, 0, 0, 0, 0 };
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
feed(100, 2, true, 6, 1010);
nl_engine_tick(10);
TEST_ASSERT_FALSE(nl_dmx_active());
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]);
}
static void test_release_hands_control_back_in_both_modes(void)
{
uint8_t d[6] = { 255, 255, 0, 0, 0, 0 };
int mode;
for (mode = 0; mode < 2; mode++) {
start("gu10_rgb", 1, mode == 1);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_dmx_active());
nl_dmx_release();
nl_engine_tick(10);
TEST_ASSERT_FALSE(nl_dmx_active());
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]);
}
}
static void test_configure_during_stream_takes_effect(void)
{
uint8_t d[9] = { 255, 1, 0, 0, 0, 0, 0, 0, 0 };
nl_dmx_cfg_t dc;
start("gu10_rgb", 1, false);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 9, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(1, last_ch[NL_CH_R]);
slot_data[3] = 255; /* Slot 4 = Dimmer, wenn start_slot 4 ist */
slot_data[4] = 222; /* Slot 5 = R, wenn start_slot 4 ist */
dc.start_slot = 4;
dc.hold = false;
dc.release_fade_ms = 0;
nl_dmx_configure(&dc);
feed(100, 2, false, 9, 1010);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(222, last_ch[NL_CH_R]);
}
/* --- Belegung "rgb": drei Kanaele, kein Dimmer --- */
static void test_rgb_layout_maps_three_slots_to_colour(void)
{
uint8_t d[3] = { 10, 20, 30 };
start_rgb("gu10_rgb", 1);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 3, 1000);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_dmx_active());
TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(20, last_ch[NL_CH_G]);
TEST_ASSERT_EQUAL_UINT8(30, last_ch[NL_CH_B]);
/* Kein Dimmer heisst auch: die Weisskanaele bleiben aus, sie haben keinen Slot. */
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_CW]);
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_WW]);
}
static void test_rgb_layout_passes_full_value_through(void)
{
/* Der Fall, der mit der Dimmer-Belegung schiefging: ein rotes Effektbild eines
* pixelorientierten Werkzeugs (255,0,0) muss rot leuchten, nicht dunkel bleiben. */
uint8_t d[3] = { 255, 0, 0 };
start_rgb("gu10_rgb", 1);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 3, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(255, last_ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_G]);
}
static void test_rgb_layout_on_a_board_without_colour_uses_the_largest_value(void)
{
uint8_t d[3] = { 40, 200, 90 };
start_rgb("g95_cw", 1);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 3, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(200, last_ch[NL_CH_CW]);
TEST_ASSERT_EQUAL_UINT8(200, last_ch[NL_CH_WW]);
TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]);
}
static void test_rgb_layout_uses_start_slot(void)
{
/* Zweite Lampe im Universe: LedFx-Offset 3, also Slots 4..6. */
uint8_t d[6] = { 9, 9, 9, 11, 22, 33 };
start_rgb("gu10_rgb", 4);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 6, 1000);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(11, last_ch[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(33, last_ch[NL_CH_B]);
}
static void test_rgb_layout_needs_only_three_slots(void)
{
/* Mit Dimmer braeuchte das gu10_rgb sechs Slots und wuerde ein Paket mit dreien
* verwerfen - im rgb-Layout reichen drei. */
uint8_t d[3] = { 1, 2, 3 };
start_rgb("gu10_rgb", 1);
memcpy(slot_data, d, sizeof d);
feed(100, 1, false, 3, 1000);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_dmx_active());
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_rgb_layout_maps_three_slots_to_colour);
RUN_TEST(test_rgb_layout_passes_full_value_through);
RUN_TEST(test_rgb_layout_on_a_board_without_colour_uses_the_largest_value);
RUN_TEST(test_rgb_layout_uses_start_slot);
RUN_TEST(test_rgb_layout_needs_only_three_slots);
RUN_TEST(test_first_packet_takes_over);
RUN_TEST(test_master_dimmer_scales);
RUN_TEST(test_dimmer_zero_is_dark);
RUN_TEST(test_board_without_rgb_uses_three_slots);
RUN_TEST(test_start_slot_offset);
RUN_TEST(test_packet_too_short_for_our_slots_is_dropped);
RUN_TEST(test_stale_sequence_is_dropped);
RUN_TEST(test_a_packet_exactly_twenty_behind_is_accepted);
RUN_TEST(test_higher_priority_source_takes_over_despite_stale_looking_sequence);
RUN_TEST(test_lower_priority_is_ignored_while_streaming);
RUN_TEST(test_timeout_release_returns_to_engine);
RUN_TEST(test_timeout_hold_keeps_last_values);
RUN_TEST(test_hold_keeps_darkness);
RUN_TEST(test_terminated_bit_ends_immediately);
RUN_TEST(test_release_hands_control_back_in_both_modes);
RUN_TEST(test_configure_during_stream_takes_effect);
return UNITY_END();
}
+231
View File
@@ -0,0 +1,231 @@
#include <string.h>
#include "unity.h"
#include "net/nl_e131.h"
static uint8_t pkt[NL_E131_MAX_PACKET];
static size_t pkt_len;
static void be16(size_t off, uint16_t v) { pkt[off] = (uint8_t)(v >> 8); pkt[off + 1] = (uint8_t)v; }
static void be32(size_t off, uint32_t v)
{
pkt[off] = (uint8_t)(v >> 24); pkt[off + 1] = (uint8_t)(v >> 16);
pkt[off + 2] = (uint8_t)(v >> 8); pkt[off + 3] = (uint8_t)v;
}
/* Baut ein gueltiges Paket mit `slots` Slots, aufsteigend gefuellt (1, 2, 3, ...). */
static void build(uint16_t universe, uint8_t prio, uint8_t seq, uint8_t options, uint16_t slots)
{
pkt_len = 126u + slots;
memset(pkt, 0, sizeof pkt);
be16(0, 0x0010);
be16(2, 0x0000);
memcpy(pkt + 4, "ASC-E1.17\0\0\0", 12);
be16(16, (uint16_t)(0x7000u | (pkt_len - 16u)));
be32(18, 0x00000004u);
be16(38, (uint16_t)(0x7000u | (pkt_len - 38u)));
be32(40, 0x00000002u);
memcpy(pkt + 44, "ninlight test", 13);
pkt[108] = prio;
pkt[111] = seq;
pkt[112] = options;
be16(113, universe);
be16(115, (uint16_t)(0x7000u | (pkt_len - 115u)));
pkt[117] = 0x02;
pkt[118] = 0xa1;
be16(119, 0x0000);
be16(121, 0x0001);
be16(123, (uint16_t)(slots + 1u));
pkt[125] = 0x00; /* Start-Code */
for (uint16_t i = 0; i < slots; i++)
pkt[126 + i] = (uint8_t)(i + 1u);
}
void setUp(void) { build(1, 100, 0, 0, 6); }
void tearDown(void) {}
static void test_valid_packet(void)
{
nl_e131_packet_t p;
TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p));
TEST_ASSERT_EQUAL_UINT16(1, p.universe);
TEST_ASSERT_EQUAL_UINT8(100, p.priority);
TEST_ASSERT_EQUAL_UINT8(0, p.sequence);
TEST_ASSERT_FALSE(p.terminated);
TEST_ASSERT_EQUAL_UINT16(6, p.slot_count);
TEST_ASSERT_EQUAL_UINT8(1, p.slots[0]);
TEST_ASSERT_EQUAL_UINT8(6, p.slots[5]);
}
static void test_full_universe(void)
{
nl_e131_packet_t p;
build(1, 100, 0, 0, 512);
TEST_ASSERT_EQUAL_size_t(638, pkt_len);
TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p));
TEST_ASSERT_EQUAL_UINT16(512, p.slot_count);
}
static void test_empty_slot_set(void)
{
nl_e131_packet_t p;
build(1, 100, 0, 0, 0);
TEST_ASSERT_EQUAL_size_t(126, pkt_len);
TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p));
TEST_ASSERT_EQUAL_UINT16(0, p.slot_count);
}
static void test_terminated_bit(void)
{
nl_e131_packet_t p;
build(1, 100, 0, 0x40, 6);
TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p));
TEST_ASSERT_TRUE(p.terminated);
}
static void test_preview_bit_rejected(void)
{
nl_e131_packet_t p;
build(1, 100, 0, 0x80, 6);
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_bad_preamble(void)
{
nl_e131_packet_t p;
pkt[1] = 0x11;
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_bad_package_identifier(void)
{
nl_e131_packet_t p;
pkt[4] = 'X';
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_bad_root_vector(void)
{
nl_e131_packet_t p;
be32(18, 0x00000008u);
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_bad_framing_vector(void)
{
nl_e131_packet_t p;
be32(40, 0x00000001u); /* VECTOR_E131_EXTENDED_SYNCHRONIZATION */
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_bad_dmp_vector(void)
{
nl_e131_packet_t p;
pkt[117] = 0x03;
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_bad_address_type(void)
{
nl_e131_packet_t p;
pkt[118] = 0xa2;
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_per_channel_priority_start_code_rejected(void)
{
nl_e131_packet_t p;
pkt[125] = 0xdd;
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_truncated_length_fields(void)
{
nl_e131_packet_t p;
/* Root-Laenge behauptet mehr, als das Datagramm hergibt. */
be16(16, (uint16_t)(0x7000u | (pkt_len - 16u + 4u)));
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
build(1, 100, 0, 0, 6);
be16(38, (uint16_t)(0x7000u | (pkt_len - 38u - 2u)));
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
build(1, 100, 0, 0, 6);
be16(115, (uint16_t)(0x7000u | (pkt_len - 115u + 1u)));
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_property_value_count_mismatch(void)
{
nl_e131_packet_t p;
be16(123, 9); /* behauptet 8 Slots, das Paket traegt 6 */
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_too_short_and_too_long(void)
{
nl_e131_packet_t p;
TEST_ASSERT_FALSE(nl_e131_parse(pkt, 125, &p));
TEST_ASSERT_FALSE(nl_e131_parse(pkt, 0, &p));
build(1, 100, 0, 0, 512);
TEST_ASSERT_FALSE(nl_e131_parse(pkt, 639, &p)); /* laenger als jedes gueltige Paket */
}
static void test_universe_is_read(void)
{
nl_e131_packet_t p;
build(63999, 200, 7, 0, 6);
TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p));
TEST_ASSERT_EQUAL_UINT16(63999, p.universe);
TEST_ASSERT_EQUAL_UINT8(200, p.priority);
TEST_ASSERT_EQUAL_UINT8(7, p.sequence);
}
static void test_universe_zero_rejected(void)
{
nl_e131_packet_t p;
build(0, 100, 0, 0, 6);
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_universe_too_high_rejected(void)
{
nl_e131_packet_t p;
build(64000, 100, 0, 0, 6);
TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p));
}
static void test_group_address_per_annex_a(void)
{
/* 239.255.<hi>.<lo>. Der Fehler, den dieser Test festhaelt: stand die Rechnung direkt
* im htonl()-Aufruf, zerriss das nicht geklammerte SDK-Makro sie, und aus Universe 1
* wurde 239.255.239.255 - eine Gruppe, auf die niemand sendet. */
TEST_ASSERT_EQUAL_HEX32(0xefff0001u, nl_e131_group_addr(1));
TEST_ASSERT_EQUAL_HEX32(0xefff000cu, nl_e131_group_addr(12));
TEST_ASSERT_EQUAL_HEX32(0xefff012cu, nl_e131_group_addr(300));
TEST_ASSERT_EQUAL_HEX32(0xefff00ffu, nl_e131_group_addr(255));
TEST_ASSERT_EQUAL_HEX32(0xefff0100u, nl_e131_group_addr(256));
TEST_ASSERT_EQUAL_HEX32(0xeffff9ffu, nl_e131_group_addr(63999));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_group_address_per_annex_a);
RUN_TEST(test_valid_packet);
RUN_TEST(test_full_universe);
RUN_TEST(test_empty_slot_set);
RUN_TEST(test_terminated_bit);
RUN_TEST(test_preview_bit_rejected);
RUN_TEST(test_bad_preamble);
RUN_TEST(test_bad_package_identifier);
RUN_TEST(test_bad_root_vector);
RUN_TEST(test_bad_framing_vector);
RUN_TEST(test_bad_dmp_vector);
RUN_TEST(test_bad_address_type);
RUN_TEST(test_per_channel_priority_start_code_rejected);
RUN_TEST(test_truncated_length_fields);
RUN_TEST(test_property_value_count_mismatch);
RUN_TEST(test_too_short_and_too_long);
RUN_TEST(test_universe_is_read);
RUN_TEST(test_universe_zero_rejected);
RUN_TEST(test_universe_too_high_rejected);
return UNITY_END();
}
+85
View File
@@ -183,6 +183,86 @@ static void test_blink_max_clamped(void)
TEST_ASSERT_TRUE(nl_engine_idle()); TEST_ASSERT_TRUE(nl_engine_idle());
} }
static void test_override_takes_over_output(void)
{
nl_light_state_t on = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 3000);
uint8_t ov[NL_CH_COUNT] = { 10, 20, 30, 40, 50 };
nl_engine_set(&on, 0);
nl_engine_tick(10);
TEST_ASSERT_FALSE(nl_engine_override_active());
nl_engine_override(ov);
nl_engine_tick(10);
TEST_ASSERT_TRUE(nl_engine_override_active());
TEST_ASSERT_EQUAL_UINT8(10, last[NL_CH_R]);
TEST_ASSERT_EQUAL_UINT8(50, last[NL_CH_WW]);
}
static void test_override_clear_without_fade_is_immediate(void)
{
nl_light_state_t off = st(false, 100, NL_MODE_WHITE, 0, 0, 0, 3000);
uint8_t ov[NL_CH_COUNT] = { 255, 255, 255, 255, 255 };
nl_engine_set(&off, 0);
nl_engine_tick(10);
nl_engine_override(ov);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(255, last[NL_CH_WW]);
nl_engine_override_clear(0);
nl_engine_tick(10);
TEST_ASSERT_FALSE(nl_engine_override_active());
TEST_ASSERT_EQUAL_UINT8(0, last[NL_CH_WW]); /* die Lampe war aus, also sofort dunkel */
}
static void test_override_clear_fades_back(void)
{
nl_light_state_t off = st(false, 100, NL_MODE_WHITE, 0, 0, 0, 3000);
uint8_t ov[NL_CH_COUNT] = { 0, 0, 0, 0, 200 };
nl_engine_set(&off, 0);
nl_engine_tick(10);
nl_engine_override(ov);
nl_engine_tick(10);
nl_engine_override_clear(1000);
nl_engine_tick(500);
/* auf halber Strecke zwischen 200 und 0, mit Spielraum fuer die Rundung */
TEST_ASSERT_UINT8_WITHIN(12, 100, last[NL_CH_WW]);
nl_engine_tick(500);
TEST_ASSERT_EQUAL_UINT8(0, last[NL_CH_WW]);
TEST_ASSERT_FALSE(nl_engine_override_active());
}
static void test_blink_wins_over_override(void)
{
uint8_t ov[NL_CH_COUNT] = { 0, 0, 0, 0, 0 };
nl_engine_override(ov);
nl_engine_blink(3);
nl_engine_tick(10);
/* Blink leuchtet hell weiss, obwohl der Override alles auf 0 haelt */
TEST_ASSERT_NOT_EQUAL_UINT8(0, last[NL_CH_WW]);
}
static void test_underlying_state_survives_the_override(void)
{
nl_light_state_t on = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 3000);
uint8_t ov[NL_CH_COUNT] = { 0, 0, 0, 0, 0 };
uint8_t before;
nl_engine_set(&on, 0);
nl_engine_tick(10);
before = last[NL_CH_WW];
nl_engine_override(ov);
nl_engine_tick(2000);
nl_engine_override_clear(0);
nl_engine_tick(10);
TEST_ASSERT_EQUAL_UINT8(before, last[NL_CH_WW]);
}
int main(void) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -197,5 +277,10 @@ int main(void)
RUN_TEST(test_blink_overrides_and_restores); RUN_TEST(test_blink_overrides_and_restores);
RUN_TEST(test_pulse); RUN_TEST(test_pulse);
RUN_TEST(test_blink_max_clamped); RUN_TEST(test_blink_max_clamped);
RUN_TEST(test_override_takes_over_output);
RUN_TEST(test_override_clear_without_fade_is_immediate);
RUN_TEST(test_override_clear_fades_back);
RUN_TEST(test_blink_wins_over_override);
RUN_TEST(test_underlying_state_survives_the_override);
return UNITY_END(); return UNITY_END();
} }
+173
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)
@@ -167,10 +182,158 @@ static void test_erase(void)
TEST_ASSERT_FALSE(nl_settings_load(&out)); TEST_ASSERT_FALSE(nl_settings_load(&out));
} }
static void test_sacn_defaults(void)
{
nl_settings_t s;
TEST_ASSERT_FALSE(nl_settings_load(&s));
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_enabled);
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_universe);
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_start_slot);
TEST_ASSERT_EQUAL_UINT8(0, s.sacn_hold);
}
static void test_sacn_roundtrip(void)
{
nl_settings_t in = sample("dmx"), out;
in.sacn_enabled = 0;
in.sacn_universe = 4711;
in.sacn_start_slot = 300;
in.sacn_hold = 1;
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_enabled);
TEST_ASSERT_EQUAL_UINT16(4711, out.sacn_universe);
TEST_ASSERT_EQUAL_UINT16(300, out.sacn_start_slot);
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_hold);
}
/* Ein Datensatz aus einer Firmware VOR sACN: die vier Felder muessen als echte Vorgaben
* zurueckkommen, nicht als Null - sonst waere sACN auf jeder aktualisierten Lampe aus.
* Die Laenge ist bewusst die Literalzahl 208: das war sizeof(nl_settings_t) vor diesem
* Zweig und damit die Laenge, die auf jeder ausgelieferten Lampe im Flash steht. Ein
* offsetof() auf ein heutiges Feld wuerde mit der Struktur mitwandern und genau den Fall
* verfehlen, um den es hier geht. Das letzte Byte (207) war im 208-Byte-Satz reines
* Fuellbyte und daher 0 - es wird hier nachgestellt, denn genau dieses Byte legt sich
* beim Lesen ueber das erste neue Feld. */
static void test_record_without_sacn_gets_defaults(void)
{
nl_settings_t in = sample("alt"), out;
uint32_t crc;
nl_settings_save(&in);
{
uint16_t shorter = 208u; /* sizeof(nl_settings_t) vor sACN */
fake_flash_mem[NL_FL_SETTINGS_A + 12 + 207] = 0; /* Fuellbyte des alten Satzes */
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
}
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_STRING("alt", out.name);
TEST_ASSERT_EQUAL_STRING("ninHome", out.wifi_ssid);
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_universe);
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_start_slot);
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_hold);
}
/* Die Satzlaenge muss durch 4 teilbar bleiben, sonst laufen Schreiber und Leser beim CRC
* auseinander (siehe NL_SETTINGS_CRC_OFF in nl_settings.c). */
static void test_sacn_layout_defaults_to_rgb(void)
{
nl_settings_t s;
TEST_ASSERT_FALSE(nl_settings_load(&s));
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_layout);
}
static void test_sacn_layout_roundtrip(void)
{
nl_settings_t in = sample("layout"), out;
in.sacn_layout = 0; /* weg von der Vorgabe, sonst prueft der Test nur diese */
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_layout);
}
/* Derselbe Fallstrick wie bei sacn_enabled, eine Feldgeneration weiter: ein Satz, der von
* einer Firmware MIT sACN, aber OHNE layout geschrieben wurde, ist 216 Byte lang. Laege
* sacn_layout darunter, kaeme es als Fuellbyte dieses Satzes zurueck statt als Vorgabe. */
static void test_record_without_layout_gets_the_default(void)
{
nl_settings_t in = sample("vor-layout"), out;
uint32_t crc;
in.sacn_layout = 0; /* der abgeschnittene Satz darf diese 0 nicht zurueckliefern */
nl_settings_save(&in);
{
uint16_t shorter = 216u; /* sizeof(nl_settings_t) vor dem Layout-Feld */
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
}
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_STRING("vor-layout", out.name);
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_layout);
}
static void test_standalone_defaults(void)
{
nl_settings_t s;
TEST_ASSERT_FALSE(nl_settings_load(&s));
TEST_ASSERT_EQUAL_UINT8(0, s.standalone);
TEST_ASSERT_EQUAL_UINT8(0, s.ap_channel); /* 0 heisst "Vorgabe 6" */
TEST_ASSERT_EQUAL_UINT8(1, s.standalone_pulse);
TEST_ASSERT_EQUAL_STRING("", s.ap_ssid);
TEST_ASSERT_EQUAL_STRING("", s.ap_pass);
}
static void test_standalone_roundtrip(void)
{
nl_settings_t in = sample("ap"), out;
in.standalone = 1;
in.ap_channel = 11;
in.standalone_pulse = 0;
strcpy(in.ap_ssid, "Wohnzimmerlampe");
strcpy(in.ap_pass, "geheim123");
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_UINT8(1, out.standalone);
TEST_ASSERT_EQUAL_UINT8(11, out.ap_channel);
TEST_ASSERT_EQUAL_UINT8(0, out.standalone_pulse);
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", out.ap_ssid);
TEST_ASSERT_EQUAL_STRING("geheim123", out.ap_pass);
}
/* Ein Datensatz aus einer Firmware VOR dem Standalone-Modus: die neuen Felder muessen
* als echte Vorgaben zurueckkommen, nicht als Null. Sonst stuende standalone_pulse nach
* dem Update auf jeder Lampe auf 0 - der Startpuls waere still abgeschaltet. */
static void test_record_without_standalone_gets_defaults(void)
{
nl_settings_t in = sample("alt"), out;
uint32_t crc;
nl_settings_save(&in);
{
uint16_t shorter = 220u; /* sizeof(nl_settings_t) vor diesem Zweig */
memset(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + 217], 0, 3); /* Fuellbytes */
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
}
TEST_ASSERT_TRUE(nl_settings_load(&out));
TEST_ASSERT_EQUAL_STRING("alt", out.name);
TEST_ASSERT_EQUAL_UINT8(0, out.standalone);
TEST_ASSERT_EQUAL_UINT8(1, out.standalone_pulse);
}
static void test_record_size_stays_aligned(void)
{
TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u);
}
int main(void) 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);
@@ -180,5 +343,15 @@ int main(void)
RUN_TEST(test_ota_pending_default_and_roundtrip); RUN_TEST(test_ota_pending_default_and_roundtrip);
RUN_TEST(test_crc_offset_matches_reader); RUN_TEST(test_crc_offset_matches_reader);
RUN_TEST(test_erase); RUN_TEST(test_erase);
RUN_TEST(test_sacn_defaults);
RUN_TEST(test_sacn_roundtrip);
RUN_TEST(test_record_without_sacn_gets_defaults);
RUN_TEST(test_sacn_layout_defaults_to_rgb);
RUN_TEST(test_sacn_layout_roundtrip);
RUN_TEST(test_record_without_layout_gets_the_default);
RUN_TEST(test_record_size_stays_aligned);
RUN_TEST(test_standalone_defaults);
RUN_TEST(test_standalone_roundtrip);
RUN_TEST(test_record_without_standalone_gets_defaults);
return UNITY_END(); return UNITY_END();
} }
+284 -12
View File
@@ -10,7 +10,7 @@ void tearDown(void) {}
static void test_start_without_credentials_opens_ap(void) static void test_start_without_credentials_opens_ap(void)
{ {
a = nl_wifi_sm_start(&sm, false, 0); a = nl_wifi_sm_start(&sm, false, false, false, 0);
TEST_ASSERT_TRUE(a.start_ap); TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_TRUE(a.pulse_on); TEST_ASSERT_TRUE(a.pulse_on);
TEST_ASSERT_FALSE(a.connect); TEST_ASSERT_FALSE(a.connect);
@@ -21,7 +21,7 @@ static void test_start_without_credentials_opens_ap(void)
static void test_start_with_credentials_connects(void) static void test_start_with_credentials_connects(void)
{ {
a = nl_wifi_sm_start(&sm, true, 0); a = nl_wifi_sm_start(&sm, true, false, false, 0);
TEST_ASSERT_TRUE(a.connect); TEST_ASSERT_TRUE(a.connect);
TEST_ASSERT_FALSE(a.use_test_creds); TEST_ASSERT_FALSE(a.use_test_creds);
TEST_ASSERT_FALSE(a.start_ap); TEST_ASSERT_FALSE(a.start_ap);
@@ -33,7 +33,7 @@ static void test_start_with_credentials_connects(void)
static void test_retry_with_backoff(void) static void test_retry_with_backoff(void)
{ {
nl_wifi_sm_start(&sm, true, 0); nl_wifi_sm_start(&sm, true, false, false, 0);
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 1000); nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 1000);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).connect); TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).connect);
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000).connect); TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000).connect);
@@ -50,7 +50,7 @@ static void test_retry_with_backoff(void)
static void test_fallback_after_five_minutes(void) static void test_fallback_after_five_minutes(void)
{ {
nl_wifi_sm_start(&sm, true, 0); nl_wifi_sm_start(&sm, true, false, false, 0);
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_NOT_FOUND, 1000); nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_NOT_FOUND, 1000);
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1); a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1);
TEST_ASSERT_FALSE(a.start_ap); TEST_ASSERT_FALSE(a.start_ap);
@@ -74,7 +74,7 @@ static void test_fallback_after_five_minutes(void)
static void test_disconnect_while_connected(void) static void test_disconnect_while_connected(void)
{ {
nl_wifi_sm_start(&sm, true, 0); nl_wifi_sm_start(&sm, true, false, false, 0);
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000); nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
nl_wifi_sm_event(&sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, 100000); nl_wifi_sm_event(&sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, 100000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state); TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
@@ -86,7 +86,7 @@ static void test_disconnect_while_connected(void)
static void test_onboarding_success(void) static void test_onboarding_success(void)
{ {
nl_wifi_sm_start(&sm, false, 0); nl_wifi_sm_start(&sm, false, false, false, 0);
a = nl_wifi_sm_onboard(&sm, 5000); a = nl_wifi_sm_onboard(&sm, 5000);
TEST_ASSERT_FALSE(a.start_ap); /* already up */ TEST_ASSERT_FALSE(a.start_ap); /* already up */
TEST_ASSERT_TRUE(a.connect); TEST_ASSERT_TRUE(a.connect);
@@ -106,7 +106,7 @@ static void test_onboarding_success(void)
static void test_onboarding_failure_without_credentials(void) static void test_onboarding_failure_without_credentials(void)
{ {
nl_wifi_sm_start(&sm, false, 0); nl_wifi_sm_start(&sm, false, false, false, 0);
nl_wifi_sm_onboard(&sm, 5000); nl_wifi_sm_onboard(&sm, 5000);
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000); a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000);
TEST_ASSERT_FALSE(a.pulse_off); TEST_ASSERT_FALSE(a.pulse_off);
@@ -121,7 +121,7 @@ static void test_onboarding_failure_without_credentials(void)
static void test_onboarding_timeout(void) static void test_onboarding_timeout(void)
{ {
nl_wifi_sm_start(&sm, false, 0); nl_wifi_sm_start(&sm, false, false, false, 0);
nl_wifi_sm_onboard(&sm, 5000); nl_wifi_sm_onboard(&sm, 5000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state); TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS - 1); nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS - 1);
@@ -136,7 +136,7 @@ static void test_onboarding_connected_event_while_ap_onboarding_is_stray(void)
{ {
/* I1: fail_test() returns to AP_ONBOARDING; a late CONNECTED for the timed-out /* I1: fail_test() returns to AP_ONBOARDING; a late CONNECTED for the timed-out
* attempt must be treated as stray and disconnected, not accepted. */ * attempt must be treated as stray and disconnected, not accepted. */
nl_wifi_sm_start(&sm, false, 0); nl_wifi_sm_start(&sm, false, false, false, 0);
nl_wifi_sm_onboard(&sm, 5000); nl_wifi_sm_onboard(&sm, 5000);
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS); /* -> AP_ONBOARDING */ nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS); /* -> AP_ONBOARDING */
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 40000); a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 40000);
@@ -146,7 +146,7 @@ static void test_onboarding_connected_event_while_ap_onboarding_is_stray(void)
static void test_onboarding_while_connected_falls_back_to_old_credentials(void) static void test_onboarding_while_connected_falls_back_to_old_credentials(void)
{ {
nl_wifi_sm_start(&sm, true, 0); nl_wifi_sm_start(&sm, true, false, false, 0);
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000); nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
a = nl_wifi_sm_onboard(&sm, 2000); a = nl_wifi_sm_onboard(&sm, 2000);
TEST_ASSERT_TRUE(a.start_ap); TEST_ASSERT_TRUE(a.start_ap);
@@ -167,7 +167,7 @@ static void test_onboard_while_test_running_disconnects_stale_attempt(void)
{ {
/* M1: a repeated onboarding request while a test connect is already in flight /* M1: a repeated onboarding request while a test connect is already in flight
* must disconnect first so stale events from the previous attempt don't count. */ * must disconnect first so stale events from the previous attempt don't count. */
nl_wifi_sm_start(&sm, false, 0); nl_wifi_sm_start(&sm, false, false, false, 0);
nl_wifi_sm_onboard(&sm, 5000); nl_wifi_sm_onboard(&sm, 5000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state); TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
a = nl_wifi_sm_onboard(&sm, 6000); a = nl_wifi_sm_onboard(&sm, 6000);
@@ -185,7 +185,7 @@ static void test_error_names(void)
static void test_connect_without_any_event_retries(void) static void test_connect_without_any_event_retries(void)
{ {
nl_wifi_sm_start(&sm, true, 0); nl_wifi_sm_start(&sm, true, false, false, 0);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 29999).connect); TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 29999).connect);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 30000).connect); /* retry scheduled at 31000 */ TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 30000).connect); /* retry scheduled at 31000 */
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 31000).connect); TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 31000).connect);
@@ -202,6 +202,257 @@ static void test_connect_without_any_event_retries(void)
TEST_ASSERT_TRUE(a.connect); TEST_ASSERT_TRUE(a.connect);
} }
static void test_standalone_opens_ap_without_connecting(void)
{
a = nl_wifi_sm_start(&sm, false, true, false, 0);
TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
TEST_ASSERT_EQUAL_STRING("ap", nl_wifi_sm_mode(&sm));
}
/* Der Standalone-AP ist KEIN offener Onboarding-AP. Haengt diese Zusicherung nicht,
* koennte jeder in Funkreichweite die Lampe ohne Token flashen oder zuruecksetzen. */
static void test_standalone_is_not_open_access(void)
{
nl_wifi_sm_start(&sm, false, true, false, 0);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
}
/* Das Fenster, das der Ruhezustand nicht abdeckt: Onboarding-Test aus dem Standalone-Modus.
* Dann ist state == ONBOARD_TEST und have_creds == false - ohne !standalone in open_access()
* fiele der Tokenschutz bis zu 30 s lang, waehrend der passwortgeschuetzte AP oben steht. */
static void test_standalone_onboard_test_is_not_open_access(void)
{
nl_wifi_sm_start(&sm, false, true, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
TEST_ASSERT_FALSE(sm.have_creds);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
}
/* Gespeicherte WLAN-Daten aendern nichts: wer standalone gewaehlt hat, will keinen
* Verbindungsversuch, auch wenn von frueher noch Zugangsdaten herumliegen. */
static void test_standalone_wins_over_stored_credentials(void)
{
a = nl_wifi_sm_start(&sm, true, true, false, 0);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
}
/* Kein Rueckfall und kein Wiederholungsversuch: die Maschine darf im Standalone-Modus
* nie von selbst anfangen, ein WLAN zu suchen. */
static void test_standalone_never_falls_back(void)
{
nl_wifi_sm_start(&sm, true, true, false, 0);
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS + 60000);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_FALSE(a.start_ap);
TEST_ASSERT_FALSE(a.stop_ap);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
}
/* Ein verirrter Erfolg eines alten Versuchs darf die Lampe nicht aus ihrem AP holen. */
static void test_standalone_drops_a_stray_connect(void)
{
nl_wifi_sm_start(&sm, true, true, false, 0);
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
TEST_ASSERT_TRUE(a.disconnect);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
}
/* Der Puls ist zeitlich begrenzt, und das ist keine Kosmetik: nl_engine_pulse() legt sich
* ueber die normale Ausgabe und uebermalt auch sACN. Am 2026-10-01 am EVK beobachtet -
* ein gueltiger sACN-Strom kam an, waehrend die Lampe weiter den Puls zeigte. Ein
* dauerhafter Puls machte sACN im Standalone-Modus unbrauchbar. */
static void test_standalone_pulse_stops_by_itself(void)
{
a = nl_wifi_sm_start(&sm, false, true, true, 0);
TEST_ASSERT_TRUE(a.pulse_on);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS - 1).pulse_off);
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
/* und danach nicht noch einmal */
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS + 5000).pulse_off);
}
static void test_standalone_without_pulse_stays_dark(void)
{
a = nl_wifi_sm_start(&sm, false, true, false, 0);
TEST_ASSERT_FALSE(a.pulse_on);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
}
/* Erfolg: die Lampe wechselt ins WLAN UND verlaesst den Standalone-Modus dauerhaft.
* Ohne clear_standalone staende sie nach dem naechsten Neustart wieder im eigenen AP. */
static void test_onboarding_from_standalone_clears_the_mode(void)
{
nl_wifi_sm_start(&sm, false, true, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 2000);
TEST_ASSERT_TRUE(a.save_test_creds);
TEST_ASSERT_TRUE(a.clear_standalone);
TEST_ASSERT_FALSE(sm.standalone);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTED, sm.state);
}
/* Fehlschlag: zurueck in den EIGENEN AP, nicht in den offenen Onboarding-AP. Sonst
* verloere ein Tippfehler im WLAN-Passwort den vom Nutzer gesetzten AP-Namen samt
* Passwort und entsicherte die Lampe, weil open_access() dann wieder true waere. */
static void test_failed_onboarding_returns_to_standalone(void)
{
nl_wifi_sm_start(&sm, false, true, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
TEST_ASSERT_TRUE(sm.standalone);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
TEST_ASSERT_FALSE(a.clear_standalone);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_TRUE(a.disconnect);
TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_EQUAL(NL_WIFI_ERR_AUTH, sm.error);
}
/* Auch der Zeitablauf des Tests fuehrt zurueck in den eigenen AP. */
static void test_onboarding_timeout_returns_to_standalone(void)
{
nl_wifi_sm_start(&sm, false, true, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
a = nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_ONBOARD_TIMEOUT_MS);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
TEST_ASSERT_EQUAL(NL_WIFI_ERR_TIMEOUT, sm.error);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_TRUE(a.disconnect);
TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
}
/* Der bisherige Weg bleibt unberuehrt: ohne Standalone und ohne gespeicherte Daten
* landet ein gescheiterter Test weiterhin im offenen Onboarding-AP. */
static void test_failed_onboarding_without_standalone_opens_ap(void)
{
nl_wifi_sm_start(&sm, false, false, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_AP_ONBOARDING, sm.state);
TEST_ASSERT_TRUE(nl_wifi_sm_open_access(&sm));
}
/* Aus dem laufenden Stationsbetrieb in den eigenen AP. */
static void test_switching_to_standalone_at_runtime(void)
{
nl_wifi_sm_start(&sm, true, false, false, 0);
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
a = nl_wifi_sm_standalone(&sm, true, 2000);
TEST_ASSERT_TRUE(a.disconnect);
TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_TRUE(a.pulse_on);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
TEST_ASSERT_TRUE(sm.standalone);
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000 + NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
}
/* Zuruecknehmen MIT gespeicherten Zugangsdaten: ab ins WLAN, nicht in den offenen AP. */
static void test_leaving_standalone_with_credentials_connects(void)
{
nl_wifi_sm_start(&sm, true, true, false, 0);
a = nl_wifi_sm_leave_standalone(&sm, true, 1000);
TEST_ASSERT_TRUE(a.connect);
TEST_ASSERT_FALSE(a.start_ap);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
TEST_ASSERT_FALSE(sm.standalone);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
}
/* Zuruecknehmen OHNE Zugangsdaten: offener Onboarding-AP, damit man wieder herankommt. */
static void test_leaving_standalone_without_credentials_opens_onboarding(void)
{
nl_wifi_sm_start(&sm, false, true, false, 0);
a = nl_wifi_sm_leave_standalone(&sm, false, 1000);
TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_TRUE(a.pulse_on);
TEST_ASSERT_EQUAL(NL_WIFI_ST_AP_ONBOARDING, sm.state);
TEST_ASSERT_TRUE(nl_wifi_sm_open_access(&sm));
}
/* Laeuft beim Start eines Onboarding-Tests noch der Startpuls, raeumt onboard() ihn ab:
* pulse_off sofort, danach kein verwaister pulse_off mehr -- weder nach einem
* gescheiterten noch nach einem gelungenen Test. */
static void test_onboard_cancels_running_start_pulse(void)
{
nl_wifi_sm_start(&sm, false, true, true, 0);
a = nl_wifi_sm_onboard(&sm, 1000);
TEST_ASSERT_TRUE(a.pulse_off);
TEST_ASSERT_FALSE(sm.pulse_armed);
TEST_ASSERT_FALSE(sm.pulsing);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 6000);
TEST_ASSERT_FALSE(a.pulse_off);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 20000).pulse_off);
}
/* Der eigentliche Spec-Fall: gespeicherte Zugangsdaten UND Standalone, der Test scheitert.
* Die Lampe darf nicht in den Rueckfall (APSTA_FALLBACK) mit den alten Daten gehen und
* damit ihren eigenen AP verlassen, sondern bleibt im Standalone-AP. */
static void test_failed_onboarding_with_credentials_stays_standalone(void)
{
nl_wifi_sm_start(&sm, true, true, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_TRUE(a.disconnect);
TEST_ASSERT_TRUE(a.start_ap);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
}
static void test_onboarding_timeout_with_credentials_stays_standalone(void)
{
nl_wifi_sm_start(&sm, true, true, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
a = nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_ONBOARD_TIMEOUT_MS);
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
TEST_ASSERT_FALSE(a.connect);
TEST_ASSERT_TRUE(a.disconnect);
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
}
/* Nach gelungenem Onboarding laeuft noch der AP-Stopptimer. Schaltet man jetzt auf
* Standalone, muss er weg sein, sonst schaltet tick() den AP ab und die Lampe ist
* ueber nichts mehr erreichbar. */
static void test_standalone_cancels_pending_ap_stop(void)
{
nl_wifi_sm_start(&sm, false, false, false, 0);
nl_wifi_sm_onboard(&sm, 1000);
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 2000);
nl_wifi_sm_standalone(&sm, false, 3000);
a = nl_wifi_sm_tick(&sm, 2000 + NL_WIFI_AP_CLOSE_ONBOARD_MS);
TEST_ASSERT_FALSE(a.stop_ap);
TEST_ASSERT_TRUE(sm.ap_up);
}
/* Ein unbefristeter Puls aus dem Onboarding-AP muss beim Wechsel ohne Puls enden, denn
* im Standalone-Modus gibt es keinen Timer mehr, der ihn beendete. */
static void test_standalone_without_pulse_ends_running_pulse(void)
{
nl_wifi_sm_start(&sm, false, false, false, 0);
TEST_ASSERT_TRUE(sm.pulsing);
a = nl_wifi_sm_standalone(&sm, false, 1000);
TEST_ASSERT_TRUE(a.pulse_off);
TEST_ASSERT_FALSE(a.pulse_on);
TEST_ASSERT_FALSE(sm.pulsing);
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
}
/* onboard() aus dem Onboarding-AP laesst den unbefristeten Puls in Ruhe. */
static void test_onboard_from_ap_onboarding_keeps_pulse(void)
{
nl_wifi_sm_start(&sm, false, false, false, 0);
a = nl_wifi_sm_onboard(&sm, 1000);
TEST_ASSERT_FALSE(a.pulse_off);
TEST_ASSERT_TRUE(sm.pulsing);
}
int main(void) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -218,5 +469,26 @@ int main(void)
RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt); RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt);
RUN_TEST(test_error_names); RUN_TEST(test_error_names);
RUN_TEST(test_connect_without_any_event_retries); RUN_TEST(test_connect_without_any_event_retries);
RUN_TEST(test_standalone_opens_ap_without_connecting);
RUN_TEST(test_standalone_is_not_open_access);
RUN_TEST(test_standalone_onboard_test_is_not_open_access);
RUN_TEST(test_standalone_wins_over_stored_credentials);
RUN_TEST(test_standalone_never_falls_back);
RUN_TEST(test_standalone_drops_a_stray_connect);
RUN_TEST(test_standalone_pulse_stops_by_itself);
RUN_TEST(test_standalone_without_pulse_stays_dark);
RUN_TEST(test_onboarding_from_standalone_clears_the_mode);
RUN_TEST(test_failed_onboarding_returns_to_standalone);
RUN_TEST(test_onboarding_timeout_returns_to_standalone);
RUN_TEST(test_failed_onboarding_without_standalone_opens_ap);
RUN_TEST(test_switching_to_standalone_at_runtime);
RUN_TEST(test_leaving_standalone_with_credentials_connects);
RUN_TEST(test_leaving_standalone_without_credentials_opens_onboarding);
RUN_TEST(test_onboard_cancels_running_start_pulse);
RUN_TEST(test_failed_onboarding_with_credentials_stays_standalone);
RUN_TEST(test_onboarding_timeout_with_credentials_stays_standalone);
RUN_TEST(test_standalone_cancels_pending_ap_stop);
RUN_TEST(test_standalone_without_pulse_ends_running_pulse);
RUN_TEST(test_onboard_from_ap_onboarding_keeps_pulse);
return UNITY_END(); return UNITY_END();
} }
+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 "$@"
+12
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
+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"]
+68
View File
@@ -208,3 +208,71 @@ def test_verify_with_build_arguments_is_an_error(tmp_path):
) )
assert r.returncode != 0 assert r.returncode != 0
assert "cannot be combined" in r.stderr 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 "$@"
+367
View File
@@ -0,0 +1,367 @@
#!/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 gegen die echten Werte aus config.local.cmake
# geprueft. Lieber ein abgebrochener Release als ein Archiv mit Michaels WLAN-Passwort
# bei Dritten - dieses Archiv geht an Fremde, und wiederholen laesst sich das nicht.
#
# Gesucht wird in ZWEI Sichten:
# roh - Kopf, Trailer und die Nicht-Abbilder (README.md, onboard.sh, bruno/) sind
# unkomprimiert; eine versehentlich notierte Zeile faellt nur so auf.
# entpackt- jedes Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann (beim _ota.img
# bzw. den signierten migrate/-Bildern) 512 Byte Trailer bzw. Signatur. Im
# komprimierten Strom steht kein Klartext. Die rohe Suche allein meldete
# deshalb IMMER "sauber" - auch fuer Abzuege aus build/, die die
# Zugangsdaten nachweislich tragen. Sie hat nie etwas geprueft.
#
# Entpackt wird ab Byte 56 OHNE das Dateiende abzuschneiden: die drei Bildarten im
# Archiv haben unterschiedliche Enden (flash/_gz.img traegt gar keinen Trailer,
# ota/_ota.img einen 512-Byte-Trailer, migrate/*.img eine 512-Byte-Signatur). Ein
# festes raw[56:-512] wie in tools/station/deploy.sh - wo nur Abbilder MIT Trailer
# vorkommen - wuerde hier jedes _gz.img fuer unentpackbar halten und jeden Release
# abbrechen. decompressobj hoert am Ende des gzip-Stroms von selbst auf; was danach
# kommt, ist ohnehin schon roh geprueft.
#
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
# "dann eben ungeprueft durchlassen". Das gilt auch fuer einen abgeschnittenen
# gzip-Strom, den decompressobj sonst stillschweigend annimmt.
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
import os
import re
import sys
import zlib
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"}
# Kopflaenge des vom wm_tool erzeugten Abbilds; danach beginnt der gzip-Strom.
HEADER_LEN = 56
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()))
def unpack(raw):
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
if len(raw) <= HEADER_LEN:
raise ValueError("Datei ist kuerzer als der Kopf")
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
body = dec.decompress(raw[HEADER_LEN:])
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
# ungeprueft - und genau dort koennte das Passwort stehen.
if not dec.eof:
raise ValueError("gzip-Strom ist unvollstaendig")
return body
hits = []
broken = []
checked = 0
images = 0
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
rel = os.path.relpath(path, stage)
with open(path, "rb") as fh:
data = fh.read()
checked += 1
views = [data]
if fn.endswith(".img"):
try:
views.append(unpack(data))
except Exception as exc:
broken.append("%s (%s)" % (rel, exc))
continue
images += 1
for name, needle in needles:
if any(needle in view for view in views):
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
hits.append("%s in %s" % (name, rel))
if broken:
sys.stderr.write(
"Fehler: Abbild laesst sich nicht entpacken, also auch nicht auf Zugangsdaten "
"pruefen - nichts gepackt:\n " + "\n ".join(broken) + "\n")
sys.exit(1)
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 Dateien geprueft, davon %d Abbilder entpackt)" % (checked, images))
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"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Schickt sACN/E1.31-Pakete an eine ninlight-Lampe - fuer die Abnahme von Hand.
Aufruf:
tools/sacn_send.py --universe 1 --start-slot 1 --values 255,255,0,0,0,0 --seconds 5
tools/sacn_send.py --host 192.168.2.122 --values 255,0,255,0,0,0 --seconds 5
tools/sacn_send.py --values 255,255,255,255,255,255 --seconds 3 --terminate
Ohne --host geht es per Multicast an 239.255.<hi>.<lo>, mit --host per Unicast direkt an
die Lampe. Die Werte sind der Slot-Satz ab --start-slot: Dimmer, R, G, B, CW, WW (Boards
ohne RGB: Dimmer, CW, WW).
"""
import argparse
import socket
import struct
import time
E131_PORT = 5568
ACN_PID = b"ASC-E1.17\0\0\0"
def packet(universe, sequence, values, start_slot, priority, terminated):
slots = bytearray(512)
for i, v in enumerate(values):
slots[start_slot - 1 + i] = v
count = start_slot - 1 + len(values)
data = bytes(slots[:count])
total = 126 + count
p = bytearray(total)
struct.pack_into(">HH", p, 0, 0x0010, 0x0000)
p[4:16] = ACN_PID
struct.pack_into(">H", p, 16, 0x7000 | (total - 16))
struct.pack_into(">I", p, 18, 0x00000004)
p[22:38] = b"ninlight-abnahme" # CID, 16 Byte
struct.pack_into(">H", p, 38, 0x7000 | (total - 38))
struct.pack_into(">I", p, 40, 0x00000002)
p[44:44 + 13] = b"ninlight test"
p[108] = priority
p[111] = sequence & 0xFF
p[112] = 0x40 if terminated else 0x00
struct.pack_into(">H", p, 113, universe)
struct.pack_into(">H", p, 115, 0x7000 | (total - 115))
p[117] = 0x02
p[118] = 0xA1
struct.pack_into(">HH", p, 119, 0x0000, 0x0001)
struct.pack_into(">H", p, 123, count + 1)
p[125] = 0x00
p[126:] = data
return bytes(p)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--universe", type=int, default=1)
ap.add_argument("--start-slot", type=int, default=1)
ap.add_argument("--values", default="255,255,255,0,0,0")
ap.add_argument("--priority", type=int, default=100)
ap.add_argument("--seconds", type=float, default=5.0)
ap.add_argument("--fps", type=float, default=30.0)
ap.add_argument("--host", help="Unicast statt Multicast")
ap.add_argument("--terminate", action="store_true",
help="zum Schluss ein Paket mit gesetztem stream_terminated-Bit")
a = ap.parse_args()
values = [int(v) for v in a.values.split(",")]
dest = a.host or "239.255.%d.%d" % (a.universe >> 8, a.universe & 0xFF)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 8)
seq, deadline, step = 0, time.time() + a.seconds, 1.0 / a.fps
while time.time() < deadline:
s.sendto(packet(a.universe, seq, values, a.start_slot, a.priority, False),
(dest, E131_PORT))
seq += 1
time.sleep(step)
if a.terminate:
s.sendto(packet(a.universe, seq, values, a.start_slot, a.priority, True),
(dest, E131_PORT))
print("%d Pakete an %s:%d, Universe %d" % (seq, dest, E131_PORT, a.universe))
if __name__ == "__main__":
main()
+133
View File
@@ -11,14 +11,147 @@ Lötkolben käme.
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und 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. 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 ## Bedienung
tools/station/setup_pi.sh <pi-host> einmalig einrichten tools/station/setup_pi.sh <pi-host> einmalig einrichten
tools/station/deploy.sh <pi-host> [target] Images ausliefern tools/station/deploy.sh <pi-host> [target] Images ausliefern
ssh <pi-host> ninlight-station on einschalten ssh <pi-host> ninlight-station on einschalten
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen 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 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 — **für beide
Betriebsarten**, Migration **und Rettung**:
| Umschalterstellung | Migration | Rettung | gebaut mit | die Lampe … |
|---|---|---|---|---|
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `images/rescue-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
| `mit-wlan` | `images/migrate-wlan/` | `images/rescue-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/
Rettung**: 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.
**Der Umschalter galt bisher nur für die Migration — jetzt auch für die Rettung, und das
hat eine spürbare Folge.** Bis zu dieser Erweiterung bekam eine Rettung *immer* ein
Abbild **mit** Zugangsdaten (der flache Bestand aus `build/`). Seitdem gilt für die
Rettung dieselbe Vorgabe `ohne-wlan` wie für die Migration: **eine gerettete Lampe steht
danach im eigenen Onboarding-Zugangspunkt statt im eigenen WLAN**, bis jemand ihr eines
nennt. Das ist gewollt (dieselbe Abwägung wie oben — der teure Fehler darf nie der sein,
der ohne Zutun passiert), soll aber niemanden überraschen: die Fußzeile des Panels zeigt
den Bildsatz deshalb ausdrücklich auch im Rettungsmodus, und stellt man bewusst auf
`mit-wlan` um, gilt das für beide Betriebsarten gemeinsam.
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie
gelaufen), wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz,
und auch nicht auf einen alten, flachen Bestand direkt in `images/` (siehe unten). Der
Server antwortet mit 404 (Migration zusätzlich mit `status: noimageset`), und auf dem
Panel steht „SATZ FEHLT" — in JEDEM Modus. „Vorhanden" heißt dabei überall dasselbe:
Verzeichnis da **und** mindestens ein Image darin.
`deploy.sh` liefert alle vier Sätze aus (migrate-wlan/migrate-nocreds für die Migration,
rescue-wlan/rescue-nocreds für die Rettung). Fehlt `build-nocreds/` oder ist es für ein
Ziel unvollständig, bleibt der jeweils betroffene Satz `ohne-wlan` auf dem Pi unangetastet
(statt geleert zu werden) und der Lauf meldet, womit er entsteht.
**Rückwärtskompatibilität bewusst nicht gegeben:** vor dieser Erweiterung lagen
Rettungsabbilder flach in `images/` (immer mit Zugangsdaten). Ein Pi mit diesem alten
Bestand muss **einmal neu per `deploy.sh` bestückt werden** — der Deploy räumt den
flachen Altbestand dabei automatisch weg. Bis dahin liefert die Rettung in der Vorgabe
`ohne-wlan` sichtbar nichts aus (statt den alten Bestand mit Zugangsdaten stillschweigend
weiter zu bedienen, was den ganzen Sinn dieser Erweiterung unterlaufen würde).
## 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 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 Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht. `config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
+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 = "CONFIRM",
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("Shut down", kind="shutdown", confirm=True,
done_text="Shutting down")
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: listen", ("migrate", "on", "--listen"),
done_text="Migration: listening")
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: AUTOMATIC", ("migrate", "on"),
done_text="Migration: automatic",
confirm_title="WARNING",
confirm_details=["Flashes EVERY old Klyqa lamp",
"in range - other people's too"])
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("Switch to: with Wi-Fi", ("bildsatz", "mit-wlan"),
done_text="Image set: with Wi-Fi",
confirm_title="WARNING",
confirm_details=["Every migrated lamp receives",
"your Wi-Fi password"])
return Action("Switch to: no Wi-Fi", ("bildsatz", "ohne-wlan"),
done_text="Image set: no Wi-Fi")
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 off", ("off",), confirm=False, done_text="Station off")
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("Start recovery", ("recovery", "on"), done_text="Recovery running"),
# 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("Arm: {}".format(target), ("arm", str(target)),
done_text="Armed: {}".format(target)))
items.append(Action("Station off", ("off",), done_text="Station off"))
items.append(action_imageset(imageset))
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
items.append(Action("Refresh display", ("display", "test"), confirm=False,
done_text="Display refreshed"))
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 OFF",
"Shutting down in {} s".format(
_countdown(SHUTDOWN_HOLD_S, held)),
["Keep holding - release to cancel"])
if held >= SHORT_PRESS_MAX_S:
return _view("hold", "HOLD",
"Cancel in {} s".format(_countdown(CANCEL_HOLD_S, held)),
["Keep holding - release to cancel"])
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", "MENU", "> " + selected.label,
[a.label for a in rest] + ["tap: next, hold: select"])
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 += ["hold (1.5 s): yes", "tap: no"]
return _view("confirm", self.pending.confirm_title, self.pending.label,
details)
if self.screen == "busy" and self.busy is not None:
return _view("busy", "PLEASE WAIT", self.busy.label, [])
if self.result is not None:
text, ok = self.result
return _view("result", "DONE" if ok else "ERROR", 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()
+636 -10
View File
@@ -9,8 +9,19 @@ shift || true
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd) ROOT=$(cd "$HERE/../.." && pwd)
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py" 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}
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
# dieselbe Variable wie dort und in tools/release.sh). Hier schon gebraucht, nicht erst
# beim Signieren der Migrationsimages weiter unten: seit der Bildsatz-Umschalter auch die
# RETTUNG betrifft, braucht dieser Baum auch fuer die Rettungsabzuege (_ota.img statt
# _gz.img) einen eigenen Fundlauf, parallel zu dem fuer build/ unten.
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
declare -a FILES=() declare -a FILES=()
declare -a RESCUE_NOCREDS_FILES=()
if [ $# -gt 0 ]; then if [ $# -gt 0 ]; then
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle # Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC # anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
@@ -20,17 +31,60 @@ if [ $# -gt 0 ]; then
f="$ROOT/build/$t/ninlight_${t}_ota.img" f="$ROOT/build/$t/ninlight_${t}_ota.img"
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; } [ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
FILES+=("$f") FILES+=("$f")
# build-nocreds/ ist wie beim Signieren unten OPTIONAL/nachsichtig: ein Deploy darf
# nicht daran scheitern, dass tools/build_nocreds.sh fuer dieses Ziel noch nicht
# gelaufen ist. Fehlt die Datei, bleibt der Rettungs-Bildsatz "ohne-wlan" fuer GENAU
# dieses Ziel auf dem Pi unangetastet (sync_image_set unten arbeitet ohne --delete,
# wenn diese Liste nicht jedes Ziel aus FILES abdeckt).
nf="$ROOT/$NOCREDS_BUILD_REL/$t/ninlight_${t}_ota.img"
if [ -f "$nf" ]; then
RESCUE_NOCREDS_FILES+=("$nf")
else
echo "Hinweis: $nf fehlt - $t bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert" >&2
fi
done done
else else
FULL_SYNC=1 FULL_SYNC=1
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort) # 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; } [ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
# Derselbe Fundlauf fuer build-nocreds/ - genauso nachsichtig wie beim gezielten Deploy
# oben: fehlt der Baum komplett (tools/build_nocreds.sh nie gelaufen), bleibt
# RESCUE_NOCREDS_FILES einfach leer, kein Abbruch.
if [ -d "$ROOT/$NOCREDS_BUILD_REL" ]; then
declare -A SEEN_AT_NOCREDS=()
while IFS= read -r f; do
base=$(basename "$f")
if [ -n "${SEEN_AT_NOCREDS[$base]:-}" ]; then
echo "Fehler: $base mehrfach unter $NOCREDS_BUILD_REL gefunden - ${SEEN_AT_NOCREDS[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar." >&2
exit 1
fi
SEEN_AT_NOCREDS[$base]=$f
RESCUE_NOCREDS_FILES+=("$f")
done < <(find "$ROOT/$NOCREDS_BUILD_REL" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
fi
fi fi
echo "== Images pruefen ==" echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) =="
STAGE=$(mktemp -d) STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT trap 'rm -rf "$STAGE"' EXIT
printf '%-26s %-10s %s\n' GERAET VERSION BYTES printf '%-26s %-10s %s\n' GERAET VERSION BYTES
declare -A NINL_VERSION=()
for f in "${FILES[@]}"; do for f in "${FILES[@]}"; do
base=$(basename "$f") base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img} target=${base#ninlight_}; target=${target%_ota.img}
@@ -41,9 +95,355 @@ for f in "${FILES[@]}"; do
# shellcheck disable=SC2086 # shellcheck disable=SC2086
set -- $out set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3" printf '%-26s %-10s %s\n' "$1" "$2" "$3"
cp "$f" "$STAGE/$base" NINL_VERSION[$target]="$2"
# Landet jetzt in einem eigenen Unterverzeichnis (rescue-wlan/) statt flach in STAGE -
# server.py:image_path() folgt seit diesem Umschalter demselben Bildsatz-Umschalter
# wie migrate_dir(), und "mit-wlan" ist genau dieser Satz (siehe RESCUE_SET_DIRS dort).
mkdir -p "$STAGE/rescue-wlan"
cp "$f" "$STAGE/rescue-wlan/$base"
done done
# Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten
# Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den
# gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine
# Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie,
# ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer
# jedes Ziel gelaufen" (siehe oben).
if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then
echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) =="
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
# shellcheck disable=SC2086
set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
mkdir -p "$STAGE/rescue-nocreds"
cp "$f" "$STAGE/rescue-nocreds/$base"
done
else
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
fi
# "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz
# 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf
# dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die
# Migrationsimages weiter unten, nur ohne Signierschritt dazwischen).
RESCUE_NOCREDS_COMPLETE=1
if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then
RESCUE_NOCREDS_COMPLETE=0
else
declare -A RESCUE_NOCREDS_TARGETS=()
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
RESCUE_NOCREDS_TARGETS[$t]=1
done
for f in "${FILES[@]}"; do
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
[ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0
done
fi
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
# NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
# 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.
# Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der
# Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden:
# er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an
# migrate-nocreds/, der Rettungssatz lief ungeprueft durch.
# migrate-wlan/ und rescue-wlan/ bleiben ungeprueft: die SOLLEN die Zugangsdaten
# enthalten, das ist ihr Zweck.
#
# Gesucht wird im ENTPACKTEN Rumpf, nicht in der Datei, wie sie auf der Platte liegt:
# jedes Abbild (_ota.img wie die signierten _prod.img/_dev.img) ist 56 Byte Kopf, dann
# ein gzip-Strom, dann 512 Byte Trailer bzw. Signatur. Im komprimierten Strom steht der
# Klartext des Passworts nicht - eine Suche im Rohbyteinhalt meldete deshalb IMMER
# "sauber", auch fuer den Satz aus build/, der die Zugangsdaten nachweislich traegt. Die
# Pruefung hat bis hierhin also nie irgendetwas geprueft.
#
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
# "dann eben ungeprueft durchlassen". Genau diese Nachsicht waere der Weg, auf dem das
# Passwort am Ende doch hinausgeht.
#
# Der Pruefkoerper in tools/release.sh ist verwandt, aber nicht mehr identisch: dort
# wird ein fertiges Archiv aus lauter verschiedenen Dateiarten geprueft, hier ein
# Bildsatz aus Firmware-Abbildern.
# Als if-Zweige und nicht als "[ -d … ] && CHECK_SETS+=(…)": unter "set -e" waere eine
# solche Zeile, wenn das Verzeichnis fehlt, ein fehlgeschlagenes letztes Kommando und
# risse den Deploy ab.
declare -a CHECK_SETS=()
if [ -d "$STAGE/migrate-nocreds" ]; then CHECK_SETS+=(migrate-nocreds); fi
if [ -d "$STAGE/rescue-nocreds" ]; then CHECK_SETS+=(rescue-nocreds); fi
if [ ${#CHECK_SETS[@]} -gt 0 ]; then
echo "== Bildsaetze 'ohne-wlan' auf Zugangsdaten pruefen =="
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1
import os
import re
import sys
import zlib
local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:]
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"}
# Aufbau eines Abbilds: 56 Byte Kopf (vom wm_tool erzeugt), dann der gzip-Strom mit der
# eigentlichen Firmware, dann 512 Byte ninlight-Trailer bzw. RSA-Signatur.
HEADER_LEN = 56
TRAILER_LEN = 512
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()))
def unpack(raw):
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
if len(raw) <= HEADER_LEN + TRAILER_LEN:
raise ValueError("Datei ist kuerzer als Kopf + Trailer")
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
body = dec.decompress(raw[HEADER_LEN:-TRAILER_LEN])
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
# ungeprueft - und genau dort koennte das Passwort stehen.
if not dec.eof:
raise ValueError("gzip-Strom ist unvollstaendig")
return body
hits = []
broken = []
images = 0
for image_set in sets:
root_dir = os.path.join(stage, image_set)
for root, _dirs, files in os.walk(root_dir):
for fn in sorted(files):
path = os.path.join(root, fn)
rel = os.path.relpath(path, stage)
with open(path, "rb") as fh:
raw = fh.read()
# Durchsucht werden beide Sichten: der entpackte Rumpf (dort steht die
# Firmware) UND die Rohbytes (Kopf und Trailer werden nicht komprimiert,
# und Beidateien wie manifest.json gibt es nur roh).
views = [raw]
if fn.endswith(".img"):
images += 1
try:
views.append(unpack(raw))
except Exception as exc:
broken.append("%s (%s)" % (rel, exc))
continue
for name, needle in needles:
if any(needle in view for view in views):
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
hits.append("%s in %s" % (name, rel))
if broken:
sys.stderr.write(
"Fehler: Abbild im Bildsatz 'ohne-wlan' laesst sich nicht entpacken, also auch "
"nicht auf Zugangsdaten pruefen - nichts uebertragen:\n "
+ "\n ".join(broken) + "\n")
sys.exit(1)
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 Abbilder in %d Saetzen geprueft: %s)"
% (images, len(sets), ", ".join(sets)))
CHECKPY
else
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
fi
fi
echo "== uebertragen ==" echo "== uebertragen =="
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues # -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei # Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
@@ -54,32 +454,233 @@ echo "== uebertragen =="
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen # /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live # Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
# ausgelieferte Verzeichnis. # ausgelieferte Verzeichnis.
rsync -a -c --delete "$STAGE/" "$HOST:/tmp/ninlight-images/" # -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
rsync -a "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" 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 # Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. # ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
ssh "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE' "$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE'
set -e set -e
FULL_SYNC=$1 FULL_SYNC=$1
MIGRATE_COMPLETE=$2
NOCREDS_COMPLETE=$3
RESCUE_NOCREDS_COMPLETE=$4
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} 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" sudo install -d -m 0755 "$ROOT_DIR/images"
if [ "$FULL_SYNC" -eq 1 ]; then if [ "$FULL_SYNC" -eq 1 ]; then
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen. # Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt" # Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/" # 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-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
# hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
# Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
# unter /tmp/ninlight-images deshalb gar nicht existiert.
#
# Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die
# Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die
# Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem
# Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle
# unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt
# noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt.
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
else else
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle # Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete). # anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten" echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/" sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds \
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
fi
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
# beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
#
# Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
# seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
# fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
# tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
# geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
# frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
# diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten
# betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die
# Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren
# greift dort also einfach nicht.
sync_image_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"/"build-nocreds/ fehlt" noch "Verzeichnis
# vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
# ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
# ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
# (Fixrunde 2, Kleinigkeit).
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
fi
}
# Alle vier 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_image_set migrate-wlan "$MIGRATE_COMPLETE"
sync_image_set migrate-nocreds "$NOCREDS_COMPLETE"
# rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht
# 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie
# das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein
# Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen
# Schluesseln.
sync_image_set rescue-wlan 1
sync_image_set rescue-nocreds "$RESCUE_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
# Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege
# FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest
# dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung,
# warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die
# normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja
# nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht
# uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser
# Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv
# (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen
# gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten.
OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null)
if [ -n "$OLD_FLAT_RESCUE" ]; then
echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f
echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)"
fi fi
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" 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 0755 /tmp/ninlight-station "$BIN_PATH"
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service # 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 sudo systemctl daemon-reload
if systemctl is-active --quiet ninlight-station; then if systemctl is-active --quiet ninlight-station; then
if sudo systemctl restart ninlight-station; then if sudo systemctl restart ninlight-station; then
@@ -91,6 +692,31 @@ if systemctl is-active --quiet ninlight-station; then
else else
echo " Dienst laeuft nicht (Station ist aus)" echo " Dienst laeuft nicht (Station ist aus)"
fi 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 REMOTE
echo "fertig." 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.
+948
View File
@@ -0,0 +1,948 @@
#!/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 datetime import datetime, timezone
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"))
# Der Modus, den "recovery on"/"migrate on" zuletzt angefordert haben - fuer die
# Fusszeile GENAUSO dauerhaft gebraucht wie der Bildsatz-Umschalter oben, aus demselben
# Grund und nach demselben Muster (eigene Datei unter /var/lib, NICHT unter RUN_DIR).
#
# Fixrunde 1, Befund 3: RUN_DIR/mode taugt dafuer NICHT. Die Unit setzt
# RuntimeDirectoryPreserve=restart - das erhaelt /run/ninlight-station nur ueber einen
# "systemctl restart" hinweg (das, was "recovery on"/"migrate on" fuer einen bereits
# laufenden Dienst ausloesen). "ninlight-station off" aber STOPPT den Dienst
# (systemctl stop), und GENAU DANN raeumt systemd das RuntimeDirectory leer, "restart"
# deckt einen echten Stop nicht ab. Ein frueherer Docstring hier behauptete das
# Gegenteil ("die Datei ueberlebt das Abschalten") - das war schlicht falsch, und der
# Fehler war unauffaellig, weil eine fehlende Datei auf "recovery" faellt: genau der
# Modus, den viele Tests ohnehin erwarteten, ohne dass die Faelligkeit der Datei
# je geprueft wurde. Die Folge am Geraet waere die falsche Beruhigung, gegen die die
# Fusszeile ueberhaupt gebaut ist - "in Ordnung" vor einer geplanten Migration, obwohl
# der Migrationssatz leer ist, weil das Panel mangels Datei den Rettungssatz geprueft
# haette.
#
# write_mode() im CLI schreibt deshalb NEBEN der ephemeren RUN_DIR/mode (die
# ausschliesslich server.py's naechsten Start betrifft) auch hierher - dieselbe
# Zeichenkette, zwei Ablagen fuer zwei verschiedene Leser mit verschiedenen
# Haltbarkeitsanforderungen.
LAST_MODE_FILE = Path(os.environ.get("NINLIGHT_LAST_MODE_FILE",
"/var/lib/ninlight-station/mode"))
# 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"
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (server.py: rescue_dir()) - mit
# eigenen Verzeichnissen, denn Rettungs- und Migrationsabzuege sind verschieden hergestellt
# (siehe server.py fuer die Begruendung). Welches Paar am Panel zu pruefen ist, haengt vom
# Modus ab: _read_imageset() bekommt ihn deshalb als Parameter statt fest verdrahtet.
RESCUE_IMAGESET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-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)
# Lebenszeichen: ein kleiner Punkt, der erscheint, sobald irgendeine Anfrage hereinkommt
# (auch eine erfolglose - "notfound"/"listen"/"unknown"/"noimageset" zaehlen genauso wie
# eine erfolgreiche Auslieferung), und nach ACTIVITY_WINDOW_S Sekunden von selbst wieder
# verschwindet. Der Wunsch dahinter (Rueckmeldung eines Bedieners, 2026-09-23): bisher war
# nur am Servierzaehler ("Served: N") zu sehen, ob es geklappt HAT - bis dahin war offen,
# ob ueberhaupt jemand anklopft. server.py schreibt den Zeitpunkt der letzten Anfrage
# jeder Art schon nach status.json (Station.last_activity); hier wird daraus nur noch ein
# Ja/Nein fuer "gerade eben".
#
# Bewusst KEIN eigener Zeittakt/Timer: das Panel pollt status.json ohnehin schon alle
# POLL_INTERVAL_S Sekunden, und render.blocks() haengt den Punkt an eine bestehende
# Textzeile an (siehe dort) - er faehrt also ueber genau denselben Textvergleich
# (RefreshPlanner.decide()) mit, der auch jede andere Aenderung erkennt. Ein Erscheinen
# und ein Verschwinden kosten damit je einen gewoehnlichen Teilaufbau, keinen eigenen
# Mechanismus und kein zusaetzliches Wachhalten des E-Papers - fuegt sich also in die
# vorhandene, fuer E-Paper sparsame Taktung ein, statt eine eigene, potenziell haeufigere
# aufzumachen.
ACTIVITY_WINDOW_S = 15
def _recent_request(status: dict, now: float | None = None) -> bool:
"""War die letzte Anfrage (jeder Art) hoechstens ACTIVITY_WINDOW_S Sekunden her?
"last_request_ts" fehlt bei einer aelteren server.py-Fassung oder wenn noch nie eine
Anfrage einging - dann gilt False, kein Punkt erfunden. Ein Zeitstempel aus der
Zukunft (Uhr verstellt, oder Server- und Anzeigerechner-Uhr laufen leicht auseinander)
zaehlt ebenfalls als "nicht mehr aktuell" statt als potenziell dauerhaft aktiv."""
ts = status.get("last_request_ts")
if not isinstance(ts, str):
return False
try:
parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError:
return False
now_s = time.time() if now is None else now
age_s = now_s - parsed.timestamp()
return 0 <= age_s <= ACTIVITY_WINDOW_S
def _read_mode_file(path: Path) -> str:
"""Zuletzt angeforderter Modus laut `path`. Fehlt die Datei, ist sie unlesbar
(inklusive ungueltigem UTF-8 - eine halb geschriebene/beschaedigte Datei ist nicht
nur per fehlendem Inhalt kaputt), oder ist ihr Inhalt kein bekannter Modus, gilt
"recovery" - derselbe Vorgabewert wie in server.py:resolve_mode(). Wirft nie: eine
kaputte Datei darf weder das Panel noch "off" aufhalten.
`path` ist bewusst eine Datei, kein Verzeichnis (anders als frueher, wo hier
`run_dir / "mode"` gelesen wurde) - siehe LAST_MODE_FILE oben, warum die Fusszeile
NICHT mehr aus RUN_DIR liest."""
try:
value = path.read_text().strip()
except (OSError, UnicodeDecodeError):
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], mode: str) -> tuple[str, bool]:
"""(Name des eingestellten Bildsatzes, liegt der fuer `mode` PASSENDE Satz 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 fuer den GERADE GELTENDEN Modus nicht auf dem
Pi liegt - nach derselben Regel wie server.py:migrate_set_present()/
rescue_set_present(): Verzeichnis vorhanden UND mindestens ein Image darin. Welches
Verzeichnispaar das ist (migrate-*/rescue-*), entscheidet `mode` - Rettung und
Migration liegen seit der Erweiterung des Umschalters auf die Rettung in getrennten
Verzeichnissen (server.py begruendet das). Eine falsche Zuordnung hier wuerde das
Panel in GENAU DEM Modus luegen lassen, fuer den es gerade steht: "ok", obwohl die
Rettung nichts ausliefern kann, oder "FEHLT", obwohl sie es koennte.
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, UnicodeDecodeError):
value = ""
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
image_dir = conf.get("IMAGE_DIR")
if not image_dir:
return name, True
dirs = RESCUE_IMAGESET_DIRS if mode == "recovery" else IMAGESET_DIRS
set_dir = Path(image_dir) / dirs[name]
try:
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
except OSError:
return name, False
def _trailer_version(path: Path) -> str:
"""Firmware-Version aus dem NINL-Anhaenger eines ninlight-Abzugs, sonst "".
Nur die letzten 512 Byte werden gelesen: die Rettungsabzuege liegen als
_ota.img vor und tragen ihre Version im Anhaenger (Geraetetyp bei Offset 5,
Version bei 21) - das Entpacken des gzip-Rumpfs waere dafuer unnoetige Arbeit auf
einem Pi, der alle paar Sekunden neu zeichnet.
"""
try:
with path.open("rb") as f:
f.seek(-512, 2)
anhaenger = f.read(512)
except OSError:
return ""
if len(anhaenger) != 512 or anhaenger[0:4] != b"NINL":
return ""
return anhaenger[21:37].split(b"\x00", 1)[0].decode("ascii", errors="replace")
def delivered_version(conf: dict, imageset: str, mode: str) -> str:
"""Die Firmware-Version, die die Station gerade ausliefern wuerde, sonst "".
Quelle ist der GEWAEHLTE Bildsatz, nicht irgendein Verzeichnis: wer am Taster
zwischen "mit WLAN" und "ohne WLAN" umschaltet, bekommt sonst eine Zahl angezeigt,
die zum anderen Satz gehoert.
Im Migrationsmodus steht die Version in `manifest.json` - die Migrationsabzuege
selbst tragen keinen ninlight-Anhaenger (siehe server.py:image_version). Weichen
die Eintraege voneinander ab, ist das Ergebnis "gemischt": ein zusammengefuehrter
Deploy kann Alt-Eintraege mit anderer Version enthalten, und eine davon
herauszugreifen waere geraten. Im Rettungsmodus liegen gewoehnliche _ota.img, deren
Anhaenger die Version tragen.
Fehlt die Quelle oder ist sie unlesbar, bleibt das Ergebnis leer - dann zeigt die
Anzeige gar nichts, statt eine Version zu erfinden.
"""
image_dir = conf.get("IMAGE_DIR")
if not image_dir:
return ""
if mode == "recovery":
verzeichnis = Path(image_dir) / RESCUE_IMAGESET_DIRS.get(imageset, "")
try:
abzuege = sorted(p for p in verzeichnis.iterdir() if p.name.endswith(".img"))
except OSError:
return ""
versionen = {v for v in (_trailer_version(p) for p in abzuege) if v}
else:
pfad = Path(image_dir) / IMAGESET_DIRS.get(imageset, "") / "manifest.json"
try:
daten = json.loads(pfad.read_text())
except (OSError, ValueError):
return ""
if not isinstance(daten, dict):
return ""
versionen = set()
eintraege = daten.get("images")
if isinstance(eintraege, list):
versionen = {
e["ninlight_version"]
for e in eintraege
if isinstance(e, dict) and isinstance(e.get("ninlight_version"), str)
and e["ninlight_version"]
}
if not versionen:
oben = daten.get("ninlight_version")
versionen = {oben} if isinstance(oben, str) and oben else set()
if not versionen:
return ""
return versionen.pop() if len(versionen) == 1 else "gemischt"
def _route_lookup() -> str:
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt.
Eigene Funktion, damit die Tests sie ersetzen koennen, ohne einen Unterprozess zu
starten - und damit local_address() nur noch das Auswerten macht."""
import subprocess
try:
fertig = subprocess.run(
# Bewusst eine dokumentierte Testadresse (RFC 5737) statt eines echten Ziels
# wie 8.8.8.8: "route get" fragt nur den Kernel, es geht kein Paket hinaus,
# aber es soll auch beim Mitlesen nichts Fremdes anklingen.
["ip", "-4", "route", "get", "192.0.2.1"],
capture_output=True, text=True, timeout=2, check=False)
except (OSError, subprocess.SubprocessError):
return ""
return fertig.stdout if fertig.returncode == 0 else ""
def local_address() -> tuple[str, str]:
"""(Geraet, Adresse), unter der der Pi im Heimnetz erreichbar ist - ("", "") ohne Netz.
Ueber "ip route get", NICHT ueber einen verbundenen UDP-Socket: die Unit erlaubt dem
Anzeigedienst nur AF_UNIX und AF_NETLINK (ninlight-display.service:
RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort mit EAFNOSUPPORT, und
genau daran ist die Adresszeile am echten Panel gescheitert, waehrend derselbe
Aufruf von Hand - ohne Sandbox - die Adresse lieferte (Rueckmeldung 2026-09-23).
"ip" spricht Netlink und laeuft damit innerhalb der Erlaubnis. Der Unterprozess
kostet einen fork alle POLL_INTERVAL_S Sekunden und faellt nur im Aus-Zustand an,
dem einzigen Statusbild mit dieser Zeile.
Das Geraet kommt mit, weil "LAN" am Panel eine Falschauskunft war: dieser Pi haengt
per WLAN im Netz, nicht am Kabel. Der Kernel nennt in derselben Auskunft beides -
"192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000".
Wirft nie: ohne Route gibt es nichts auszuwerten, dann liefert der Aufruf ("", "")
und die Anzeige sagt das - eine Ausnahme aus der Adressermittlung darf das Panel
nicht aufhalten."""
worte = _route_lookup().split()
geraet = adresse = ""
for i, w in enumerate(worte):
if w == "dev" and i + 1 < len(worte) and not geraet:
geraet = worte[i + 1]
elif w == "src" and i + 1 < len(worte) and not adresse:
adresse = worte[i + 1]
# Ohne Adresse ist auch der Geraetename wertlos - er wuerde ein Netz vortaeuschen,
# das nicht steht.
return (geraet, adresse) if adresse else ("", "")
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME,
imageset_path: Path = IMAGESET_FILE,
last_mode_path: Path = LAST_MODE_FILE) -> dict:
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
Modus kommt vorrangig aus `last_mode_path` (Vorgabe "recovery", wie
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
zuletzt geschrieben haben, dauerhaft (siehe LAST_MODE_FILE oben - NICHT die ephemere
RUN_DIR/mode, die server.py fuer den naechsten Start liest) 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.
Ob "imageset_ok" den Rettungs- oder den Migrations-Bildsatz prueft, richtet sich nach
dem MODUS, in dem die Station beim naechsten Einschalten anlaufen wuerde - also
`last_mode_path`, nicht danach, ob sie gerade laeuft. Deshalb wird der Modus HIER,
vor der "ist der Dienst aktiv"-Abfrage, gelesen: er ist auch im Zustand "off"
verfuegbar (die Datei ist dauerhaft, siehe LAST_MODE_FILE) und genau dort soll die
Vorschau ja gelten - "off" zeigt ja noch keinen Modus im engeren Sinn, aber der
naechste Start faehrt in genau diesen Modus, und die Fusszeile soll VORHER zeigen,
ob dessen Bildsatz bereitliegt.
"activity" (nur in Recovery/Migration, siehe _recent_request()) sagt, ob die letzte
Anfrage jeder Art hoechstens ACTIVITY_WINDOW_S Sekunden her ist - das Lebenszeichen,
das render.blocks() an die Servierzeile anhaengt."""
conf = _read_conf(conf_path)
mode = _read_mode_file(last_mode_path)
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
if not _service_active(service_name):
# Die IP-Zeile gibt es NUR hier: ist die Station aus, ist der Pi ein gewoehnlicher
# Rechner im Heimnetz, und die einzige Auskunft, die man dann am Panel braucht,
# ist die Adresse, unter der man per SSH drankommt. Im Betrieb waere sie sinnlos
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
geraet, adresse = local_address()
# Auch im Aus-Zustand: welche Version die Station ausliefern WUERDE, ist genau
# dann interessant, wenn man sie noch nicht eingeschaltet hat.
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
"ip": adresse, "iface": geraet,
"image_version": delivered_version(conf, imageset, "migration")}
status = _read_json(run_dir / "status.json")
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
"imageset": imageset, "imageset_ok": imageset_ok,
"activity": _recent_request(status),
"image_version": delivered_version(conf, imageset, mode)}
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 _safe_read_state(read_state, fallback: dict) -> dict:
"""Ruft `read_state()` auf und faengt JEDE Ausnahme ab - nicht nur die, die die
einzelnen build_state()-Bausteine schon selbst abfangen (siehe _read_json() etc.
oben). run_daemon() laeuft als Endlosschleife, und main() fängt darum nur
(DisplayUnavailable, ImportError) ab; ein einzelner, unvorhergesehener Fehler beim
Erheben wuerde diese Schleife sonst beenden - und weil E-Paper sein zuletzt
gezeichnetes Bild dauerhaft behaelt, saehe das Panel einfach so aus, als sei nichts
passiert, nur eben fuer immer eingefroren. `fallback` (der vorherige Zustand, oder
beim allerersten Aufruf ein neutraler Platzhalter) gilt dann fuer diesen einen Takt;
beim naechsten wird es erneut versucht."""
try:
return read_state()
except Exception as e: # noqa: BLE001 - bewusst pauschal, siehe Docstring
log.warning("Zustand konnte nicht gelesen werden, vorheriger Stand bleibt: %s", e)
return fallback
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 = _safe_read_state(read_state, {"mode": None})
last_state_poll = clock.monotonic()
while True:
now = clock.monotonic()
if now - last_state_poll >= POLL_INTERVAL_S:
# Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler
# schon ab (siehe _read_json/_read_conf/_service_active/local_address oben) - aber
# main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError)
# ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim
# Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese
# Endlosschleife sonst ganz beenden - und E-Paper behaelt sein zuletzt
# gezeichnetes Bild fuer immer (siehe Modul-Docstring): die Anzeige wuerde
# dann OHNE jede Fehlermeldung am Geraet einfach stehen bleiben, ununterscheidbar
# von einem echten, andauernden Zustand. read_state() bleibt deshalb hier
# abgesichert - ein einzelner Fehlschlag ueberspringt genau diese Erhebung
# (der vorherige Zustand bleibt stehen) statt den ganzen Dienst zu beenden;
# beim naechsten Takt in POLL_INTERVAL_S wird es erneut versucht.
base_state = _safe_read_state(read_state, base_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
+806
View File
@@ -0,0 +1,806 @@
"""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"
# Rechtsbuendig in DERSELBEN Zeile wie die zuletzt gezeichnete Detailzeile.
# Genau ein Block benutzt sie: die ausgelieferte Firmware-Version neben der
# Adresse im Aus-Zustand. Eigene Rolle statt einer zweiten Zeile, weil der
# Platz auf 250x122 knapp ist und beide Angaben zusammengehoeren.
ROLE_DETAIL_RIGHT = "detail_right"
ROLE_HERO = "hero"
ROLE_DETAIL = "detail"
ROLE_BIG = "big"
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": "RECOVERY", "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 = "ARMED"
# Praefix der Zeile mit der ausgelieferten Version - grenzt sie von "FW:" ab, das die
# Version der erkannten Lampe traegt.
DELIVERS_PREFIX = "liefert: "
ARMED_DETAIL_PREFIX = "Armed: "
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
# 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": "no Wi-Fi data (onboarding AP)",
"mit-wlan": "WITH Wi-Fi data (own network)",
}
# 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": "SET MISSING: no Wi-Fi data",
"mit-wlan": "SET MISSING: WITH Wi-Fi data",
}
# 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": ("POWERED OFF", "Safe to unplug", []),
"reboot": ("REBOOTING", "Do NOT unplug!",
["The Pi is back in about 2 min.",
"This screen stays until then."]),
}
# 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 [("Shutting down", 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 off", ROLE_BIG))
# Die Adresse, unter der der Pi gerade erreichbar ist - die einzige Auskunft, die
# man bei ausgeschalteter Station am Geraet wirklich braucht (per SSH draufgehen,
# ohne den Router zu befragen). display.py legt sie nur in DIESEN Zustand; fehlt
# das Feld (aeltere display.py-Fassung, von Hand gebauter Zustand im Test), bleibt
# die Zeile weg statt eine Adresse zu erfinden. Ist das Feld da, aber leer, sagt
# die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz"
# sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert.
#
# Beschriftet mit dem GERAET ("wlan0: 192.168.2.96"), nicht mit "LAN": derselbe
# Bediener las "LAN" als Falschauskunft, weil dieser Pi per WLAN im Netz haengt
# und gar kein Kabel hat (Rueckmeldung 2026-09-23). Der Geraetename grenzt
# ausserdem genauso eindeutig vom Zugangspunkt-Netz ab, das auf jedem ANDEREN
# Statusbild als SSID steht - und er sagt zusaetzlich, WORUEBER der Pi haengt.
# Ohne Netz "no home network" statt eines nackten "no network": letzteres wurde
# auf den Migrations-/Rettungs-Zugangspunkt bezogen (der im Aus-Zustand ja
# tatsaechlich aus ist). Gemeint ist das GEGENTEIL - das gewoehnliche Heimnetz.
ip = state.get("ip")
if isinstance(ip, str):
geraet = state.get("iface")
if not ip:
out.append(("no home network", ROLE_DETAIL))
elif isinstance(geraet, str) and geraet:
out.append((f"{geraet}: {ip}", ROLE_DETAIL))
else:
out.append((f"IP: {ip}", ROLE_DETAIL))
out += _delivered_version_blocks(state, ROLE_DETAIL_RIGHT)
return out + footer
if mode not in ("recovery", "migration"):
# 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 unknown ({mode})" if mode else "Status unknown", ROLE_BIG))
return out + footer
out.append((MODE_LABELS[mode], ROLE_HEADER))
ssid = state.get("ssid")
if mode == "recovery":
out += _delivered_version_blocks(state, ROLE_HEADER_PHASE)
# Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
# Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
# wie vor der zweiten Hardware-Abnahme, siehe dort).
#
# 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))
# Beschriftet und unmittelbar hinter "FW:", weil beides Versionsnummern sind
# und nebeneinander sonst niemand weiss, welche wem gehoert: "FW:" ist die
# der erkannten LAMPE, "liefert:" die des Bildsatzes. Nicht in die Kopfzeile:
# dort steht im Migrationsmodus die Phase, und neben dem Modus bleiben nur
# rund 112 px - daran ist schon "FREIGEGEBEN: <ziel>" gescheitert.
for text, _ in _delivered_version_blocks(state, ROLE_DETAIL):
out.append((f"{DELIVERS_PREFIX}{text}", ROLE_DETAIL))
if ssid and armed_target is None:
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
# SSID zweimal auf dem Panel.
#
# 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-off in {} min".format(autooff_minutes), ROLE_DETAIL))
else:
out.append(("Auto-off pending", ROLE_DETAIL))
served = state.get("served")
served_line = f"Served: {served if isinstance(served, int) else 0}"
# Lebenszeichen: ein kleiner Punkt, sobald IRGENDEINE Anfrage vor kurzem hereinkam
# (display.py:_recent_request() - auch eine erfolglose zaehlt). Angehaengt an eine
# bereits bestehende, immer vorhandene Zeile statt an einer eigenen Stelle im Bild
# gezeichnet: er braucht dadurch keine eigene Zeichenlogik in compose() und keinen
# eigenen Platz im knappen Zeilenbudget, und er faehrt ueber denselben
# Textvergleich (RefreshPlanner.decide() in display.py) mit, der ueber ein
# Neuzeichnen entscheidet - kein zusaetzlicher Takt, kein zusaetzliches Wachhalten
# des E-Papers, siehe display.py:ACTIVITY_WINDOW_S fuer die volle Begruendung.
if state.get("activity"):
served_line += " •"
out.append((served_line, 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 _delivered_version_blocks(state: dict, role: str) -> list[tuple[str, str]]:
"""Die Version, die die Station ausliefert - oder [], wenn sie unbekannt ist.
Bewusst keine Ersatzanzeige: eine erfundene oder veraltete Versionsnummer ist
schlimmer als keine, denn sie beantwortet genau die Frage falsch, fuer die sie da
ist. display.py liefert "" , wenn manifest.json bzw. die Abzuege fehlen.
"""
version = state.get("image_version")
if not isinstance(version, str) or not version:
return []
return [(version, role)]
def _imageset_blocks(state: dict) -> list[tuple[str, str]]:
"""Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt
(aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test).
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(("Button menu", 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_DETAIL_RIGHT: (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
# Oberkante und rechter Rand der zuletzt gezeichneten Flusszeile - ROLE_DETAIL_RIGHT
# setzt seinen Text daneben.
letzte_zeile_oben: int | None = None
letzte_zeile_rechts = 0.0
for raw_text, role in block_list:
font = font_for(role)
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
if role == ROLE_DETAIL_RIGHT:
# Rechtsbuendig neben der zuletzt gezeichneten Zeile, gemessen ab deren
# tatsaechlichem rechten Rand - wie die Phase neben dem Modus. Ohne
# vorangehende Zeile (von Hand zusammengesetzter Zustand im Test) oder bei
# zu wenig Platz entfaellt die Angabe: ein Ueberlappen waere schlimmer als
# eine fehlende Zusatzauskunft.
if letzte_zeile_oben is None:
continue
frei_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - letzte_zeile_rechts
- HEADER_PHASE_GAP_PX)
if frei_px >= HEADER_PHASE_MIN_WIDTH_PX:
text = fit(raw_text, font, width_px=frei_px)
_draw_text(draw, text, font, top=letzte_zeile_oben,
height=DETAIL_LINE_HEIGHT_PX, fill=0, align="right")
continue
text = fit(raw_text, font)
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
if y + line_height > flow_limit_px:
# 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)
letzte_zeile_oben = y
letzte_zeile_rechts = TEXT_MARGIN_PX + draw.textlength(text, font=font)
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
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -20,7 +20,7 @@ StartLimitBurst=5
[Service] [Service]
Type=simple Type=simple
EnvironmentFile=/etc/ninlight-station.conf EnvironmentFile=/etc/ninlight-station.conf
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} 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 Restart=always
RestartSec=2 RestartSec=2
@@ -32,6 +32,18 @@ ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
RuntimeDirectory=ninlight-station 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 ReadWritePaths=/run/ninlight-station
RestrictAddressFamilies=AF_INET AF_UNIX RestrictAddressFamilies=AF_INET AF_UNIX
MemoryMax=128M MemoryMax=128M
+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)
+568 -33
View File
@@ -1,15 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""ninlight recovery station. """ninlight recovery/migration station.
Serves firmware images to lamps that look for a rescue station while booting. Deliberately Serves firmware images to lamps that look for a rescue station while booting (mode
minimal: it hands out nothing but files whose name matches ninlight_<target>_ota.img, so a "recovery": ninlight_<target>_ota.img) or to old Klyqa lamps looking for their original
stray file in the image directory can never leave the machine. 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. Standard library only - the Pi gets no extra packages.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import contextlib
import json import json
import os import os
import re import re
@@ -23,7 +26,15 @@ from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw") # 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$") PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
TRAILER_SIZE = 512 TRAILER_SIZE = 512
TRAILER_MAGIC = b"NINL" TRAILER_MAGIC = b"NINL"
@@ -52,42 +63,396 @@ def trailer_version(path: Path) -> str:
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace") 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"
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (nicht nur die Migration): der
# Eigentuemer hat das ausdruecklich verlangt, denn bisher bekam eine Rettung IMMER ein
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ stammten aus build/). Die
# Rettungsabzuege OHNE Zugangsdaten kommen aus tools/build_nocreds.sh, genau wie ihr
# Migrations-Gegenstueck - deshalb dasselbe Namensschema, nur mit "rescue-" statt
# "migrate-" (deploy.sh legt sie entsprechend ab, siehe dort).
#
# Zwei getrennte Verzeichnispaare (migrate-*/rescue-*) statt einem gemeinsamen: die
# Migrationsabzuege sind signiert und tragen ein eigenes manifest.json, die
# Rettungsabzuege sind die gewoehnlichen _ota.img mit ninlight-Trailer. Sie
# unterscheiden sich in Form und Herstellung; ein gemeinsames Verzeichnis wuerde beide
# Herstellungswege ununterscheidbar ineinander mischen (siehe auch: "migrate-wlan/ und
# migrate-nocreds/ werden bei der Rettung nie angefasst" im Auftrag).
RESCUE_SET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
def read_image_set(path: Path | None) -> str:
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
ist (inklusive ungueltigem UTF-8 - Fixrunde 1, Kleinigkeit: ein reines "except
OSError" liess eine Datei mit kaputten Bytes durchschlagen statt auf die Vorgabe zu
fallen) 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, UnicodeDecodeError):
return DEFAULT_IMAGE_SET
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
class Station: class Station:
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None: 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 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.state_path = state_path
self.stall_after = stall_after 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.since = now_iso()
self.served = 0 self.served = 0
self.recent: list[dict] = [] self.recent: list[dict] = []
# Zeitpunkt der letzten Anfrage JEDER Art (erfolgreich oder nicht) - das
# Lebenszeichen fuer die Anzeige (display.py:_recent_request()). Anders als
# "served" zaehlt hier auch eine erfolglose Anfrage: der Eigentuemer wollte
# gerade nicht erst am Servierzaehler raten muessen, ob ueberhaupt etwas
# anklopft (Bedienerwunsch 2026-09-23). None, solange seit dem Start der
# Station noch keine einzige Anfrage einging.
self.last_activity: str | None = None
# 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 # ThreadingHTTPServer runs one handler thread per connection, and several lamps
# can be downloading at once. Every mutation of served/recent and the snapshot # can be downloading at once. Every mutation of served/recent/stall_sent and the
# that gets serialized to disk must happen under this same lock, or concurrent # snapshot that gets serialized to disk must happen under this same lock, or
# requests lose increments and stomp on each other's status.json writes. # concurrent requests lose increments and stomp on each other's status.json writes.
self.lock = threading.RLock() self.lock = threading.RLock()
self.write_state() self.write_state()
def rescue_dir(self) -> Path:
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes fuer die RETTUNG. Dieselbe Regel
wie migrate_dir() (dort steht die ausfuehrliche Begruendung): der Umschalter
entscheidet, nicht eine Abfrage je Rettung, und es wird nie stillschweigend auf
den jeweils anderen Satz ausgewichen.
Bestandskompatibilitaet bewusst NICHT gegeben: vor diesem Umschalter lagen
Rettungsabzuege FLACH in image_dir (immer MIT Zugangsdaten, aus build/). Ein
Pi mit diesem alten Bestand wuerde, faende image_path() dort noch etwas, bei der
Vorgabe "ohne-wlan" weiterhin klaglos ein Abbild MIT Zugangsdaten ausliefern -
also genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend
fortsetzen, die dieser Umschalter gerade abstellen soll. Ein Pi mit altem
Bestand muss deshalb einmal per deploy.sh neu bestueckt werden (das raeumt den
flachen Altbestand auch auf); bis dahin liefert die Rettung in der Vorgabe
"ohne-wlan" sichtbar nichts aus (Status "notfound"), statt einen Fehlgriff zu
verstecken."""
return self.image_dir / RESCUE_SET_DIRS[self.image_set()]
def rescue_set_present(self) -> bool:
"""Liegt der eingestellte Rettungs-Bildsatz ueberhaupt auf dem Pi? Dieselbe Regel
wie migrate_set_present() (Verzeichnis vorhanden UND mindestens ein Image
darin), hier fuer die Startzeile im Journal (main()) verwendet. Panel
(display.py) und CLI (ninlight-station) sind eigene Prozesse und rufen diese
Methode nicht auf, urteilen aber nach DERSELBEN REGEL - eigene, gleich lautende
Pruefungen, kein gemeinsamer Code."""
try:
return any(p.name.endswith(".img") for p in self.rescue_dir().iterdir())
except OSError:
return False
def image_path(self, target: str) -> Path: def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img" return self.rescue_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: def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
with self.lock: with self.lock:
if status == "ok": if status == "ok":
self.served += 1 self.served += 1
self.recent.append({"ts": now_iso(), "ip": ip, "target": target, ts = now_iso()
# Jede Anfrage zaehlt als Lebenszeichen, nicht nur eine erfolgreiche - siehe
# self.last_activity oben.
self.last_activity = ts
self.recent.append({"ts": ts, "ip": ip, "target": target,
"version": version, "bytes": nbytes, "status": status}) "version": version, "bytes": nbytes, "status": status})
del self.recent[:-RECENT_MAX] del self.recent[:-RECENT_MAX]
self.write_state() 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}
# Jede Anfrage zaehlt als Lebenszeichen, auch "listen"/"unknown"/"notfound"/
# "noimageset" - siehe self.last_activity oben und record()'s Gegenstueck.
self.last_activity = entry["ts"]
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: def write_state(self) -> None:
with self.lock: with self.lock:
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)} payload = {"since": self.since, "mode": self.mode, "served": self.served,
"recent": list(self.recent),
# In BEIDEN Modi (nicht nur migration, siehe unten) - das
# Lebenszeichen auf dem Panel gilt fuer die Rettung genauso wie
# fuer die Migration (display.py:_recent_request()).
"last_request_ts": self.last_activity}
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: try:
self.state_path.parent.mkdir(parents=True, exist_ok=True) self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-") fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
try:
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2) 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) os.replace(tmp, self.state_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
except OSError as e: except OSError as e:
print(f"warning: cannot write {self.state_path}: {e}", flush=True) print(f"warning: cannot write {self.state_path}: {e}", flush=True)
@@ -103,12 +468,26 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
pass 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: def log(self, status: int, nbytes: int, started: float) -> None:
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} " print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True) f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
def do_POST(self) -> None: def do_POST(self) -> None:
started = time.monotonic()
self.reject(405, "method not allowed") self.reject(405, "method not allowed")
self.log(405, 0, started)
do_PUT = do_DELETE = do_HEAD = do_POST do_PUT = do_DELETE = do_HEAD = do_POST
@@ -123,6 +502,12 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
started = time.monotonic() 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) m = PATH_RE.match(self.path)
if not m: if not m:
self.reject(404, "not found") self.reject(404, "not found")
@@ -138,6 +523,90 @@ class Handler(BaseHTTPRequestHandler):
self.log(404, 0, started) self.log(404, 0, started)
return 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 size = path.stat().st_size
start = 0 start = 0
rng = self.headers.get("Range", "") rng = self.headers.get("Range", "")
@@ -146,7 +615,6 @@ class Handler(BaseHTTPRequestHandler):
if first.isdigit() and int(first) < size: if first.isdigit() and int(first) < size:
start = int(first) start = int(first)
version = trailer_version(path)
self.send_response(206 if start else 200) self.send_response(206 if start else 200)
self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(size - start)) self.send_header("Content-Length", str(size - start))
@@ -159,16 +627,22 @@ class Handler(BaseHTTPRequestHandler):
sent = 0 sent = 0
status = "ok" status = "ok"
remaining = size - start remaining = size - start
client_ip = self.client_address[0]
try: try:
with path.open("rb") as f: with path.open("rb") as f:
f.seek(start) f.seek(start)
while sent < remaining: while sent < remaining:
if self.station.stall_after is not None and sent >= self.station.stall_after: read_size = min(CHUNK, remaining - sent)
# test aid: hold the connection open and send nothing more, so the if self.station.stall_after is not None:
# firmware's stall handling can be exercised on real hardware. We # Reserve against this client's cumulative budget, not just this
# still notice if the peer gives up and closes its side, so a test # request's byte count - see Station.reserve_stall_bytes.
# (or an impatient client) that hangs up is recorded as "aborted" read_size = self.station.reserve_stall_bytes(client_ip, read_size)
# instead of leaking the thread forever. 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: while True:
readable, _, _ = select.select([self.connection], [], [], 1.0) readable, _, _ = select.select([self.connection], [], [], 1.0)
if readable: if readable:
@@ -178,32 +652,63 @@ class Handler(BaseHTTPRequestHandler):
peek = b"" peek = b""
if not peek: if not peek:
raise ConnectionResetError("peer closed during stall") raise ConnectionResetError("peer closed during stall")
read_size = CHUNK
if self.station.stall_after is not None:
# Don't overshoot the stall boundary within a single read: without
# this a whole CHUNK could be sent before the check above ever
# notices we've passed --stall-after.
read_size = min(read_size, max(1, self.station.stall_after - sent))
chunk = f.read(read_size) chunk = f.read(read_size)
if not chunk: 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 break
self.wfile.write(chunk) self.wfile.write(chunk)
sent += len(chunk) sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, TimeoutError): except (BrokenPipeError, ConnectionResetError, TimeoutError):
status = "aborted" status = "aborted"
# 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.log(206 if start else 200, sent, started) self.log(206 if start else 200, sent, started)
self.station.record(self.client_address[0], target, version, sent, status) 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: def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="ninlight recovery station HTTP server") p = argparse.ArgumentParser(description="ninlight recovery/migration station HTTP server")
p.add_argument("--image-dir", required=True, type=Path) p.add_argument("--image-dir", required=True, type=Path)
p.add_argument("--bind", default="192.168.8.1") p.add_argument("--bind", default="192.168.8.1")
p.add_argument("--port", type=int, default=80) p.add_argument("--port", type=int, default=80)
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json")) 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, p.add_argument("--stall-after", type=int, default=None,
help="test aid: stop sending after N bytes but keep the connection open") help="test aid: stop sending after N bytes but keep the connection open")
a = p.parse_args(argv) a = p.parse_args(argv)
@@ -212,13 +717,43 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr) print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
return 2 return 2
station = Station(a.image_dir, a.state, a.stall_after) 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 = ThreadingHTTPServer((a.bind, a.port), Handler)
httpd.daemon_threads = True httpd.daemon_threads = True
httpd.station = station # type: ignore[attr-defined] httpd.station = station # type: ignore[attr-defined]
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img")) # Fixrunde 1, Kleinigkeit: vor dem Bildsatz-Umschalter fuer die Rettung lagen deren
print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): " # Abbilder flach in a.image_dir, das Glob dort war also die richtige Auskunft. Seit
# image_path() aus rescue_dir() liest (dem eingestellten Satz), muss diese Zeile
# demselben Verzeichnis folgen - sonst zeigt das Journal "0 image(s)" fuer eine
# Rettung, die tatsaechlich welche hat (oder umgekehrt).
if mode == "recovery":
try:
images = sorted(p.name for p in station.rescue_dir().glob("ninlight_*_ota.img"))
except OSError:
images = []
else:
images = []
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
f"{', '.join(images) or 'none'}", flush=True) f"{', '.join(images) or 'none'}", flush=True)
if mode == "recovery":
# Bildsatz auch fuer die Rettung im Journal - dieselbe Begruendung wie unten fuer
# die Migration: "warum bekam die Lampe ein Abbild ohne/mit Zugangsdaten?" ist
# sonst nirgends nachschlagbar. rescue_set_present() (bislang toter Code) ist das
# hier tatsaechlich Verwendete.
selected = station.image_set()
present = "vorhanden" if station.rescue_set_present() else "FEHLT"
print(f"{now_iso()} image set: {selected} ({station.rescue_dir()}, {present})",
flush=True)
elif 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: if a.stall_after is not None:
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True) print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
try: try:
+524 -44
View File
@@ -1,42 +1,432 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent: # Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches
# mehrfaches Ausfuehren aendert nichts und schadet nicht. # 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> # Aufruf: tools/station/setup_pi.sh <pi-host>
set -euo pipefail set -euo pipefail
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>} HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
HERE=$(cd "$(dirname "$0")" && pwd) 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 ==" echo "== Voraussetzungen auf $HOST pruefen =="
ssh "$HOST" 'set -e "$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; } 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; } 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; } 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"' echo " ok"
REMOTE
echo "== Konfiguration ==" echo "== Konfiguration =="
scp -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example" "$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example"
ssh "$HOST" 'set -e # CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es
if [ -f /etc/ninlight-station.conf ]; then # beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert" # Systempfade laufen.
else "$SSH_BIN" "$HOST" bash -s <<'REMOTE'
sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf set -e
echo " /etc/ninlight-station.conf aus der Vorlage angelegt" 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
rm -f /tmp/ninlight-station.conf.example' 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 Unit installieren ==" echo "== Server, CLI und Units installieren =="
scp -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" # Reststand der Ablage wegraeumen, BEVOR das scp laeuft (Task 6b F1, I1). "scp -r quelle
ssh "$HOST" 'set -e # ziel" kopiert das Quellverzeichnis IN ein bereits vorhandenes Ziel hinein - aus
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images # einem liegengebliebenen "…/ninlight-display/" wird dann "…/ninlight-display/display/",
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py # die Installationsschleife unten ueberspringt Verzeichnisse und findet gar nichts mehr.
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station # Ergebnis waere ein Lauf, der Erfolg meldet, den Anzeigedienst aktiviert und ein leeres
sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service # $ROOT_DIR/display hinterlaesst: 203/EXEC im 5-Sekunden-Takt, Panel schwarz. Genau so
rm -f /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service # 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 sudo systemctl daemon-reload
echo " installiert"' echo " installiert"
REMOTE
echo "== NetworkManager-Profil ==" 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: # Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht # - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes # jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
@@ -49,29 +439,91 @@ echo "== NetworkManager-Profil =="
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root) # stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer # geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
# Prozessliste. # Prozessliste.
ssh "$HOST" bash -s <<'REMOTE' "$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e set -e
eval "$(sudo cat /etc/ninlight-station.conf)" CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection" NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections}
if sudo test -f "$CONN_FILE"; then eval "$(sudo cat "$CONF")"
UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1)
ACTION="aktualisiert" # Die Keyfiles werden erst in einer Temp-Datei aufgebaut und dann per "install"
else # an ihren Platz gelegt - dazwischen liegt das WLAN-Passwort in einer Datei, die
UUID=$(cat /proc/sys/kernel/random/uuid) # noch nicht 0600 root:root ist. Ein Abbruch (set -e) genau dort darf sie nicht
ACTION="angelegt" # 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 fi
TMP=$(mktemp) local conn_file="$NM_DIR/${id}.nmconnection"
trap 'rm -f "$TMP"' EXIT 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 '%s\n' '[connection]'
printf 'id=%s\n' "$AP_CONNECTION" printf 'id=%s\n' "$id"
printf 'uuid=%s\n' "$UUID" printf 'uuid=%s\n' "$uuid"
printf '%s\n' 'type=wifi' printf '%s\n' 'type=wifi'
printf '%s\n' 'interface-name=wlan0' printf '%s\n' 'interface-name=wlan0'
printf '%s\n' 'autoconnect=false' printf '%s\n' 'autoconnect=false'
printf '\n%s\n' '[wifi]' printf '\n%s\n' '[wifi]'
printf '%s\n' 'mode=ap' printf '%s\n' 'mode=ap'
printf 'ssid=%s\n' "$SSID" printf 'ssid=%s\n' "$ssid"
printf '%s\n' 'band=bg' printf '%s\n' 'band=bg'
printf '%s\n' 'channel=6' printf '%s\n' 'channel=6'
printf '\n%s\n' '[wifi-security]' printf '\n%s\n' '[wifi-security]'
@@ -79,25 +531,53 @@ trap 'rm -f "$TMP"' EXIT
printf '%s\n' 'proto=rsn' printf '%s\n' 'proto=rsn'
printf '%s\n' 'pairwise=ccmp;' printf '%s\n' 'pairwise=ccmp;'
printf '%s\n' 'group=ccmp;' printf '%s\n' 'group=ccmp;'
printf 'psk=%s\n' "$PSK" printf 'psk=%s\n' "$psk"
printf '\n%s\n' '[ipv4]' printf '\n%s\n' '[ipv4]'
printf '%s\n' 'method=shared' printf '%s\n' 'method=shared'
printf '%s\n' 'address1=192.168.8.1/24' printf '%s\n' 'address1=192.168.8.1/24'
printf '\n%s\n' '[ipv6]' printf '\n%s\n' '[ipv6]'
printf '%s\n' 'method=disabled' printf '%s\n' 'method=disabled'
} >"$TMP" } >"$tmp"
sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE" sudo install -o root -g root -m 0600 "$tmp" "$conn_file"
rm -f "$TMP" rm -f "$tmp"
sudo nmcli connection load "$CONN_FILE" >/dev/null sudo nmcli connection load "$conn_file" >/dev/null
echo " Profil $AP_CONNECTION $ACTION" 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 REMOTE
cat <<'EOF' # 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. Fertig. Die Station ist absichtlich noch AUS.
$SPI_NOTE
Naechste Schritte: 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 tools/station/deploy.sh <pi-host> Images ausliefern
ssh <pi-host> ninlight-station on Station einschalten ssh <pi-host> ninlight-station on Station einschalten
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen) 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 EOF
+12
View File
@@ -24,3 +24,15 @@ HOME_CONNECTION='preconfigured'
# NetworkManager-Profil der Station selbst. # NetworkManager-Profil der Station selbst.
AP_CONNECTION='ninlight-recovery' 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) == ["Start recovery", "Migration: listen",
"Migration: AUTOMATIC", "Switch to: with Wi-Fi",
"Shut down"]
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: listen") < labels.index("Migration: AUTOMATIC")
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station off", "Switch to: with Wi-Fi",
"Refresh display", "Shut down"]
def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Arm: gu10_rgb", "Station off",
"Switch to: with Wi-Fi", "Refresh display",
"Shut down"]
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 "Armed: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station off", "Switch to: with Wi-Fi",
"Refresh display", "Shut down"]
def test_only_the_refresh_entry_skips_the_confirmation():
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Refresh display"
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 != "Refresh display"), 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"] == "> Start recovery"
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: listen"
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"] == "> Start recovery"
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"] == "Start recovery"
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: listen") 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: AUTOMATIC") 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"] == "WARNING"
assert view["hero"] == "Migration: AUTOMATIC"
text = " ".join(view["details"]).lower()
assert "every" in text and "other people" in text
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
assert view["details"][-2:] == ["hold (1.5 s): yes", "tap: no"]
# 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"] == "CONFIRM"
assert plain["details"] == ["hold (1.5 s): yes", "tap: no"]
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("Shutting down 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 "Shutting down in 6 s" in heroes
assert "Shutting down 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") == "ERROR"
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": "MENU", "hero": "> Station off", "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 "Switch to: with Wi-Fi" in _labels(STATE_OFF)
ohne = dict(STATE_OFF, imageset="mit-wlan")
assert "Switch to: no Wi-Fi" in _labels(ohne)
assert "Switch to: with Wi-Fi" 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 == "WARNING"
assert back.confirm_title == "CONFIRM"
assert "Wi-Fi password" 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"
+1501
View File
File diff suppressed because it is too large Load Diff
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
+663
View File
@@ -0,0 +1,663 @@
#!/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.
#
# Und zwar an der Stelle, an der sie beim echten Bau auch landen: IM KOMPRIMIERTEN
# RUMPF. Ein Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann - beim _ota.img - 512
# Byte Trailer. Frueher schrieb der Stub reinen Klartext in eine Datei, die er nur
# "..._ota.img" nannte; damit fand die rohe Bytesuche in release.sh die Werte, und Fall
# 5 war gruen, obwohl der Pruefkoerper an echten Abbildern nie irgendetwas gefunden
# haette. Ein Testartefakt, das dem Pruefling recht gibt, ist schlimmer als kein Test.
#
# 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)
# STUB_TRUNC_TARGET schneidet beim _ota.img dieses Ziels den gzip-Strom ab - das
# Abbild laesst sich dann nicht entpacken. Das _gz.img bleibt heil,
# damit das Signieren davor nicht schon abbricht.
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" TRUNC="${STUB_TRUNC_TARGET:-}" python3 - <<'PY'
import gzip
import io
import os
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
# Der Rumpf ist ~100 kB gleichfoermiger Text - so wie echte Firmware gross genug, dass
# deflate ihn WIRKLICH komprimiert. Zwei Klippen liegen hier dicht nebeneinander:
# zu kleiner Rumpf -> gzip-Strom unter 1024 Byte, und nl_sign.py lehnt ab;
# unkomprimierbarer Rumpf (etwa Zufallsbytes) -> deflate legt ihn als "stored block"
# UNVERAENDERT ab, der Klartext stuende wieder roh in der Datei und der Test prueft
# erneut eine Welt, die es nicht gibt.
body = bytearray(
b"".join(b"ninlight-%s-block-%06d\n" % (target.encode(), i) for i in range(4000)))
tag = ("firmware-%s-" % target).encode()
body[0:len(tag)] = tag
blob = creds.encode()
if blob:
# Die Zugangsdaten liegen IM Rumpf - also nach dem Packen im komprimierten Strom
# und nicht im Klartext in der Datei. Genau wie beim echten Compiler.
body[100:100 + len(blob)] = blob
# Aufbau wie beim echten Bau: 56 Byte Kopf (wm_tool), dann der gzip-Strom.
header = bytearray(b"\x9f\xff\xff\xa0" + b"\x00" * 52)
header[4:8] = b"STUB"
buf = io.BytesIO()
# mtime=0: sonst haengt der Dateiinhalt an der Uhrzeit und Vergleiche werden wacklig.
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gf:
gf.write(bytes(body))
stream = buf.getvalue()
raw = bytes(header) + stream
# nl_sign.py verlangt eine durch 4 teilbare Laenge von mindestens 1024 Byte. Die
# Fuellbytes stehen hinter dem gzip-Strom und stoeren das Entpacken nicht.
raw += b"\x00" * (-len(raw) % 4)
assert len(raw) >= 1024, "Stub-Abbild zu kurz fuer nl_sign.py"
# Selbstkontrolle gegen genau den Fehler, der diese Suite jahrelang wertlos machte.
assert not blob or blob not in raw, \
"Zugangsdaten stehen roh in der Datei - der Rumpf wurde nicht wirklich komprimiert"
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
fh.write(raw) # roh, ohne NINL-Trailer - das signiert nl_sign.py
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
ota_body = raw
if os.environ.get("TRUNC") == target:
# Abgeschnittener gzip-Strom: decompressobj liefert dafuer stillschweigend nur den
# lesbaren Teil, wenn niemand dec.eof abfragt.
ota_body = bytes(header) + stream[:len(stream) // 2]
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
fh.write(ota_body + 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_TRUNC_TARGET="${STUB_TRUNC_TARGET:-}" \
STUB_LEAK_VALUE="$SECRET_SSID" \
"$sandbox/tools/release.sh" "$version"
}
# Sucht die Zugangsdaten in jeder Datei unterhalb eines Verzeichnisses - in den
# Rohbytes UND im entpackten Rumpf jedes Abbilds. Nur roh zu suchen hiesse, die
# Zusage "keine Zugangsdaten im Archiv" gar nicht zu pruefen: im gzip-Strom steht
# kein Klartext. Ein Abbild, das sich nicht entpacken laesst, wird hier gemeldet und
# nicht verschwiegen - sonst haette die Gegenprobe ein stilles Loch.
# $2 (optional) = "roh": nur die Rohbytes durchsuchen, nichts entpacken.
grep_secrets() {
local dir=$1 mode=${2:-entpackt}
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
python3 - "$dir" "$mode" <<'PY'
import os
import sys
import zlib
HEADER_LEN = 56
root, mode = sys.argv[1], sys.argv[2]
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)
rel = os.path.relpath(p, root)
if os.path.islink(p):
continue
with open(p, "rb") as fh:
data = fh.read()
views = [data]
if mode != "roh" and fn.endswith(".img"):
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
try:
plain = dec.decompress(data[HEADER_LEN:])
if not dec.eof:
raise ValueError("gzip-Strom unvollstaendig")
views.append(plain)
except Exception as exc:
print("NICHT ENTPACKBAR %s (%s)" % (rel, exc))
for name, needle in needles.items():
if any(needle in view for view in views):
print("%s in %s" % (name, rel))
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 (roh UND im entpackten Rumpf 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 (oder ein Abbild liess sich nicht entpacken):
$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
# Und zwar realistisch: IM komprimierten Rumpf, nicht als Klartext in der Datei. Faende
# die rohe Suche sie, prueften alle Faelle oben eine Welt, die es nicht gibt - an echten
# Abbildern findet eine rohe Suche nie etwas.
if grep_secrets "$TMPROOT/probe" roh | grep -q .; then
fail "Gegenprobe: der Stub legt die Zugangsdaten im Klartext ab - unrealistisches Abbild"
else
pass "Gegenprobe: im Rohbyteinhalt des Stub-Abbilds steht kein Klartext (nur im gzip-Rumpf)"
fi
# Und das Abbild hat wirklich den Aufbau des echten: 56 Byte Kopf, gzip, 512 Byte
# Trailer mit NINL am Anfang.
if python3 - "$TMPROOT/probe/evk" <<'PY'
import os
import sys
import zlib
d = sys.argv[1]
ota = open(os.path.join(d, "ninlight_evk_ota.img"), "rb").read()
gz = open(os.path.join(d, "ninlight_evk_gz.img"), "rb").read()
assert ota[-512:][:4] == b"NINL", "kein NINL am Trailer-Anfang"
assert ota[:-512] == gz, "_ota.img ist nicht _gz.img + 512 Byte Trailer"
assert gz[56:58] == b"\x1f\x8b", "kein gzip-Strom ab Byte 56"
for raw in (ota, gz):
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
dec.decompress(raw[56:])
assert dec.eof, "gzip-Strom unvollstaendig"
PY
then
pass "Gegenprobe: Stub-Abbild hat den echten Aufbau (56 Byte Kopf, gzip, NINL-Trailer)"
else
fail "Gegenprobe: Stub-Abbild hat nicht den Aufbau eines echten Abbilds"
fi
echo "--- Meldung der Zugangsdatenpruefung nennt Dateien und entpackte Abbilder ---"
# 9 ota + 9 flash + 18 migrate + bruno/ninlight.yml + README.md + onboard.sh = 39,
# davon 36 Abbilder. SHA256SUMS entsteht erst nach der Pruefung.
#
# Die Zahlen haengen an der Laenge von TARGETS: mit ledshield_rgb kamen vier
# Dateien dazu (je ein _ota, ein _gz und zwei signierte Migrationsabzuege), alle
# vier sind Abbilder. Wer ein Ziel ergaenzt, passt sie hier mit an - der Merge
# des Shield-Zweigs lief konfliktfrei durch und liess trotzdem 35/32 stehen.
if echo "$OUT" | grep -qF "sauber (39 Dateien geprueft, davon 36 Abbilder entpackt)"; then
pass "Meldung nennt Dateizahl und Zahl der entpackten Abbilder"
else
fail "Meldung der Zugangsdatenpruefung unerwartet: $(echo "$OUT" | grep -i sauber || echo '(keine sauber-Zeile)')"
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 KOMPRIMIERTEN Rumpf brechen den Lauf ab ==="
# Das ist der Kern: der Stub legt die SSID im gzip-Rumpf jedes Abbilds ab, wie es der
# echte Compiler taete. Ein Pruefkoerper, der nur die Rohbytes durchsucht, laesst diesen
# Lauf anstandslos durch und packt Michaels WLAN-Daten ins Archiv.
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
if echo "$OUT" | grep -q "NINLIGHT_WIFI_SSID in ota/ninlight_evk_ota.img"; then
pass "Meldung nennt den Namen des Wertes und die Datei"
else
fail "Meldung nennt Name und Datei nicht: $OUT"
fi
echo "=== Szenario 6b: Klartext in einer Nicht-Abbild-Datei bricht den Lauf ab ==="
# Nicht jede Datei im Archiv ist ein Abbild: README, onboard.sh und die Bruno-Collection
# sind Text. Fuer sie bleibt die rohe Bytesuche die einzige Pruefung - sie darf durch das
# Entpacken nicht verlorengehen.
SANDBOX="$TMPROOT/s6b"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
printf '\n# versehentlich notiert: %s\n' "$SECRET_PASS" >>"$SANDBOX/bruno/ninlight.yml"
set +e
OUT=$(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 Klartext in bruno/ninlight.yml erfolgreich"; fi
if echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD in bruno/ninlight.yml"; then
pass "Meldung nennt NINLIGHT_WIFI_PASSWORD und die Datei"
else
fail "Meldung nennt den Fund in bruno/ninlight.yml nicht: $OUT"
fi
if echo "$OUT" | grep -qF "$SECRET_PASS"; then
fail "die Meldung druckt das Passwort selbst"
else
pass "die Meldung druckt das Passwort 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 6c: ein nicht entpackbares Abbild bricht den Lauf ab ==="
# Ein abgeschnittener gzip-Strom ist der heimtueckische Fall: decompressobj liefert
# stillschweigend nur den lesbaren Teil. Was dahinter steht, bliebe ungeprueft - und
# genau dort koennte das Passwort liegen. Ungeprueft durchlassen ist keine Option.
SANDBOX="$TMPROOT/s6c"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
set +e
OUT=$(STUB_TRUNC_TARGET=e14_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 "Lauf mit unentpackbarem Abbild war erfolgreich"; fi
if echo "$OUT" | grep -q "ota/ninlight_e14_cw_ota.img"; then
pass "Meldung nennt die betroffene Datei"
else
fail "Meldung nennt die betroffene Datei nicht: $OUT"
fi
if echo "$OUT" | grep -q "laesst sich nicht entpacken"; then
pass "Meldung sagt, dass das Abbild nicht entpackbar ist"
else
fail "Meldung erklaert den Grund nicht: $OUT"
fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz unentpackbarem Abbild 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"