Commit Graph
188 Commits
Author SHA1 Message Date
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
Michael Sauer 71a579c0f9 fix(display): Abschlussbild sagt 'Strom trennen erlaubt' - der alte Satz war abgeschnitten
Zweiter Fund derselben Art wie die Bildsatz-Fusszeile: 'Strom kann getrennt werden'
misst in der Hero-Schrift 262,1 px bei 246 px Platz und kam am Geraet als
'Strom kann getrennt wer...' an.

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

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

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

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

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

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

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

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

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

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

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

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

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

Nebenbei behoben: display_run() reichte nur "$1" an display.py weiter
und haette "--final poweroff" halbiert.
2026-09-15 03:02:21 +02:00
Michael Sauer 300edafc3e feat(station): Umschalter fuer Migrationsimages mit/ohne WLAN-Daten
Die WLAN-Zugangsdaten stecken zur Bauzeit in der Firmware, es gibt also
zwei Abzuege je Ziel. Die Station haelt jetzt beide vor - images/
migrate-nocreds (aus tools/build_nocreds.sh, Lampe startet im
Onboarding-AP) und images/migrate-wlan (aus build/, Lampe haengt sofort
im eigenen WLAN) - und der Bediener waehlt per Umschalter, welcher
ausgeliefert wird: "ninlight-station bildsatz" oder der Menueeintrag am
Taster.

Der Zustand liegt in /var/lib/ninlight-station/imageset, ueberlebt
Neustarts und steht dauerhaft in der Fusszeile des Panels - in jedem
Modus, auch bei ausgeschalteter Station: man soll vor dem Einschalten
sehen, was gilt.

Vorgabe und Rueckfallwert in JEDEM Zweifelsfall ist "ohne-wlan". Im
Automatikmodus wird jede Klyqa-Lampe in Reichweite migriert, auch
fremde; ein Abzug ohne Zugangsdaten ist dann ein harmloser Fehlgriff,
einer mit Zugangsdaten traegt das eigene WLAN-Passwort in ein fremdes
Geraet.

Fehlt der gewaehlte Satz (Verzeichnis weg oder leer), wird NICHTS
ausgeliefert - kein stilles Ausweichen auf den anderen Satz. Der Server
antwortet 404 mit Status "noimageset", CLI und Panel sagen "FEHLT".

server.py und die Stations-Unit muessen mit: die Auswahl der
ausgelieferten Datei ist genau die Aufgabe des Servers, und ein
Symlink-Tausch haette "gewaehlt, aber nicht vorhanden" von "dieses
Modell fehlt" nicht mehr unterscheidbar gemacht.
2026-09-15 02:17:36 +02:00
Michael Sauer 3642672c71 feat(station): Automatikmodus als eigener Menueeintrag am Taster
Das Tastermenue bot bisher nur "Migration starten" an, fest verdrahtet
auf die Horchphase; den Automatikmodus gab es nur ueber die
Kommandozeile - also ausgerechnet dort nicht, wo man ihn braucht, denn
"on" macht wlan0 zum Zugangspunkt und reisst jede SSH-Sitzung ab.

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

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

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

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

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

Das ist dieselbe Klasse Fehler wie in 00d5a4c fuer die Gegenrichtung
behoben (WY-Zweig schrieb nie die RGB-Register), nur bislang unentdeckt
in dieser Richtung. Der RGB-Zweig schreibt jetzt zusaetzlich WY mit
0/0 - ein zweiter Bus-Transfer pro Aktualisierung, symmetrisch zum
WY-Zweig, der ebenfalls zwei Transfers braucht.
2026-09-14 23:59:38 +02:00