Author SHA1 Message Date
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
58 changed files with 4216 additions and 1503 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)
+37 -1
View File
@@ -65,12 +65,48 @@ 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`). - REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. den Typ `sacn`, 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`.
## 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.
@@ -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,214 +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
from typing import Any
import httpx
from .const import DEFAULT_PORT
class NinlightError(Exception):
"""Base error, raised on connection problems and non-JSON replies."""
class NinlightAuthError(NinlightError):
"""The bulb rejected our token (HTTP 401)."""
class NinlightBadRequest(NinlightError):
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
def __init__(self, message: str, reason: str | None) -> None:
super().__init__(f"{message}: {reason}" if reason else message)
self.reason = reason
@dataclass(slots=True)
class NinlightState:
"""A parsed `GET /api/v1/device/state` plus the identity from `system/info`."""
on: bool
brightness: int # 1..100, the firmware's native unit
mode: str # "color" | "white"
r: int
g: int
b: int
color_temp: int # kelvin
ct_min: int
ct_max: int
has_rgb: bool
# Settings echoed by the same reply; the coordinator exposes them as entities.
power_on_behaviour: str
fade_in: int
fade_out: int
transition_default: int
wake_on_change: bool
# From system/info, merged in by the coordinator.
device_type: str | None = None
product_name: str | None = None
fw_version: str | None = None
mac: str | None = None
name: str | None = None
rssi: int | None = None
ip: str | None = None
# Segment model only (ninstrip): the raw segment list; the light maps onto
# segment 0 while a scene across the remaining segments stays untouched.
segments: list[dict[str, Any]] = field(default_factory=list)
# True for ninstrip firmware, whose light state lives in segments. Decided by
# presence of `segment_count` in device/state and `product` in system/info -
# both keys are ninstrip-only (nl_api_device.c / nl_api_system.c). Bulb
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
# name is not a signal, but the keys are.
strip: bool = False
@classmethod
def from_api(
cls,
state: dict[str, Any],
info: dict[str, Any] | None = None,
segments: list[dict[str, Any]] | None = None,
) -> NinlightState:
color = state.get("color") or {}
caps = state.get("capabilities") or {}
ct = caps.get("color_temp") or [2700, 6500]
wifi = (info or {}).get("wifi") or {}
seg = (segments or [None])[0] or {}
# `segment_count` and `product` exist only on ninstrip firmware; the bulb's
# device_type is a board id and carries no signal either way.
strip = "segment_count" in state or "product" in (info or {})
# On a strip the light lives in segment 0; mode "off" carries no color at all,
# so mode/color fall back to white there and is_on decides darkness anyway.
seg_mode = seg.get("mode")
return cls(
on=bool(state.get("on", False)),
brightness=int(state.get("brightness", 0)),
mode="color" if seg_mode == "solid" else str(state.get("mode", "white")),
r=int((seg.get("color") or color).get("r", 0)),
g=int((seg.get("color") or color).get("g", 0)),
b=int((seg.get("color") or color).get("b", 0)),
color_temp=int(seg.get("color_temp", state.get("color_temp", 2700))),
ct_min=int(ct[0]),
ct_max=int(ct[1]),
has_rgb=bool(caps.get("color", True)) if strip else bool(caps.get("color", False)),
power_on_behaviour=str(state.get("power_on_behaviour", "last")),
fade_in=int(state.get("fade_in", 0)),
fade_out=int(state.get("fade_out", 0)),
transition_default=int(state.get("transition_default", 0)),
wake_on_change=bool(state.get("wake_on_change", False)),
device_type=(info or {}).get("device_type"),
product_name=(info or {}).get("product_name"),
fw_version=(info or {}).get("fw_version"),
mac=(info or {}).get("mac"),
name=(info or {}).get("name"),
rssi=wifi.get("rssi"),
ip=wifi.get("sta_ip") or wifi.get("ip"),
segments=list(segments or []),
strip=strip,
)
class NinlightClient:
"""One client per bulb (per config entry)."""
def __init__(
self,
host: str,
token: str,
*,
port: int = DEFAULT_PORT,
session: httpx.AsyncClient | None = None,
) -> None:
self._base = f"http://{host}:{port}/api/v1"
# Raw token, not "Bearer <token>": nl_http_parse hands the header value straight to
# token_ok(), which compares it against the stored token byte for byte.
self._headers = {"Authorization": token}
self._owns_session = session is None
self._session: httpx.AsyncClient = session or httpx.AsyncClient(
headers=self._headers,
timeout=httpx.Timeout(8.0, connect=4.0),
# keepalive_expiry of ~0 makes httpx close after each response instead of
# holding one of the bulb's four listen-queue slots open between polls.
limits=httpx.Limits(max_connections=1, max_keepalive_connections=0, keepalive_expiry=0.1),
)
async def close(self) -> None:
if self._owns_session:
await self._session.aclose()
async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
except httpx.InvalidURL as err: # programming error, not a transient one
raise NinlightError(f"invalid bulb URL: {err}") from err
except httpx.HTTPError as err:
raise NinlightError(f"request to bulb failed: {err}") from err
if resp.status_code == 401:
raise NinlightAuthError("bulb rejected the API token")
if resp.status_code == 400:
raise NinlightBadRequest("bulb rejected the request", _error_field(resp))
if resp.status_code != 200:
raise NinlightError(f"bulb answered {resp.status_code}: {_error_field(resp)}")
try:
data = resp.json()
except ValueError as err:
raise NinlightError("bulb sent a non-JSON body") from err
if not isinstance(data, dict):
raise NinlightError("bulb sent an unexpected JSON shape")
return data
async def get_state(self) -> dict[str, Any]:
return await self._request("GET", "/device/state")
async def get_segments(self) -> dict[str, Any]:
"""Segment model of the ninstrip firmware (bulbs have no such endpoint)."""
return await self._request("GET", "/device/segments")
async def set_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]:
"""Replace the whole segment list. ninstrip answers with `{"status": "ok"}`."""
return await self._request("PUT", "/device/segments", {"segments": segments})
async def patch_segment(self, index: int, patch: dict[str, Any]) -> dict[str, Any]:
"""Update one segment; omitted fields keep their current value."""
return await self._request("PUT", f"/device/segments/{index}", patch)
async def get_info(self) -> dict[str, Any]:
return await self._request("GET", "/system/info")
async def get_settings(self) -> dict[str, Any]:
"""`system/settings` is the one endpoint that always requires the token.
`system/info` and a state GET answer without auth unless `auth_all` is set
(nl_api.c: the state branch tests `s.auth_all && !authed`, the info branch tests
nothing), so only settings proves the token is right.
"""
return await self._request("GET", "/system/settings")
async def set_state(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/device/state", payload)
async def ping(self) -> dict[str, Any]:
return await self._request("PUT", "/system/command", {"command": {"type": "ping"}})
async def reboot(self) -> dict[str, Any]:
return await self._request("PUT", "/system/command", {"command": {"type": "reboot"}})
def _error_field(resp: httpx.Response) -> str | None:
try:
body = resp.json()
except ValueError:
return resp.text[:120] or None
if isinstance(body, dict):
err = body.get("error")
return str(err) if err is not None else None
return None
@@ -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,107 +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,
)
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
class NinlightCoordinator(DataUpdateCoordinator[NinlightState]):
"""Polls one bulb. One coordinator, and one HTTP client, per config entry.
`system/info` is fetched on the first successful refresh only. It carries identity
(MAC, product name, firmware) that does not change while the bulb runs, and the bulb
accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so
skipping it on every poll keeps one of those slots free for the UI.
"""
config_entry: NinlightConfigEntry
def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} {entry.title}",
update_interval=SCAN_INTERVAL,
)
self.client = NinlightClient(
entry.data["host"],
entry.data.get(CONF_TOKEN, ""),
port=entry.data.get("port", DEFAULT_PORT),
)
self.info: dict[str, Any] | None = None
self._identity_ok = False
self._is_strip = False
async def async_shutdown(self) -> None:
"""Close the HTTP client alongside the coordinator's own teardown."""
await self.client.close()
await super().async_shutdown()
async def _async_update_data(self) -> NinlightState:
try:
state = await self.client.get_state()
except NinlightAuthError as err:
# Only possible when the bulb has `auth_all` set; a wrong token will not fix
# itself, so surface a re-auth flow instead of retrying every interval.
raise ConfigEntryAuthFailed(str(err)) from err
except NinlightBadRequest as err:
# A poll sends no body, so this can only mean a firmware we do not match.
raise UpdateFailed(str(err)) from err
except NinlightError as err:
raise UpdateFailed(str(err)) from err
if not self._identity_ok:
# One extra pair of requests, then never again: the bulb accepts at most four
# queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll
# path to a single GET leaves slots free for the UI.
with suppress(NinlightError):
# system/info carries identity that only changes on a reboot or an OTA.
self.info = await self.client.get_info()
try:
# system/settings always demands the token, so it is the only call that
# proves ours is right. A failure here on a bulb whose state endpoint
# answered happily means the token is wrong while `auth_all` is off - the
# light would work and silently never be able to be renamed or updated.
await self.client.get_settings()
except NinlightAuthError as err:
raise ConfigEntryAuthFailed(str(err)) from err
# `product` is an ninstrip-only key in system/info (bulbs send
# product_name); once known, the heavier segment poll follows every refresh.
self._is_strip = "product" in (self.info or {})
self._identity_ok = True
segments: list[dict[str, Any]] | None = None
if self._is_strip:
# The light lives in the segment list; the plain state GET above carries
# only on/brightness for it. A firmware without the endpoint (misdetected
# strip) must not fail the whole poll for that.
try:
segments = (await self.client.get_segments()).get("segments")
except NinlightError as err:
_LOGGER.debug("segment poll failed: %s", err)
return NinlightState.from_api(state, self.info, segments)
# Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the
# project's 3.11 tooling and runs on HA's 3.14.
NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator]
@@ -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,182 +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)
state = coordinator.data
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
# Both modes are advertised for RGB bulbs: the firmware keeps the last color and
# the last color temperature and picks between them with `mode`, which is exactly
# how HA models a color_temp + rgb light.
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {
ColorMode.COLOR_TEMP
}
self._attr_supported_features = LightEntityFeature.TRANSITION
self._attr_min_color_temp_kelvin = state.ct_min
self._attr_max_color_temp_kelvin = state.ct_max
@property
def is_on(self) -> bool:
return self.coordinator.data.on
@property
def brightness(self) -> int | None:
return _to_ha_brightness(self.coordinator.data.brightness)
@property
def color_mode(self) -> ColorMode:
state = self.coordinator.data
if not state.on:
# Nothing is lit; reporting a mode would contradict is_on=False.
return ColorMode.UNKNOWN
if state.has_rgb and state.mode == "color":
return ColorMode.RGB
return ColorMode.COLOR_TEMP
@property
def color_temp_kelvin(self) -> int | None:
state = self.coordinator.data
return state.color_temp if self.color_mode == ColorMode.COLOR_TEMP else None
@property
def rgb_color(self) -> tuple[int, int, int] | None:
state = self.coordinator.data
if self.color_mode == ColorMode.RGB:
return (state.r, state.g, state.b)
return None
async def async_turn_on(self, **kwargs: Any) -> None:
state = self.coordinator.data
payload: dict[str, Any] = {"on": True}
if (level := kwargs.get(ATTR_BRIGHTNESS)) is not None:
payload["brightness"] = _to_bulb_brightness(int(level))
elif not state.on:
# HA expects turn_on without a brightness to restore the previous level. The
# firmware already does that (it keeps `brightness` while off), so sending it
# would only re-assert a value the bulb may have clamped itself - but a bulb
# that has never been on reports brightness 0, which its API rejects. Mirror
# the last known value only when the bulb actually has one.
if state.brightness < 1:
payload["brightness"] = 100
# The firmware rejects color and color_temp in one request with
# "color and color_temp are exclusive", so pick one. HA only sends both when a
# caller passes them together; color is the more specific intent and wins.
kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
rgb = kwargs.get(ATTR_RGB_COLOR)
seg: dict[str, Any] | None = None
if rgb is not None and state.has_rgb:
r, g, b = (int(max(0, min(255, v))) for v in rgb)
if state.strip:
seg = {"mode": "solid", "color": {"r": r, "g": g, "b": b}}
else:
payload["color"] = {"r": r, "g": g, "b": b}
elif kelvin is not None:
ct = max(state.ct_min, min(state.ct_max, int(kelvin)))
if state.strip:
seg = {"mode": "white", "color_temp": ct}
else:
payload["color_temp"] = ct
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
await self._apply(payload, seg)
async def async_turn_off(self, **kwargs: Any) -> None:
payload: dict[str, Any] = {"on": False}
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
await self._apply(payload, None)
async def _apply(self, payload: dict[str, Any], seg_patch: dict[str, Any] | None) -> None:
"""Send one command and update immediately.
POST /device/state answers with the full new state, so the coordinator does not
need a refresh round-trip and the UI settles on the real bulb state at once.
On the segment-model firmware the color goes into segment 0 as a separate PATCH
(`PUT /device/segments/0`, fields omitted keep their value); the master on and
brightness stay in the state POST, because the firmware's fade rides on them.
Segment brightness stays untouched so master scaling remains the single knob.
"""
try:
data = await self.coordinator.client.set_state(payload)
if seg_patch is not None:
segs = self.coordinator.data.segments
start = segs[0]["start"] if segs else 0
end = segs[0]["end"] if segs else max(0, int(data.get("pixel_count", 1)) - 1)
await self.coordinator.client.patch_segment(
0, {**seg_patch, "start": start, "end": end}
)
except NinlightBadRequest as err:
# A rejected command is a bug or a firmware mismatch, not a network fault:
# surface it to the caller and leave the entity's state alone.
raise HomeAssistantError(str(err)) from err
except NinlightError as err:
self.coordinator.async_set_update_error(err)
raise HomeAssistantError(str(err)) from err
# Push the device's own reply through the coordinator so every entity of this
# device (light, sensors) settles on the real state at once.
segments = None
if self.coordinator.data.strip:
try:
segments = (await self.coordinator.client.get_segments()).get("segments")
except NinlightError:
pass # the next poll recovers it; the state reply above is still right
self.coordinator.async_set_updated_data(
NinlightState.from_api(data, self.coordinator.info, segments)
)
@@ -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)"
+31 -7
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"
@@ -147,6 +149,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 +163,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 +209,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);
@@ -231,9 +236,23 @@ static void app_task(void *arg)
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 +281,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 +297,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
+32 -3
View File
@@ -29,15 +29,39 @@ 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];
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 +73,11 @@ 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;
} }
void nl_settings_init(uint32_t addr_a, uint32_t addr_b) void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
+13
View File
@@ -28,6 +28,19 @@ 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 */
} 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
+78
View File
@@ -265,6 +265,17 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
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);
}
reply_json(resp, 200, o); reply_json(resp, 200, o);
} }
@@ -286,6 +297,17 @@ 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));
}
reply_json(resp, 200, o); reply_json(resp, 200, o);
} }
@@ -333,6 +355,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";
} }
+2
View File
@@ -24,6 +24,8 @@ 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 */
} 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. */
+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
+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
+18 -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,10 +67,14 @@ 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); }
@@ -79,7 +90,13 @@ 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();
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);
+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)
+204
View File
@@ -346,6 +346,134 @@ 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_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")));
}
/* --- system/command --- */ /* --- system/command --- */
static void test_commands(void) static void test_commands(void)
{ {
@@ -384,9 +512,76 @@ 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);
}
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 +601,15 @@ 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_commands); RUN_TEST(test_commands);
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();
} }
+105
View File
@@ -182,6 +182,104 @@ 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_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 +294,12 @@ 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);
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}
+226 -43
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 = []
images = 0
for image_set in sets:
root_dir = os.path.join(stage, image_set)
for root, _dirs, files in os.walk(root_dir):
for fn in sorted(files): 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() 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: 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 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
+195 -124
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
_mode=${2:-$(current_mode)}
if [ "$_mode" = "recovery" ]; then
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" ;; mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;;
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;; *) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;;
esac 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}')"
# Die Rettungsabbilder des eingestellten Bildsatzes - unabhaengig von $_modus (der
# 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 :" echo "Images :"
images | while read -r f; do 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"