Author SHA1 Message Date
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
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
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
69 changed files with 5614 additions and 1606 deletions
+3
View File
@@ -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)
+97 -2
View File
@@ -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:
+23 -1
View File
@@ -328,10 +328,32 @@ 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: - info:
name: Commands name: Commands
type: folder type: folder
seq: 7 seq: 8
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.
+19 -1
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
@@ -65,6 +65,24 @@ Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in
- [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 - [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 — *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.* - [ ] 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
Beim Test mit dem verfälschten Image meldete lwIP einmalig Beim Test mit dem verfälschten Image meldete lwIP einmalig
@@ -1,54 +0,0 @@
"""The Ninlight integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_TOKEN, DEFAULT_PORT, PLATFORMS
from .coordinator import NinlightConfigEntry, NinlightCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
"""Set up a Ninlight bulb from a config entry."""
coordinator = NinlightCoordinator(hass, entry)
# Register the teardown before the first refresh: HA runs the on_unload callbacks both
# when setup fails and when the entry unloads, and awaits the coroutine they return.
# That covers every exit path, so no bulb client can be left with an open connection
# slot. The bulb serves one request per connection, so this matters.
entry.async_on_unload(coordinator.async_shutdown)
try:
await coordinator.async_config_entry_first_refresh()
except Exception:
# ConfigEntryNotReady / ConfigEntryAuthFailed propagate to HA, which retries or
# raises reauth. Close now rather than waiting for the retry.
await coordinator.async_shutdown()
raise
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate an entry written by an earlier version of this integration."""
if config_entry.version > 1:
return False
if "port" not in config_entry.data or CONF_TOKEN not in config_entry.data:
new = dict(config_entry.data)
new.setdefault("port", DEFAULT_PORT)
new.setdefault(CONF_TOKEN, new.pop("api_key", ""))
hass.config_entries.async_update_entry(config_entry, data=new, version=1)
_LOGGER.debug("Migrated ninlight entry %s to v1", config_entry.entry_id)
return True
@@ -1,238 +0,0 @@
"""Asynchronous client for the Ninlight REST API.
The firmware (src/net/nl_api.c) speaks plain HTTP/1.1 on port 3333 with a raw token in
the `Authorization` header - no "Bearer" scheme prefix, no TLS, no sessions. The bulb
serves one request per connection and accepts at most four queued connections, so the
client must not keep a hot connection pool open.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace as _dc_replace
from typing import Any
import httpx
from .const import DEFAULT_PORT
class NinlightError(Exception):
"""Base error, raised on connection problems and non-JSON replies."""
class NinlightAuthError(NinlightError):
"""The bulb rejected our token (HTTP 401)."""
class NinlightUnavailable(NinlightError):
"""The bulb could not be reached at all (connection refused or timeout).
A bulb that is switched off or has lost Wi-Fi is an expected state, not a fault,
so the coordinator treats this differently from protocol errors.
"""
class NinlightBadRequest(NinlightError):
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
def __init__(self, message: str, reason: str | None) -> None:
super().__init__(f"{message}: {reason}" if reason else message)
self.reason = reason
@dataclass(slots=True)
class NinlightState:
"""A parsed `GET /api/v1/device/state` plus the identity from `system/info`."""
on: bool
brightness: int # 1..100, the firmware's native unit
mode: str # "color" | "white"
r: int
g: int
b: int
color_temp: int # kelvin
ct_min: int
ct_max: int
has_rgb: bool
# Settings echoed by the same reply; the coordinator exposes them as entities.
power_on_behaviour: str
fade_in: int
fade_out: int
transition_default: int
wake_on_change: bool
# From system/info, merged in by the coordinator.
device_type: str | None = None
product_name: str | None = None
fw_version: str | None = None
mac: str | None = None
name: str | None = None
rssi: int | None = None
ip: str | None = None
# Segment model only (ninstrip): the raw segment list; the light maps onto
# segment 0 while a scene across the remaining segments stays untouched.
segments: list[dict[str, Any]] = field(default_factory=list)
# True for ninstrip firmware, whose light state lives in segments. Decided by
# presence of `segment_count` in device/state and `product` in system/info -
# both keys are ninstrip-only (nl_api_device.c / nl_api_system.c). Bulb
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
# name is not a signal, but the keys are.
strip: bool = False
# False when the last poll could not reach the bulb (powered off / offline).
# Kept separate from `on` so the entity can show "unavailable" instead of an
# error, while the light keeps its last known appearance.
available: bool = True
@classmethod
def unavailable_copy(cls, last: "NinlightState") -> "NinlightState":
"""The last known state, flagged unavailable.
Keeps the light's appearance (on/off, color) at its last real value while the
entity shows as unavailable, instead of falling back to defaults.
"""
return _dc_replace(last, available=False)
@classmethod
def from_api(
cls,
state: dict[str, Any],
info: dict[str, Any] | None = None,
segments: list[dict[str, Any]] | None = None,
) -> NinlightState:
color = state.get("color") or {}
caps = state.get("capabilities") or {}
ct = caps.get("color_temp") or [2700, 6500]
wifi = (info or {}).get("wifi") or {}
seg = (segments or [None])[0] or {}
# `segment_count` and `product` exist only on ninstrip firmware; the bulb's
# device_type is a board id and carries no signal either way.
strip = "segment_count" in state or "product" in (info or {})
# On a strip the light lives in segment 0; mode "off" carries no color at all,
# so mode/color fall back to white there and is_on decides darkness anyway.
seg_mode = seg.get("mode")
return cls(
on=bool(state.get("on", False)),
brightness=int(state.get("brightness", 0)),
mode="color" if seg_mode == "solid" else str(state.get("mode", "white")),
r=int((seg.get("color") or color).get("r", 0)),
g=int((seg.get("color") or color).get("g", 0)),
b=int((seg.get("color") or color).get("b", 0)),
color_temp=int(seg.get("color_temp", state.get("color_temp", 2700))),
ct_min=int(ct[0]),
ct_max=int(ct[1]),
has_rgb=bool(caps.get("color", True)) if strip else bool(caps.get("color", False)),
power_on_behaviour=str(state.get("power_on_behaviour", "last")),
fade_in=int(state.get("fade_in", 0)),
fade_out=int(state.get("fade_out", 0)),
transition_default=int(state.get("transition_default", 0)),
wake_on_change=bool(state.get("wake_on_change", False)),
device_type=(info or {}).get("device_type"),
product_name=(info or {}).get("product_name"),
fw_version=(info or {}).get("fw_version"),
mac=(info or {}).get("mac"),
name=(info or {}).get("name"),
rssi=wifi.get("rssi"),
ip=wifi.get("sta_ip") or wifi.get("ip"),
segments=list(segments or []),
strip=strip,
)
class NinlightClient:
"""One client per bulb (per config entry)."""
def __init__(
self,
host: str,
token: str,
*,
port: int = DEFAULT_PORT,
session: httpx.AsyncClient | None = None,
) -> None:
self._base = f"http://{host}:{port}/api/v1"
# Raw token, not "Bearer <token>": nl_http_parse hands the header value straight to
# token_ok(), which compares it against the stored token byte for byte.
self._headers = {"Authorization": token}
self._owns_session = session is None
self._session: httpx.AsyncClient = session or httpx.AsyncClient(
headers=self._headers,
timeout=httpx.Timeout(8.0, connect=4.0),
# keepalive_expiry of ~0 makes httpx close after each response instead of
# holding one of the bulb's four listen-queue slots open between polls.
limits=httpx.Limits(max_connections=1, max_keepalive_connections=0, keepalive_expiry=0.1),
)
async def close(self) -> None:
if self._owns_session:
await self._session.aclose()
async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
except httpx.InvalidURL as err: # programming error, not a transient one
raise NinlightError(f"invalid bulb URL: {err}") from err
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as err:
# No answer at all: bulb powered off, rebooting, or gone from the WLAN.
raise NinlightUnavailable(f"bulb unreachable: {err}") from err
except httpx.HTTPError as err:
raise NinlightError(f"request to bulb failed: {err}") from err
if resp.status_code == 401:
raise NinlightAuthError("bulb rejected the API token")
if resp.status_code == 400:
raise NinlightBadRequest("bulb rejected the request", _error_field(resp))
if resp.status_code != 200:
raise NinlightError(f"bulb answered {resp.status_code}: {_error_field(resp)}")
try:
data = resp.json()
except ValueError as err:
raise NinlightError("bulb sent a non-JSON body") from err
if not isinstance(data, dict):
raise NinlightError("bulb sent an unexpected JSON shape")
return data
async def get_state(self) -> dict[str, Any]:
return await self._request("GET", "/device/state")
async def get_segments(self) -> dict[str, Any]:
"""Segment model of the ninstrip firmware (bulbs have no such endpoint)."""
return await self._request("GET", "/device/segments")
async def set_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]:
"""Replace the whole segment list. ninstrip answers with `{"status": "ok"}`."""
return await self._request("PUT", "/device/segments", {"segments": segments})
async def patch_segment(self, index: int, patch: dict[str, Any]) -> dict[str, Any]:
"""Update one segment; omitted fields keep their current value."""
return await self._request("PUT", f"/device/segments/{index}", patch)
async def get_info(self) -> dict[str, Any]:
return await self._request("GET", "/system/info")
async def get_settings(self) -> dict[str, Any]:
"""`system/settings` is the one endpoint that always requires the token.
`system/info` and a state GET answer without auth unless `auth_all` is set
(nl_api.c: the state branch tests `s.auth_all && !authed`, the info branch tests
nothing), so only settings proves the token is right.
"""
return await self._request("GET", "/system/settings")
async def set_state(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/device/state", payload)
async def ping(self) -> dict[str, Any]:
return await self._request("PUT", "/system/command", {"command": {"type": "ping"}})
async def reboot(self) -> dict[str, Any]:
return await self._request("PUT", "/system/command", {"command": {"type": "reboot"}})
def _error_field(resp: httpx.Response) -> str | None:
try:
body = resp.json()
except ValueError:
return resp.text[:120] or None
if isinstance(body, dict):
err = body.get("error")
return str(err) if err is not None else None
return None
@@ -1,166 +0,0 @@
"""Config flow for Ninlight."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .client import NinlightAuthError, NinlightClient, NinlightError
from .const import CONF_TOKEN, DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
vol.Optional("port", default=DEFAULT_PORT): vol.All(vol.Coerce(int), vol.Range(1, 65535)),
vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): vol.All(str, vol.Length(min=1, max=64)),
}
)
def _normalize_mac(mac: str) -> str:
"""Keep one canonical form for the unique id (zeroconf and system/info both use `aa:bb:...`)."""
return mac.strip().lower()
def _is_full_mac(mac: str) -> bool:
"""True for a colon-separated 12-hex-digit MAC, the only TXT form usable as unique id.
ninstrip devices publish the 4-byte suffix (`eb4b98`, nl_wifi.c's mac4) in their TXT
record; two bulbs sharing a last octet would collide, so those need a probe instead.
"""
parts = mac.strip().lower().split(":")
return len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts)
class NinlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ninlight."""
VERSION = 1
async def _probe_mac(self, host: str, port: int) -> str:
"""Return the bulb's MAC. Needs no token: `system/info` is unauthenticated."""
client = NinlightClient(host, DEFAULT_TOKEN, port=port)
try:
info = await client.get_info()
finally:
await client.close()
mac = info.get("mac")
if not isinstance(mac, str) or not mac:
raise NinlightError("bulb did not report a MAC address")
return _normalize_mac(mac)
async def _probe_mac_and_token(self, host: str, port: int, token: str) -> str:
"""As above, plus verify the token against `system/settings`.
Only that endpoint authenticates, so a probe that stops at system/info would
happily accept a wrong token (nl_api.c's info branch never checks `authed`).
"""
client = NinlightClient(host, token, port=port)
try:
info = await client.get_info()
await client.get_settings()
finally:
await client.close()
mac = info.get("mac")
if not isinstance(mac, str) or not mac:
raise NinlightError("bulb did not report a MAC address")
return _normalize_mac(mac)
async def _entry_from_data(self, data: dict[str, Any], title: str) -> ConfigFlowResult:
await self.async_set_unique_id(data["mac"])
# Addresses come from DHCP, so rediscovering a known MAC at a new IP must move the
# existing entry rather than abort a re-add.
self._abort_if_unique_id_configured(updates=data)
return self.async_create_entry(title=title, data=data)
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
host = user_input["host"].strip()
port = int(user_input["port"])
token = user_input[CONF_TOKEN].strip()
try:
mac = await self._probe_mac_and_token(host, port, token)
except NinlightAuthError:
errors[CONF_TOKEN] = "invalid_auth"
except NinlightError:
errors["base"] = "cannot_connect"
else:
return await self._entry_from_data(
{"host": host, "port": port, CONF_TOKEN: token, "mac": mac}, title=host
)
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery of a `_ninlight._tcp` service."""
props = {str(k).lower(): str(v) for k, v in discovery_info.properties.items()}
port = discovery_info.port or DEFAULT_PORT
mac = props.get("mac", "")
# A TXT record may hold no MAC at all (bulb still announcing) or only the short
# 4-byte suffix form ninstrip publishes (`eb4b98`). Unique ids are full MACs, so
# anything that is not one colon-form is replaced by a probe of system/info.
if not _is_full_mac(mac):
try:
mac = await self._probe_mac(discovery_info.host, port)
except NinlightError:
raise AbortFlow("cannot_connect") from None
mac = _normalize_mac(mac)
await self.async_set_unique_id(mac)
updates = {
"host": discovery_info.host,
"port": port,
"mac": mac,
"device_type": props.get("type", ""),
}
self._abort_if_unique_id_configured(updates=updates)
# Instance name is `ninlight-<mac4>` until someone names the bulb; a name set on
# the device is published in the TXT record and wins.
title = props.get("name") or discovery_info.name.split(".", 1)[0]
self.context["title_placeholders"] = {"name": title}
# Create rather than prompt. A confirm step would leave the flow parked in
# `in_progress`, where it blocks a manual add of the same bulb with
# already_in_progress - and a bulb that is reachable but not announcing mDNS
# (two of ours) has to be added manually, so that collision is not theoretical.
return self.async_create_entry(title=title, data={**updates, CONF_TOKEN: DEFAULT_TOKEN})
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert entry is not None
errors: dict[str, str] = {}
if user_input is not None:
token = user_input[CONF_TOKEN].strip()
try:
await self._probe_mac_and_token(entry.data["host"], entry.data.get("port", DEFAULT_PORT), token)
except NinlightAuthError:
errors[CONF_TOKEN] = "invalid_auth"
except NinlightError:
errors["base"] = "cannot_connect"
else:
self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_TOKEN: token})
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_TOKEN, default=entry.data.get(CONF_TOKEN, "")): str}),
errors=errors,
description_placeholders={"name": entry.title},
)
@@ -1,25 +0,0 @@
"""Constants for the Ninlight integration."""
from __future__ import annotations
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN = "ninlight"
PLATFORMS: Final = [Platform.LIGHT]
CONF_TOKEN = "token"
DEFAULT_PORT = 3333
DEFAULT_TOKEN = "aabbccddeeff0011223344"
# The firmware has no push channel, so state is polled. A bulb answers in well under
# a second on the LAN; 30 s keeps the UI honest without adding noticeable REST traffic
# for a nine bulb setup.
SCAN_INTERVAL = timedelta(seconds=30)
# Mirror of NL_MAX_TRANSITION_MS in src/light/nl_control.h.
MAX_TRANSITION_MS = 60000
@@ -1,120 +0,0 @@
"""Data coordinator for a single Ninlight bulb."""
from __future__ import annotations
import logging
from contextlib import suppress
from typing import Any, TypeAlias
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .client import (
NinlightAuthError,
NinlightBadRequest,
NinlightClient,
NinlightError,
NinlightState,
NinlightUnavailable,
)
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
class NinlightCoordinator(DataUpdateCoordinator[NinlightState]):
"""Polls one bulb. One coordinator, and one HTTP client, per config entry.
`system/info` is fetched on the first successful refresh only. It carries identity
(MAC, product name, firmware) that does not change while the bulb runs, and the bulb
accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so
skipping it on every poll keeps one of those slots free for the UI.
"""
config_entry: NinlightConfigEntry
def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} {entry.title}",
update_interval=SCAN_INTERVAL,
)
self.client = NinlightClient(
entry.data["host"],
entry.data.get(CONF_TOKEN, ""),
port=entry.data.get("port", DEFAULT_PORT),
)
self.info: dict[str, Any] | None = None
self._identity_ok = False
self._is_strip = False
async def async_shutdown(self) -> None:
"""Close the HTTP client alongside the coordinator's own teardown."""
await self.client.close()
await super().async_shutdown()
async def _async_update_data(self) -> NinlightState:
try:
state = await self.client.get_state()
except NinlightAuthError as err:
# Only possible when the bulb has `auth_all` set; a wrong token will not fix
# itself, so surface a re-auth flow instead of retrying every interval.
raise ConfigEntryAuthFailed(str(err)) from err
except NinlightUnavailable as err:
# A bulb that is switched off or off the WLAN is an expected state, not a
# fault. Keep the last known data with `available=False` so the entity shows
# "unavailable" and the config entry stays loaded - no error log, no retry
# backoff, no reload. The next poll picks the bulb up when it answers again.
_LOGGER.debug("%s unreachable (assumed powered off): %s", self.name, err)
if isinstance(self.data, NinlightState):
return NinlightState.unavailable_copy(self.data)
# No successful poll yet (e.g. HA started while the bulb was off): a default
# state carries no identity, but the entry still loads cleanly and the
# identity fetch below reruns on the first poll that reaches the bulb.
return NinlightState.unavailable_copy(NinlightState.from_api({}))
except NinlightBadRequest as err:
# A poll sends no body, so this can only mean a firmware we do not match.
raise UpdateFailed(str(err)) from err
except NinlightError as err:
raise UpdateFailed(str(err)) from err
if not self._identity_ok:
# One extra pair of requests, then never again: the bulb accepts at most four
# queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll
# path to a single GET leaves slots free for the UI.
with suppress(NinlightError):
# system/info carries identity that only changes on a reboot or an OTA.
self.info = await self.client.get_info()
try:
# system/settings always demands the token, so it is the only call that
# proves ours is right. A failure here on a bulb whose state endpoint
# answered happily means the token is wrong while `auth_all` is off - the
# light would work and silently never be able to be renamed or updated.
await self.client.get_settings()
except NinlightAuthError as err:
raise ConfigEntryAuthFailed(str(err)) from err
# `product` is an ninstrip-only key in system/info (bulbs send
# product_name); once known, the heavier segment poll follows every refresh.
self._is_strip = "product" in (self.info or {})
self._identity_ok = True
segments: list[dict[str, Any]] | None = None
if self._is_strip:
# The light lives in the segment list; the plain state GET above carries
# only on/brightness for it. A firmware without the endpoint (misdetected
# strip) must not fail the whole poll for that.
try:
segments = (await self.client.get_segments()).get("segments")
except NinlightError as err:
_LOGGER.debug("segment poll failed: %s", err)
return NinlightState.from_api(state, self.info, segments)
# Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the
# project's 3.11 tooling and runs on HA's 3.14.
NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator]
@@ -1,40 +0,0 @@
"""Base entity for Ninlight devices."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import NinlightCoordinator, NinlightConfigEntry
class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
"""Common device info for every entity of one bulb."""
_attr_has_entity_name = True
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
super().__init__(coordinator, context=entry.entry_id)
mac = (entry.unique_id or "unknown").lower()
info = coordinator.info or {}
# Deliberately no `connections={("mac", mac)}`: HA treats a MAC in `connections`
# as a network-topology link, so a bulb the FRITZ!Box tracker already knows merged
# into that router-adjacent device and its entity id fell back to the IP. The MAC
# still identifies the device via unique_id; identifiers are the sole identity here.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
# The bulb itself has no user-visible name field that survives rediscovery
# cleanly, so the entry title (set from mDNS or by the user) names the device.
name=entry.title,
manufacturer="Klyqa",
model=info.get("product_name") or info.get("product"),
model_id=info.get("device_type"),
sw_version=info.get("fw_version"),
configuration_url=f"http://{entry.data['host']}:{entry.data.get('port', DEFAULT_PORT)}/",
)
def _entry_mac(entry: NinlightConfigEntry) -> str:
mac: str = entry.unique_id or "unknown"
return mac.lower()
@@ -1,198 +0,0 @@
"""Light platform for Ninlight bulbs."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .client import NinlightBadRequest, NinlightError, NinlightState
from .const import MAX_TRANSITION_MS
from .coordinator import NinlightConfigEntry, NinlightCoordinator
from .entity import NinlightEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: NinlightConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the light for a Ninlight bulb config entry."""
async_add_entities([NinlightLight(entry.runtime_data, entry)])
def _to_ha_brightness(pct: int) -> int:
"""Bulb brightness is 1..100; HA brightness is 0..255."""
return max(1, min(255, round(pct * 255 / 100)))
def _to_bulb_brightness(level: int) -> int:
"""The bulb rejects brightness 0 and anything above 100 (invalid brightness)."""
return max(1, min(100, round(level * 100 / 255)))
class NinlightLight(NinlightEntity, LightEntity):
"""A Klyqa bulb running the Ninlight firmware."""
# No translation_key: with has_entity_name and a single entity per device, HA names the
# light after the device, giving light.schreibtisch_links rather than ..._bulb.
_attr_name = None
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
self._attr_supported_features = LightEntityFeature.TRANSITION
# Capabilities come from the polled state, not from a one-time __init__ snapshot: an
# entry set up while the bulb was powered off starts from defaults and must pick up
# the real RGB/CT range as soon as the bulb answers again.
@property
def available(self) -> bool:
# An unreachable (powered-off) bulb is an expected state: the entity goes
# unavailable instead of the config entry erroring.
return self.coordinator.last_update_success and self.coordinator.data.available
@property
def supported_color_modes(self) -> set[ColorMode]:
state = self.coordinator.data
# Both modes for RGB bulbs: the firmware keeps the last color and the last color
# temperature and picks between them with `mode`, exactly how HA models a
# color_temp + rgb light.
return {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {ColorMode.COLOR_TEMP}
@property
def min_color_temp_kelvin(self) -> int:
return self.coordinator.data.ct_min
@property
def max_color_temp_kelvin(self) -> int:
return self.coordinator.data.ct_max
@property
def is_on(self) -> bool:
return self.coordinator.data.on
@property
def brightness(self) -> int | None:
return _to_ha_brightness(self.coordinator.data.brightness)
@property
def color_mode(self) -> ColorMode:
state = self.coordinator.data
if not state.on:
# Nothing is lit; reporting a mode would contradict is_on=False.
return ColorMode.UNKNOWN
if state.has_rgb and state.mode == "color":
return ColorMode.RGB
return ColorMode.COLOR_TEMP
@property
def color_temp_kelvin(self) -> int | None:
state = self.coordinator.data
return state.color_temp if self.color_mode == ColorMode.COLOR_TEMP else None
@property
def rgb_color(self) -> tuple[int, int, int] | None:
state = self.coordinator.data
if self.color_mode == ColorMode.RGB:
return (state.r, state.g, state.b)
return None
async def async_turn_on(self, **kwargs: Any) -> None:
state = self.coordinator.data
payload: dict[str, Any] = {"on": True}
if (level := kwargs.get(ATTR_BRIGHTNESS)) is not None:
payload["brightness"] = _to_bulb_brightness(int(level))
elif not state.on:
# HA expects turn_on without a brightness to restore the previous level. The
# firmware already does that (it keeps `brightness` while off), so sending it
# would only re-assert a value the bulb may have clamped itself - but a bulb
# that has never been on reports brightness 0, which its API rejects. Mirror
# the last known value only when the bulb actually has one.
if state.brightness < 1:
payload["brightness"] = 100
# The firmware rejects color and color_temp in one request with
# "color and color_temp are exclusive", so pick one. HA only sends both when a
# caller passes them together; color is the more specific intent and wins.
kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
rgb = kwargs.get(ATTR_RGB_COLOR)
seg: dict[str, Any] | None = None
if rgb is not None and state.has_rgb:
r, g, b = (int(max(0, min(255, v))) for v in rgb)
if state.strip:
seg = {"mode": "solid", "color": {"r": r, "g": g, "b": b}}
else:
payload["color"] = {"r": r, "g": g, "b": b}
elif kelvin is not None:
ct = max(state.ct_min, min(state.ct_max, int(kelvin)))
if state.strip:
seg = {"mode": "white", "color_temp": ct}
else:
payload["color_temp"] = ct
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
await self._apply(payload, seg)
async def async_turn_off(self, **kwargs: Any) -> None:
payload: dict[str, Any] = {"on": False}
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
await self._apply(payload, None)
async def _apply(self, payload: dict[str, Any], seg_patch: dict[str, Any] | None) -> None:
"""Send one command and update immediately.
POST /device/state answers with the full new state, so the coordinator does not
need a refresh round-trip and the UI settles on the real bulb state at once.
On the segment-model firmware the color goes into segment 0 as a separate PATCH
(`PUT /device/segments/0`, fields omitted keep their value); the master on and
brightness stay in the state POST, because the firmware's fade rides on them.
Segment brightness stays untouched so master scaling remains the single knob.
"""
try:
data = await self.coordinator.client.set_state(payload)
if seg_patch is not None:
segs = self.coordinator.data.segments
start = segs[0]["start"] if segs else 0
end = segs[0]["end"] if segs else max(0, int(data.get("pixel_count", 1)) - 1)
await self.coordinator.client.patch_segment(
0, {**seg_patch, "start": start, "end": end}
)
except NinlightBadRequest as err:
# A rejected command is a bug or a firmware mismatch, not a network fault:
# surface it to the caller and leave the entity's state alone.
raise HomeAssistantError(str(err)) from err
except NinlightError as err:
self.coordinator.async_set_update_error(err)
raise HomeAssistantError(str(err)) from err
# Push the device's own reply through the coordinator so every entity of this
# device (light, sensors) settles on the real state at once.
segments = None
if self.coordinator.data.strip:
try:
segments = (await self.coordinator.client.get_segments()).get("segments")
except NinlightError:
pass # the next poll recovers it; the state reply above is still right
self.coordinator.async_set_updated_data(
NinlightState.from_api(data, self.coordinator.info, segments)
)
@@ -1,12 +0,0 @@
{
"domain": "ninlight",
"name": "Ninlight",
"codeowners": [],
"config_flow": true,
"documentation": "https://github.com/ninharp/ninlight-w600",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": [],
"version": "0.1.0",
"zeroconf": ["_ninlight._tcp.local.", "_ninstrip._tcp.local."]
}
@@ -1,35 +0,0 @@
{
"config": {
"steps": {
"user": {
"title": "Ninlight bulb",
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
"data": {
"host": "Host",
"port": "Port",
"token": "API token"
},
"data_description": {
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
}
},
"reauth_confirm": {
"title": "Reauthenticate {name}",
"description": "The bulb rejected the stored API token. Enter its current token.",
"data": {
"token": "API token"
}
}
},
"error": {
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
"invalid_auth": "Wrong API token."
},
"abort": {
"already_configured": "This bulb is already configured.",
"already_in_progress": "A setup flow for this bulb is already running.",
"cannot_connect": "Could not reach the bulb.",
"reauth_successful": "The API token was updated."
}
}
}
@@ -1,35 +0,0 @@
{
"config": {
"steps": {
"user": {
"title": "Ninlight bulb",
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
"data": {
"host": "Host",
"port": "Port",
"token": "API token"
},
"data_description": {
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
}
},
"reauth_confirm": {
"title": "Reauthenticate {name}",
"description": "The bulb rejected the stored API token. Enter its current token.",
"data": {
"token": "API token"
}
}
},
"error": {
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
"invalid_auth": "Wrong API token."
},
"abort": {
"already_configured": "This bulb is already configured.",
"already_in_progress": "A setup flow for this bulb is already running.",
"cannot_connect": "Could not reach the bulb.",
"reauth_successful": "The API token was updated."
}
}
}
-13
View File
@@ -1,13 +0,0 @@
# dnsmasq for the qconnex-firmware listener (no service integration)
interface=wlan0
bind-interfaces
listen-address=192.168.8.1
no-resolv
server=192.168.178.1
address=/#/192.168.8.1
dhcp-range=192.168.8.10,192.168.8.200,255.255.255.0,10m
dhcp-option=3,192.168.8.1
dhcp-option=6,192.168.8.1
log-dhcp
log-queries
no-ping
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Tear down the qconnex-firmware migration listener.
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
SUDO=${SUDO:-sudo -n}
[ -f "$HERE/otaserve.pid" ] && $SUDO pkill -f "python3 $HERE/otaserve.py" 2>/dev/null; rm -f "$HERE/otaserve.pid"
[ -f "$HERE/dnsmasq.pid" ] && $SUDO kill "$(cat "$HERE/dnsmasq.pid")" 2>/dev/null && rm -f "$HERE/dnsmasq.pid"
$SUDO pkill -f "hostapd -B -f $HERE/hostapd.log" 2>/dev/null
$SUDO ip addr flush dev wlan0 2>/dev/null
$SUDO nmcli dev set wlan0 managed yes 2>/dev/null || true
echo "listener down"
-15
View File
@@ -1,15 +0,0 @@
# qconnex-firmware listener AP — temporary experiment, do not enable as a service
interface=wlan0
driver=nl80211
ssid=qconnex-firmware
hw_mode=g
channel=6
ieee80211n=0
auth_algs=1
# WPA2 with the password the firmware's bootup-OTA STA config expects.
# (Open AP worked for the W600 bulbs; ESP32-C3 STA threshold is WPA_PSK.)
wpa=2
wpa_passphrase=qconnex-firmware
wpa_pairwise=CCMP
rsn_pairwise=CCMP
wmm_enabled=0
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env python3
"""Serve qcx OTA images from ./images with HTTP Range support, plus request logging."""
import http.server, os, re, sys, datetime
ROOT = os.path.join(sys.path[0], "images")
LOG = os.path.join(sys.path[0], "qcx-requests.log")
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def do_GET(self):
path = self.path.lstrip("/")
with open(LOG, "a") as f:
f.write("=== GET %s (%s) %s\nPATH: %s\n" % (datetime.datetime.now(), self.client_address[0],
dict(self.headers), path))
fp = os.path.join(ROOT, path)
# any bootup-OTA request for a qcx image that doesn't exist on purpose
# (unknown product/version combo) -> serve the ninlight migrate image
if not os.path.isfile(fp) and path.startswith("qcx_firmware_"):
if "ledshield" in path:
guess = "ninlight_ledshield_rgb_migrate_dev.img"
elif "rgbc3" in path:
guess = "ninlight_ninstrip-rgbc3_migrate_dev.img"
elif ".gu10" in path:
guess = "ninlight_gu10_rgb_migrate_dev.img"
else:
guess = "ninlight_e27_rgb_migrate_dev.img"
fp = os.path.join(ROOT, guess)
if not os.path.isfile(fp):
p = b"not found\n"
self.send_response(404)
self.send_header("Content-Length", str(len(p)))
self.end_headers()
self.wfile.write(p)
return
size = os.path.getsize(fp)
start, rng = 0, self.headers.get("Range")
if rng and (m := re.match(r"bytes=(\d+)-(\d*)$", rng)):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else size - 1
else:
end = size - 1
end = min(end, size - 1)
length = max(0, end - start + 1)
self.send_response(206 if rng else 200)
self.send_header("Content-Length", str(length))
self.send_header("Content-Type", "application/octet-stream")
if rng:
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
self.end_headers()
with open(fp, "rb") as f:
f.seek(start)
left = length
while left > 0:
chunk = f.read(min(8192, left))
if not chunk:
break
self.wfile.write(chunk)
left -= len(chunk)
print("served %s %d-%d to %s" % (path, start, end, self.client_address[0]), flush=True)
do_POST = do_HEAD = do_PUT = do_GET
if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True)
print("qcx ota server on 0.0.0.0:80 root=%s" % ROOT, flush=True)
http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# Bring up the qconnex-firmware migration listener (AP + DHCP/DNS + OTA server).
# Takes wlan0 as an open AP "qconnex-firmware" on 192.168.8.1. Machine stays
# online via ethernet. Teardown: ./down.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
SUDO=${SUDO:-sudo -n}
# wlan0 must be free of any NetworkManager claim
$SUDO nmcli dev set wlan0 managed no 2>/dev/null || true
$SUDO ip link set wlan0 up
$SUDO ip addr replace 192.168.8.1/24 dev wlan0
$SUDO dnsmasq --conf-file="$HERE/dnsmasq-qcx.conf" --pid-file="$HERE/dnsmasq.pid"
$SUDO hostapd -B -f "$HERE/hostapd.log" "$HERE/hostapd.conf"
sleep 1
# serve images on :80 (needs root to bind <1024)
nohup $SUDO python3 "$HERE/otaserve.py" >/dev/null 2>&1 &
echo $! > "$HERE/otaserve.pid"
echo "AP qconnex-firmware up on 192.168.8.1; serving $HERE/images (log: $HERE/qcx-requests.log)"
+57 -10
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
@@ -205,6 +222,7 @@ static void app_task(void *arg)
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_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); 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);
@@ -223,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;
@@ -262,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;
@@ -277,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();
} }
} }
+7
View File
@@ -57,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
+39 -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)
@@ -49,6 +79,12 @@ void nl_settings_defaults(nl_settings_t *s)
s->fade_out_ms = 500; s->fade_out_ms = 500;
s->transition_ms = 300; s->transition_ms = 300;
s->wake_on_change = 0; 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)
+24
View File
@@ -28,6 +28,30 @@ typedef struct {
* switching on or off. */ * switching on or off. */
uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was 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. */ * 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);
+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
+162
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)
@@ -259,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);
} }
@@ -286,6 +310,27 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
* kommen, ohne die Bedeutung von "ssid" zu aendern. */ * kommen, ohne die Bedeutung von "ssid" zu aendern. */
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled"); 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);
} }
@@ -317,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";
@@ -333,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);
+309 -18
View File
@@ -16,8 +16,12 @@
#define NL_NET_PRIO 35 #define NL_NET_PRIO 35
#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,7 +598,16 @@ 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);
copy(out->ssid, s_ssid, sizeof out->ssid); /* 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);
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
+33 -1
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();
@@ -60,15 +67,27 @@ static int save_settings(const nl_settings_t *s)
} }
nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0); 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;
+5
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"
@@ -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)
+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;
+490
View File
@@ -346,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)
{ {
@@ -384,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);
@@ -406,6 +870,32 @@ int main(void)
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback); 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();
} }
+11
View File
@@ -116,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();
@@ -125,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();
} }
+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();
} }
+157
View File
@@ -182,6 +182,153 @@ 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();
@@ -196,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();
} }
+64 -6
View File
@@ -254,15 +254,37 @@ README
echo "== Auf Zugangsdaten pruefen ==" echo "== Auf Zugangsdaten pruefen =="
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt # Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
# wird, wird JEDE Datei des Archivs binaer gegen die echten Werte aus # wird, wird JEDE Datei des Archivs gegen die echten Werte aus config.local.cmake
# config.local.cmake geprueft. Lieber ein abgebrochener Release als ein Archiv mit # geprueft. Lieber ein abgebrochener Release als ein Archiv mit Michaels WLAN-Passwort
# Michaels WLAN-Passwort bei Dritten. # 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" LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then if [ -f "$LOCAL" ]; then
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1 python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
import os import os
import re import re
import sys import sys
import zlib
stage, local = sys.argv[1], sys.argv[2] stage, local = sys.argv[1], sys.argv[2]
text = open(local, encoding="utf-8", errors="replace").read() text = open(local, encoding="utf-8", errors="replace").read()
@@ -271,27 +293,63 @@ text = open(local, encoding="utf-8", errors="replace").read()
# haette nur falsche Alarme erzeugt. # haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN") SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"} PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
# Kopflaenge des vom wm_tool erzeugten Abbilds; danach beginnt der gzip-Strom.
HEADER_LEN = 56
needles = [] needles = []
for name in SECRETS: for name in SECRETS:
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M) m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
if m and m.group(1) not in PUBLIC: if m and m.group(1) not in PUBLIC:
needles.append((name, m.group(1).encode())) 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 = [] hits = []
broken = []
checked = 0
images = 0
for root, _dirs, files in os.walk(stage): for root, _dirs, files in os.walk(stage):
for fn in sorted(files): for fn in sorted(files):
path = os.path.join(root, fn) path = os.path.join(root, fn)
rel = os.path.relpath(path, stage)
with open(path, "rb") as fh: with open(path, "rb") as fh:
data = fh.read() 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: for name, needle in needles:
if needle in data: if any(needle in view for view in views):
hits.append("%s in %s" % (name, os.path.relpath(path, stage))) # 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: if hits:
sys.stderr.write( sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n " "Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
+ "\n ".join(hits) + "\n") + "\n ".join(hits) + "\n")
sys.exit(1) sys.exit(1)
print(" sauber (%d geprueft)" % len(needles)) print(" sauber (%d Dateien geprueft, davon %d Abbilder entpackt)" % (checked, images))
PY PY
fi fi
+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()
+39 -18
View File
@@ -29,20 +29,21 @@ würde dort nur mit einer Fehlermeldung enden.
## Bildsatz: mit oder ohne WLAN-Zugangsdaten ## Bildsatz: mit oder ohne WLAN-Zugangsdaten
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es 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: gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor — **für beide
Betriebsarten**, Migration **und Rettung**:
| Umschalterstellung | Verzeichnis auf dem Pi | gebaut mit | die migrierte Lampe … | | Umschalterstellung | Migration | Rettung | gebaut mit | die Lampe … |
|---|---|---|---| |---|---|---|---|---|
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN | | `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/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` | | `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 Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration**: der (Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration/
Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts und bleibt, bis Rettung**: der Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts
ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der Fußzeile des Panels** — und bleibt, bis ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der
in jedem Modus, auch bei ausgeschalteter Station: man soll vor jedem Einschalten sehen, Fußzeile des Panels** — in jedem Modus, auch bei ausgeschalteter Station: man soll vor
was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu flashen, weil die jedem Einschalten sehen, was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu
Einstellung von vorletzter Woche noch stand. flashen, weil die Einstellung von vorletzter Woche noch stand.
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt, **Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
@@ -51,14 +52,34 @@ ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden 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. Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie gelaufen), **Der Umschalter galt bisher nur für die Migration — jetzt auch für die Rettung, und das
wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz. Der Server hat eine spürbare Folge.** Bis zu dieser Erweiterung bekam eine Rettung *immer* ein
antwortet mit 404 und `status: noimageset`, `ninlight-station status` sagt „FEHLT", und auf Abbild **mit** Zugangsdaten (der flache Bestand aus `build/`). Seitdem gilt für die
dem Panel steht „SATZ FEHLT". „Vorhanden" heißt dabei überall dasselbe: Verzeichnis da Rettung dieselbe Vorgabe `ohne-wlan` wie für die Migration: **eine gerettete Lampe steht
**und** mindestens ein Image darin. 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.
`deploy.sh` liefert beide Sätze aus. Fehlt `build-nocreds/`, bleibt der Satz `ohne-wlan` **Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie
auf dem Pi unangetastet (statt geleert zu werden) und der Lauf meldet, womit er entsteht. 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 ## Taster am Gerät
+35 -35
View File
@@ -139,7 +139,7 @@ class Action:
diesem Moment gar nicht vor Augen hat.""" diesem Moment gar nicht vor Augen hat."""
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True, def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True,
done_text: str | None = None, confirm_title: str = "RUECKFRAGE", done_text: str | None = None, confirm_title: str = "CONFIRM",
confirm_details=None) -> None: confirm_details=None) -> None:
self.label = label self.label = label
self.argv = tuple(argv) self.argv = tuple(argv)
@@ -154,16 +154,16 @@ class Action:
def action_shutdown() -> Action: def action_shutdown() -> Action:
return Action("Herunterfahren", kind="shutdown", confirm=True, return Action("Shut down", kind="shutdown", confirm=True,
done_text="Faehrt herunter") done_text="Shutting down")
def action_migrate_listen() -> Action: def action_migrate_listen() -> Action:
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus, """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 bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
vor dem Automatikmodus und traegt die uebliche Rueckfrage.""" vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
return Action("Migration: horchen", ("migrate", "on", "--listen"), return Action("Migration: listen", ("migrate", "on", "--listen"),
done_text="Migration: horcht") done_text="Migration: listening")
def action_migrate_auto() -> Action: def action_migrate_auto() -> Action:
@@ -179,11 +179,11 @@ def action_migrate_auto() -> Action:
danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der 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 Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
Rueckfrage selbst.""" Rueckfrage selbst."""
return Action("Migration: AUTOMATIK", ("migrate", "on"), return Action("Migration: AUTOMATIC", ("migrate", "on"),
done_text="Migration: automatisch", done_text="Migration: automatic",
confirm_title="ACHTUNG", confirm_title="WARNING",
confirm_details=["Flasht JEDE alte Klyqa-Lampe", confirm_details=["Flashes EVERY old Klyqa lamp",
"in Reichweite - auch fremde"]) "in range - other people's too"])
def action_imageset(current: str) -> Action: def action_imageset(current: str) -> Action:
@@ -196,19 +196,19 @@ def action_imageset(current: str) -> Action:
Lampe, die sie erwischt.""" Lampe, die sie erwischt."""
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan" target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
if target == "mit-wlan": if target == "mit-wlan":
return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"), return Action("Switch to: with Wi-Fi", ("bildsatz", "mit-wlan"),
done_text="Bildsatz: mit WLAN", done_text="Image set: with Wi-Fi",
confirm_title="ACHTUNG", confirm_title="WARNING",
confirm_details=["Jede migrierte Lampe bekommt", confirm_details=["Every migrated lamp receives",
"dein WLAN-Passwort"]) "your Wi-Fi password"])
return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"), return Action("Switch to: no Wi-Fi", ("bildsatz", "ohne-wlan"),
done_text="Bildsatz: ohne WLAN") done_text="Image set: no Wi-Fi")
def action_cancel() -> Action: def action_cancel() -> Action:
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die """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.""" Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
return Action("Station aus", ("off",), confirm=False, done_text="Station aus") return Action("Station off", ("off",), confirm=False, done_text="Station off")
def menu_actions(state: dict) -> list[Action]: def menu_actions(state: dict) -> list[Action]:
@@ -221,7 +221,7 @@ def menu_actions(state: dict) -> list[Action]:
if not running: if not running:
return [ return [
Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"), Action("Start recovery", ("recovery", "on"), done_text="Recovery running"),
# Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der # Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo # naheliegende und der harmlose ist - wer nur blaettert und irgendwo
# loslaesst, landet nicht im Automatikmodus. # loslaesst, landet nicht im Automatikmodus.
@@ -237,13 +237,13 @@ def menu_actions(state: dict) -> list[Action]:
if mode == "migration" and target: if mode == "migration" and target:
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es # Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen. # nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)), items.append(Action("Arm: {}".format(target), ("arm", str(target)),
done_text="Freigegeben: {}".format(target))) done_text="Armed: {}".format(target)))
items.append(Action("Station aus", ("off",), done_text="Station aus")) items.append(Action("Station off", ("off",), done_text="Station off"))
items.append(action_imageset(imageset)) items.append(action_imageset(imageset))
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage. # Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False, items.append(Action("Refresh display", ("display", "test"), confirm=False,
done_text="Anzeige aufgefrischt")) done_text="Display refreshed"))
items.append(action_shutdown()) items.append(action_shutdown())
return items return items
@@ -561,32 +561,32 @@ class ButtonUI:
and self.screen in ("idle", "busy")): and self.screen in ("idle", "busy")):
held = now - self._press_t held = now - self._press_t
if self._hold_stage == 1: if self._hold_stage == 1:
return _view("hold", "STATION AUS", return _view("hold", "STATION OFF",
"Herunterfahren in {} s".format( "Shutting down in {} s".format(
_countdown(SHUTDOWN_HOLD_S, held)), _countdown(SHUTDOWN_HOLD_S, held)),
["Weiter halten - Loslassen bricht ab"]) ["Keep holding - release to cancel"])
if held >= SHORT_PRESS_MAX_S: if held >= SHORT_PRESS_MAX_S:
return _view("hold", "HALTEN", return _view("hold", "HOLD",
"Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)), "Cancel in {} s".format(_countdown(CANCEL_HOLD_S, held)),
["Weiter halten - Loslassen bricht ab"]) ["Keep holding - release to cancel"])
if self.screen == "menu" and self.items: if self.screen == "menu" and self.items:
selected = self.items[self.index] selected = self.items[self.index]
rest = self.items[self.index + 1:] + self.items[:self.index] rest = self.items[self.index + 1:] + self.items[:self.index]
return _view("menu", "MENUE", "> " + selected.label, return _view("menu", "MENU", "> " + selected.label,
[a.label for a in rest] + ["kurz: weiter, halten: waehlen"]) [a.label for a in rest] + ["tap: next, hold: select"])
if self.screen == "confirm" and self.pending is not None: if self.screen == "confirm" and self.pending is not None:
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei # Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des # der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird. # Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
details = list(self.pending.confirm_details or []) details = list(self.pending.confirm_details or [])
details += ["halten (1,5 s): ja", "kurz: nein"] details += ["hold (1.5 s): yes", "tap: no"]
return _view("confirm", self.pending.confirm_title, self.pending.label, return _view("confirm", self.pending.confirm_title, self.pending.label,
details) details)
if self.screen == "busy" and self.busy is not None: if self.screen == "busy" and self.busy is not None:
return _view("busy", "BITTE WARTEN", self.busy.label, []) return _view("busy", "PLEASE WAIT", self.busy.label, [])
if self.result is not None: if self.result is not None:
text, ok = self.result text, ok = self.result
return _view("result", "FERTIG" if ok else "FEHLER", text, []) return _view("result", "DONE" if ok else "ERROR", text, [])
return {"view": "idle", "active": False} return {"view": "idle", "active": False}
+230 -47
View File
@@ -13,8 +13,15 @@ MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt # Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten. # einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh} 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
@@ -24,6 +31,17 @@ 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
@@ -45,9 +63,24 @@ else
FILES+=("$f") FILES+=("$f")
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort) 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
@@ -63,9 +96,54 @@ for f in "${FILES[@]}"; do
set -- $out set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3" printf '%-26s %-10s %s\n' "$1" "$2" "$3"
NINL_VERSION[$target]="$2" NINL_VERSION[$target]="$2"
cp "$f" "$STAGE/$base" # 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 ==" echo "== Migrationsimages signieren =="
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur # 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). # gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
@@ -89,9 +167,7 @@ MIGRATE_COMPLETE=0
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur # zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
# einer, darf --delete auf dem Pi nicht laufen. # einer, darf --delete auf dem Pi nicht laufen.
NOCREDS_COMPLETE=0 NOCREDS_COMPLETE=0
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh, # NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
# dieselbe Variable wie dort und in tools/release.sh).
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json. # Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut # $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
@@ -253,46 +329,115 @@ fi
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare # Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
# Fehler. Lieber ein abgebrochener Deploy. # Fehler. Lieber ein abgebrochener Deploy.
# #
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur # Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen.
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck. # Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh. # Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden:
if [ -d "$STAGE/migrate-nocreds" ]; then # er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen ==" # 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" LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then if [ -f "$LOCAL" ]; then
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1 python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1
import os import os
import re import re
import sys import sys
import zlib
stage, local = sys.argv[1], sys.argv[2] local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:]
text = open(local, encoding="utf-8", errors="replace").read() text = open(local, encoding="utf-8", errors="replace").read()
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der # Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen # Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
# haette nur falsche Alarme erzeugt. # haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN") SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"} 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 = [] needles = []
for name in SECRETS: for name in SECRETS:
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M) m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
if m and m.group(1) not in PUBLIC: if m and m.group(1) not in PUBLIC:
needles.append((name, m.group(1).encode())) 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 = [] hits = []
for root, _dirs, files in os.walk(stage): broken = []
for fn in sorted(files): images = 0
path = os.path.join(root, fn) for image_set in sets:
with open(path, "rb") as fh: root_dir = os.path.join(stage, image_set)
data = fh.read() for root, _dirs, files in os.walk(root_dir):
for name, needle in needles: for fn in sorted(files):
if needle in data: path = os.path.join(root, fn)
hits.append("%s in %s" % (name, os.path.relpath(path, stage))) 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: if hits:
sys.stderr.write( sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, " "Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
"nichts uebertragen:\n " + "\n ".join(hits) + "\n") "nichts uebertragen:\n " + "\n ".join(hits) + "\n")
sys.exit(1) sys.exit(1)
print(" sauber (%d geprueft)" % len(needles)) print(" sauber (%d Abbilder in %d Saetzen geprueft: %s)"
% (images, len(sets), ", ".join(sets)))
CHECKPY CHECKPY
else else
echo " uebersprungen - $LOCAL nicht vorhanden" >&2 echo " uebersprungen - $LOCAL nicht vorhanden" >&2
@@ -326,11 +471,12 @@ rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/" 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_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" <<'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 MIGRATE_COMPLETE=$2
NOCREDS_COMPLETE=$3 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}
@@ -358,27 +504,41 @@ if [ "$FULL_SYNC" -eq 1 ]; then
else else
echo " voller Abgleich - keine Images werden entfernt" echo " voller Abgleich - keine Images werden entfernt"
fi fi
# migrate/ wird gleich in einem eigenen Schritt behandelt (siehe unten) - hier # migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
# ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne # hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
# Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht # Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
# existiert. # unter /tmp/ninlight-images deshalb gar nicht existiert.
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/" #
# 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 --exclude=/migrate-wlan --exclude=/migrate-nocreds /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 fi
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG # $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
# beschreibt (beide Schluessel, alle Ziele) und --delete deshalb erlaubt ist. # beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
# #
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf # Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel # seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden - # fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys # tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen # geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
# statt beim Ersetzen). # frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
sync_migrate_set() { # 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 set=$1
complete=$2 complete=$2
if [ -d "/tmp/ninlight-images/$set" ]; then if [ -d "/tmp/ninlight-images/$set" ]; then
@@ -455,20 +615,28 @@ MERGE
fi fi
fi fi
else else
# Weder "kein Schluesselverzeichnis" noch "Verzeichnis vorhanden, aber leer" laesst # Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis
# sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von # vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
# diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau; # ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
# diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit). # ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
echo " keine Migrationsimages fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet" # (Fixrunde 2, Kleinigkeit).
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
fi fi
} }
# Beide Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem Rechner # Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem
# vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der andere auf # Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der
# dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der fehlt, und die # andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der
# Station liefert in dieser Stellung sichtbar nichts aus. # fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus.
sync_migrate_set migrate-wlan "$MIGRATE_COMPLETE" sync_image_set migrate-wlan "$MIGRATE_COMPLETE"
sync_migrate_set migrate-nocreds "$NOCREDS_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 # 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 # 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. # es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
@@ -476,6 +644,21 @@ if [ -d "$ROOT_DIR/images/migrate" ]; then
sudo rm -rf "$ROOT_DIR/images/migrate" sudo rm -rf "$ROOT_DIR/images/migrate"
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)" echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
fi 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
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 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"
+214 -30
View File
@@ -34,6 +34,7 @@ import os
import subprocess import subprocess
import sys import sys
import time import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package, # Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
@@ -66,10 +67,39 @@ UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"
# schreibt server.py nichts mehr. # schreibt server.py nichts mehr.
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE", IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
"/var/lib/ninlight-station/imageset")) "/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 # Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
# Satz OHNE Zugangsdaten (dort steht die Begruendung). # Satz OHNE Zugangsdaten (dort steht die Begruendung).
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"} IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
IMAGESET_DEFAULT = "ohne-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 POLL_INTERVAL_S = 5
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck # Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
@@ -170,13 +200,58 @@ def _read_autooff_minutes(run_dir: Path, now: float | None = None):
return int(whole) + (1 if rest else 0) return int(whole) + (1 if rest else 0)
def _read_mode_file(run_dir: Path) -> str: # Lebenszeichen: ein kleiner Punkt, der erscheint, sobald irgendeine Anfrage hereinkommt
"""Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die # (auch eine erfolglose - "notfound"/"listen"/"unknown"/"noimageset" zaehlen genauso wie
Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert # eine erfolgreiche Auslieferung), und nach ACTIVITY_WINDOW_S Sekunden von selbst wieder
wie in server.py:resolve_mode().""" # 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: try:
value = (run_dir / "mode").read_text().strip() parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except OSError: 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 "recovery"
return value if value in ("recovery", "migration") else "recovery" return value if value in ("recovery", "migration") else "recovery"
@@ -209,46 +284,105 @@ def _service_active(service_name: str) -> bool:
return True return True
def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool]: def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tuple[str, bool]:
"""(Name des eingestellten Bildsatzes, liegt er ueberhaupt auf dem Pi?). """(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 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 IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
Panel etwas anderes behaupten als der Server tut. Panel etwas anderes behaupten als der Server tut.
`ok` ist False, wenn der GEWAEHLTE Satz nicht auf dem Pi liegt - nach derselben `ok` ist False, wenn der GEWAEHLTE Satz fuer den GERADE GELTENDEN Modus nicht auf dem
Regel wie server.py:migrate_set_present(): Verzeichnis vorhanden UND mindestens ein Pi liegt - nach derselben Regel wie server.py:migrate_set_present()/
Image darin. Dann liefert server.py nichts aus (Status "noimageset"), und genau das rescue_set_present(): Verzeichnis vorhanden UND mindestens ein Image darin. Welches
muss am Panel stehen, statt dass der Bediener vor einer Lampe steht, die nichts Verzeichnispaar das ist (migrate-*/rescue-*), entscheidet `mode` - Rettung und
bekommt. Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts Migration liegen seit der Erweiterung des Umschalters auf die Rettung in getrennten
behauptet: dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine.""" 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: try:
value = imageset_path.read_text().strip() value = imageset_path.read_text().strip()
except OSError: except (OSError, UnicodeDecodeError):
value = "" value = ""
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
image_dir = conf.get("IMAGE_DIR") image_dir = conf.get("IMAGE_DIR")
if not image_dir: if not image_dir:
return name, True return name, True
set_dir = Path(image_dir) / IMAGESET_DIRS[name] dirs = RESCUE_IMAGESET_DIRS if mode == "recovery" else IMAGESET_DIRS
set_dir = Path(image_dir) / dirs[name]
try: try:
return name, any(p.name.endswith(".img") for p in set_dir.iterdir()) return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
except OSError: except OSError:
return name, False return name, False
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, def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME, service_name: str = SERVICE_NAME,
imageset_path: Path = IMAGESET_FILE) -> dict: imageset_path: Path = IMAGESET_FILE,
last_mode_path: Path = LAST_MODE_FILE) -> dict:
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten. """Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie Modus kommt vorrangig aus `last_mode_path` (Vorgabe "recovery", wie
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on" server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
zuletzt geschrieben haben und unabhaengig davon verfuegbar, ob/wann der Serverprozess zuletzt geschrieben haben, dauerhaft (siehe LAST_MODE_FILE oben - NICHT die ephemere
seit dem letzten Start schon einmal status.json geschrieben hat. status.json selbst RUN_DIR/mode, die server.py fuer den naechsten Start liest) und unabhaengig davon
liefert nur served/recent/last_seen; sein "mode"-Feld wird bewusst NICHT als verfuegbar, ob/wann der Serverprozess seit dem letzten Start schon einmal
Modusquelle verwendet (Fixrunde 1: die urspruengliche Fassung hatte den Vorrang status.json geschrieben hat. status.json selbst liefert nur served/recent/
umgekehrt). 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 Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
@@ -257,17 +391,38 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in 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 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 Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
Menue auffaellt, hat genau den Zweck verfehlt.""" 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) conf = _read_conf(conf_path)
imageset, imageset_ok = _read_imageset(imageset_path, conf) mode = _read_mode_file(last_mode_path)
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
if not _service_active(service_name): if not _service_active(service_name):
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok} # 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()
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
"ip": adresse, "iface": geraet}
mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json") status = _read_json(run_dir / "status.json")
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"), state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
"imageset": imageset, "imageset_ok": imageset_ok} "imageset": imageset, "imageset_ok": imageset_ok,
"activity": _recent_request(status)}
if mode == "recovery": if mode == "recovery":
state["ssid"] = conf.get("SSID") state["ssid"] = conf.get("SSID")
@@ -585,6 +740,23 @@ def run_clear(revision: str) -> None:
log.info("Panel geleert (EPD_REVISION=%s)", revision) 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, def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
read_state=build_state, read_ui=read_ui) -> None: read_state=build_state, read_ui=read_ui) -> None:
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle """Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
@@ -612,13 +784,25 @@ def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
panel = Panel(revision) panel = Panel(revision)
panel.clear() panel.clear()
planner = RefreshPlanner(clock.monotonic()) planner = RefreshPlanner(clock.monotonic())
base_state = read_state() base_state = _safe_read_state(read_state, {"mode": None})
last_state_poll = clock.monotonic() last_state_poll = clock.monotonic()
while True: while True:
now = clock.monotonic() now = clock.monotonic()
if now - last_state_poll >= POLL_INTERVAL_S: if now - last_state_poll >= POLL_INTERVAL_S:
base_state = read_state() # 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 last_state_poll = now
ui = read_ui() ui = read_ui()
if ui: if ui:
+54 -18
View File
@@ -172,7 +172,7 @@ FONT_SEARCH_PATHS = (
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"), ("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
) )
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"} MODE_LABELS = {"recovery": "RECOVERY", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem # "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
# Zielnamen nach sich), deshalb hier nicht gelistet. # Zielnamen nach sich), deshalb hier nicht gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"} PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
@@ -186,8 +186,8 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft # 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 # verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite. # Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
ARMED_LABEL = "FREIGEGEBEN" ARMED_LABEL = "ARMED"
ARMED_DETAIL_PREFIX = "Freigabe: " ARMED_DETAIL_PREFIX = "Armed: "
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert # Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts, # und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts,
@@ -201,16 +201,16 @@ ARMED_DETAIL_PREFIX = "Freigabe: "
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe # 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. # test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
IMAGESET_LABELS = { IMAGESET_LABELS = {
"ohne-wlan": "ohne WLAN-Daten (Onboarding-AP)", "ohne-wlan": "no Wi-Fi data (onboarding AP)",
"mit-wlan": "MIT WLAN-Daten (eigenes Netz)", "mit-wlan": "WITH Wi-Fi data (own network)",
} }
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie # 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 # 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 # 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. # Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
IMAGESET_MISSING_LABELS = { IMAGESET_MISSING_LABELS = {
"ohne-wlan": "SATZ FEHLT: ohne WLAN-Daten", "ohne-wlan": "SET MISSING: no Wi-Fi data",
"mit-wlan": "SATZ FEHLT: MIT WLAN-Daten", "mit-wlan": "SET MISSING: WITH Wi-Fi data",
} }
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt # Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
@@ -228,10 +228,10 @@ FINAL_LABELS = {
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es # Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an # ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font. # test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
"poweroff": ("AUSGESCHALTET", "Strom trennen erlaubt", []), "poweroff": ("POWERED OFF", "Safe to unplug", []),
"reboot": ("NEUSTART", "Strom NICHT trennen!", "reboot": ("REBOOTING", "Do NOT unplug!",
["Der Pi kommt in rund 2 min wieder.", ["The Pi is back in about 2 min.",
"Solange bleibt dieses Bild stehen."]), "This screen stays until then."]),
} }
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant): # Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
@@ -293,7 +293,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
if label is None: if label is None:
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet" # Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
# zu sagen, wenn gleich wieder gebootet wird. # zu sagen, wenn gleich wieder gebootet wird.
return [("Faehrt herunter", ROLE_BIG)] return [("Shutting down", ROLE_BIG)]
title, hero, details = label title, hero, details = label
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)] return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
+ [(d, ROLE_DETAIL) for d in details]) + [(d, ROLE_DETAIL) for d in details])
@@ -308,14 +308,39 @@ def blocks(state: dict) -> list[tuple[str, str]]:
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen # diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt. # gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
out.append(("Station aus", ROLE_BIG)) 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))
return out + footer return out + footer
if mode not in ("recovery", "migration"): if mode not in ("recovery", "migration"):
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
# unterstellen. # unterstellen.
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG)) out.append((f"Status unknown ({mode})" if mode else "Status unknown", ROLE_BIG))
return out + footer return out + footer
out.append((MODE_LABELS[mode], ROLE_HEADER)) out.append((MODE_LABELS[mode], ROLE_HEADER))
@@ -409,12 +434,23 @@ def blocks(state: dict) -> list[tuple[str, str]]:
autooff_minutes = state.get("autooff_minutes") autooff_minutes = state.get("autooff_minutes")
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool): if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
if autooff_minutes > 0: if autooff_minutes > 0:
out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL)) out.append(("Auto-off in {} min".format(autooff_minutes), ROLE_DETAIL))
else: else:
out.append(("Auto-Aus steht an", ROLE_DETAIL)) out.append(("Auto-off pending", ROLE_DETAIL))
served = state.get("served") served = state.get("served")
out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL)) 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") recent = state.get("recent")
if isinstance(recent, list) and recent: if isinstance(recent, list) and recent:
@@ -467,7 +503,7 @@ def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text # Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als # (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
# ein leeres Panel zu zeigen, das nach "haengt" aussieht. # ein leeres Panel zu zeigen, das nach "haengt" aussieht.
out.append(("Bedienung", ROLE_BIG)) out.append(("Button menu", ROLE_BIG))
return out return out
+201 -130
View File
@@ -107,14 +107,14 @@ elif [ -e "$CONF" ]; then
# Rechte aufzuweichen. # Rechte aufzuweichen.
eval "$(sudo cat "$CONF")" eval "$(sudo cat "$CONF")"
else else
echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2 echo "Error: $CONF missing - run tools/station/setup_pi.sh first" >&2
exit 1 exit 1
fi fi
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
eval "test -n \"\${$_key-}\"" || { eval "test -n \"\${$_key-}\"" || {
echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2 echo "Error: $CONF: key $_key missing or empty - run tools/station/setup_pi.sh" >&2
exit 1 exit 1
} }
done done
@@ -125,13 +125,38 @@ need_profile() {
# -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit # -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
# "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen. # "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
nmcli -t --escape no -f NAME con show | grep -Fqx "$1" || { nmcli -t --escape no -f NAME con show | grep -Fqx "$1" || {
echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 echo "Error: NetworkManager profile '$1' missing - run tools/station/setup_pi.sh" >&2
exit 1 exit 1
} }
} }
images() { # --- Modus lesen/schreiben (Fixrunde 1, Befund 3) -----------------------------------
find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort #
# RUN_DIR/mode (write_mode() unten schreibt sie zusaetzlich) taugt NICHT als Quelle
# fuer "welcher Modus gilt gerade/als naechstes", solange die Station AUS ist: die Unit
# setzt RuntimeDirectoryPreserve=restart, das erhaelt /run/ninlight-station nur ueber
# einen "systemctl restart" (das, was "recovery on"/"migrate on" bei einem schon
# laufenden Dienst ausloesen), NICHT ueber einen echten "systemctl stop" - und genau
# den fuehrt "ninlight-station off" aus. Fehlt die Datei, gilt ueberall stillschweigend
# "recovery" (derselbe sichere Vorgabewert wie beim Bildsatz) - eine fruehere Fassung
# dieses Skripts UND von display.py hat das mit "die Datei bleibt bei jedem Zustand
# erhalten" verwechselt.
#
# LAST_MODE_FILE ist deshalb wie IMAGESET_FILE eine EIGENE, dauerhafte Ablage unter
# /var/lib - dieselbe Datei, die display.py:LAST_MODE_FILE liest (Panel und CLI muessen
# sich hier einig sein, sonst zeigt das eine "Rettung: MISSING" waehrend das andere
# "Migration: 3 image(s)" meldet).
LAST_MODE_FILE=${NINLIGHT_LAST_MODE_FILE:-/var/lib/ninlight-station/mode}
# Der zuletzt angeforderte Modus, oder "recovery" bei fehlender/unlesbarer/unbekannter
# Datei - dieselbe Vorgabe und dieselbe Begruendung wie imageset_get() unten. Scheitert
# nie: eine kaputte Datei darf weder "status" noch "bildsatz" aufhalten.
current_mode() {
_m=$(cat "$LAST_MODE_FILE" 2>/dev/null | tr -d ' \t\r\n') || true
case "$_m" in
recovery|migration) printf '%s' "$_m" ;;
*) printf '%s' "recovery" ;;
esac
} }
# --- Bildsatz lesen, schreiben, benennen -------------------------------------------- # --- Bildsatz lesen, schreiben, benennen --------------------------------------------
@@ -153,18 +178,33 @@ imageset_get() {
esac esac
} }
# Das Verzeichnis zum Namen des Bildsatzes ($1). # Das Verzeichnis zum Namen des Bildsatzes ($1), fuer den Modus $2 (Vorgabe:
# current_mode()). Rettung und Migration liegen in getrennten Verzeichnispaaren
# (server.py:RESCUE_SET_DIRS/IMAGE_SET_DIRS begruendet das) - dieselbe Unterscheidung
# wie display.py:_read_imageset(). Fixrunde 1, Befund 1: vor dieser Aenderung pruefte
# dieses Skript hier IMMER die Migrationsverzeichnisse, auch im Rettungsbetrieb - ein
# falsches "MISSING" dort verleitet dazu, auf mit-wlan umzuschalten, und DANN liefert
# die Rettung tatsaechlich Abbilder mit dem WLAN-Passwort aus.
imageset_dir() { imageset_dir() {
case "$1" in _name=$1
mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;; _mode=${2:-$(current_mode)}
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;; if [ "$_mode" = "recovery" ]; then
esac case "$_name" in
mit-wlan) printf '%s' "$IMAGE_DIR/rescue-wlan" ;;
*) printf '%s' "$IMAGE_DIR/rescue-nocreds" ;;
esac
else
case "$_name" in
mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;;
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;;
esac
fi
} }
imageset_label() { imageset_label() {
case "$1" in case "$1" in
mit-wlan) printf 'mit-wlan (Lampe haengt sofort im eigenen WLAN)' ;; mit-wlan) printf 'mit-wlan (lamp joins your Wi-Fi right away)' ;;
*) printf 'ohne-wlan (Lampe startet im Onboarding-Zugangspunkt)' ;; *) printf 'ohne-wlan (lamp starts its onboarding access point)' ;;
esac esac
} }
@@ -179,8 +219,14 @@ imageset_set() {
sudo mv -f "$_tmp" "$IMAGESET_FILE" sudo mv -f "$_tmp" "$IMAGESET_FILE"
} }
migrate_images() { # Die Abbilder des eingestellten Bildsatzes fuer den Modus $1 (Vorgabe: current_mode()).
find "$(imageset_dir "$(imageset_get)")" -maxdepth 1 -name '*.img' 2>/dev/null | sort # Ersetzt das fruehere migrate_images() (nur Migration) UND das fruehere images() (immer
# flach in $IMAGE_DIR, Fixrunde 1, Befund 2: nach dem Deploy-Umbau liegt dort nichts
# mehr - recovery_on() meldete seither bei JEDEM Start faelschlich "answers everything
# with 404", ununterscheidbar vom echten Fehlerfall).
imageset_images() {
_mode=${1:-$(current_mode)}
find "$(imageset_dir "$(imageset_get)" "$_mode")" -maxdepth 1 -name '*.img' 2>/dev/null | sort
} }
# "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten. # "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten.
@@ -194,49 +240,54 @@ bildsatz_cmd() {
case "$_want" in case "$_want" in
ohne-wlan|mit-wlan) ;; ohne-wlan|mit-wlan) ;;
*) *)
echo "Fehler: unbekannter Bildsatz '$_want' - erlaubt sind 'ohne-wlan' und 'mit-wlan'" >&2 echo "Error: unknown image set '$_want' - allowed are 'ohne-wlan' and 'mit-wlan'" >&2
exit 2 exit 2
;; ;;
esac esac
imageset_set "$_want" || { imageset_set "$_want" || {
echo "Fehler: $IMAGESET_FILE konnte nicht geschrieben werden" >&2 echo "Error: could not write $IMAGESET_FILE" >&2
exit 1 exit 1
} }
echo "Bildsatz: $(imageset_label "$_want")" echo "Image set: $(imageset_label "$_want")"
[ "$_now" = "$_want" ] && echo " (war schon eingestellt)" [ "$_now" = "$_want" ] && echo " (was already set)"
# Der Umschalter gilt fuer BEIDE Betriebsarten - hier wird deshalb der Satz gemeldet,
# der fuer den Modus zaehlt, in dem die Station gerade steht bzw. als naechstes
# anlaufen wuerde (current_mode()), nicht pauschal die Migration.
_dir=$(imageset_dir "$_want") _dir=$(imageset_dir "$_want")
if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then
# Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts # Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts
# aus (Status "noimageset"), und das Panel sagt "FEHLT". Hier trotzdem sofort # aus (Status "noimageset"), und das Panel sagt "MISSING". Hier trotzdem sofort
# warnen, damit niemand erst an der Lampe merkt, dass nichts kommt. # warnen, damit niemand erst an der Lampe merkt, dass nichts kommt.
echo "Warnung: $_dir fehlt oder ist leer - in diesem Bildsatz wird NICHTS ausgeliefert. tools/station/deploy.sh erneut laufen lassen." >&2 echo "Warning: $_dir missing or empty - this image set serves NOTHING. Run tools/station/deploy.sh again." >&2
fi fi
if [ "$_want" = "mit-wlan" ]; then if [ "$_want" = "mit-wlan" ]; then
echo "Achtung: migrierte Lampen haengen damit sofort im WLAN aus config.local.cmake - auch fremde Lampen, die im Automatikmodus mitgenommen werden." >&2 echo "Caution: any lamp migrated OR rescued while this is set joins the Wi-Fi from config.local.cmake right away - including lamps that are not yours and got picked up in automatic mode." >&2
fi fi
} }
# Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben. # Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben - fuer
# den Modus, in dem die Station gerade steht bzw. als naechstes anlaufen wuerde.
imageset_status_lines() { imageset_status_lines() {
_mode=$(current_mode)
_set=$(imageset_get) _set=$(imageset_get)
_dir=$(imageset_dir "$_set") _dir=$(imageset_dir "$_set" "$_mode")
# "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie # "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie
# server.py:migrate_set_present() und display.py:_read_imageset(). Ein leeres # server.py:migrate_set_present()/rescue_set_present() und display.py:_read_imageset().
# Verzeichnis liefert genauso nichts aus wie ein fehlendes. # Ein leeres Verzeichnis liefert genauso nichts aus wie ein fehlendes.
_n=$(migrate_images | wc -l | tr -d ' ') _n=$(imageset_images "$_mode" | wc -l | tr -d ' ')
if [ "$_n" -gt 0 ]; then if [ "$_n" -gt 0 ]; then
_state="$_n Image(s)" _state="$_n image(s)"
else else
_state="FEHLT - es wird nichts ausgeliefert" _state="MISSING - nothing will be served"
fi fi
printf 'Bildsatz: %s\n' "$(imageset_label "$_set")" printf 'Image set: %s\n' "$(imageset_label "$_set")"
printf ' %s (%s)\n' "$_dir" "$_state" printf ' %s (%s)\n' "$_dir" "$_state"
# Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die # Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die
# Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst # Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst
# glaubt er, sein Eintrag von Hand haette gewirkt. # glaubt er, sein Eintrag von Hand haette gewirkt.
_raw=$(imageset_raw) _raw=$(imageset_raw)
if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then
printf " (unbekannte Einstellung '%s' in %s - Vorgabe gilt)\n" \ printf " (unknown setting '%s' in %s - default applies)\n" \
"$_raw" "$IMAGESET_FILE" "$_raw" "$IMAGESET_FILE"
fi fi
} }
@@ -276,7 +327,7 @@ ap_switch() {
# gehoert zum Wert). # gehoert zum Wert).
_active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-) _active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)
if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2 echo "Error: after switching off, wlan0 still shows '$_active' active - check NetworkManager (access point stays up)" >&2
return 1 return 1
fi fi
return 0 return 0
@@ -287,18 +338,28 @@ ap_switch() {
if [ "$_active" != "$_target" ]; then if [ "$_active" != "$_target" ]; then
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true
echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2 echo "Error: after switching to '$_target', wlan0 unexpectedly shows '$_active' - both station profiles brought down again" >&2
return 1 return 1
fi fi
} }
# Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann # Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann
# umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file. # umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file. ZUSAETZLICH
# dieselbe Zeichenkette nach $LAST_MODE_FILE (dauerhaft, siehe dort) - RUN_DIR/mode
# ueberlebt nur einen "systemctl restart", nicht das "systemctl stop" von
# "ninlight-station off" (Fixrunde 1, Befund 3). Beide Schreibvorgaenge muessen
# gelingen: schlaegt der dauerhafte fehl, wuerden Panel und CLI nach einem "off" auf
# den falschen (naemlich den vorherigen) Modus zurueckfallen, ohne dass das hier
# auffiele.
write_mode() { write_mode() {
sudo mkdir -p "$RUN_DIR" sudo mkdir -p "$RUN_DIR"
_tmp="$RUN_DIR/.mode-$$" _tmp="$RUN_DIR/.mode-$$"
printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
sudo mv -f "$_tmp" "$RUN_DIR/mode" sudo mv -f "$_tmp" "$RUN_DIR/mode" || return 1
sudo mkdir -p "$(dirname "$LAST_MODE_FILE")"
_tmp2="$(dirname "$LAST_MODE_FILE")/.mode-$$"
printf '%s' "$1" | sudo tee "$_tmp2" >/dev/null && sudo chmod 0644 "$_tmp2" || return 1
sudo mv -f "$_tmp2" "$LAST_MODE_FILE"
} }
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im # Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im
@@ -326,16 +387,16 @@ write_control() {
autooff_check_minutes() { autooff_check_minutes() {
case "${1:-}" in case "${1:-}" in
'' ) '' )
echo "Fehler: '--for' braucht eine Angabe in Minuten ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2 echo "Error: '--for' needs a value in minutes ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2
return 1 return 1
;; ;;
*[!0-9]* ) *[!0-9]* )
echo "Fehler: '$1' ist keine Minutenangabe - erlaubt sind ganze Zahlen von $AUTOOFF_MIN_MINUTES bis $AUTOOFF_MAX_MINUTES" >&2 echo "Error: '$1' is not a number of minutes - allowed are whole numbers from $AUTOOFF_MIN_MINUTES to $AUTOOFF_MAX_MINUTES" >&2
return 1 return 1
;; ;;
esac esac
if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then
echo "Fehler: $1 Minuten liegen ausserhalb von $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2 echo "Error: $1 minutes is outside $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2
return 1 return 1
fi fi
} }
@@ -424,11 +485,11 @@ autooff_arm() {
set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB" set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB"
fi fi
if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then
echo "Fehler: Zeitschalter liess sich nicht anlegen (systemd-run '$AUTOOFF_UNIT') - die Station bleibt AN, 'ninlight-station off' nicht vergessen." >&2 echo "Error: could not create the timer (systemd-run '$AUTOOFF_UNIT') - the station stays ON, do not forget 'ninlight-station off'." >&2
return 1 return 1
fi fi
autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || { autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || {
echo "Fehler: $AUTOOFF_FILE konnte nicht geschrieben werden - Zeitschalter wieder abgeraeumt" >&2 echo "Error: could not write $AUTOOFF_FILE - timer removed again" >&2
autooff_clear autooff_clear
return 1 return 1
} }
@@ -455,7 +516,7 @@ autooff_busy() {
# "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur # "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur
# Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der # Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der
# Nutzer sonst nicht mehr an den Pi kommt. # Nutzer sonst nicht mehr an den Pi kommt.
echo "Warnung: 'ss' nicht gefunden - laufende Auslieferungen konnten nicht geprueft werden" >&2 echo "Warning: 'ss' not found - could not check for downloads in progress" >&2
return 1 return 1
fi fi
_conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true) _conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true)
@@ -485,10 +546,10 @@ autooff_fire() {
if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then
_deferrals=$((_deferrals + 1)) _deferrals=$((_deferrals + 1))
autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals" autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals"
echo "Zeitschalter verschoben ($_deferrals/$AUTOOFF_MAX_DEFERRALS): eine Auslieferung laeuft noch" echo "Timer deferred ($_deferrals/$AUTOOFF_MAX_DEFERRALS): a download is still running"
exit 0 exit 0
fi fi
echo "Warnung: $AUTOOFF_MAX_DEFERRALS Verschiebungen erreicht - es steht immer noch eine Verbindung offen, sie haengt vermutlich. Station wird jetzt trotzdem abgeschaltet." >&2 echo "Warning: reached $AUTOOFF_MAX_DEFERRALS deferrals - a connection is still open and has probably stalled. Switching the station off anyway." >&2
fi fi
full_off full_off
} }
@@ -529,7 +590,7 @@ start_and_verify() {
# Lebendigkeitspruefung unten je erreicht wird, und der eben # Lebendigkeitspruefung unten je erreicht wird, und der eben
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen. # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 echo "Error: could not (re)start ninlight-station - access point switched off again. Details: journalctl -u ninlight-station" >&2
exit 1 exit 1
fi fi
# "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess # "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess
@@ -542,14 +603,14 @@ start_and_verify() {
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist. # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 echo "Error: ninlight-station is not active after starting - access point switched off again. Details: journalctl -u ninlight-station" >&2
exit 1 exit 1
fi fi
_actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true) _actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true)
if [ "$_actual_mode" != "$_expect_mode" ]; then if [ "$_actual_mode" != "$_expect_mode" ]; then
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2 echo "Error: after the restart $RUN_DIR/mode holds '$_actual_mode' instead of '$_expect_mode' - the RuntimeDirectory was probably wiped (check RuntimeDirectoryPreserve=restart in the unit). Access point switched off again." >&2
exit 1 exit 1
fi fi
if [ -n "$_expect_phase" ]; then if [ -n "$_expect_phase" ]; then
@@ -568,7 +629,7 @@ PY
if [ "$_actual_phase" != "$_expect_phase" ]; then if [ "$_actual_phase" != "$_expect_phase" ]; then
sudo systemctl stop ninlight-station 2>/dev/null || true sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2 echo "Error: after the restart $RUN_DIR/control.json holds phase '$_actual_phase' instead of '$_expect_phase' - the RuntimeDirectory was probably wiped. Access point switched off again." >&2
exit 1 exit 1
fi fi
fi fi
@@ -606,26 +667,30 @@ parse_for_option() {
autooff_arm_if_requested() { autooff_arm_if_requested() {
[ -n "$_for_minutes" ] || return 0 [ -n "$_for_minutes" ] || return 0
autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1 autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1
echo "Zeitschalter: Auto-Aus in $_for_minutes min ('ninlight-station renew' verlaengert, 'hold' bestellt ab)" echo "Timer: auto-off in $_for_minutes min ('ninlight-station renew' extends it, 'hold' cancels it)"
} }
recovery_on() { recovery_on() {
parse_for_option recovery "$@" parse_for_option recovery "$@"
need_profile "$AP_CONNECTION" need_profile "$AP_CONNECTION"
n=$(images | wc -l | tr -d ' ') # Explizit "recovery" statt current_mode(): dieser Aufruf ENTSCHEIDET gerade, dass
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2 # der naechste Start Rettung ist - write_mode() darunter schreibt das erst gleich.
# current_mode() koennte hier noch "migration" von einem vorherigen Lauf zeigen.
_setdir=$(imageset_dir "$(imageset_get)" recovery)
n=$(imageset_images recovery | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warning: $_setdir holds no image - the station answers everything with 404" >&2
ap_switch "$AP_CONNECTION" ap_switch "$AP_CONNECTION"
write_mode "recovery" write_mode "recovery"
start_and_verify "$AP_CONNECTION" "recovery" start_and_verify "$AP_CONNECTION" "recovery"
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)" echo "Station on: SSID $SSID at 192.168.8.1, $n image(s)"
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in # Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist # Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder # die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
# ausschalten) steht unten. # ausschalten) steht unten.
if [ "$PSK" = "ninlight-recovery" ]; then if [ "$PSK" = "ninlight-recovery" ]; then
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2 echo "Warning: PSK is still the public default 'ninlight-recovery' - set your own password in /etc/ninlight-station.conf." >&2
fi fi
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." echo "Do not forget: 'ninlight-station off' when you are done."
autooff_arm_if_requested autooff_arm_if_requested
} }
@@ -656,25 +721,27 @@ migrate_on() {
[ "$_for_seen" -eq 1 ] || _for_minutes="" [ "$_for_seen" -eq 1 ] || _for_minutes=""
need_profile "$MIGRATE_AP_CONNECTION" need_profile "$MIGRATE_AP_CONNECTION"
_set=$(imageset_get) _set=$(imageset_get)
_setdir=$(imageset_dir "$_set") # Explizit "migration", aus demselben Grund wie in recovery_on(): dieser Aufruf legt
n=$(migrate_images | wc -l | tr -d ' ') # den naechsten Modus gerade erst fest.
[ "$n" -gt 0 ] || echo "Warnung: $_setdir enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 _setdir=$(imageset_dir "$_set" migration)
n=$(imageset_images migration | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warning: $_setdir holds no migration images - the station answers everything with 404" >&2
# Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
ap_switch "$MIGRATE_AP_CONNECTION" ap_switch "$MIGRATE_AP_CONNECTION"
write_mode "migration" write_mode "migration"
write_control "$_phase" "" || { write_control "$_phase" "" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 echo "Error: could not write $RUN_DIR/control.json" >&2
exit 1 exit 1
} }
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase" start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)" echo "Migration on: SSID $MIGRATE_SSID at 192.168.8.1, phase $_phase, $n migration image(s)"
# Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte # Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte
# Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche # Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche
# stehen. # stehen.
echo "Bildsatz: $(imageset_label "$_set")" echo "Image set: $(imageset_label "$_set")"
echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2 echo "Caution: the password of this access point is publicly known from the old firmware - see README.md." >&2
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." echo "Do not forget: 'ninlight-station off' when you are done."
autooff_arm_if_requested autooff_arm_if_requested
} }
@@ -722,7 +789,7 @@ display_run() {
display_service_resume() { display_service_resume() {
[ "${_was_active:-0}" -eq 1 ] || return 0 [ "${_was_active:-0}" -eq 1 ] || return 0
sudo systemctl start ninlight-display 2>/dev/null \ sudo systemctl start ninlight-display 2>/dev/null \
|| echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2 || echo "Warning: could not start the display service again - check 'systemctl status ninlight-display'" >&2
} }
display_handover() { display_handover() {
@@ -789,7 +856,7 @@ display_off() {
sudo systemctl stop ninlight-display 2>/dev/null || true sudo systemctl stop ninlight-display 2>/dev/null || true
# Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und # Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und
# ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten. # ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten.
display_run --clear || echo "Warnung: Panel konnte nicht geleert werden - Anzeige pruefen" >&2 display_run --clear || echo "Warning: could not clear the panel - check the display" >&2
} }
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart ---------------------- # --- Abschlussbildschirm beim Herunterfahren und beim Neustart ----------------------
@@ -820,13 +887,13 @@ display_final() {
# Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der # Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der
# Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche # Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche
# Zeile im Journal als ein haengendes Herunterfahren. # Zeile im Journal als ein haengendes Herunterfahren.
echo "Warnung: Anzeigedienst laeuft nach ${DISPLAY_RELEASE_WAIT_S}s noch - kein Abschlussbild (GPIO belegt)" >&2 echo "Warning: display service still running after ${DISPLAY_RELEASE_WAIT_S}s - no final image (GPIO busy)" >&2
return 0 return 0
fi fi
sleep 1 sleep 1
_waited=$((_waited + 1)) _waited=$((_waited + 1))
done done
display_run --final "$1" || echo "Warnung: Abschlussbild konnte nicht gezeichnet werden - Anzeige pruefen" >&2 display_run --final "$1" || echo "Warning: could not draw the final image - check the display" >&2
return 0 return 0
} }
@@ -846,18 +913,18 @@ full_off() {
ap_switch "" ap_switch ""
if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)" echo "Station off, wlan0 back in the home Wi-Fi ($HOME_CONNECTION)"
else else
echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2 echo "Station off. Switching back to '$HOME_CONNECTION' failed - check the wlan0 status." >&2
fi fi
else else
echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2 echo "Station off. Profile '$HOME_CONNECTION' missing, wlan0 stays unused - access over the cable is unaffected." >&2
fi fi
} }
listen_cmd() { listen_cmd() {
write_control "listen" "" || { write_control "listen" "" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 echo "Error: could not write $RUN_DIR/control.json" >&2
exit 1 exit 1
} }
echo "Phase: listen" echo "Phase: listen"
@@ -882,7 +949,7 @@ if not t:
print(t) print(t)
PY PY
); then ); then
echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm <target>" >&2 echo "Error: no recently detected model (last_seen) in $_status_file - name a target: ninlight-station arm <target>" >&2
exit 1 exit 1
fi fi
fi fi
@@ -896,14 +963,14 @@ target = sys.argv[2]
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1) sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
PY PY
then then
echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2 echo "Error: unknown target '$_target' - valid targets are listed in products.py" >&2
exit 1 exit 1
fi fi
write_control "armed" "$_target" || { write_control "armed" "$_target" || {
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 echo "Error: could not write $RUN_DIR/control.json" >&2
exit 1 exit 1
} }
echo "Freigegeben: $_target" echo "Armed: $_target"
} }
# "renew [<minuten>]" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die # "renew [<minuten>]" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die
@@ -915,7 +982,7 @@ PY
renew_cmd() { renew_cmd() {
_arg=${1:-} _arg=${1:-}
if ! _info=$(autooff_read); then if ! _info=$(autooff_read); then
echo "Fehler: kein Zeitschalter aktiv - 'renew' stellt nur einen laufenden zurueck. Einen neuen gibt es nur mit 'ninlight-station on --for <minuten>'." >&2 echo "Error: no timer active - 'renew' only resets a running one. A new one comes from 'ninlight-station on --for <minutes>'." >&2
exit 1 exit 1
fi fi
# Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt. # Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt.
@@ -927,17 +994,17 @@ renew_cmd() {
_minutes=$_arg _minutes=$_arg
fi fi
autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1 autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1
echo "Zeitschalter zurueckgestellt: Auto-Aus in $_minutes min" echo "Timer reset: auto-off in $_minutes min"
} }
# "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt. # "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt.
hold_cmd() { hold_cmd() {
if ! autooff_read >/dev/null; then if ! autooff_read >/dev/null; then
echo "Fehler: kein Zeitschalter aktiv - es gibt nichts abzubestellen." >&2 echo "Error: no timer active - there is nothing to cancel." >&2
exit 1 exit 1
fi fi
autooff_clear autooff_clear
echo "Zeitschalter abbestellt - die Station bleibt an, bis 'ninlight-station off'." echo "Timer cancelled - the station stays on until 'ninlight-station off'."
} }
# Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der # Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der
@@ -949,9 +1016,9 @@ autooff_status_line() {
set -- $_info set -- $_info
_rest=$(( ($2 - $(date +%s) + 59) / 60 )) _rest=$(( ($2 - $(date +%s) + 59) / 60 ))
if [ "$_rest" -gt 0 ]; then if [ "$_rest" -gt 0 ]; then
printf 'Auto-Aus: in %s min (angefordert: %s min)\n' "$_rest" "$1" printf 'Auto-off: in %s min (requested: %s min)\n' "$_rest" "$1"
else else
printf 'Auto-Aus: steht an (angefordert: %s min)\n' "$1" printf 'Auto-off: pending (requested: %s min)\n' "$1"
fi fi
} }
@@ -994,8 +1061,8 @@ PY
_modus=$(printf '%s\n' "$_info" | sed -n '1p') _modus=$(printf '%s\n' "$_info" | sed -n '1p')
_erkannt=$(printf '%s\n' "$_info" | sed -n '2p') _erkannt=$(printf '%s\n' "$_info" | sed -n '2p')
elif [ -e "$_status_file" ]; then elif [ -e "$_status_file" ]; then
_modus="unbekannt (Datei nicht lesbar)" _modus="unknown (file not readable)"
_erkannt="unbekannt (Datei nicht lesbar)" _erkannt="unknown (file not readable)"
else else
_modus="-" _modus="-"
_erkannt="-" _erkannt="-"
@@ -1044,16 +1111,20 @@ PY
else else
_phase="-" _phase="-"
fi fi
printf 'Modus : %s\n' "$_modus" printf 'Mode : %s\n' "$_modus"
printf 'Phase : %s\n' "$_phase" printf 'Phase : %s\n' "$_phase"
imageset_status_lines imageset_status_lines
printf 'Erkannt: %s\n' "$_erkannt" printf 'Detected: %s\n' "$_erkannt"
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" printf 'Service : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
autooff_status_line autooff_status_line
printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)" printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)"
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')" printf 'Address : %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
echo "Images :" # Die Rettungsabbilder des eingestellten Bildsatzes - unabhaengig von $_modus (der
images | while read -r f; do # Migrationsbildsatz hat kein passendes Trailer-Format). Fixrunde 1, Befund 2:
# frueher stand hier "images" (flach in $IMAGE_DIR) - dort liegt seit dem
# Deploy-Umbau nichts mehr.
echo "Images :"
imageset_images recovery | while read -r f; do
printf ' %-34s %s\n' "$(basename "$f")" \ printf ' %-34s %s\n' "$(basename "$f")" \
"$(python3 - "$f" <<'PY' "$(python3 - "$f" <<'PY'
import pathlib, sys import pathlib, sys
@@ -1063,10 +1134,10 @@ PY
)" )"
done done
if [ -r "$_status_file" ]; then if [ -r "$_status_file" ]; then
echo "Zuletzt ausgeliefert:" echo "Last served:"
sed 's/^/ /' "$_status_file" sed 's/^/ /' "$_status_file"
elif [ -e "$_status_file" ]; then elif [ -e "$_status_file" ]; then
echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2 echo "Error: $_status_file exists but is not readable (check permissions)" >&2
fi fi
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie # "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
# ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier # ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier
@@ -1082,7 +1153,7 @@ display_cmd() {
final) final)
case "${2:-}" in case "${2:-}" in
poweroff|reboot) display_final "$2" ;; poweroff|reboot) display_final "$2" ;;
*) echo "Fehler: 'display final' braucht 'poweroff' oder 'reboot'" >&2; exit 2 ;; *) echo "Error: 'display final' needs 'poweroff' or 'reboot'" >&2; exit 2 ;;
esac esac
;; ;;
*) usage; exit 2 ;; *) usage; exit 2 ;;
@@ -1090,48 +1161,48 @@ display_cmd() {
} }
usage() { usage() {
echo "Aufruf: ninlight-station on [--for <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2 echo "Usage: ninlight-station on [--for <minutes>]|recovery {on [--for <minutes>]|off}|migrate {on [--listen] [--for <minutes>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minutes>]|hold|display {test|clear|off}|off|status" >&2
echo "" >&2 echo "" >&2
echo " bildsatz Umschalter: welcher Satz Migrationsimages ausgeliefert wird." >&2 echo " bildsatz Switch: which set of recovery/migration images is served." >&2
echo " Ohne Argument nur berichten. 'ohne-wlan' (Vorgabe) liefert die" >&2 echo " Without an argument it only reports. 'ohne-wlan' (default) serves" >&2
echo " Abzuege aus tools/build_nocreds.sh - die migrierte Lampe startet" >&2 echo " the builds from tools/build_nocreds.sh - the migrated lamp comes" >&2
echo " im eigenen Onboarding-Zugangspunkt. 'mit-wlan' liefert die" >&2 echo " up in its own onboarding access point. 'mit-wlan' serves the" >&2
echo " Abzuege aus build/ - die Lampe haengt sofort im WLAN aus" >&2 echo " builds from build/ - the lamp joins the Wi-Fi from" >&2
echo " config.local.cmake, auch wenn es eine fremde Lampe ist, die im" >&2 echo " config.local.cmake right away, including a lamp that is not" >&2
echo " Automatikmodus mitgenommen wurde. Der Zustand bleibt, bis ihn" >&2 echo " yours and got picked up in automatic mode. The setting sticks" >&2
echo " jemand aendert, und steht dauerhaft auf dem Panel." >&2 echo " until someone changes it, and is shown on the panel." >&2
echo " --for <minuten> Zeitschalter: die Station schaltet sich nach <minuten> selbst" >&2 echo " --for <minutes> Timer: the station switches itself off after <minutes> (back to" >&2
echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&2 echo " the home Wi-Fi). OPTIONAL - without '--for' it stays on until" >&2
echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2 echo " someone says 'off'. Useful when you are logged in over Wi-Fi:" >&2
echo " WLAN angemeldet bist: 'on' macht wlan0 zum Zugangspunkt und" >&2 echo " 'on' turns wlan0 into an access point and drops your SSH" >&2
echo " reisst die SSH-Sitzung ab. Erlaubt: 1-240." >&2 echo " session. Allowed: 1-240." >&2
echo " renew [<min>] Uhr zurueckstellen (ohne Angabe auf die zuletzt angeforderte" >&2 echo " renew [<min>] Reset the timer (without an argument to the duration last" >&2
echo " Dauer). Laeuft keiner, ist das ein Fehler." >&2 echo " requested). If none is running, that is an error." >&2
echo " hold Zeitschalter abbestellen, Station bleibt an." >&2 echo " hold Cancel the timer, station stays on." >&2
echo " display test Testbild aufs Panel zeichnen. Das ist der erste Handgriff bei der" >&2 echo " display test Draw a test image on the panel. This is the first step of the" >&2
echo " Hardware-Abnahme und die EINZIGE Moeglichkeit, die Panel-Revision" >&2 echo " hardware acceptance and the ONLY way to determine the panel" >&2
echo " zu bestimmen: die steht am Geraet nirgends. Bleibt das Bild leer," >&2 echo " revision: it is printed nowhere on the device. If the image" >&2
echo " verzerrt oder verschoben, ist EPD_REVISION in" >&2 echo " stays blank, distorted or shifted, EPD_REVISION in" >&2
echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2 echo " /etc/ninlight-station.conf is wrong - switch it to the other" >&2
echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2 echo " revision (V2 instead of V3 or the other way round), power the" >&2
echo " stromlos machen und erneut 'display test'." >&2 echo " panel down briefly and run 'display test' again." >&2
echo " Ein laufender Anzeigedienst wird dafuer kurz gestoppt und" >&2 echo " A running display service is stopped for this and started" >&2
echo " danach wieder gestartet (er haelt sonst die GPIO-Leitungen)." >&2 echo " again afterwards (it holds the GPIO lines otherwise)." >&2
echo " display clear Panel leeren (ebenfalls mit kurzer Unterbrechung des Dienstes)." >&2 echo " display clear Clear the panel (also with a short interruption of the service)." >&2
echo " display final {poweroff|reboot}" >&2 echo " display final {poweroff|reboot}" >&2
echo " Abschlussbild aufs Panel. Kein Handgriff fuer Menschen: das" >&2 echo " Final image on the panel. Not a step for humans: this is what" >&2
echo " rufen ninlight-poweroff.service/ninlight-reboot.service beim" >&2 echo " ninlight-poweroff.service/ninlight-reboot.service call during" >&2
echo " Herunterfahren bzw. Neustart auf, nachdem der Anzeigedienst" >&2 echo " shutdown or reboot, once the display service has released the" >&2
echo " die GPIO-Leitungen freigegeben hat. E-Paper behaelt sein Bild" >&2 echo " GPIO lines. E-paper keeps its image forever - without this step" >&2
echo " dauerhaft - ohne diesen Schritt bliebe die letzte Rueckfrage" >&2 echo " the last confirmation prompt would stay on the panel. The two" >&2
echo " auf dem Panel stehen. Die Texte unterscheiden sich: beim" >&2 echo " texts differ: on a reboot you must NOT unplug." >&2
echo " Neustart darf man den Stecker gerade NICHT ziehen." >&2 echo " display off Stop the display service for good and clear the panel. The" >&2
echo " display off Anzeigedienst dauerhaft stoppen und das Panel leeren. 'on'/'off'" >&2 echo " station's 'on'/'off' do not need this - the display service" >&2
echo " der Station brauchen das nicht - der Anzeigedienst laeuft" >&2 echo " keeps running and draws the state by itself." >&2
echo " unabhaengig davon durch und zeichnet den Zustand selbst." >&2
echo "" >&2 echo "" >&2
echo " Taster (GPIO23): kurzer Druck oeffnet am Geraet ein Menue ueber genau diese" >&2 echo " Button (GPIO23): a short press opens a menu on the device covering exactly" >&2
echo " Unterbefehle, 3 s gehalten brechen eine laufende Migration/Rettung ab." >&2 echo " these subcommands, holding 3 s aborts a running migration or" >&2
echo " recovery." >&2
} }
_cmd=${1:-} _cmd=${1:-}
+93 -8
View File
@@ -87,17 +87,34 @@ def trailer_version(path: Path) -> str:
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"} IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
DEFAULT_IMAGE_SET = "ohne-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: def read_image_set(path: Path | None) -> str:
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar """Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
ist oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei darf ist (inklusive ungueltigem UTF-8 - Fixrunde 1, Kleinigkeit: ein reines "except
den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der 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).""" beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
if path is None: if path is None:
return DEFAULT_IMAGE_SET return DEFAULT_IMAGE_SET
try: try:
value = path.read_text().strip() value = path.read_text().strip()
except OSError: except (OSError, UnicodeDecodeError):
return DEFAULT_IMAGE_SET return DEFAULT_IMAGE_SET
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
@@ -118,6 +135,13 @@ class Station:
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 # Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2). # wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
self.last_seen: dict | None = None self.last_seen: dict | None = None
@@ -149,8 +173,38 @@ class Station:
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: def image_set(self) -> str:
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan").""" """Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
@@ -327,7 +381,11 @@ class Station:
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()
@@ -348,6 +406,9 @@ class Station:
"variant_source": variant_source, "variant_source": variant_source,
"product_id": product_id, "lamp_version": lamp_version, "product_id": product_id, "lamp_version": lamp_version,
"version": version, "bytes": nbytes, "status": status} "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) self.recent.append(entry)
del self.recent[:-RECENT_MAX] del self.recent[:-RECENT_MAX]
if target is not None: if target is not None:
@@ -360,7 +421,11 @@ class Station:
def write_state(self) -> None: def write_state(self) -> None:
with self.lock: with self.lock:
payload = {"since": self.since, "mode": self.mode, "served": self.served, payload = {"since": self.since, "mode": self.mode, "served": self.served,
"recent": list(self.recent)} "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": if self.mode == "migration":
phase, armed_target = self.read_control() phase, armed_target = self.read_control()
payload["phase"] = phase payload["phase"] = phase
@@ -658,10 +723,30 @@ def main(argv: list[str] | None = None) -> int:
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
# 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): " 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 == "migration": 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. # Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends # Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand. # nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
+37 -37
View File
@@ -167,43 +167,43 @@ def _labels(state):
def test_menu_off_offers_starting_and_shutdown(): def test_menu_off_offers_starting_and_shutdown():
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen", assert _labels(STATE_OFF) == ["Start recovery", "Migration: listen",
"Migration: AUTOMATIK", "Umschalten: mit WLAN", "Migration: AUTOMATIC", "Switch to: with Wi-Fi",
"Herunterfahren"] "Shut down"]
def test_the_listening_migration_comes_before_the_automatic_one(): def test_the_listening_migration_comes_before_the_automatic_one():
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und """Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
irgendwo loslaesst, soll nicht im Automatikmodus landen.""" irgendwo loslaesst, soll nicht im Automatikmodus landen."""
labels = _labels(STATE_OFF) labels = _labels(STATE_OFF)
assert labels.index("Migration: horchen") < labels.index("Migration: AUTOMATIK") assert labels.index("Migration: listen") < labels.index("Migration: AUTOMATIC")
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown(): def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN", assert _labels(STATE_RECOVERY) == ["Station off", "Switch to: with Wi-Fi",
"Anzeige auffrischen", "Herunterfahren"] "Refresh display", "Shut down"]
def test_menu_in_migration_offers_the_detected_target(): def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus", assert _labels(STATE_MIGRATION) == ["Arm: gu10_rgb", "Station off",
"Umschalten: mit WLAN", "Anzeige auffrischen", "Switch to: with Wi-Fi", "Refresh display",
"Herunterfahren"] "Shut down"]
def test_menu_in_migration_without_a_detected_target_offers_no_release(): 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 """Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde).""" eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND)) assert "Armed: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN", assert _labels(STATE_MIGRATION_BLIND) == ["Station off", "Switch to: with Wi-Fi",
"Anzeige auffrischen", "Herunterfahren"] "Refresh display", "Shut down"]
def test_only_the_refresh_entry_skips_the_confirmation(): def test_only_the_refresh_entry_skips_the_confirmation():
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Anzeige auffrischen" """Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Refresh display"
veraendert nichts und braucht keine.""" veraendert nichts und braucht keine."""
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION): for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
for action in button.menu_actions(state): for action in button.menu_actions(state):
assert action.confirm is (action.label != "Anzeige auffrischen"), action.label assert action.confirm is (action.label != "Refresh display"), action.label
def test_actions_run_through_the_station_cli(): def test_actions_run_through_the_station_cli():
@@ -222,7 +222,7 @@ def test_a_short_press_opens_the_menu():
rec = _drive([_short(1.0)], 4.0) rec = _drive([_short(1.0)], 4.0)
assert rec.argvs() == [] assert rec.argvs() == []
t, view = rec.first_view("menu") t, view = rec.first_view("menu")
assert view["hero"] == "> Rettung starten" assert view["hero"] == "> Start recovery"
assert view["active"] is True assert view["active"] is True
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem # Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe # Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
@@ -233,19 +233,19 @@ def test_a_short_press_opens_the_menu():
def test_a_short_press_steps_to_the_next_entry(): def test_a_short_press_steps_to_the_next_entry():
rec = _drive([_short(1.0), _short(2.0)], 4.0) rec = _drive([_short(1.0), _short(2.0)], 4.0)
assert rec.argvs() == [] assert rec.argvs() == []
assert rec.views()[-1][1]["hero"] == "> Migration: horchen" assert rec.views()[-1][1]["hero"] == "> Migration: listen"
def test_a_short_press_wraps_around_the_last_entry(): 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), rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
_short(6.0)], 8.0) _short(6.0)], 8.0)
assert rec.views()[-1][1]["hero"] == "> Rettung starten" assert rec.views()[-1][1]["hero"] == "> Start recovery"
def test_holding_one_and_a_half_seconds_selects_the_entry(): def test_holding_one_and_a_half_seconds_selects_the_entry():
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0) rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
t, view = rec.first_view("confirm") t, view = rec.first_view("confirm")
assert view["hero"] == "Rettung starten" assert view["hero"] == "Start recovery"
assert 3.5 <= t <= 3.6 assert 3.5 <= t <= 3.6
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da. # Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
assert rec.argvs() == [] assert rec.argvs() == []
@@ -271,7 +271,7 @@ def test_the_refresh_entry_runs_without_a_confirmation():
def test_the_listening_entry_calls_the_cli_with_listen(): def test_the_listening_entry_calls_the_cli_with_listen():
"""Der zweite Eintrag ("Migration: horchen") ruft das CLI MIT --listen: der """Der zweite Eintrag ("Migration: listen") ruft das CLI MIT --listen: der
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
freigibt.""" freigibt."""
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0) rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
@@ -280,7 +280,7 @@ def test_the_listening_entry_calls_the_cli_with_listen():
def test_the_automatic_entry_calls_the_cli_without_listen(): def test_the_automatic_entry_calls_the_cli_without_listen():
"""Der dritte Eintrag ("Migration: AUTOMATIK") ruft dasselbe CLI OHNE --listen - das """Der dritte Eintrag ("Migration: AUTOMATIC") ruft dasselbe CLI OHNE --listen - das
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
fremde Lampen ungefragt migriert werden.""" 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) rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
@@ -293,18 +293,18 @@ def test_the_automatic_entry_asks_a_louder_question_than_the_others():
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege.""" 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) rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
_t, view = rec.first_view("confirm") _t, view = rec.first_view("confirm")
assert view["title"] == "ACHTUNG" assert view["title"] == "WARNING"
assert view["hero"] == "Migration: AUTOMATIK" assert view["hero"] == "Migration: AUTOMATIC"
text = " ".join(view["details"]).lower() text = " ".join(view["details"]).lower()
assert "jede" in text and "fremde" in text assert "every" in text and "other people" in text
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt. # Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
assert view["details"][-2:] == ["halten (1,5 s): ja", "kurz: nein"] assert view["details"][-2:] == ["hold (1.5 s): yes", "tap: no"]
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung. # Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0) rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
_t, plain = rec.first_view("confirm") _t, plain = rec.first_view("confirm")
assert plain["title"] == "RUECKFRAGE" assert plain["title"] == "CONFIRM"
assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"] assert plain["details"] == ["hold (1.5 s): yes", "tap: no"]
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held(): def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
@@ -316,7 +316,7 @@ def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
assert 4.0 <= t <= 4.1 assert 4.0 <= t <= 4.1
# ... und danach laeuft am Panel der Countdown der zweiten Stufe. # ... und danach laeuft am Panel der Countdown der zweiten Stufe.
later = [v for vt, v in rec.views() if vt > t] later = [v for vt, v in rec.views() if vt > t]
assert any("Herunterfahren in" in v.get("hero", "") for v in later) 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(): def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
@@ -337,8 +337,8 @@ def test_the_shutdown_countdown_is_visible_before_it_happens():
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben.""" erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
rec = _drive([(1.0, 12.0)], 14.0) rec = _drive([(1.0, 12.0)], 14.0)
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0] heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
assert "Herunterfahren in 6 s" in heroes assert "Shutting down in 6 s" in heroes
assert "Herunterfahren in 1 s" in heroes assert "Shutting down in 1 s" in heroes
def test_a_blocking_action_does_not_break_the_hold_gesture(): def test_a_blocking_action_does_not_break_the_hold_gesture():
@@ -392,7 +392,7 @@ def test_an_expired_confirmation_leaves_nothing_pending():
def test_a_failed_action_says_so_on_the_panel(): 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) rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
assert any(v.get("view") == "result" and v.get("title") == "FEHLER" assert any(v.get("view") == "result" and v.get("title") == "ERROR"
for _t, v in rec.views()) for _t, v in rec.views())
@@ -411,7 +411,7 @@ def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
geschrieben.""" geschrieben."""
path = tmp_path / "run" / "ui.json" path = tmp_path / "run" / "ui.json"
writer = button.UiWriter(path) writer = button.UiWriter(path)
view = {"view": "menu", "title": "MENUE", "hero": "> Station aus", "details": [], view = {"view": "menu", "title": "MENU", "hero": "> Station off", "details": [],
"active": True} "active": True}
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt # Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage # werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
@@ -509,10 +509,10 @@ def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
def test_the_image_set_entry_names_where_it_switches_to(): def test_the_image_set_entry_names_where_it_switches_to():
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der """Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand.""" Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
assert "Umschalten: mit WLAN" in _labels(STATE_OFF) assert "Switch to: with Wi-Fi" in _labels(STATE_OFF)
ohne = dict(STATE_OFF, imageset="mit-wlan") ohne = dict(STATE_OFF, imageset="mit-wlan")
assert "Umschalten: ohne WLAN" in _labels(ohne) assert "Switch to: no Wi-Fi" in _labels(ohne)
assert "Umschalten: mit WLAN" not in _labels(ohne) assert "Switch to: with Wi-Fi" not in _labels(ohne)
def test_the_image_set_entry_switches_through_the_cli(): def test_the_image_set_entry_switches_through_the_cli():
@@ -527,9 +527,9 @@ def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
loud = button.action_imageset("ohne-wlan") loud = button.action_imageset("ohne-wlan")
back = button.action_imageset("mit-wlan") back = button.action_imageset("mit-wlan")
assert loud.confirm and back.confirm assert loud.confirm and back.confirm
assert loud.confirm_title == "ACHTUNG" assert loud.confirm_title == "WARNING"
assert back.confirm_title == "RUECKFRAGE" assert back.confirm_title == "CONFIRM"
assert "WLAN-Passwort" in " ".join(loud.confirm_details) assert "Wi-Fi password" in " ".join(loud.confirm_details)
assert back.confirm_details is None assert back.confirm_details is None
+52 -44
View File
@@ -263,12 +263,19 @@ setup_sandbox() {
local sandbox=$1 unitfile=${2:-$REAL_UNIT} local sandbox=$1 unitfile=${2:-$REAL_UNIT}
rm -rf "$sandbox" rm -rf "$sandbox"
mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \ mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \
"$sandbox/images/rescue-nocreds" "$sandbox/images/rescue-wlan" \
"$sandbox/bin" "$sandbox/nmstate" "$sandbox/lib/display" "$sandbox/var" "$sandbox/bin" "$sandbox/nmstate" "$sandbox/lib/display" "$sandbox/var"
# Je ein Image je Bildsatz - "vorhanden" heisst fuer CLI, Server und Anzeige # Je ein Image je Bildsatz - "vorhanden" heisst fuer CLI, Server und Anzeige
# gleichermassen "mindestens ein Image darin". # gleichermassen "mindestens ein Image darin". Getrennte Verzeichnispaare fuer
# Rettung (rescue-*) und Migration (migrate-*), seit der Bildsatz-Umschalter auch
# die Rettung betrifft (Fixrunde 1, Befund 1/2) - VORHER lag hier ein flaches
# "images/ninlight_foo_ota.img", das server.py/deploy.sh seit diesem Umschalter
# nicht mehr benutzen; eine Testvorrichtung, die weiter diese alte Welt aufbaut,
# haette genau die beiden Befunde verdeckt.
echo "ohne" >"$sandbox/images/migrate-nocreds/foo_dev.img" echo "ohne" >"$sandbox/images/migrate-nocreds/foo_dev.img"
echo "mit" >"$sandbox/images/migrate-wlan/foo_dev.img" echo "mit" >"$sandbox/images/migrate-wlan/foo_dev.img"
echo "dummy" >"$sandbox/images/ninlight_foo_ota.img" echo "ohne" >"$sandbox/images/rescue-nocreds/ninlight_foo_ota.img"
echo "mit" >"$sandbox/images/rescue-wlan/ninlight_foo_ota.img"
# NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst. # NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst.
# products.py wird gebraucht (arm prueft das Ziel dagegen) und deshalb echt kopiert; # products.py wird gebraucht (arm prueft das Ziel dagegen) und deshalb echt kopiert;
@@ -342,6 +349,7 @@ run_cli() {
NINLIGHT_RUN_DIR="$sandbox/run" \ NINLIGHT_RUN_DIR="$sandbox/run" \
NINLIGHT_STATION_LIB="$sandbox/lib" \ NINLIGHT_STATION_LIB="$sandbox/lib" \
NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \ NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \
NINLIGHT_LAST_MODE_FILE="$sandbox/var/last-mode" \
"$CLI" "$@" "$CLI" "$@"
} }
@@ -364,7 +372,7 @@ set -e
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log" grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log" grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)"
echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
echo echo
echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ===" echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ==="
@@ -378,7 +386,7 @@ set -e
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log" grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log" grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch"
echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT" echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
echo echo
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ===" echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
@@ -427,7 +435,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR" [ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR"
echo "$ERR" | grep -q "Warnung" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR" echo "$ERR" | grep -q "Warning" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR"
echo echo
echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ===" echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ==="
@@ -469,7 +477,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")"
echo echo
@@ -482,7 +490,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt"
echo echo
@@ -513,7 +521,7 @@ ACTIVE=$(cat "$SBX/nmstate/active")
echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv" echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv"
echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv" echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv"
echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE" echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE"
echo "$OUT" | grep -q "Heim-WLAN" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT" echo "$OUT" | grep -q "home Wi-Fi" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT"
echo echo
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ==="
@@ -531,9 +539,9 @@ OUT=$(run_cli "$SBX" status 2>&1)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "Modus.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT" echo "$OUT" | grep -q "Mode.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT"
echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT" echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT"
echo "$OUT" | grep -q "Erkannt.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT" echo "$OUT" | grep -q "Detected.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT"
echo echo
echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ===" echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ==="
@@ -547,7 +555,7 @@ RC=$?
set -e set -e
chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "existiert, ist aber nicht lesbar" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT" echo "$OUT" | grep -q "not readable" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT"
echo echo
echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ===" echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ==="
@@ -558,7 +566,7 @@ OUT=$(run_cli "$SBX" frobnicate 2>&1)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2" [ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2"
echo "$OUT" | grep -qi "Aufruf" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT" echo "$OUT" | grep -qi "usage" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT"
echo echo
echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ===" echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ==="
@@ -597,7 +605,7 @@ set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT" [ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT"
echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR" echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR"
echo "$OUT" | grep -q "Migration an" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'" echo "$OUT" | grep -q "Migration on" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active") ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER" [ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER"
# Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei - # Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei -
@@ -646,8 +654,8 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT" [ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT"
echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung" echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung"
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR" echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active") ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER" [ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER"
@@ -679,7 +687,7 @@ ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] \ [ "$RC" -ne 0 ] \
&& pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \ && pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \
|| fail "Exit-Code war 0, obwohl 'ninlight:recovery' weiterhin aktiv war: $OUT" || fail "Exit-Code war 0, obwohl 'ninlight:recovery' weiterhin aktiv war: $OUT"
echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" \ echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" \
|| pass "keine falsche 'Station aus'-Meldung" || pass "keine falsche 'Station aus'-Meldung"
echo "$ERR" | grep -q "ninlight:recovery" \ echo "$ERR" | grep -q "ninlight:recovery" \
&& pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \ && pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \
@@ -695,7 +703,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT" [ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT"
echo "$ERR" | grep -q "unerwartet" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR" echo "$ERR" | grep -q "unexpectedly" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active") ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER" [ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER"
@@ -714,7 +722,7 @@ cat >"$SBX/run/status.json" <<'JSON'
{"mode": "recovery"} {"mode": "recovery"}
JSON JSON
OUT=$(run_cli "$SBX" status 2>&1) OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qE "Modus[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT" echo "$OUT" | grep -qE "Mode[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT"
echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus" echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus"
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT" echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
@@ -728,7 +736,7 @@ run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1
run_cli "$SBX" on >/dev/null 2>&1 run_cli "$SBX" on >/dev/null 2>&1
rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie
OUT=$(run_cli "$SBX" status 2>&1) OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qE "Modus[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT" echo "$OUT" | grep -qE "Mode[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT"
echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus" echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus"
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT" echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
@@ -1024,9 +1032,9 @@ set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zeitschalter-Zustandsdatei angelegt" || fail "ohne --for entstand trotzdem ein Zeitschalter: $(cat "$SBX/run/autooff.json")" [ ! -e "$SBX/run/autooff.json" ] && pass "keine Zeitschalter-Zustandsdatei angelegt" || fail "ohne --for entstand trotzdem ein Zeitschalter: $(cat "$SBX/run/autooff.json")"
grep -q "systemd-run" "$SBX/cmd.log" && fail "ohne --for wurde trotzdem ein Timer angelegt: $(grep systemd-run "$SBX/cmd.log")" || pass "kein systemd-run-Aufruf" grep -q "systemd-run" "$SBX/cmd.log" && fail "ohne --for wurde trotzdem ein Timer angelegt: $(grep systemd-run "$SBX/cmd.log")" || pass "kein systemd-run-Aufruf"
echo "$OUT" | grep -qi "Auto-Aus" && fail "Ausgabe verspricht einen Zeitschalter, den es nicht gibt: $OUT" || pass "Ausgabe erwaehnt keinen Zeitschalter" echo "$OUT" | grep -qi "Auto-off" && fail "Ausgabe verspricht einen Zeitschalter, den es nicht gibt: $OUT" || pass "Ausgabe erwaehnt keinen Zeitschalter"
OUT=$(run_cli "$SBX" status 2>&1) OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qi "Auto-Aus" && fail "'status' zeigt eine Zeitschalter-Zeile, obwohl keiner laeuft: $OUT" || pass "'status' schweigt ueber den Zeitschalter" echo "$OUT" | grep -qi "Auto-off" && fail "'status' zeigt eine Zeitschalter-Zeile, obwohl keiner laeuft: $OUT" || pass "'status' schweigt ueber den Zeitschalter"
echo echo
echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ===" echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ==="
@@ -1046,7 +1054,7 @@ grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "Timer feuert nach 30 min"
grep -q "__autooff" "$SBX/cmd.log" && pass "Timer ruft das CLI mit seiner Abschaltaktion" || fail "Timer ruft nichts Abschaltendes: $(grep systemd-run "$SBX/cmd.log")" grep -q "__autooff" "$SBX/cmd.log" && pass "Timer ruft das CLI mit seiner Abschaltaktion" || fail "Timer ruft nichts Abschaltendes: $(grep systemd-run "$SBX/cmd.log")"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch" [ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch"
OUT=$(run_cli "$SBX" status 2>&1) OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qE "Auto-Aus: in (29|30) min" && pass "'status' zeigt die Restzeit" || fail "'status' zeigt keine brauchbare Restzeit: $OUT" echo "$OUT" | grep -qE "Auto-off: in (29|30) min" && pass "'status' zeigt die Restzeit" || fail "'status' zeigt keine brauchbare Restzeit: $OUT"
echo echo
echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ===" echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ==="
@@ -1070,7 +1078,7 @@ for BAD in "0" "241" "abc" "-5" "1.5" ""; do
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT" [ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT"
echo "$ERR" | grep -qi "fehler" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR" echo "$ERR" | grep -qi "error" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR"
grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren" grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren"
grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet" grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet"
[ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")" [ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")"
@@ -1115,7 +1123,7 @@ grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "die Station w
grep -q "nmcli con down" "$SBX/cmd.log" && fail "der Zugangspunkt wurde mitten in der Auslieferung heruntergefahren" || pass "der Zugangspunkt bleibt oben" grep -q "nmcli con down" "$SBX/cmd.log" && fail "der Zugangspunkt wurde mitten in der Auslieferung heruntergefahren" || pass "der Zugangspunkt bleibt oben"
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "1" ] && pass "Verschiebung gezaehlt (1)" || fail "deferrals nicht hochgezaehlt: $(cat "$SBX/run/autooff.json" 2>&1)" [ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "1" ] && pass "Verschiebung gezaehlt (1)" || fail "deferrals nicht hochgezaehlt: $(cat "$SBX/run/autooff.json" 2>&1)"
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "die angeforderte Dauer bleibt fuer 'renew' erhalten" || fail "minutes wurde ueberschrieben: $(cat "$SBX/run/autooff.json")" [ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "die angeforderte Dauer bleibt fuer 'renew' erhalten" || fail "minutes wurde ueberschrieben: $(cat "$SBX/run/autooff.json")"
echo "$OUT" | grep -qi "verschoben" && pass "die Verschiebung wird gemeldet" || fail "keine Meldung ueber die Verschiebung: $OUT" echo "$OUT" | grep -qi "deferred" && pass "die Verschiebung wird gemeldet" || fail "keine Meldung ueber die Verschiebung: $OUT"
# Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab. # Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab.
SBX="$TMPROOT/tZ5b" SBX="$TMPROOT/tZ5b"
setup_sandbox "$SBX" setup_sandbox "$SBX"
@@ -1149,7 +1157,7 @@ set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR" [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR"
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "an der Obergrenze wird trotz offener Verbindung abgeschaltet" || fail "die haengende Verbindung hebelt den Zeitschalter dauerhaft aus: $(cat "$SBX/cmd.log")" grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "an der Obergrenze wird trotz offener Verbindung abgeschaltet" || fail "die haengende Verbindung hebelt den Zeitschalter dauerhaft aus: $(cat "$SBX/cmd.log")"
echo "$ERR" | grep -qi "warnung" && pass "das Abschalten trotz offener Verbindung wird gemeldet" || fail "keine Warnung: $ERR" echo "$ERR" | grep -qi "warning" && pass "das Abschalten trotz offener Verbindung wird gemeldet" || fail "keine Warnung: $ERR"
[ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen" [ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen"
# Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die # Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die
# Grenzpruefung auch einfach immer abschalten). # Grenzpruefung auch einfach immer abschalten).
@@ -1195,7 +1203,7 @@ for CMD in renew hold; do
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT" [ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT"
echo "$ERR" | grep -qi "kein Zeitschalter" && pass "'$CMD': klare Meldung auf stderr" || fail "'$CMD': unklare Meldung: $ERR" echo "$ERR" | grep -qi "no timer active" && pass "'$CMD': klare Meldung auf stderr" || fail "'$CMD': unklare Meldung: $ERR"
[ ! -e "$SBX/run/autooff.json" ] && pass "'$CMD' hat keinen Zeitschalter angelegt" || fail "'$CMD' hat stillschweigend einen angelegt: $(cat "$SBX/run/autooff.json")" [ ! -e "$SBX/run/autooff.json" ] && pass "'$CMD' hat keinen Zeitschalter angelegt" || fail "'$CMD' hat stillschweigend einen angelegt: $(cat "$SBX/run/autooff.json")"
grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run" grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run"
done done
@@ -1215,7 +1223,7 @@ set -e
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "dieselbe Dauer wie zuletzt angefordert (30)" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")" [ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "dieselbe Dauer wie zuletzt angefordert (30)" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")"
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "Verschiebungszaehler zurueckgesetzt" || fail "deferrals nicht zurueckgesetzt: $(cat "$SBX/run/autooff.json")" [ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "Verschiebungszaehler zurueckgesetzt" || fail "deferrals nicht zurueckgesetzt: $(cat "$SBX/run/autooff.json")"
STATUS=$(run_cli "$SBX" status 2>&1) STATUS=$(run_cli "$SBX" status 2>&1)
echo "$STATUS" | grep -qE "Auto-Aus: in (29|30) min" && pass "die Restzeit steht wieder bei 30 min" || fail "Restzeit nicht zurueckgestellt: $STATUS" echo "$STATUS" | grep -qE "Auto-off: in (29|30) min" && pass "die Restzeit steht wieder bei 30 min" || fail "Restzeit nicht zurueckgestellt: $STATUS"
grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "der Timer wurde neu gestellt" || fail "kein neuer Timer: $(cat "$SBX/cmd.log")" grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "der Timer wurde neu gestellt" || fail "kein neuer Timer: $(cat "$SBX/cmd.log")"
# Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt. # Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt.
set +e set +e
@@ -1262,7 +1270,7 @@ RC=$?
set -e set -e
ERR=$(cat "$SBX/stderr.log") ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT" [ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT"
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR" echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")" [ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")"
echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR" echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR"
@@ -1278,7 +1286,7 @@ RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
echo "$OUT" | grep -q "ohne-wlan" && pass "ohne Datei gilt 'ohne-wlan'" || fail "Vorgabe ist nicht 'ohne-wlan': $OUT" echo "$OUT" | grep -q "ohne-wlan" && pass "ohne Datei gilt 'ohne-wlan'" || fail "Vorgabe ist nicht 'ohne-wlan': $OUT"
echo "$OUT" | grep -q "migrate-nocreds" && pass "und das zugehoerige Verzeichnis wird genannt" || fail "Verzeichnis fehlt in der Ausgabe: $OUT" echo "$OUT" | grep -q "rescue-nocreds" && pass "und das zugehoerige Verzeichnis wird genannt (Vorgabemodus: recovery)" || fail "Verzeichnis fehlt in der Ausgabe: $OUT"
[ ! -e "$SBX/var/imageset" ] && pass "das blosse Berichten legt keine Datei an" || fail "'bildsatz' ohne Argument hat geschrieben" [ ! -e "$SBX/var/imageset" ] && pass "das blosse Berichten legt keine Datei an" || fail "'bildsatz' ohne Argument hat geschrieben"
echo echo
@@ -1294,7 +1302,7 @@ set -e
MODE=$(file_mode "$SBX/var/imageset") MODE=$(file_mode "$SBX/var/imageset")
[ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644" [ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644"
[ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da" [ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da"
echo "$OUT" | grep -qi "achtung" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT" echo "$OUT" | grep -qi "caution" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT"
# ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn. # ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn.
OUT2=$(run_cli "$SBX" bildsatz 2>&1) OUT2=$(run_cli "$SBX" bildsatz 2>&1)
echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2" echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2"
@@ -1313,7 +1321,7 @@ RC=$?
set -e set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz" [ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz"
[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")" [ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")"
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR" echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
echo echo
echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ===" echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ==="
@@ -1323,16 +1331,16 @@ setup_sandbox "$SBX"
printf 'voellig-anderes\n' >"$SBX/var/imageset" printf 'voellig-anderes\n' >"$SBX/var/imageset"
OUT=$(run_cli "$SBX" bildsatz 2>&1) OUT=$(run_cli "$SBX" bildsatz 2>&1)
echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fail "kaputter Inhalt fuehrte nicht auf die Vorgabe: $OUT" echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fail "kaputter Inhalt fuehrte nicht auf die Vorgabe: $OUT"
echo "$OUT" | grep -q "migrate-nocreds" && pass "und das Verzeichnis des sicheren Satzes" || fail "falsches Verzeichnis: $OUT" echo "$OUT" | grep -q "rescue-nocreds" && pass "und das Verzeichnis des sicheren Satzes (Vorgabemodus: recovery)" || fail "falsches Verzeichnis: $OUT"
# Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die # Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die
# Station fuer umgeschaltet. # Station fuer umgeschaltet.
echo "$OUT" | grep -q "unbekannte Einstellung 'voellig-anderes'" \ echo "$OUT" | grep -q "unknown setting 'voellig-anderes'" \
&& pass "die unbekannte Einstellung wird beim Namen genannt" \ && pass "die unbekannte Einstellung wird beim Namen genannt" \
|| fail "die unbekannte Einstellung wird verschwiegen: $OUT" || fail "die unbekannte Einstellung wird verschwiegen: $OUT"
# Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen. # Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen.
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
OUT=$(run_cli "$SBX" bildsatz 2>&1) OUT=$(run_cli "$SBX" bildsatz 2>&1)
echo "$OUT" | grep -q "unbekannte Einstellung" \ echo "$OUT" | grep -q "unknown setting" \
&& fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \ && fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \
|| pass "kein Fehlalarm bei gueltigem Eintrag" || pass "kein Fehlalarm bei gueltigem Eintrag"
@@ -1340,23 +1348,23 @@ echo
echo "=== Bildsatz E: ein gewaehlter, aber fehlender Satz wird beim Umschalten UND im Status gemeldet ===" echo "=== Bildsatz E: ein gewaehlter, aber fehlender Satz wird beim Umschalten UND im Status gemeldet ==="
SBX="$TMPROOT/bs5" SBX="$TMPROOT/bs5"
setup_sandbox "$SBX" setup_sandbox "$SBX"
rm -rf "$SBX/images/migrate-wlan" rm -rf "$SBX/images/rescue-wlan" # Vorgabemodus (ohne vorheriges "on") ist recovery
set +e set +e
ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null) ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC" [ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC"
echo "$ERR" | grep -qi "warnung" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR" echo "$ERR" | grep -qi "warning" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR"
OUT=$(run_cli "$SBX" status 2>&1) OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -q "FEHLT" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT" echo "$OUT" | grep -q "MISSING" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT"
echo echo
echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ===" echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ==="
SBX="$TMPROOT/bs6" SBX="$TMPROOT/bs6"
setup_sandbox "$SBX" setup_sandbox "$SBX"
rm -f "$SBX/images/migrate-nocreds"/*.img rm -f "$SBX/images/rescue-nocreds"/*.img # Vorgabemodus (ohne vorheriges "on") ist recovery
OUT=$(run_cli "$SBX" status 2>&1) OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -q "FEHLT" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT" echo "$OUT" | grep -q "MISSING" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT"
echo echo
echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ===" echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ==="
@@ -1369,7 +1377,7 @@ OUT=$(run_cli "$SBX" migrate on --listen 2>&1)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT" [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
echo "$OUT" | grep -q "Bildsatz: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT" echo "$OUT" | grep -q "Image set: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT"
echo echo
echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ===" echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ==="
@@ -1379,12 +1387,12 @@ rm -f "$SBX/images/migrate-wlan"/*.img # nur der andere Satz ist leer
set +e set +e
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e set -e
echo "$ERR" | grep -q "keine Migrationsimages" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz" echo "$ERR" | grep -q "no migration images" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz"
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1 run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
set +e set +e
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null) ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e set -e
echo "$ERR" | grep -q "keine Migrationsimages" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR" echo "$ERR" | grep -q "no migration images" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR"
echo echo
echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ===" echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ==="
@@ -1466,7 +1474,7 @@ set -e
unset FAKE_DISPLAY_ACTIVE_FOR unset FAKE_DISPLAY_ACTIVE_FOR
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR" [ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR"
grep -q "STUB display.py" "$SBX/cmd.log" && fail "es wurde auf belegte Leitungen gezeichnet - 'GPIO busy'" || pass "nicht gezeichnet, solange der Dienst haelt" grep -q "STUB display.py" "$SBX/cmd.log" && fail "es wurde auf belegte Leitungen gezeichnet - 'GPIO busy'" || pass "nicht gezeichnet, solange der Dienst haelt"
echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR" echo "$ERR" | grep -qi "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
# Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in # Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in
# der Abschalt-Transaktion. # der Abschalt-Transaktion.
grep -q "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'systemctl stop' mitten im Herunterfahren" || pass "kein 'systemctl stop' aus dem Abschaltschritt heraus" grep -q "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'systemctl stop' mitten im Herunterfahren" || pass "kein 'systemctl stop' aus dem Abschaltschritt heraus"
@@ -1482,7 +1490,7 @@ RC=$?
set -e set -e
unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - ein Anzeigefehler wuerde das Herunterfahren rot machen: $ERR" [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - ein Anzeigefehler wuerde das Herunterfahren rot machen: $ERR"
echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR" echo "$ERR" | grep -qi "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
echo echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
+376 -34
View File
@@ -217,18 +217,63 @@ if with_dev:
PY PY
} }
# Erzeugt eine gueltige rohe _gz.img-Attrappe: >= 1024 Byte, durch 4 teilbar, ohne # Baut eine Attrappe mit dem AUFBAU EINES ECHTEN ABBILDS: 56 Byte Kopf, dann ein
# NINL-Trailer - genau das, was nl_sign.py als Eingabe akzeptiert. # gzip-Strom mit dem Rumpf, dann ggf. 512 Byte Trailer. Genau diesen Aufbau setzt die
make_gz_file() { # Zugangsdatenpruefung in deploy.sh voraus - eine unkomprimierte Attrappe wuerde die
local path=$1 fill=${2:-A} # Pruefung an einem Abbild vorfuehren, das es so nie gibt, und damit nichts belegen.
python3 -c " # $1 = Pfad, $2 = Fuellzeichen (auch die 56 Kopfbytes, damit Szenarien den Quellbaum
# am Dateianfang wiedererkennen), $3 = "creds", wenn die Zugangsdaten in den
# KOMPRIMIERTEN Rumpf eingebaut werden sollen, $4 = "ota" fuer den NINL-Trailer.
# Ergebnis ohne Trailer ist eine gueltige Eingabe fuer nl_sign.py: >= 1024 Byte, durch 4
# teilbar, kein NINL im Signaturblock.
make_image_file() {
local path=$1 fill=${2:-A} with_creds=${3:-} kind=${4:-gz}
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
python3 - "$path" "$fill" "$with_creds" "$kind" <<'PY'
import gzip
import os
import sys import sys
path, fill = sys.argv[1], sys.argv[2].encode()
data = bytearray((fill * 2048)[:2048]) path, fill, with_creds, kind = sys.argv[1], sys.argv[2].encode(), sys.argv[3], sys.argv[4]
data[-512:-508] = b'\\x00\\x00\\x00\\x00' # nie zufaellig wie 'NINL' aussehen
with open(path, 'wb') as fh: body = bytearray((fill * 4096)[:4096])
if with_creds == "creds":
creds = (os.environ["SSID"] + "\0" + os.environ["PASS"] + "\0"
+ os.environ["TOKEN"] + "\0").encode()
body[64:64 + len(creds)] = creds
# mtime=0: sonst haengt die Dateigroesse an der Uhrzeit und Szenarien, die Groessen
# vergleichen, waeren launisch.
packed = gzip.compress(bytes(body), mtime=0)
data = bytearray((fill * 56)[:56] + packed)
# Auffuellen auf die Mindestlaenge und auf ein Vielfaches von 4 - der Rumpf eines echten
# Abbilds ist deutlich groesser als der komprimierte Testrumpf. Die Nullen hinter dem
# gzip-Strom stoeren das Entpacken nicht.
while len(data) < 1024 or len(data) % 4 != 0:
data.append(0)
if kind == "ota":
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
data += trailer
with open(path, "wb") as fh:
fh.write(bytes(data)) fh.write(bytes(data))
" "$path" "$fill" PY
}
# Erzeugt eine gueltige rohe _gz.img-Attrappe (ohne Trailer, ohne Zugangsdaten).
make_gz_file() {
make_image_file "$1" "${2:-A}" "" gz
}
# Erzeugt eine _ota.img-Attrappe im echten Aufbau, ohne Zugangsdaten.
make_ota_image() {
make_image_file "$1" "${2:-A}" "" ota
}
# Dasselbe, aber mit den Zugangsdaten im komprimierten Rumpf.
make_ota_image_with_creds() {
make_image_file "$1" "${2:-A}" creds ota
} }
# Erzeugt eine _ota.img-Attrappe mit ANDEREM Inhalt als das zugehoerige _gz.img und # Erzeugt eine _ota.img-Attrappe mit ANDEREM Inhalt als das zugehoerige _gz.img und
@@ -270,33 +315,36 @@ EOF
# test_release.sh: nur weil er die Werte tatsaechlich einbaut, sagt ein bestandener # test_release.sh: nur weil er die Werte tatsaechlich einbaut, sagt ein bestandener
# Pruefdurchlauf ueberhaupt etwas. # Pruefdurchlauf ueberhaupt etwas.
make_gz_file_with_creds() { make_gz_file_with_creds() {
local path=$1 fill=${2:-A} make_image_file "$1" "${2:-A}" creds gz
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 -c "
import os, sys
path, fill = sys.argv[1], sys.argv[2].encode()
creds = (os.environ['SSID'] + '\\0' + os.environ['PASS'] + '\\0'
+ os.environ['TOKEN'] + '\\0').encode()
data = bytearray((fill * 2048)[:2048])
data[64:64 + len(creds)] = creds
data[-512:-508] = b'\\x00\\x00\\x00\\x00' # nie zufaellig wie 'NINL' aussehen
with open(path, 'wb') as fh:
fh.write(bytes(data))
" "$path" "$fill"
} }
# Sucht eine Zeichenkette BINAER in jeder Datei unterhalb eines Verzeichnisses. # Sucht eine Zeichenkette in jeder Datei unterhalb eines Verzeichnisses - roh UND im
# Rueckgabe 0 = gefunden. Wie contains_secrets() in test_release.sh. # ENTPACKTEN Rumpf jedes .img (56 Byte Kopf, gzip, 512 Byte Trailer/Signatur).
# Rueckgabe 0 = gefunden. Wie contains_secrets() in test_release.sh, nur dass die
# Gegenproben seit den echt aufgebauten Attrappen sonst nichts mehr faenden: im
# komprimierten Strom steht der Klartext nicht. Genau das war der Fehler in deploy.sh.
tree_contains() { tree_contains() {
local dir=$1 needle=$2 local dir=$1 needle=$2
NEEDLE="$needle" python3 - "$dir" <<'PY' NEEDLE="$needle" python3 - "$dir" <<'PY'
import os import os
import sys import sys
import zlib
needle = os.environ["NEEDLE"].encode() needle = os.environ["NEEDLE"].encode()
for root, _dirs, files in os.walk(sys.argv[1]): for root, _dirs, files in os.walk(sys.argv[1]):
for fn in files: for fn in files:
with open(os.path.join(root, fn), "rb") as fh: with open(os.path.join(root, fn), "rb") as fh:
if needle in fh.read(): raw = fh.read()
if needle in raw:
sys.exit(0)
if not fn.endswith(".img") or len(raw) <= 56 + 512:
continue
for body in (raw[56:-512], raw[56:]):
try:
clear = zlib.decompressobj(zlib.MAX_WBITS | 16).decompress(body)
except Exception:
continue
if needle in clear:
sys.exit(0) sys.exit(0)
sys.exit(1) sys.exit(1)
PY PY
@@ -355,10 +403,12 @@ setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar" mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar"
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img" echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img" echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img"
# Zielverzeichnis simuliert einen vorherigen Voll-Deploy: Images fuer foo UND bar liegen schon da. # Zielverzeichnis simuliert einen vorherigen Voll-Deploy MIT dieser Fassung von
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images" # deploy.sh: Rettungsabbilder fuer foo UND bar liegen schon in images/rescue-wlan/ (dem
echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" # "mit-wlan"-Bildsatz aus build/, siehe server.py:RESCUE_SET_DIRS).
echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan"
echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img"
echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_bar_ota.img"
: >"$LOG" : >"$LOG"
set +e set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1) OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
@@ -370,13 +420,13 @@ if echo "$OUT" | grep -q "andere Geraetetypen im Zielverzeichnis bleiben erhalte
else else
fail "Ausgabe erwaehnt den gezielten Deploy nicht" fail "Ausgabe erwaehnt den gezielten Deploy nicht"
fi fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_bar_ota.img" ]; then
pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten" pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten"
else else
fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben" fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben"
fi fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \ if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ] \
&& [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img")" = "foo-image-neu" ]; then && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img")" = "foo-image-neu" ]; then
pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert" pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert"
else else
fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert" fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert"
@@ -646,7 +696,7 @@ OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; then if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert" pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert"
else else
fail "Rettungsimage fehlt: $OUT" fail "Rettungsimage fehlt: $OUT"
@@ -901,7 +951,7 @@ OUT=$(TEST_SIGN_DIR="$SIGN_EMPTY" run_deploy "$SANDBOX" pi-test 2>&1)
RC=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; then if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert" pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert"
else else
fail "Rettungsimage fehlt: $OUT" fail "Rettungsimage fehlt: $OUT"
@@ -1419,6 +1469,298 @@ else
fail "der Satz 'ohne-wlan' auf dem Pi enthaelt Zugangsdaten" fail "der Satz 'ohne-wlan' auf dem Pi enthaelt Zugangsdaten"
fi fi
echo
echo "=== Szenario 35: der Bildsatz-Umschalter gilt jetzt auch fuer die Rettung - beide Rettungssaetze werden ausgeliefert ==="
# Analog zu Szenario 28 (Migration), aber fuer die Rettungsabbilder: rescue-wlan/ kommt
# aus build/ (dasselbe _ota.img, das ohnehin schon fuer die Trailer-Pruefung gebraucht
# wird), rescue-nocreds/ aus build-nocreds/ - ein eigener, unsignierter Fund.
SANDBOX="$TMPROOT/s35"; STUBBIN="$TMPROOT/bin35"; LOG="$TMPROOT/s35.log"; SANDBOX_STATE="$TMPROOT/s35-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
python3 -c "
import sys
path = sys.argv[1]
body = b'N' * 2048
trailer = bytearray(b'\xff' * 512)
trailer[0:4] = b'NINL'
with open(path, 'wb') as fh:
fh.write(body + bytes(trailer))
" "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
RESCUE_WLAN="$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img"
RESCUE_NOCREDS="$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img"
if [ -f "$RESCUE_WLAN" ] && [ -f "$RESCUE_NOCREDS" ]; then
pass "beide Rettungssaetze liegen getrennt auf dem Pi"
else
fail "mindestens ein Rettungssatz fehlt: $OUT"
fi
if head -c 1 "$RESCUE_WLAN" | grep -q "B"; then
pass "rescue-wlan/ kommt wirklich aus build/"
else
fail "rescue-wlan/ hat nicht den erwarteten Inhalt aus build/"
fi
if head -c 1 "$RESCUE_NOCREDS" | grep -q "N"; then
pass "rescue-nocreds/ kommt wirklich aus build-nocreds/"
else
fail "rescue-nocreds/ hat nicht den erwarteten Inhalt aus build-nocreds/"
fi
echo
echo "=== Szenario 36: ohne build-nocreds/ bleibt der Rettungssatz 'ohne-wlan' auf dem Pi unangetastet ==="
SANDBOX="$TMPROOT/s36"; STUBBIN="$TMPROOT/bin36"; LOG="$TMPROOT/s36.log"; SANDBOX_STATE="$TMPROOT/s36-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds"
echo "alter-rescue-nocreds-abzug" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlendem build-nocreds/" || fail "Exit-Code $RC: $OUT"
if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img" 2>/dev/null)" \
= "alter-rescue-nocreds-abzug" ]; then
pass "vorhandener Rettungssatz 'ohne-wlan' blieb unangetastet"
else
fail "der Rettungssatz 'ohne-wlan' wurde geleert oder ueberschrieben, obwohl build-nocreds/ fehlte"
fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
pass "der Rettungssatz 'mit-wlan' wurde trotzdem ausgeliefert"
else
fail "der Rettungssatz 'mit-wlan' fehlt: $OUT"
fi
if echo "$OUT" | grep -q "build-nocreds"; then
pass "der Hinweis nennt, womit der fehlende Rettungssatz entsteht"
else
fail "kein Hinweis auf build-nocreds/ in der Ausgabe: $OUT"
fi
echo
echo "=== Szenario 37: gezielter Deploy ohne build-nocreds/<ziel> bricht nicht ab, sondern laesst 'ohne-wlan' fuer dieses Ziel unangetastet ==="
SANDBOX="$TMPROOT/s37"; STUBBIN="$TMPROOT/bin37"; LOG="$TMPROOT/s37.log"; SANDBOX_STATE="$TMPROOT/s37-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlendem build-nocreds/foo" || fail "Exit-Code $RC: $OUT"
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
pass "der Rettungssatz 'mit-wlan' fuer foo wurde trotzdem ausgeliefert"
else
fail "der Rettungssatz 'mit-wlan' fuer foo fehlt: $OUT"
fi
if [ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img" ]; then
pass "kein Rettungsimage 'ohne-wlan' fuer foo entstanden - build-nocreds/foo fehlte"
else
fail "es entstand ein Rettungsimage 'ohne-wlan' fuer foo, obwohl build-nocreds/foo fehlte"
fi
if echo "$OUT" | grep -q "bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert"; then
pass "Hinweis auf das fehlende build-nocreds/foo vorhanden"
else
fail "kein Hinweis auf das fehlende build-nocreds/foo: $OUT"
fi
echo
echo "=== Szenario 38: Altbestand - vor diesem Umschalter flach in images/ liegende Rettungsabbilder werden auch bei einem gezielten Deploy aufgeraeumt ==="
# Anders als bei den Migrationsabbildern (die ein eigenes, benanntes Verzeichnis erst
# seit einem frueheren Umschalter haben) gab es fuer die Rettung nie eine Uebergangsphase
# mit --delete: der flache Bestand wird von server.py seit diesem Umschalter nicht mehr
# gelesen (siehe rescue_dir() dort) und ist deshalb toter Ballast, sobald IRGENDEIN
# Deploy mit dieser Fassung laeuft - auch ein gezielter, der images/ sonst nicht mit
# --delete abgleicht.
SANDBOX="$TMPROOT/s38"; STUBBIN="$TMPROOT/bin38"; LOG="$TMPROOT/s38.log"; SANDBOX_STATE="$TMPROOT/s38-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
echo "alter-flacher-bestand" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img"
echo "alter-flacher-bestand-bar" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
if [ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \
&& [ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then
pass "der flache Altbestand wurde auch bei einem gezielten Deploy entfernt"
else
fail "flacher Altbestand liegt noch da - er wuerde von niemandem mehr gelesen, aber am Pi einen gueltigen Satz vortaeuschen"
fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
pass "das neue Rettungsimage liegt an der richtigen Stelle (images/rescue-wlan/)"
else
fail "das neue Rettungsimage fehlt: $OUT"
fi
echo
echo "=== Szenario 39: Zugangsdaten im RETTUNGSSATZ 'ohne-wlan' brechen den Deploy ab ==="
# Der Rettungssatz ist der gefaehrlichere der beiden Saetze "ohne-wlan": er geht ohne
# Signatur an die Lampe. Trotzdem hing die Zugangsdatenpruefung frueher allein an
# migrate-nocreds/ - rescue-nocreds/ lief ungeprueft durch. Hier ohne Schluessel, damit
# es NUR um den Rettungssatz geht: es entsteht gar kein migrate-nocreds/.
SANDBOX="$TMPROOT/s39"; STUBBIN="$TMPROOT/bin39"; LOG="$TMPROOT/s39.log"; SANDBOX_STATE="$TMPROOT/s39-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
write_local_cmake "$SANDBOX"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
make_ota_image "$SANDBOX/build/foo/ninlight_foo_ota.img" W
# Der undichte Rettungsabzug: die Zugangsdaten stecken im KOMPRIMIERTEN Rumpf.
make_ota_image_with_creds "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" N
# GEGENPROBE: ohne sie waere dieses Szenario wertlos - der "Compiler" dieser Suite muss
# die Zugangsdaten wirklich einbauen.
if tree_contains "$SANDBOX/build-nocreds" "$SECRET_PASS"; then
pass "Gegenprobe: der Rettungsabzug enthaelt das WLAN-Passwort wirklich (im gzip-Rumpf)"
else
fail "Gegenprobe fehlgeschlagen - der Rettungsabzug enthaelt gar keine Zugangsdaten, Szenario 39 waere wertlos"
fi
# Und die Gegenprobe zur Gegenprobe: ROH ist nichts davon zu finden. Genau deshalb hat
# die alte Pruefung nie etwas gemeldet.
if grep -q "$SECRET_PASS" "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" 2>/dev/null; then
fail "das Passwort steht im Klartext in der Datei - die Attrappe ist nicht komprimiert und belegt nichts"
else
pass "Gegenprobe: roh ist im Abbild nichts zu finden - nur entpackt"
fi
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds"
echo "alter-sauberer-abzug" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0 - der undichte Rettungssatz waere ausgeliefert worden: $OUT"
echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD" \
&& pass "Fehlermeldung nennt den betroffenen Schluessel" \
|| fail "Fehlermeldung nennt den Schluessel nicht: $OUT"
echo "$OUT" | grep -q "ninlight_foo_ota.img" \
&& pass "Fehlermeldung nennt die betroffene Datei" \
|| fail "Fehlermeldung nennt die Datei nicht: $OUT"
if echo "$OUT" | grep -q "$SECRET_PASS"; then
fail "die Meldung gibt den WERT aus - sie darf nur den Namen nennen"
else
pass "die Meldung nennt nur den Namen, nie den Wert"
fi
if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then
pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen"
else
fail "es wurde trotz gefundener Zugangsdaten uebertragen: $(cat "$LOG")"
fi
[ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img")" = "alter-sauberer-abzug" ] \
&& pass "der Bestand auf dem Pi blieb unangetastet" \
|| fail "der Bestand auf dem Pi wurde veraendert"
if ! tree_contains "$SANDBOX_STATE" "$SECRET_PASS"; then
pass "kein Zugangsdatum hat es in die Sandbox-Zielablage geschafft"
else
fail "Zugangsdaten sind trotz Abbruch beim Ziel gelandet"
fi
echo
echo "=== Szenario 40 (Abgrenzung zu 39): rescue-wlan DARF die Zugangsdaten tragen ==="
# Dieselbe Abgrenzung wie Szenario 34 fuer die Migration: der Satz 'mit-wlan' SOLL die
# Zugangsdaten enthalten. Eine Pruefung, die auch ihn abweist, waere unbenutzbar.
SANDBOX="$TMPROOT/s40"; STUBBIN="$TMPROOT/bin40"; LOG="$TMPROOT/s40.log"; SANDBOX_STATE="$TMPROOT/s40-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
write_local_cmake "$SANDBOX"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
make_ota_image_with_creds "$SANDBOX/build/foo/ninlight_foo_ota.img" W
make_ota_image "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" N
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - die Pruefung weist einen sauberen Rettungssatz ab: $OUT"
[ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ] \
&& pass "rescue-wlan kam trotz einkompilierter Zugangsdaten an" \
|| fail "rescue-wlan wurde faelschlich mit abgewiesen: $OUT"
[ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img" ] \
&& pass "der saubere Rettungssatz 'ohne-wlan' kam an" \
|| fail "der saubere Rettungssatz 'ohne-wlan' fehlt: $OUT"
if tree_contains "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan" "$SECRET_PASS"; then
pass "Gegenprobe: rescue-wlan traegt die Zugangsdaten wirklich - die Pruefung haette anschlagen KOENNEN"
else
fail "Gegenprobe fehlgeschlagen - rescue-wlan traegt gar keine Zugangsdaten, die Abgrenzung waere wertlos"
fi
if ! tree_contains "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds" "$SECRET_PASS"; then
pass "und der Rettungssatz 'ohne-wlan' auf dem Pi ist frei davon"
else
fail "der Rettungssatz 'ohne-wlan' auf dem Pi enthaelt Zugangsdaten"
fi
echo
echo "=== Szenario 41: ein Abbild, das sich nicht entpacken laesst, bricht den Deploy ab ==="
# Nachsicht an dieser Stelle ("dann eben ungeprueft durchlassen") ist der Weg, auf dem
# das Passwort am Ende doch hinausgeht: ein Abbild in einem anderen Format ist
# genau das, was niemand geprueft hat.
SANDBOX="$TMPROOT/s41"; STUBBIN="$TMPROOT/bin41"; LOG="$TMPROOT/s41.log"; SANDBOX_STATE="$TMPROOT/s41-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
write_local_cmake "$SANDBOX"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
make_ota_image "$SANDBOX/build/foo/ninlight_foo_ota.img" W
# Kein gzip-Rumpf: aufgebaut wie ein Abbild (56 Byte Kopf, 512 Byte Trailer), aber der
# Rumpf dazwischen ist unkomprimiert - die alte, unkomprimierte Attrappe dieser Suite.
make_ota_file "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0 - ein ungepruefter Rettungssatz waere ausgeliefert worden: $OUT"
echo "$OUT" | grep -q "nicht entpacken" \
&& pass "Fehlermeldung nennt den Grund" \
|| fail "keine Fehlermeldung zum Entpacken: $OUT"
echo "$OUT" | grep -q "rescue-nocreds/ninlight_foo_ota.img" \
&& pass "Fehlermeldung nennt die betroffene Datei" \
|| fail "Fehlermeldung nennt die Datei nicht: $OUT"
if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then
pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen"
else
fail "es wurde trotz ungepruefbarem Abbild uebertragen: $(cat "$LOG")"
fi
echo
echo "=== Szenario 42: die Meldung sagt, wie viele Abbilder in wie vielen Saetzen geprueft wurden ==="
# Die alte Meldung "sauber (2 geprueft)" zaehlte die SUCHBEGRIFFE und las sich wie zwei
# Dateien - sie haette genauso gelautet, wenn kein einziges Abbild angefasst worden waere.
SANDBOX="$TMPROOT/s42"; STUBBIN="$TMPROOT/bin42"; LOG="$TMPROOT/s42.log"; SANDBOX_STATE="$TMPROOT/s42-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
write_local_cmake "$SANDBOX"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" W
make_ota_image "$SANDBOX/build/foo/ninlight_foo_ota.img" W
make_gz_file "$SANDBOX/build-nocreds/foo/ninlight_foo_gz.img" N
make_ota_image "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" N
: >"$LOG"
set +e
OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
# migrate-nocreds/: foo_prod.img + foo_dev.img, rescue-nocreds/: ninlight_foo_ota.img.
echo "$OUT" | grep -q "sauber (3 Abbilder in 2 Saetzen geprueft" \
&& pass "die Meldung nennt Zahl der Abbilder UND der Saetze" \
|| fail "die Meldung sagt nicht, was geprueft wurde: $OUT"
echo "$OUT" | grep -q "migrate-nocreds, rescue-nocreds" \
&& pass "die Meldung nennt beide geprueften Saetze beim Namen" \
|| fail "die Meldung nennt die Saetze nicht: $OUT"
echo echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden." echo "Alle Tests bestanden."
+375 -55
View File
@@ -14,10 +14,60 @@ import render # noqa: E402
def test_off_mode_says_so_and_shows_nothing_else(): def test_off_mode_says_so_and_shows_nothing_else():
lines = render.lines({"mode": "off"}) lines = render.lines({"mode": "off"})
assert any("Station aus" in l for l in lines) assert any("Station off" in l for l in lines)
assert not any("SSID" in l for l in lines) assert not any("SSID" in l for l in lines)
def test_off_mode_shows_the_address_the_pi_is_reachable_at():
"""Bei ausgeschalteter Station ist der Pi ein gewoehnlicher Rechner im Heimnetz - die
Adresse am Panel erspart die Suche im Router, bevor man per SSH drangeht."""
lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"})
assert any("wlan0: 192.168.2.96" in l for l in lines)
def test_off_mode_names_the_interface_the_address_belongs_to():
"""Das Geraet gehoert dazu: Michael betreibt den Pi ohne Kabel, "LAN" las sich
deshalb als Falschauskunft (Rueckmeldung 2026-09-23). Am Kabel steht dort eth0."""
lines = render.lines({"mode": "off", "ip": "192.168.1.92", "iface": "eth0"})
assert any("eth0: 192.168.1.92" in l for l in lines)
assert not any("LAN" in l for l in lines)
def test_off_mode_shows_the_address_even_without_an_interface_name():
"""Notnagel: Adresse ohne Geraetenamen ist immer noch die Auskunft, die gebraucht
wird - dann ohne Beschriftung raten, sondern schlicht "IP"."""
lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": ""})
assert any("IP: 192.168.2.96" in l for l in lines)
def test_off_mode_says_when_there_is_no_network():
"""Leeres Feld heisst "kein Netz" und wird ausdruecklich gesagt: das erklaert, warum
SSH gerade scheitert. Eine fehlende Zeile wuerde die Frage offen lassen.
"no home network" statt eines nackten "no network": ein Bediener las die fruehere
Formulierung so, als sei der Migrations-/Rettungs-Zugangspunkt gemeint - der ist im
Aus-Zustand ja tatsaechlich aus. Gemeint ist aber das GEGENTEIL, das Heimnetz, in dem
der Pi als gewoehnlicher Rechner haengt (siehe render.py)."""
lines = render.lines({"mode": "off", "ip": "", "iface": ""})
assert any("no home network" in l for l in lines)
def test_off_mode_without_an_address_field_claims_nothing():
"""Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand): ohne das Feld
bleibt die Zeile weg, statt eine Adresse zu erfinden."""
lines = render.lines({"mode": "off"})
assert not any("home network" in l or ": 192." in l for l in lines)
def test_the_address_line_belongs_to_the_off_screen_only():
"""Im Betrieb traegt wlan0 die Zugangspunkt-Adresse, die ohnehin auf jedem Statusbild
steht - eine zweite Adresse waere irrefuehrend und kostet die knappe Zeile."""
for mode in ("migration", "recovery"):
lines = render.lines({"mode": mode, "phase": "auto", "ip": "192.168.2.96",
"iface": "wlan0"})
assert not any("192.168.2.96" in l for l in lines)
def test_migration_shows_detected_lamp(): def test_migration_shows_detected_lamp():
lines = render.lines({ lines = render.lines({
"mode": "migration", "phase": "auto", "served": 3, "mode": "migration", "phase": "auto", "served": 3,
@@ -57,7 +107,7 @@ def test_recovery_mode_without_traffic():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
"served": 0, "recent": []}) "served": 0, "recent": []})
text = "\n".join(lines) text = "\n".join(lines)
assert "RETTUNG" in text assert "RECOVERY" in text
assert "ninlight-recovery" in text assert "ninlight-recovery" in text
@@ -151,7 +201,7 @@ def _migration_state():
def test_off_screen_is_drawn_big_and_centered(): def test_off_screen_is_drawn_big_and_centered():
""""Station aus" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein. """"Station off" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links
wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind
@@ -519,7 +569,7 @@ def test_font_cache_keeps_different_modules_and_search_paths_apart(monkeypatch):
def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch): def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch):
""""Station aus" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar """"Station off" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
nicht erst gebaut werden (faules Laden, Befund M4).""" nicht erst gebaut werden (faules Laden, Befund M4)."""
real_load_font = render._load_font real_load_font = render._load_font
requested = [] requested = []
@@ -663,7 +713,7 @@ def test_missing_fields_do_not_raise():
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) ----- # --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
# #
# Frueher stand "FREIGEGEBEN: <ziel>" als Phase im Kopfbalken. Neben dem Modus bleiben # Frueher stand "ARMED: <target>" als Phase im Kopfbalken. Neben dem Modus bleiben
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein # dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein
# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu # Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war # (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war
@@ -760,11 +810,11 @@ def test_armed_phase_label_is_visible():
def test_autooff_line_appears_only_with_a_running_timer(): def test_autooff_line_appears_only_with_a_running_timer():
without = render.lines(_migration_state()) without = render.lines(_migration_state())
assert not any("Auto-Aus" in line for line in without), without assert not any("Auto-off" in line for line in without), without
state = dict(_migration_state(), autooff_minutes=7) state = dict(_migration_state(), autooff_minutes=7)
with_timer = render.lines(state) with_timer = render.lines(state)
assert "Auto-Aus in 7 min" in with_timer, with_timer assert "Auto-off in 7 min" in with_timer, with_timer
def test_autooff_line_also_appears_in_recovery_mode(): def test_autooff_line_also_appears_in_recovery_mode():
@@ -772,7 +822,7 @@ def test_autooff_line_also_appears_in_recovery_mode():
die SSH-Sitzung genauso ab.""" die SSH-Sitzung genauso ab."""
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0, lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0,
"recent": [], "autooff_minutes": 42}) "recent": [], "autooff_minutes": 42})
assert "Auto-Aus in 42 min" in lines, lines assert "Auto-off in 42 min" in lines, lines
def test_autooff_line_is_actually_drawn_on_the_panel(): def test_autooff_line_is_actually_drawn_on_the_panel():
@@ -781,7 +831,7 @@ def test_autooff_line_is_actually_drawn_on_the_panel():
unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete
Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten.""" Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten."""
state = dict(_armed_state(), autooff_minutes=7) state = dict(_armed_state(), autooff_minutes=7)
expected = "Auto-Aus in 7 min" expected = "Auto-off in 7 min"
# Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile # Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile
# sind eigene Rollen und belegen keine Beiwerkzeile.) # sind eigene Rollen und belegen keine Beiwerkzeile.)
details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL] details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL]
@@ -800,34 +850,36 @@ def test_autooff_line_is_actually_drawn_on_the_panel():
def test_autooff_line_says_so_when_the_deadline_has_arrived(): def test_autooff_line_says_so_when_the_deadline_has_arrived():
lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [], lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [],
"autooff_minutes": 0}) "autooff_minutes": 0})
assert "Auto-Aus steht an" in lines, lines assert "Auto-off pending" in lines, lines
def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch): def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery") (tmp_path / "last-mode").write_text("recovery")
conf_path = tmp_path / "station.conf" conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n") conf_path.write_text("SSID='ninlight-recovery'\n")
# Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet. # Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet.
(tmp_path / "autooff.json").write_text(json.dumps( (tmp_path / "autooff.json").write_text(json.dumps(
{"minutes": 30, "deadline": time.time() + 361, "deferrals": 0})) {"minutes": 30, "deadline": time.time() + 361, "deferrals": 0}))
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
last_mode_path=tmp_path / "last-mode")
assert state["autooff_minutes"] == 7 assert state["autooff_minutes"] == 7
assert "Auto-Aus in 7 min" in render.lines(state) assert "Auto-off in 7 min" in render.lines(state)
def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch): def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery") (tmp_path / "last-mode").write_text("recovery")
conf_path = tmp_path / "station.conf" conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n") conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
last_mode_path=tmp_path / "last-mode")
assert "autooff_minutes" not in state assert "autooff_minutes" not in state
assert not any("Auto-Aus" in line for line in render.lines(state)) assert not any("Auto-off" in line for line in render.lines(state))
def test_broken_autooff_file_is_treated_as_no_timer(tmp_path): def test_broken_autooff_file_is_treated_as_no_timer(tmp_path):
@@ -855,7 +907,7 @@ def test_autooff_remaining_time_rounds_up(tmp_path):
def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch): def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
""""off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz """"off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz
liegen bliebe, darf neben "Station aus" keine Restzeit stehen.""" liegen bliebe, darf neben "Station off" keine Restzeit stehen."""
monkeypatch.setattr(display, "_service_active", lambda service_name: False) monkeypatch.setattr(display, "_service_active", lambda service_name: False)
(tmp_path / "autooff.json").write_text(json.dumps( (tmp_path / "autooff.json").write_text(json.dumps(
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0})) {"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
@@ -866,7 +918,7 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
assert state["mode"] == "off" assert state["mode"] == "off"
assert "autooff_minutes" not in state assert "autooff_minutes" not in state
assert not any("Auto-Aus" in line for line in render.lines(state)) assert not any("Auto-off" in line for line in render.lines(state))
# --- display.build_state() (Fixrunde 1, I3) --------------------------------------- # --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
@@ -874,51 +926,180 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal # Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im # nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im # Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
# Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen. # Rettungsmodus nicht als "ARMED" durchschlagen.
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch): def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery") (tmp_path / "last-mode").write_text("recovery")
(tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"})) (tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"}))
conf_path = tmp_path / "station.conf" conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n") conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
last_mode_path=tmp_path / "last-mode")
assert state["mode"] == "recovery" assert state["mode"] == "recovery"
assert "phase" not in state assert "phase" not in state
assert "armed_target" not in state assert "armed_target" not in state
assert "FREIGEGEBEN" not in "\n".join(render.lines(state)) assert "ARMED" not in "\n".join(render.lines(state))
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch): def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: False) monkeypatch.setattr(display, "_service_active", lambda service_name: False)
# Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern - # Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern -
# "ninlight-station off" laesst status.json bewusst unangetastet liegen. # "ninlight-station off" laesst status.json bewusst unangetastet liegen.
(tmp_path / "mode").write_text("migration") (tmp_path / "last-mode").write_text("migration")
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5})) (tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x", service_name="x",
imageset_path=tmp_path / "imageset") imageset_path=tmp_path / "imageset",
last_mode_path=tmp_path / "last-mode")
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im # "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll). # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
assert state["mode"] == "off" assert state["mode"] == "off"
assert set(state) == {"mode", "imageset", "imageset_ok"} assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface"}
assert isinstance(state["ip"], str)
def test_build_state_off_carries_the_local_address(tmp_path, monkeypatch):
"""Adresse und Geraet kommen aus local_address() und landen nur im Off-Zustand."""
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
monkeypatch.setattr(display, "local_address", lambda: ("wlan0", "192.168.2.96"))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x", imageset_path=tmp_path / "imageset")
assert state["ip"] == "192.168.2.96"
assert state["iface"] == "wlan0"
def test_build_state_running_carries_no_address(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "last-mode").write_text("migration")
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x", imageset_path=tmp_path / "imageset",
last_mode_path=tmp_path / "last-mode")
assert "ip" not in state
def test_local_address_returns_strings_and_never_raises():
"""Ohne Netz gibt es keine Route - dann ("", "") statt einer Ausnahme, die das Panel
aufhielte."""
iface, ip = display.local_address()
assert isinstance(iface, str) and isinstance(ip, str)
def test_local_address_works_without_af_inet(monkeypatch):
"""Der Anzeigedienst darf laut Unit NUR AF_UNIX und AF_NETLINK (siehe
ninlight-display.service:RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort
mit EAFNOSUPPORT - genau daran ist die Adresszeile am echten Panel gescheitert,
waehrend derselbe Aufruf von Hand (ohne Sandbox) die Adresse lieferte. Dieser Test
sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem."""
import socket as _socket
def kein_af_inet(family, *a, **kw):
if family == _socket.AF_INET:
raise OSError(97, "Address family not supported by protocol")
raise AssertionError("unerwartete Socketfamilie: %r" % (family,))
monkeypatch.setattr(_socket, "socket", kein_af_inet)
monkeypatch.setattr(display, "_route_lookup",
lambda: "192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000")
assert display.local_address() == ("wlan0", "192.168.2.96")
def test_local_address_reads_interface_and_source_from_the_route(monkeypatch):
"""Das Geraet gehoert mit auf das Panel: Michael hat kein Kabel, "LAN" las sich
deshalb falsch (Rueckmeldung 2026-09-23). Beide Angaben stehen in derselben
Auskunft."""
monkeypatch.setattr(display, "_route_lookup",
lambda: "192.0.2.1 dev eth0 src 192.168.1.92 uid 1000 \n cache")
assert display.local_address() == ("eth0", "192.168.1.92")
def test_local_address_without_a_route(monkeypatch):
"""Kein Netz: "ip route get" meldet einen Fehler und liefert nichts Brauchbares."""
monkeypatch.setattr(display, "_route_lookup", lambda: "")
assert display.local_address() == ("", "")
# --- Lebenszeichen: ein kleiner Punkt, sobald eine Anfrage jeder Art hereinkommt -------
#
# Bedienerwunsch (2026-09-23): "so ist es immer raetselraten und warten das der zaehler
# hochgeht um zu wissen ob es geklappt hat" - der Punkt soll VOR dem Erfolg schon zeigen,
# dass ueberhaupt jemand anklopft.
def test_recent_request_is_true_right_after_a_request():
status = {"last_request_ts": "2026-09-23T12:00:00Z"}
now = display.datetime(2026, 9, 23, 12, 0, 5, tzinfo=display.timezone.utc).timestamp()
assert display._recent_request(status, now=now) is True
def test_recent_request_expires_after_the_window():
status = {"last_request_ts": "2026-09-23T12:00:00Z"}
now = (display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp()
+ display.ACTIVITY_WINDOW_S + 1)
assert display._recent_request(status, now=now) is False
def test_recent_request_without_a_timestamp_is_false():
"""Aeltere server.py-Fassung, oder noch nie eine Anfrage seit dem Start - kein
erfundener Punkt."""
assert display._recent_request({}) is False
assert display._recent_request({"last_request_ts": None}) is False
assert display._recent_request({"last_request_ts": "kaputt"}) is False
def test_recent_request_from_the_future_is_false():
"""Eine verstellte Uhr darf den Punkt nicht auf ewig scharf halten."""
status = {"last_request_ts": "2026-09-23T12:00:10Z"}
now = display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp()
assert display._recent_request(status, now=now) is False
def test_served_line_shows_a_dot_while_activity_is_recent():
lines_active = render.lines({"mode": "recovery", "served": 3, "recent": [],
"activity": True})
lines_idle = render.lines({"mode": "recovery", "served": 3, "recent": [],
"activity": False})
active_line = next(l for l in lines_active if l.startswith("Served:"))
idle_line = next(l for l in lines_idle if l.startswith("Served:"))
assert active_line != idle_line
assert "3" in active_line and "3" in idle_line
def test_served_line_without_an_activity_field_claims_nothing():
"""Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand im Test): ohne das
Feld bleibt die Zeile wie zuvor, kein erfundener Punkt."""
lines = render.lines({"mode": "recovery", "served": 3, "recent": []})
served_line = next(l for l in lines if l.startswith("Served:"))
assert served_line == "Served: 3"
def test_build_state_carries_activity_in_recovery_and_migration(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "last-mode").write_text("recovery")
(tmp_path / "status.json").write_text(json.dumps({"last_request_ts": None}))
conf = tmp_path / "station.conf"
conf.write_text("")
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=tmp_path / "imageset",
last_mode_path=tmp_path / "last-mode")
assert state["activity"] is False
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch): def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
"""Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld - """Der Modus-Vorrang liegt bei der dauerhaften Modusdatei (last_mode_path), nicht
status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst bei status.json's "mode"-Feld - status.json kann veraltet sein (Fixrunde 1,
umgekehrt).""" Kleinigkeit: der Vorrang war zuerst umgekehrt)."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery") (tmp_path / "last-mode").write_text("recovery")
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1})) (tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1}))
conf_path = tmp_path / "station.conf" conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n") conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
last_mode_path=tmp_path / "last-mode")
assert state["mode"] == "recovery" assert state["mode"] == "recovery"
assert state["served"] == 1 assert state["served"] == 1
@@ -927,10 +1108,36 @@ def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeyp
def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch): def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x") service_name="x", last_mode_path=tmp_path / "last-mode")
assert state["mode"] == "recovery" assert state["mode"] == "recovery"
def test_build_state_reads_the_durable_mode_file_even_after_a_real_stop(tmp_path, monkeypatch):
"""Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart", NICHT
das "systemctl stop", das "ninlight-station off" ausloest (die Unit setzt
RuntimeDirectoryPreserve=restart, nicht "yes") - ein frueherer Docstring behauptete
das Gegenteil. Dieser Test bildet GENAU diesen Ablauf nach: "migrate on" schreibt
den Modus, RUN_DIR wird dann (wie bei einem echten Stop) GELEERT, und build_state()
muss den Modus trotzdem noch aus der dauerhaften Datei kennen - nicht auf "recovery"
zurueckfallen, waehrend tatsaechlich eine Migration ansteht."""
last_mode = tmp_path / "var" / "last-mode"
run_dir = tmp_path / "run"
run_dir.mkdir()
(run_dir / "mode").write_text("migration")
last_mode.parent.mkdir(parents=True)
last_mode.write_text("migration")
# "systemctl stop" leert das RuntimeDirectory - hier nachgestellt, ohne RUN_DIR
# selbst zu entfernen (das legt server.py/das CLI bei Bedarf neu an).
(run_dir / "mode").unlink()
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
state = display.build_state(run_dir=run_dir, conf_path=tmp_path / "station.conf",
service_name="x", imageset_path=tmp_path / "imageset",
last_mode_path=last_mode)
assert state["mode"] == "migration"
def test_read_conf_accepts_export_prefix(tmp_path): def test_read_conf_accepts_export_prefix(tmp_path):
"""setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein """setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein
optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene
@@ -1457,6 +1664,68 @@ def _changing_every(period_s, mode="recovery"):
return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []} return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []}
# --- Lebenszeichen im Aus-Zustand: die IP muss auftauchen, sobald sie da ist -----------
#
# Rueckmeldung eines Bedieners (2026-09-23): am laufenden Pi blieb "no network" stehen,
# obwohl der Pi laengst im Heimnetz haengt. local_address() liefert die Adresse dabei
# nachweislich korrekt - der Verdacht war also die Anzeigeschleife: wird der Aus-Zustand
# ueberhaupt regelmaessig neu erhoben, und fliesst die IP in den Vergleich ein, der ueber
# ein Neuzeichnen entscheidet? Dieser Test treibt run_daemon() GENAU durch diesen Fall:
# beim ersten Erheben keine Adresse, beim zweiten eine.
def test_off_mode_redraws_once_the_address_appears(monkeypatch):
"""Haelt fest: run_daemon() erhebt den Zustand auch im Aus-Zustand unbedingt alle
POLL_INTERVAL_S Sekunden neu (kein Ueberspringen mangels Moduswechsel), und die IP
ist Teil des Vergleichs, der ueber ein Neuzeichnen entscheidet (render.lines() traegt
sie, siehe render.py). Schlaegt dieser Test fehl, bleibt "no home network" auf dem
Panel stehen, obwohl der Pi laengst erreichbar ist."""
seq = [{"mode": "off", "ip": "", "iface": ""},
{"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}]
def state_at(t):
# Erste POLL_INTERVAL_S-Erhebung (bei t=0) sieht noch keine Adresse, danach schon -
# weit genug auseinander, dass zwei unterschiedliche Polls sicher dazwischenliegen.
return seq[0] if t < 2 * display.POLL_INTERVAL_S else seq[1]
panel = _drive_daemon(state_at, duration_s=4 * display.POLL_INTERVAL_S)
# Erster Aufbau zeigt "keine Adresse" (Vollaufbau beim Start). Danach MUSS
# spaetestens einer der Aufrufe die neue Adresse tatsaechlich zeichnen - das
# Panel-Doppel protokolliert keinen Bildinhalt, deshalb wird direkt geprueft, dass
# render.lines() zum Zeitpunkt des naechsten Aufbaus schon die neue Adresse traegt
# (dieselbe Quelle, die run_daemon() intern fuer den Vergleich benutzt).
assert len(panel.calls) >= 2, panel.calls
draws_after_first = [name for _, name in panel.calls[1:]]
assert any(name in ("full", "partial", "wake-full") for name in draws_after_first), (
"kein weiterer Aufbau nach dem ersten - die neue Adresse waere nie gezeichnet worden",
panel.calls,
)
assert any("wlan0: 192.168.2.96" in line for line in render.lines(state_at(4 * display.POLL_INTERVAL_S)))
def test_run_daemon_survives_a_single_failed_state_read(monkeypatch):
"""Ein einzelner unerwarteter Fehler beim Erheben (ausserhalb dessen, was
build_state()s eigene Bausteine schon abfangen) darf die Endlosschleife nicht
beenden - sonst friert das Panel beim naechsten Fehlschlag fuer immer auf dem
zuletzt gezeichneten Bild ein (E-Paper vergisst nichts von selbst), und zwar OHNE
jede Fehlermeldung am Geraet. Erhebung 1 wirft, Erhebung 2 liefert einen echten
Zustand - danach muss dieser auch tatsaechlich gezeichnet sein."""
calls = {"n": 0}
good_state = {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}
def flaky_state_at(t):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("simulierter Lesefehler (z.B. RUN_DIR mitten im Neustart)")
return good_state
panel = _drive_daemon(flaky_state_at, duration_s=3 * display.POLL_INTERVAL_S)
# Kein Absturz (sonst haette _drive_daemon die Ausnahme durchgereicht statt sie
# abzufangen) und die Schleife lief tatsaechlich weiter: mindestens der initiale
# Vollaufbau (mit dem neutralen Platzhalter) und ein weiterer, sobald der zweite
# Versuch den echten Zustand liefert.
assert len(panel.calls) >= 2, panel.calls
def test_static_content_never_triggers_another_full_refresh(): def test_static_content_never_triggers_another_full_refresh():
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die """Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen. Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen.
@@ -1599,7 +1868,7 @@ def test_planner_counts_an_upgraded_partial_as_a_full_refresh():
def test_planner_draws_fully_on_mode_change(): def test_planner_draws_fully_on_mode_change():
planner = display.RefreshPlanner(0.0) planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True) planner.record_draw(0.0, ["RECOVERY"], "recovery", was_full=True)
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full" assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"
@@ -1612,14 +1881,14 @@ def test_planner_draws_fully_on_mode_change():
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und # wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein. # dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus", _MENU_UI = {"view": "menu", "title": "MENU", "hero": "> Station off",
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True} "details": ["Refresh display", "Shut down"], "active": True}
def test_the_ui_file_lives_outside_the_stations_runtime_directory(): def test_the_ui_file_lives_outside_the_stations_runtime_directory():
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim """Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station off", in dem
das Menue "Rettung starten"/"Migration starten" anbietet.""" das Menue "Start recovery"/"Migration starten" anbietet."""
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json" assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
assert not str(display.UI_FILE).startswith("/run/ninlight-station/") assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
@@ -1627,7 +1896,7 @@ def test_the_ui_file_lives_outside_the_stations_runtime_directory():
def test_a_running_operation_replaces_the_status_screen(): def test_a_running_operation_replaces_the_status_screen():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7, lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
"recent": [], "ui": _MENU_UI}) "recent": [], "ui": _MENU_UI})
assert lines == ["MENUE", "> Station aus", "Anzeige auffrischen", "Herunterfahren"] assert lines == ["MENU", "> Station off", "Refresh display", "Shut down"]
assert not any("ninlight-recovery" in l for l in lines) assert not any("ninlight-recovery" in l for l in lines)
@@ -1652,7 +1921,7 @@ def test_a_broken_ui_file_does_not_break_the_display():
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen.""" kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17, lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
"hero": None, "details": "nope"}}) "hero": None, "details": "nope"}})
assert lines == ["Bedienung"] assert lines == ["Button menu"]
render.compose({"mode": "recovery", "ui": {"view": "menu"}}) render.compose({"mode": "recovery", "ui": {"view": "menu"}})
@@ -1668,7 +1937,7 @@ def test_read_ui_ignores_a_missing_broken_or_idle_file(tmp_path):
path.write_text(json.dumps({"view": "menu", "active": False})) path.write_text(json.dumps({"view": "menu", "active": False}))
assert display.read_ui(path) == {} assert display.read_ui(path) == {}
path.write_text(json.dumps(_MENU_UI)) path.write_text(json.dumps(_MENU_UI))
assert display.read_ui(path)["hero"] == "> Station aus" assert display.read_ui(path)["hero"] == "> Station off"
def test_the_daemon_shows_an_operation_within_half_a_second(): def test_the_daemon_shows_an_operation_within_half_a_second():
@@ -1748,13 +2017,13 @@ def test_the_two_image_sets_read_differently():
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine """Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos.""" Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"] assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"] assert "Wi-Fi" in render.IMAGESET_LABELS["mit-wlan"]
def test_a_missing_image_set_says_so_on_the_panel(): def test_a_missing_image_set_says_so_on_the_panel():
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False) state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
line = _imageset_line(state) line = _imageset_line(state)
assert line is not None and "FEHLT" in line assert line is not None and "MISSING" in line
def test_without_an_image_set_field_nothing_is_claimed(): def test_without_an_image_set_field_nothing_is_claimed():
@@ -1806,7 +2075,8 @@ def test_the_image_set_line_survives_the_fullest_state():
def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch): def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
"""Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg.""" """Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("migration") last_mode = tmp_path / "last-mode"
last_mode.write_text("migration")
images = tmp_path / "images" images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True) (images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") (images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
@@ -1818,7 +2088,7 @@ def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
def state(): def state():
return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch) imageset_path=switch, last_mode_path=last_mode)
# Ohne Datei: die sichere Vorgabe. # Ohne Datei: die sichere Vorgabe.
assert state()["imageset"] == "ohne-wlan" assert state()["imageset"] == "ohne-wlan"
@@ -1831,8 +2101,16 @@ def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch): def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel """"Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts.""" wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts.
Ausdruecklich im Migrationsmodus (last_mode_path explizit auf "migration" gesetzt,
nicht dem Vorgabewert ueberlassen - Fixrunde 1, Befund 3: eine fehlende Datei faellt
auf "recovery", und dieser Test soll die MIGRATION pruefen) - das ist es, was die
migrate-*-Verzeichnisse betrifft. Der Rettungsmodus prueft ein eigenes Paar
(rescue-*), siehe test_build_state_reports_a_missing_rescue_image_set unten."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True) monkeypatch.setattr(display, "_service_active", lambda service_name: True)
last_mode = tmp_path / "last-mode"
last_mode.write_text("migration")
images = tmp_path / "images" images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True) (images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG") (images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
@@ -1842,17 +2120,58 @@ def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
switch.write_text("ohne-wlan") switch.write_text("ohne-wlan")
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is True imageset_path=switch,
last_mode_path=last_mode)["imageset_ok"] is True
switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch) imageset_path=switch, last_mode_path=last_mode)
assert state["imageset_ok"] is False assert state["imageset_ok"] is False
assert "FEHLT" in _imageset_line(state) assert "MISSING" in _imageset_line(state)
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend. # Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
(images / "migrate-wlan").mkdir() (images / "migrate-wlan").mkdir()
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is False imageset_path=switch,
last_mode_path=last_mode)["imageset_ok"] is False
def test_build_state_reports_a_missing_rescue_image_set(tmp_path, monkeypatch):
"""Derselbe Umschalter gilt jetzt auch fuer die Rettung, mit einem EIGENEN
Verzeichnispaar (rescue-*, nicht migrate-*) - server.py begruendet die Trennung.
last_mode_path wird ausdruecklich auf "recovery" gesetzt, statt den Vorgabewert zu
nutzen (Fixrunde 1, Befund 3 - ein vorheriger Zustand dieses Tests liess die Datei
ganz weg und pruefte damit nur den ZUFAELLIG gleich lautenden Rueckfallwert, nicht
die Wirklichkeit: mit einer fehlenden Datei haette dieser Test nicht bemerkt, wenn
"recovery" faelschlich auch fuer eine echte Migration zurueckkaeme)."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
last_mode = tmp_path / "last-mode"
last_mode.write_text("recovery")
images = tmp_path / "images"
(images / "rescue-nocreds").mkdir(parents=True)
(images / "rescue-nocreds" / "ninlight_g95_cw_ota.img").write_bytes(b"IMG")
# Ein voll bestueckter Migrations-Bildsatz daneben - er darf das Ergebnis nicht
# beeinflussen (falsche Zuordnung waere der Fehler, den dieser Test faengt).
(images / "migrate-wlan").mkdir(parents=True)
conf = tmp_path / "station.conf"
conf.write_text(f"IMAGE_DIR='{images}'\n")
switch = tmp_path / "imageset"
switch.write_text("ohne-wlan")
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch,
last_mode_path=last_mode)["imageset_ok"] is True
switch.write_text("mit-wlan") # rescue-wlan/ gibt es nicht
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch, last_mode_path=last_mode)
assert state["imageset_ok"] is False
assert "MISSING" in _imageset_line(state)
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
(images / "rescue-wlan").mkdir()
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch,
last_mode_path=last_mode)["imageset_ok"] is False
def test_the_display_and_the_server_agree_on_the_set_names(): def test_the_display_and_the_server_agree_on_the_set_names():
@@ -1865,6 +2184,7 @@ def test_the_display_and_the_server_agree_on_the_set_names():
spec.loader.exec_module(server) spec.loader.exec_module(server)
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
assert display.RESCUE_IMAGESET_DIRS == server.RESCUE_SET_DIRS
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart ----------------------- # --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
@@ -1884,14 +2204,14 @@ def test_the_shutdown_and_the_reboot_screen_differ():
gekostet).""" gekostet)."""
off, reboot = _final_text("poweroff"), _final_text("reboot") off, reboot = _final_text("poweroff"), _final_text("reboot")
assert off != reboot assert off != reboot
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot assert "POWERED OFF" in off and "REBOOTING" in reboot
# Ausschalten: erlaubt das Trennen ausdruecklich. Geprueft wird die ZUSAGE, nicht # Ausschalten: erlaubt das Trennen ausdruecklich. Geprueft wird die ZUSAGE, nicht
# der genaue Satz - der Wortlaut musste am 2026-09-15 kuerzer werden, weil die alte # der genaue Satz - der Wortlaut musste am 2026-09-15 kuerzer werden, weil die alte
# Fassung ("Strom kann getrennt werden", 262,1 px) auf dem Panel abgeschnitten war. # Fassung ("Strom kann getrennt werden", 262,1 px) auf dem Panel abgeschnitten war.
assert "trennen erlaubt" in off assert "Safe to unplug" in off
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil. # Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
assert "NICHT trennen" in reboot assert "Do NOT unplug" in reboot
assert "trennen erlaubt" not in reboot assert "Safe to unplug" not in reboot
def test_the_reboot_screen_says_how_long_it_stays(): def test_the_reboot_screen_says_how_long_it_stays():
@@ -1907,14 +2227,14 @@ def test_the_final_screen_carries_nothing_else():
"imageset_ok": True, "served": 7} "imageset_ok": True, "served": 7}
blocks = render.blocks(state) blocks = render.blocks(state)
assert not any(role == render.ROLE_FOOTER for _, role in blocks) assert not any(role == render.ROLE_FOOTER for _, role in blocks)
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks) assert not any("Bildsatz" in text or "Served" in text for text, _ in blocks)
def test_an_unknown_final_kind_claims_neither(): def test_an_unknown_final_kind_claims_neither():
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder """Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
gebootet wird.""" gebootet wird."""
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"})) text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
assert "AUSGESCHALTET" not in text assert "POWERED OFF" not in text
assert "getrennt" not in text assert "getrennt" not in text
@@ -1965,7 +2285,7 @@ def test_display_main_passes_the_final_kind_through(monkeypatch):
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild """Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
"Strom kann getrennt werden".""" "Safe to unplug"."""
drawn = [] drawn = []
class _Panel: class _Panel:
+102 -10
View File
@@ -43,6 +43,14 @@ class Station:
def __init__(self, tmp_path, *extra): def __init__(self, tmp_path, *extra):
self.image_dir = tmp_path / "images" self.image_dir = tmp_path / "images"
self.image_dir.mkdir() self.image_dir.mkdir()
# image_path() folgt jetzt dem Bildsatz-Umschalter, genauso wie migrate_dir() es
# tut - ohne --imageset-file gilt die Vorgabe "ohne-wlan" (server.py,
# DEFAULT_IMAGE_SET), also landen Rettungsabbilder in image_dir/rescue-nocreds.
# Die meisten Tests hier pruefen nur die Ausliefermechanik, nicht den
# Bildsatz-Umschalter selbst - die Bildsatz-spezifischen Tests weiter unten legen
# ihre Abbilder gezielt in andere Verzeichnisse.
self.rescue_dir = self.image_dir / "rescue-nocreds"
self.rescue_dir.mkdir()
self.state = tmp_path / "status.json" self.state = tmp_path / "status.json"
self.port = free_port() self.port = free_port()
self.proc = subprocess.Popen( self.proc = subprocess.Popen(
@@ -140,7 +148,7 @@ def station(tmp_path):
def test_serves_a_known_image(station): def test_serves_a_known_image(station):
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500) data = make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
c = station.conn() c = station.conn()
c.request("GET", "/ninlight_evk_ota.img") c.request("GET", "/ninlight_evk_ota.img")
r = c.getresponse() r = c.getresponse()
@@ -157,7 +165,7 @@ def test_missing_image_is_404(station):
def test_unknown_target_is_404(station): def test_unknown_target_is_404(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100) make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"): for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
c = station.conn() c = station.conn()
c.request("GET", path) c.request("GET", path)
@@ -216,7 +224,7 @@ def test_log_error_is_not_silenced_by_log_message(capsys):
def test_range_request_returns_206_and_the_right_slice(station): def test_range_request_returns_206_and_the_right_slice(station):
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000) data = make_image(station.rescue_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
c = station.conn() c = station.conn()
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"}) c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
r = c.getresponse() r = c.getresponse()
@@ -227,7 +235,7 @@ def test_range_request_returns_206_and_the_right_slice(station):
def test_state_file_records_a_delivery(station): def test_state_file_records_a_delivery(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000) make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
c = station.conn() c = station.conn()
c.request("GET", "/ninlight_evk_ota.img") c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read() c.getresponse().read()
@@ -241,6 +249,21 @@ def test_state_file_records_a_delivery(station):
assert entry["bytes"] == 1000 + TRAILER_SIZE assert entry["bytes"] == 1000 + TRAILER_SIZE
def test_state_file_carries_a_last_request_timestamp_for_the_display(station):
"""display.py zeigt einen kleinen Lebenszeichen-Punkt, sobald eine Anfrage jeder Art
hereinkommt (auch ein 404) - dafuer muss status.json den Zeitpunkt tragen, nicht nur
den Erfolgszaehler. Vor der ersten Anfrage ist das Feld da, aber None."""
initial = station.read_state()
assert "last_request_ts" in initial
assert initial["last_request_ts"] is None
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img") # existiert nicht - zaehlt trotzdem
c.getresponse().read()
state = station.read_state(lambda s: s["last_request_ts"] is not None)
assert isinstance(state["last_request_ts"], str) and state["last_request_ts"]
def test_state_file_records_a_missing_image(station): def test_state_file_records_a_missing_image(station):
c = station.conn() c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img") c.request("GET", "/ninlight_g95_cw_ota.img")
@@ -266,7 +289,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
finally: finally:
os.umask(old_umask) os.umask(old_umask)
try: try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100) make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
def world_readable() -> bool: def world_readable() -> bool:
mode = stat.S_IMODE(s.state.stat().st_mode) mode = stat.S_IMODE(s.state.stat().st_mode)
@@ -289,7 +312,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
def test_recent_is_capped_at_eight(station): def test_recent_is_capped_at_eight(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10) make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
for _ in range(12): for _ in range(12):
c = station.conn() c = station.conn()
c.request("GET", "/ninlight_evk_ota.img") c.request("GET", "/ninlight_evk_ota.img")
@@ -302,7 +325,7 @@ def test_recent_is_capped_at_eight(station):
def test_aborted_transfer_is_recorded(tmp_path): def test_aborted_transfer_is_recorded(tmp_path):
s = Station(tmp_path, "--stall-after", "256") s = Station(tmp_path, "--stall-after", "256")
try: try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000) make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5) sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n") sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
sock.recv(4096) sock.recv(4096)
@@ -326,7 +349,7 @@ def test_aborted_transfer_is_recorded(tmp_path):
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path): def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
s = Station(tmp_path, "--stall-after", "512") s = Station(tmp_path, "--stall-after", "512")
try: try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000) make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5) sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n") sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
got = b"" got = b""
@@ -369,7 +392,7 @@ def test_stall_after_budget_survives_a_resumed_request(tmp_path):
""" """
s = Station(tmp_path, "--stall-after", "1000") s = Station(tmp_path, "--stall-after", "1000")
try: try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000) make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
# First request: burns through the whole --stall-after budget, then the server # First request: burns through the whole --stall-after budget, then the server
# holds the connection open and sends nothing further. # holds the connection open and sends nothing further.
@@ -419,7 +442,7 @@ def test_concurrent_downloads_are_all_recorded(station):
concurrent requests can lose increments (two threads read the same old value) or have concurrent requests can lose increments (two threads read the same old value) or have
one thread's status.json write clobber another's. one thread's status.json write clobber another's.
""" """
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000) make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
expected_len = 5000 + TRAILER_SIZE expected_len = 5000 + TRAILER_SIZE
n_workers = 20 n_workers = 20
errors = [] errors = []
@@ -1092,6 +1115,75 @@ def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_serve
)["recent"][-1]["version"] == "0.4.0-wlan" )["recent"][-1]["version"] == "0.4.0-wlan"
# --- Derselbe Umschalter gilt jetzt auch fuer die RETTUNG ---------------------------
#
# Der Eigentuemer hat das ausdruecklich verlangt: bisher bekam eine Rettung IMMER ein
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ kamen aus build/). image_path()
# folgt jetzt demselben Umschalter wie migrate_dir() - dieselben Tests wie oben fuer die
# Migration, nur gegen den Rettungspfad "/ninlight_<target>_ota.img".
def _both_rescue_sets(imgdir):
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"OHNE" * 100)
(imgdir / "rescue-wlan").mkdir(exist_ok=True)
make_image(imgdir / "rescue-wlan" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"MITW" * 100)
def test_rescue_default_image_set_is_the_one_without_wifi_credentials(server):
"""Wie bei der Migration: ohne Umschalterdatei gilt die sichere Vorgabe - ein
Fehlgriff darf niemandem das eigene WLAN-Passwort in ein fremdes Geraet schreiben.
Vor diesem Umschalter bekam eine Rettung IMMER das Abbild MIT Zugangsdaten; das war
genau die Nebenwirkung, die der Eigentuemer sichtbar gemacht haben wollte."""
srv, imgdir = server
_both_rescue_sets(imgdir)
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 200
assert r.body.startswith(b"OHNE")
def test_rescue_switch_decides_which_file_is_actually_delivered(server):
srv, imgdir = server
_both_rescue_sets(imgdir)
srv.set_image_set("mit-wlan")
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"MITW")
# ... und wieder zurueck, ohne Dienstneustart.
srv.set_image_set("ohne-wlan")
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"OHNE")
def test_rescue_missing_selected_set_delivers_nothing(server):
"""Der gewaehlte Rettungs-Bildsatz liegt nicht auf dem Pi: NICHTS geht raus -
insbesondere nicht das gleichnamige Abbild des anderen, danebenliegenden Satzes.
Auf den anderen Satz auszuweichen waere genau der Fehlgriff, gegen den der
Umschalter da ist (siehe rescue_dir() in server.py)."""
srv, imgdir = server
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"OHNE" * 100)
srv.set_image_set("mit-wlan") # rescue-wlan/ existiert nicht
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 404
assert b"OHNE" not in r.body
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["served"] == 0
assert st["recent"][-1]["status"] == "notfound"
def test_rescue_old_flat_layout_is_no_longer_served(server):
"""Rueckwaertskompatibilitaet bewusst NICHT gegeben (server.py, rescue_dir()): ein Pi
mit dem alten, flachen Bestand (vor diesem Umschalter, immer MIT Zugangsdaten aus
build/) muss einmal per deploy.sh neu bestueckt werden. Wuerde image_path() den
flachen Altbestand noch lesen, wuerde die Vorgabe "ohne-wlan" NICHTS daran aendern -
genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend fortgesetzt."""
srv, imgdir = server
make_image(imgdir / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", b"ALTBESTAND" * 50)
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 404
def test_read_image_set_defaults_without_a_file(tmp_path): def test_read_image_set_defaults_without_a_file(tmp_path):
module = _load_server_module() module = _load_server_module()
assert module.read_image_set(None) == "ohne-wlan" assert module.read_image_set(None) == "ohne-wlan"
+182 -15
View File
@@ -11,6 +11,13 @@
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das # wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug. # 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 # Aufruf: tools/tests/test_release.sh
set -euo pipefail set -euo pipefail
@@ -75,6 +82,9 @@ EOF
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf # (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn # STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
# config.local.cmake beiseite liegt (prueft den doppelten Boden) # 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() { setup_stub_docker() {
local stub=$1 sandbox=$2 local stub=$1 sandbox=$2
cat >"$stub" <<'STUB' cat >"$stub" <<'STUB'
@@ -115,22 +125,56 @@ fi
if [ "${STUB_LEAK:-}" = "1" ]; then if [ "${STUB_LEAK:-}" = "1" ]; then
creds="$STUB_LEAK_VALUE" creds="$STUB_LEAK_VALUE"
fi fi
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" python3 - <<'PY' CREDS="$creds" TARGET="$target" OUTDIR="$outdir" TRUNC="${STUB_TRUNC_TARGET:-}" python3 - <<'PY'
import gzip
import io
import os import os
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"] target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
body = bytearray(("firmware-%s-" % target).encode() * 200)
body = body[:4096] + b"\0" * (4096 - len(body[:4096])) # 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() blob = creds.encode()
if blob: 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 body[100:100 + len(blob)] = blob
gz = bytes(body)
# 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: with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
fh.write(gz) # roh, ohne NINL-Trailer - das signiert nl_sign.py fh.write(raw) # roh, ohne NINL-Trailer - das signiert nl_sign.py
trailer = bytearray(b"\xff" * 512) trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL" 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: with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
fh.write(gz + bytes(trailer)) fh.write(ota_body + bytes(trailer))
PY PY
STUB STUB
chmod +x "$stub" chmod +x "$stub"
@@ -171,29 +215,50 @@ run_release() {
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \ STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \ STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
STUB_LEAK="${STUB_LEAK:-}" \ STUB_LEAK="${STUB_LEAK:-}" \
STUB_TRUNC_TARGET="${STUB_TRUNC_TARGET:-}" \
STUB_LEAK_VALUE="$SECRET_SSID" \ STUB_LEAK_VALUE="$SECRET_SSID" \
"$sandbox/tools/release.sh" "$version" "$sandbox/tools/release.sh" "$version"
} }
# Sucht die Zugangsdaten BINAER in jeder Datei unterhalb eines Verzeichnisses. # 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() { grep_secrets() {
local dir=$1 local dir=$1 mode=${2:-entpackt}
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 - "$dir" <<'PY' SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
python3 - "$dir" "$mode" <<'PY'
import os import os
import sys import sys
import zlib
root = sys.argv[1] HEADER_LEN = 56
root, mode = sys.argv[1], sys.argv[2]
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")} needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
for dirpath, _dirs, files in os.walk(root): for dirpath, _dirs, files in os.walk(root):
for fn in sorted(files): for fn in sorted(files):
p = os.path.join(dirpath, fn) p = os.path.join(dirpath, fn)
rel = os.path.relpath(p, root)
if os.path.islink(p): if os.path.islink(p):
continue continue
with open(p, "rb") as fh: with open(p, "rb") as fh:
data = fh.read() 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(): for name, needle in needles.items():
if needle in data: if any(needle in view for view in views):
print("%s in %s" % (name, os.path.relpath(p, root))) print("%s in %s" % (name, rel))
PY PY
} }
@@ -283,12 +348,12 @@ else
fail "Pruefsummenpruefung fehlgeschlagen" fail "Pruefsummenpruefung fehlgeschlagen"
fi fi
echo "--- Fall 5: keine Zugangsdaten im Archiv (binaer gesucht) ---" echo "--- Fall 5: keine Zugangsdaten im Archiv (roh UND im entpackten Rumpf gesucht) ---"
HITS=$(grep_secrets "$A") HITS=$(grep_secrets "$A")
if [ -z "$HITS" ]; then if [ -z "$HITS" ]; then
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs" pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
else else
fail "Zugangsdaten im Archiv gefunden: fail "Zugangsdaten im Archiv gefunden (oder ein Abbild liess sich nicht entpacken):
$HITS" $HITS"
fi fi
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine # Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
@@ -312,6 +377,51 @@ if grep_secrets "$TMPROOT/probe" | grep -q SSID; then
else else
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos" fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
fi 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 ---" 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 if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
@@ -413,7 +523,10 @@ else
fail "andere Version schlaegt fehl: $OUT" fail "andere Version schlaegt fehl: $OUT"
fi fi
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im Abzug brechen den Lauf ab ===" 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" SANDBOX="$TMPROOT/s6"
setup_sandbox "$SANDBOX" setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX" setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
@@ -433,6 +546,60 @@ else
pass "die Meldung druckt den Wert nicht, nur den Namen" pass "die Meldung druckt den Wert nicht, nur den Namen"
fi fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; 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 ===" echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh" ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"