Compare commits
150
Commits
v0.3.1
...
c53a23daba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c53a23daba | ||
|
|
6fe5efd2c0 | ||
|
|
b198fead77 | ||
|
|
71cede19a4 | ||
|
|
ba8f2a10a0 | ||
|
|
bc3eff1a41 | ||
|
|
08ea01f944 | ||
|
|
ef321b37ef | ||
|
|
813d861cd1 | ||
|
|
a022d8ae3e | ||
|
|
20c26d16d7 | ||
|
|
fda2552e50 | ||
|
|
ded09a3516 | ||
|
|
24c4e38e4e | ||
|
|
18eb7b5c01 | ||
|
|
e36add5030 | ||
|
|
416307083f | ||
|
|
8469b92338 | ||
|
|
906a6d55f2 | ||
|
|
1c29735eaf | ||
|
|
d4b683de8f | ||
|
|
543123a60d | ||
|
|
7fd279721f | ||
|
|
e10147b70f | ||
|
|
19a0d6539a | ||
|
|
41088b13ae | ||
|
|
09ee206906 | ||
|
|
8294688e37 | ||
|
|
5cbf425e49 | ||
|
|
d7810c6cff | ||
|
|
bd467b5e52 | ||
|
|
f31ef879ee | ||
|
|
1562a7983a | ||
|
|
e67fc53b20 | ||
|
|
6d0f79a6ac | ||
|
|
ac8dfb1077 | ||
|
|
eec9fa5a21 | ||
|
|
9ee1983dff | ||
|
|
363aee12ef | ||
|
|
4231444a93 | ||
|
|
fdded43ab9 | ||
|
|
a3a48cdf68 | ||
|
|
1c960ad5cc | ||
|
|
cc14efea01 | ||
|
|
4400ea5b42 | ||
|
|
fbdf0c40ef | ||
|
|
2c7ebac6d1 | ||
|
|
d82df96655 | ||
|
|
60b662ff20 | ||
|
|
15074fb58f | ||
|
|
8f877f18d0 | ||
|
|
fd61f151d7 | ||
|
|
aa0ed770eb | ||
|
|
9b80ab0e0d | ||
|
|
c70b6db4b8 | ||
|
|
5c17c94dbc | ||
|
|
993b7c1575 | ||
|
|
f2cae9b7ce | ||
|
|
c547a9f2b2 | ||
|
|
545ca30699 | ||
|
|
021089a68b | ||
|
|
e892b441ce | ||
|
|
882cb55297 | ||
|
|
fae22f8c64 | ||
|
|
6be0473f18 | ||
|
|
33d085cbed | ||
|
|
f09a51088a | ||
|
|
cdbbe31831 | ||
|
|
ea711b6264 | ||
|
|
71a579c0f9 | ||
|
|
f4dd1386ef | ||
|
|
c482194503 | ||
|
|
fa93259426 | ||
|
|
bbe2e842b6 | ||
|
|
95ae51bc1d | ||
|
|
c296e2626e | ||
|
|
4e1a965cbb | ||
|
|
300edafc3e | ||
|
|
3642672c71 | ||
|
|
2fe2f7d9f4 | ||
|
|
6325ce073f | ||
|
|
6f0137908c | ||
|
|
24b37ee58f | ||
|
|
82a3942554 | ||
|
|
f0f6894bc9 | ||
|
|
d1535433ce | ||
|
|
5f3cf25e41 | ||
|
|
1f01b29b8e | ||
|
|
f105a0eb69 | ||
|
|
e77d3da292 | ||
|
|
03d6328dd5 | ||
|
|
bb39105ac4 | ||
|
|
f138e88f4e | ||
|
|
46b0a198d9 | ||
|
|
47532a6820 | ||
|
|
7fdccbce72 | ||
|
|
cc398ef57a | ||
|
|
22f92988dd | ||
|
|
b8c7febb8f | ||
|
|
38c70ac5ce | ||
|
|
7a5f26d717 | ||
|
|
eb9efa12f6 | ||
|
|
9b158c8c4f | ||
|
|
4a029a5d1c | ||
|
|
1cb5359754 | ||
|
|
ad2f1b8486 | ||
|
|
59c8c150bd | ||
|
|
74198f6d31 | ||
|
|
1c077a15d0 | ||
|
|
1d3627bacd | ||
|
|
d3e608dca0 | ||
|
|
b41ed51018 | ||
|
|
e897164960 | ||
|
|
78cf01ca6c | ||
|
|
e50659d954 | ||
|
|
00d5a4c532 | ||
|
|
62a2b43175 | ||
|
|
f32216d17f | ||
|
|
9768b25d10 | ||
|
|
1d7d087cff | ||
|
|
435884039b | ||
|
|
2dfb1fd1c2 | ||
|
|
d464bb167e | ||
|
|
6daf36540c | ||
|
|
b169fae89d | ||
|
|
3d69412f5a | ||
|
|
3f27ef4b26 | ||
|
|
2f25714248 | ||
|
|
908fda8240 | ||
|
|
62903a7f05 | ||
|
|
cb54dac066 | ||
|
|
456058956c | ||
|
|
3032178247 | ||
|
|
4a8a5d658f | ||
|
|
d23b0d4e02 | ||
|
|
fda732d6c4 | ||
|
|
df8e796f1e | ||
|
|
0ba269a39a | ||
|
|
cb0a4b035d | ||
|
|
10cc67d490 | ||
|
|
d41c385375 | ||
|
|
a2e87b8855 | ||
|
|
37f4dd425e | ||
|
|
76a3253322 | ||
|
|
37a5fe2ca9 | ||
|
|
c5d76efb25 | ||
|
|
5e2dff375e | ||
|
|
6adf22fb52 | ||
|
|
d56f1d25d2 | ||
|
|
6588085679 |
+4
-1
@@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE)
|
|||||||
set(CMAKE_BUILD_TYPE Release)
|
set(CMAKE_BUILD_TYPE Release)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||||
set(NINLIGHT_DEVICE "" CACHE STRING "Target device")
|
set(NINLIGHT_DEVICE "" CACHE STRING "Target device")
|
||||||
if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES)
|
if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES)
|
||||||
message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>")
|
message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>")
|
||||||
@@ -74,6 +74,7 @@ set(NL_FW_SOURCES
|
|||||||
src/board/nl_board.c
|
src/board/nl_board.c
|
||||||
src/light/nl_color.c
|
src/light/nl_color.c
|
||||||
src/light/nl_engine.c
|
src/light/nl_engine.c
|
||||||
|
src/light/nl_dmx.c
|
||||||
src/light/nl_control.c
|
src/light/nl_control.c
|
||||||
src/light/nl_boot_state.c
|
src/light/nl_boot_state.c
|
||||||
src/config/nl_settings.c
|
src/config/nl_settings.c
|
||||||
@@ -89,12 +90,14 @@ set(NL_FW_SOURCES
|
|||||||
src/sys/sdk_stubs.c
|
src/sys/sdk_stubs.c
|
||||||
src/net/nl_names.c
|
src/net/nl_names.c
|
||||||
src/net/nl_http.c
|
src/net/nl_http.c
|
||||||
|
src/net/nl_e131.c
|
||||||
src/net/nl_api.c
|
src/net/nl_api.c
|
||||||
src/net/nl_wifi_sm.c
|
src/net/nl_wifi_sm.c
|
||||||
src/net/nl_wifi_w600.c
|
src/net/nl_wifi_w600.c
|
||||||
src/net/nl_recovery.c
|
src/net/nl_recovery.c
|
||||||
src/net/nl_mdns.c
|
src/net/nl_mdns.c
|
||||||
src/net/nl_http_server.c
|
src/net/nl_http_server.c
|
||||||
|
src/net/nl_sacn.c
|
||||||
src/sys/nl_app_api.c
|
src/sys/nl_app_api.c
|
||||||
third_party/cJSON/cJSON.c)
|
third_party/cJSON/cJSON.c)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
An open source replacement firmware for the no longer supported Klyqa smart wifi light bulbs based on the Winner Micro W600 chip
|
||||||
@@ -24,7 +24,7 @@ Native Builds (mit lokal installierter ARM-Toolchain):
|
|||||||
cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
|
cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
|
||||||
cmake --build build/evk
|
cmake --build build/evk
|
||||||
|
|
||||||
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw.
|
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb (Montana Black LED Shield, nur RGB).
|
||||||
|
|
||||||
**Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`.
|
**Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`.
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Firmware flashen (Standard: App-Image):
|
|||||||
|
|
||||||
tools/flash.sh <device> <port>
|
tools/flash.sh <device> <port>
|
||||||
|
|
||||||
Das Skript druckt `Bitte bei "please manually reset the device" die Reset-Taste drücken.` — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Für blank/erased Chips: `tools/flash.sh <device> <port> full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet).
|
Das Skript druckt `Bitte bei "please manually reset the device" die Reset-Taste drücken.` — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Das Skript ruft `wm_tool` mit `-rs none` auf, es löst also **keinen** Reset aus: Der Reset muss von Hand in dem Zeitfenster kommen, in dem `wm_tool` auf den Sync wartet. Ein automatischer Reset über die Steuerleitungen (`-rs rts` o. ä.) funktioniert an diesem Board nicht. Für blank/erased Chips: `tools/flash.sh <device> <port> full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet).
|
||||||
|
|
||||||
Serielle Konsole (115200 8N1, lokales Echo):
|
Serielle Konsole (115200 8N1, lokales Echo):
|
||||||
|
|
||||||
@@ -65,12 +65,107 @@ Verfügbare Befehle in der Konsole: `help`.
|
|||||||
gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits
|
gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits
|
||||||
Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich.
|
Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich.
|
||||||
Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback).
|
Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback).
|
||||||
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
|
|
||||||
|
**Alternative beim Einrichten:** Statt ein WLAN anzugeben, kann man die Lampe im selben Schritt
|
||||||
|
dauerhaft auf ihren eigenen Zugangspunkt festlegen — dann braucht sie überhaupt keinen Router.
|
||||||
|
Im unkonfigurierten AP geht das wie das WLAN-Onboarding ohne Token:
|
||||||
|
|
||||||
|
curl -X PUT http://192.168.4.1:3333/api/v1/system/settings \
|
||||||
|
-d '{"type":"standalone","enabled":true,"password":"geheim123"}'
|
||||||
|
|
||||||
|
Einzelheiten unter [Standalone-AP](#standalone-ap).
|
||||||
|
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. die Typen `sacn` und `standalone`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
|
||||||
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
|
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
|
||||||
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
|
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
|
||||||
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
|
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
|
||||||
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
|
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
|
||||||
|
|
||||||
|
### sACN / E1.31
|
||||||
|
|
||||||
|
Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf
|
||||||
|
`239.255.<universe_hi>.<universe_lo>` und per Unicast direkt an ihre IP. Nach einem
|
||||||
|
Update ist der Empfang aktiv: Universe 1, Startslot 1, Belegung `rgb`.
|
||||||
|
|
||||||
|
Es gibt zwei Slot-Belegungen, umschaltbar über `layout`:
|
||||||
|
|
||||||
|
| `layout` | Slots ab `start_slot` | wofür |
|
||||||
|
|---|---|---|
|
||||||
|
| `dimmer` | Dimmer, R, G, B, CW, WW — ohne RGB: Dimmer, CW, WW | DMX-Pulte, die einen Master-Dimmer erwarten |
|
||||||
|
| `rgb` (Vorgabe) | R, G, B (3 Slots auf jedem Board) | pixelorientierte Werkzeuge wie LedFx, die je Pixel drei Kanäle schreiben |
|
||||||
|
|
||||||
|
Im `rgb`-Layout ist jede Lampe genau ein Pixel: Lampe 1 auf Offset 0, Lampe 2 auf Offset 3,
|
||||||
|
und so weiter. Ein Board ohne Farbkanäle nimmt den größten der drei Werte auf beide
|
||||||
|
Weißkanäle — es folgt damit der Helligkeit des Effekts.
|
||||||
|
Wieviele es sind, steht als `slots` in `GET system/settings`.
|
||||||
|
|
||||||
|
Während Pakete fließen, hat sACN Vorrang; die Werte gehen ohne Fade direkt an die
|
||||||
|
Kanäle, und der per API gesetzte Zustand bleibt unangetastet. 2,5 s nach dem letzten
|
||||||
|
Paket endet die Übernahme — je nach `on_timeout` fällt die Lampe auf den per API
|
||||||
|
gesetzten Zustand zurück (`release`, Vorgabe) oder behält die letzten Werte (`hold`).
|
||||||
|
Ein Befehl an `device/state` holt die Kontrolle in beiden Fällen sofort zurück.
|
||||||
|
|
||||||
|
Umstellen:
|
||||||
|
|
||||||
|
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
|
||||||
|
-H 'Authorization: <token>' \
|
||||||
|
-d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}'
|
||||||
|
|
||||||
|
Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im
|
||||||
|
WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts
|
||||||
|
speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`.
|
||||||
|
|
||||||
|
Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`.
|
||||||
|
|
||||||
|
### Standalone-AP
|
||||||
|
|
||||||
|
Statt sich in ein WLAN einzuwählen, kann die Lampe dauerhaft einen eigenen Zugangspunkt aufspannen
|
||||||
|
(z. B. für Bühne oder Gartenhaus ohne Router). Der Modus überlebt Neustarts: die Lampe startet
|
||||||
|
direkt im eigenen AP, ohne Onboarding und ohne Onboarding-Pulsen. Die Lampe ist dann unter
|
||||||
|
`192.168.4.1` erreichbar, per mDNS als `<hostname>.local`.
|
||||||
|
|
||||||
|
Einschalten:
|
||||||
|
|
||||||
|
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
|
||||||
|
-H 'Authorization: <token>' \
|
||||||
|
-d '{"type":"standalone","enabled":true,"ssid":"Buehne","password":"geheim123","channel":6}'
|
||||||
|
|
||||||
|
Antwort `202 {"status":"standalone"}`. Der Aufruf braucht den Token, sobald die Lampe konfiguriert
|
||||||
|
ist; im unkonfigurierten Onboarding-AP geht er ohne. Anders als dieser ist der Standalone-AP
|
||||||
|
anschließend **nicht** token-frei: der Tokenschutz gilt dort wie im Normalbetrieb. Am Gerät
|
||||||
|
gemessen (2026-10-02, EVK und eine E27 RGB): `GET system/settings` ohne Token → 401 im
|
||||||
|
Standalone-AP, 200 im unkonfigurierten Onboarding-AP. Felder:
|
||||||
|
|
||||||
|
| Feld | Pflicht | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `enabled` | ja | `true` schaltet den Modus ein, `false` aus |
|
||||||
|
| `password` | beim Einschalten, solange noch keines gespeichert ist | AP-Passwort, 8..63 Zeichen |
|
||||||
|
| `ssid` | nein | AP-Name, 1..32 Zeichen; ein leerer String wird mit 400 `invalid ssid` abgelehnt. Weggelassen bleibt der gespeicherte Name erhalten; `ninlight-<mac4>` gilt nur, solange nie ein eigener gesetzt wurde — über die API führt kein Weg zurück zum Vorgabenamen |
|
||||||
|
| `channel` | nein | Funkkanal 1..13, Vorgabe 6 |
|
||||||
|
| `pulse` | nein | Startpuls beim Hochfahren der Lampe an/aus, Vorgabe an (läuft rund 5 s, danach gilt das normale Einschaltverhalten) |
|
||||||
|
|
||||||
|
Ausschalten braucht kein Passwort: `{"type":"standalone","enabled":false}`. Hat die Lampe gespeicherte
|
||||||
|
WLAN-Daten, wählt sie sich damit wieder ins WLAN ein; ohne solche öffnet sie den unkonfigurierten
|
||||||
|
Onboarding-AP `ninlight-<mac4>`.
|
||||||
|
|
||||||
|
`GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **gewünschten** AP-Namen
|
||||||
|
(`ssid`: der gespeicherte Name, ohne eigenen Namen der Hostname), `channel` und `pulse` — das
|
||||||
|
Passwort steht nie darin. `GET system/info` meldet `wifi.standalone` und im AP-Betrieb (`mode: "ap"`,
|
||||||
|
also eigener AP oder Onboarding-AP) unter `wifi.ssid` den **laufenden AP-Namen** statt der
|
||||||
|
gespeicherten Stations-SSID — der Name des Heimnetzes steht damit nicht in einer tokenfrei lesbaren
|
||||||
|
Antwort.
|
||||||
|
|
||||||
|
Die beiden Namen können auseinanderlaufen, und das ist Absicht. Scheitert der AP-Aufbau mehrfach,
|
||||||
|
fällt der Treiber auf die zuletzt funktionierenden Daten zurück, und gibt es die nicht, auf Name und
|
||||||
|
Kanal der Vorgaben (das Nutzerpasswort bleibt); die gespeicherte Einstellung behält dabei den
|
||||||
|
Wunschnamen. Nach `{"enabled":false}` behält sie `ap_ssid` ebenfalls, der laufende AP heißt dann
|
||||||
|
wieder `ninlight-<mac4>`. Genau diese Abweichung zwischen `system/settings` (gewünscht) und
|
||||||
|
`system/info` (läuft) ist das Erkennungsmerkmal für einen Rückfall.
|
||||||
|
|
||||||
|
Der Startpuls übermalt als Überlagerung die sACN-Ausgabe: solange er läuft, ist von sACN nichts
|
||||||
|
zu sehen. Beim Ausprobieren von sACN über den eigenen AP den Puls mit `pulse: false` abschalten oder
|
||||||
|
abwarten. Außerdem muss der Sender bei zwei Default-Routen (LAN und AP) das Interface für Multicast
|
||||||
|
festlegen (`IP_MULTICAST_IF`), sonst sendet er ins eigene LAN statt in den AP.
|
||||||
|
|
||||||
## Firmware-Update
|
## Firmware-Update
|
||||||
|
|
||||||
Der Build erzeugt pro Gerät zwei Images:
|
Der Build erzeugt pro Gerät zwei Images:
|
||||||
@@ -94,19 +189,33 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
|
|||||||
(gleicher Wert wie `last_ota_error`). Läuft der Trailer- bzw. Hash-Check durch, resettet die
|
(gleicher Wert wie `last_ota_error`). Läuft der Trailer- bzw. Hash-Check durch, resettet die
|
||||||
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
|
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
|
||||||
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
|
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
|
||||||
Verbindungsabbruch mitten im Download löst dagegen einen Wiederaufnahmeversuch per HTTP-Range aus (begrenzte
|
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
|
||||||
Anzahl Runden); erst wenn auch das ausgeht, landet der Versuch bei `last_ota_error = download`.
|
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
|
||||||
|
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
|
||||||
|
|
||||||
|
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
|
||||||
|
`RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
|
||||||
|
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
|
||||||
|
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
|
||||||
|
diesen Fall darf man sich also verlassen.
|
||||||
|
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
|
||||||
|
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
|
||||||
|
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
|
||||||
|
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
|
||||||
|
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
|
||||||
|
|
||||||
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||||
|
|
||||||
| Wert | Bedeutung |
|
| Wert | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `download` | Verbindung/Transfer gescheitert (auch nach ausgeschöpftem Wiederaufnahmeversuch) |
|
| `download` | Verbindung/Transfer gescheitert |
|
||||||
| `crc` | Prüfsumme des SDK-eigenen Update-Mechanismus stimmt nicht |
|
| `crc` | Prüfsumme des SDK-eigenen Update-Mechanismus stimmt nicht |
|
||||||
| `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version |
|
| `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version |
|
||||||
| `device_mismatch` | Trailer nennt einen anderen Gerätetyp |
|
| `device_mismatch` | Trailer nennt einen anderen Gerätetyp |
|
||||||
| `hash` | SHA256 im Trailer stimmt nicht mit dem übertragenen Image überein |
|
| `hash` | SHA256 im Trailer stimmt nicht mit dem übertragenen Image überein |
|
||||||
| `busy` | es läuft bereits ein Update |
|
| `busy` | die Firmware konnte den Download-Task nicht anlegen (Speichermangel) |
|
||||||
|
|
||||||
|
Ein `fw_update`, der abgewiesen wird, weil bereits ein Update läuft, beantwortet die API mit `409` und lässt `last_ota_error` unangetastet — dort steht weiterhin das Ergebnis des laufenden bzw. letzten Updates.
|
||||||
|
|
||||||
Ein erneuter `fw_update`-Aufruf mit einem gültigen Image funktioniert nach einer Ablehnung sofort, ohne
|
Ein erneuter `fw_update`-Aufruf mit einem gültigen Image funktioniert nach einer Ablehnung sofort, ohne
|
||||||
manuellen Reboot.
|
manuellen Reboot.
|
||||||
|
|||||||
+53
-5
@@ -96,7 +96,7 @@ items:
|
|||||||
followRedirects: true
|
followRedirects: true
|
||||||
maxRedirects: 5
|
maxRedirects: 5
|
||||||
- info:
|
- info:
|
||||||
name: Color Red
|
name: Color Red + Transition
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 5
|
||||||
http:
|
http:
|
||||||
@@ -110,7 +110,8 @@ items:
|
|||||||
"r": 255,
|
"r": 255,
|
||||||
"g": 0,
|
"g": 0,
|
||||||
"b": 0
|
"b": 0
|
||||||
}
|
},
|
||||||
|
"transition": 1500
|
||||||
}
|
}
|
||||||
settings:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
@@ -118,7 +119,7 @@ items:
|
|||||||
followRedirects: true
|
followRedirects: true
|
||||||
maxRedirects: 5
|
maxRedirects: 5
|
||||||
- info:
|
- info:
|
||||||
name: Color Temp 2700
|
name: Color Temp 2700 + Transition
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 6
|
||||||
http:
|
http:
|
||||||
@@ -128,7 +129,8 @@ items:
|
|||||||
type: json
|
type: json
|
||||||
data: |-
|
data: |-
|
||||||
{
|
{
|
||||||
"color_temp": 2700
|
"color_temp": 2700,
|
||||||
|
"transition": 1500
|
||||||
}
|
}
|
||||||
settings:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
@@ -192,6 +194,25 @@ items:
|
|||||||
timeout: 0
|
timeout: 0
|
||||||
followRedirects: true
|
followRedirects: true
|
||||||
maxRedirects: 5
|
maxRedirects: 5
|
||||||
|
- info:
|
||||||
|
name: Default Transition + Wake Switch
|
||||||
|
type: http
|
||||||
|
seq: 10
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{device_address}}:{{device_port}}/api/v1/device/state'
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"transition_default": 300,
|
||||||
|
"wake_on_change": false
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
- info:
|
- info:
|
||||||
name: System
|
name: System
|
||||||
type: folder
|
type: folder
|
||||||
@@ -225,6 +246,11 @@ items:
|
|||||||
timeout: 0
|
timeout: 0
|
||||||
followRedirects: true
|
followRedirects: true
|
||||||
maxRedirects: 5
|
maxRedirects: 5
|
||||||
|
docs: |-
|
||||||
|
`wifi.source` ist `"stored"`, solange ein per PUT onboardetes WLAN gespeichert ist,
|
||||||
|
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
|
||||||
|
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
|
||||||
|
eine verbundene SSID zeigen.
|
||||||
- info:
|
- info:
|
||||||
name: Set WiFi
|
name: Set WiFi
|
||||||
type: http
|
type: http
|
||||||
@@ -302,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
|
||||||
|
|||||||
@@ -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.
|
||||||
+106
-19
@@ -1,6 +1,6 @@
|
|||||||
# Hardware-Checkliste (EVK)
|
# Hardware-Checkliste (EVK)
|
||||||
|
|
||||||
Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.sh evk <port>`.
|
Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.sh evk <port>` — das Skript nutzt `-rs none`, die Lampe braucht also einen **manuellen** Reset in dem Zeitfenster, in dem `wm_tool` auf den Sync wartet; ein automatischer Reset über die Steuerleitungen funktioniert an diesem Board nicht.
|
||||||
|
|
||||||
## Licht (Plan 1, Task 11)
|
## Licht (Plan 1, Task 11)
|
||||||
- [x] Nach dem Boot blendet warmweiß 100 % in 0,5 s ein
|
- [x] Nach dem Boot blendet warmweiß 100 % in 0,5 s ein
|
||||||
@@ -39,28 +39,115 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
|
|||||||
- [x] C2: Onboarding ok, 60 s warten, dann Reboot und Router-Neustart → AP `ninlight-<mac4>` darf nicht wiederkommen (Scan am Mac) — 2026-09-12 (Reboot geprüft, Router-Neustart offen)
|
- [x] C2: Onboarding ok, 60 s warten, dann Reboot und Router-Neustart → AP `ninlight-<mac4>` darf nicht wiederkommen (Scan am Mac) — 2026-09-12 (Reboot geprüft, Router-Neustart offen)
|
||||||
|
|
||||||
## OTA und Recovery (Plan 3)
|
## OTA und Recovery (Plan 3)
|
||||||
- [ ] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version
|
- [x] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version — 2026-09-12 (0.3.0 → 0.3.1 → 0.3.2)
|
||||||
- [ ] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter
|
- [x] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter — 2026-09-12 (Ablehnung nach ~4,5 s, Uptime lief durch)
|
||||||
- [ ] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset
|
- [x] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset — 2026-09-12 (`crc`, kein Reset)
|
||||||
- [ ] OTA: HTTP-Server während des Downloads abschalten → Wiederaufnahmeversuch (Range-Resume) sichtbar in der Konsole, nach Ausschöpfen der Runden `last_ota_error` = `download`, Lampe bootet normal
|
- [x] OTA: HTTP-Server während des Downloads abschalten → Lampe bleibt über REST erreichbar und schaltbar, weist weitere `fw_update` mit `409` ab, und beendet den Versuch mit `last_ota_error = download` — 2026-09-12 (dauerte ~7 min, kein Range-Resume; siehe README)
|
||||||
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler
|
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler — *offen, WLAN am Teststandort nicht abschaltbar*
|
||||||
- [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen
|
- [x] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen — 2026-09-12 (Pi Zero 2W, NetworkManager-AP; Scan → join → fetch → flash → Reboot)
|
||||||
- [ ] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen)
|
- [x] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) — 2026-09-12 (`scan done after 1500 ms, 7 AP(s)`, danach normaler WLAN-Start)
|
||||||
- [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben
|
- [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben
|
||||||
- [ ] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten)
|
- [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand)
|
||||||
- [ ] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version
|
- [x] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version — 2026-09-12 (erst nach dem SHA256-Kontext-Fix)
|
||||||
- [ ] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200
|
- [x] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200 — 2026-09-12 (system/info und device/state je 200)
|
||||||
- [ ] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war
|
- [x] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war — 2026-09-12 (pob=off + Licht an → blieb an; pob=on + Licht aus → blieb aus)
|
||||||
- [ ] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen)
|
- [x] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen) — 2026-09-12
|
||||||
- [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware
|
- [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware
|
||||||
- [ ] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht)
|
- [x] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht) — 2026-09-12 (zweiter Boot: `image already carries the running version, not flashing`, kein Werksreset, beide Boots melden `boot #1`)
|
||||||
- [ ] OTA im AP-/Fallback-Modus (`mode=apsta`, kein Router erreichbar) und mit `auth_all = true` → Update läuft durch wie im normalen `sta`-Modus
|
- [ ] OTA im **AP-/Fallback-Modus** (`mode=apsta`, kein Router erreichbar) → Update läuft durch wie im normalen `sta`-Modus — *offen, braucht einen abschaltbaren Access Point*. Mit `auth_all = true` am 2026-09-12 geprüft: Update läuft durch, `device/*` antwortet ohne Token korrekt mit 401 (`system/info` bleibt laut Spec §7.2 immer offen)
|
||||||
|
|
||||||
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
||||||
|
|
||||||
## Später (Lampen)
|
## Später (Lampen)
|
||||||
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
|
- [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
|
||||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
|
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
|
||||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: unteres Bereichsende deutlich warm, oberes deutlich kühler (damals noch 2500–5000 K, inzwischen 2000–5000 K), Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
||||||
- [ ] Farbtemperaturbereiche aller Varianten
|
- [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.*
|
||||||
|
|
||||||
|
## Standalone-AP-Modus (Abnahme am EVK, MAC 28:6d:cd:5c:68:1f)
|
||||||
|
|
||||||
|
- [x] `type: standalone` per `PUT system/settings` einschalten → 202 `{"status":"standalone"}`, `system/info` meldet `wifi.standalone: true` — 2026-10-02
|
||||||
|
- [x] Neustart → Lampe startet direkt im eigenen AP: Konsole `wifi: standalone AP, hostname ninlight-681f`, keine Onboarding-Zeile, kein Onboarding-Pulsen (den gibt es nur im Zustand `AP_ONBOARDING`, aus dem Code ableitbar; der Standalone-Startpuls ist davon getrennt und unten offen) — 2026-10-02
|
||||||
|
- [x] Geänderte AP-Daten → `wifi: AP data changed, rebuilding`, danach `wifi: AP ninlight-681f (ch 6) up` — 2026-10-02
|
||||||
|
- [x] Standalone mit exakt den Vorgabedaten auf einem bereits stehenden Onboarding-AP (kein Neuaufbau) löst die mDNS-Bekanntmachung ebenfalls aus — 2026-10-02
|
||||||
|
- [x] mDNS im eigenen AP: Konsole `mdns: ninlight-681f.local _ninlight._tcp up on en1 192.168.4.1`, `dns-sd -G v4 ninlight-681f.local` löst auf `192.168.4.1` auf, `ping` 0 % Verlust — 2026-10-02
|
||||||
|
- [x] REST über den AP antwortet auf `192.168.4.1:3333` — 2026-10-02
|
||||||
|
- [x] Startpuls als Sichtprüfung: Michael hat zugesehen — der Puls lief nach dem Start, hörte nach rund 5 s auf, danach stand die voreingestellte Farbe wieder. Das Einschaltverhalten greift vor dem Netz-Task (`app_main.c:224` vs. `:250`), die Lampe zeigt deshalb erst ihre Farbe und der Puls legt sich darüber — 2026-10-02
|
||||||
|
- [x] REST über den AP verlangt das Token: im Standalone-AP `GET system/settings` ohne Token → 401 `unauthorized`, mit Werkstoken → 200; `GET system/info` ohne Token → 200. Gegenprobe nach dem Werksreset im unkonfigurierten Onboarding-AP: derselbe Aufruf ohne Token → **200** — 2026-10-02
|
||||||
|
- [x] `{"enabled":false}` **ohne** gespeicherte WLAN-Daten: 202, danach heißt der AP wieder `ninlight-681f` (Beitritt mit dem Vorgabepasswort gelang, Name **und** Passwort sind also umgestellt), `system/info` meldet `wifi.ssid: ninlight-681f` und `standalone: false`, `GET system/settings` ohne Token → 200. Der gespeicherte Wunschname bleibt in `system/settings` als `standalone.ssid: Buehne` stehen — 2026-10-02
|
||||||
|
- [x] Standalone-Daten ändern, während der Aufrufer **über den AP** verbunden ist: die 202 kommt an. Direkter Vorher/Nachher-Beleg — derselbe Aufruf lieferte auf dem Stand **vor** der I6-Pause `HTTP 000` (Verbindung riss ab, bevor die Antwort raus war), mit der Pause `{"status":"standalone"} HTTP 202` — 2026-10-02
|
||||||
|
- [x] WLAN eingeben, falsches Passwort → Gerät bleibt im eigenen AP mit eigenen Daten: 202 `connecting`, nach dem Fehlschlag `wifi.error: auth`, `standalone: true`, AP weiterhin `Buehne` mit dem Nutzerpasswort, `GET system/settings` ohne Token weiterhin **401** — 2026-10-02
|
||||||
|
- [x] WLAN eingeben, richtiges Passwort → Gerät im Heimnetz (192.168.2.58, `standalone: false`), nach einem Neustart `mode: sta` und **nicht** wieder im AP — 2026-10-02
|
||||||
|
- [x] Im Standalone-Betrieb meldet `GET system/info` unter `wifi.ssid` den AP-Namen: an einer Lampe mit gespeichertem Heimnetz (`settings.wifi.ssid: ninIOT, source: stored`) meldete die **tokenfreie** Antwort `wifi.ssid: Buehne`, nicht `ninIOT`. Vor dem Wechsel in den Standalone-Modus stand dort korrekt die Stations-SSID — 2026-10-02
|
||||||
|
- [x] Werksreset über 5× ein/aus → wieder unkonfigurierter Onboarding-AP: alte Heimnetz-IP antwortet nicht mehr, AP `ninlight-681f` mit Vorgabepasswort, `settings` ohne Token → 200, alle Werte auf Vorgabe (`name: ''`, `standalone.enabled: false`) — 2026-10-02
|
||||||
|
- [x] sACN über den Standalone-AP, sichtbar an der Lampe: Michael hat grün (unicast an 192.168.4.1) und blau (multicast 239.255.0.1, Sender mit `IP_MULTICAST_IF` auf 192.168.4.2) gesehen, `sacn.active` jeweils true — 2026-10-02
|
||||||
|
|
||||||
|
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||||
|
|
||||||
|
Beim Test mit dem verfälschten Image meldete lwIP einmalig
|
||||||
|
`Assertion "pbuf_free: p->ref > 0" failed at line 747 in pbuf.c` (10:29:23). Die Lampe lief
|
||||||
|
unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läufen mit fremdem
|
||||||
|
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
|
||||||
|
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
|
||||||
|
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
|
||||||
|
|
||||||
|
## Recovery gegen die installierte Pi-Station (2026-09-13)
|
||||||
|
|
||||||
|
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
|
||||||
|
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
|
||||||
|
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
|
||||||
|
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
|
||||||
|
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
|
||||||
|
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
|
||||||
|
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
|
||||||
|
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
|
||||||
|
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
|
||||||
|
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
|
||||||
|
|
||||||
|
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
|
||||||
|
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
|
||||||
|
sieht pro Boot einen vollen Transfer.
|
||||||
|
|
||||||
|
## Migration echter Lampen (Plan 4b, 2026-09-13)
|
||||||
|
|
||||||
|
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
|
||||||
|
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
|
||||||
|
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
|
||||||
|
|
||||||
|
## Migrationsstation (Plan 4c) — Abnahme 2026-09-14
|
||||||
|
|
||||||
|
Station: Raspberry Pi Zero W Rev 1.1, Raspberry Pi OS Bookworm 12 (Python 3.11.2, Pillow 9.4.0,
|
||||||
|
armv6), Waveshare 2,13" E-Paper **Revision V3** als HAT, ein Taster auf GPIO23 (noch ohne Funktion).
|
||||||
|
|
||||||
|
- [x] `setup_pi.sh` richtet einen **nackten** Pi vollständig ein — Pakete, SPI, beide
|
||||||
|
NetworkManager-Profile, Server, CLI, Anzeigedienst. Zweimal von Grund auf durchgelaufen.
|
||||||
|
- [x] SPI wird automatisch eingeschaltet, der Neustart-Hinweis erscheint nur bei echter Änderung.
|
||||||
|
- [x] `deploy.sh` liefert 8 OTA-Images und 16 signierte Migrationsimages aus.
|
||||||
|
- [x] Panel-Revision am Testbild bestimmt: **V3**.
|
||||||
|
- [x] Horchphase: GU10 RGB meldet sich, wird erkannt, bekommt **404** — es floss kein Byte.
|
||||||
|
Protokoll: `/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.1.103_ota_light.img 404 0B`
|
||||||
|
Daraus abgeleitet: Ziel `gu10_rgb`, Variante **prod** (kein `-dev`), Lampenversion V1.1.1.103.
|
||||||
|
- [x] Erkennung am Panel sichtbar, vom Bediener bestätigt.
|
||||||
|
- [x] Zeitschalter holt die Station selbsttätig ins Heim-WLAN zurück (3 und 15 Minuten geprüft).
|
||||||
|
- [x] `arm gu10_rgb` freigegeben, Lampe migriert, startet neu, läuft mit `0.3.8-24-geaaef`,
|
||||||
|
per mDNS auffindbar und über REST steuerbar. `last_ota_error: null`.
|
||||||
|
- [x] Migrierte Lampe meldet `color: true`, `color_temp: [2700, 6500]`, `transition_default: 300`,
|
||||||
|
`wake_on_change: false`.
|
||||||
|
- [x] Ausschaltfade ohne Farbrest an der frisch migrierten Lampe — 2026-09-14 bestätigt.
|
||||||
|
- [ ] **Nicht geprüft und weiterhin offen: E27 RGB, E14 (RGB und CW), GU10 CW.** Von diesen
|
||||||
|
Varianten lag kein Exemplar vor. Ihre Pinbelegung stammt aus der alten Firmware und war
|
||||||
|
nie an echter Hardware verifiziert.
|
||||||
|
|
||||||
|
**Befund während der Abnahme, behoben in `0fe9ca6`:** `ninlight-station off` stoppte den
|
||||||
|
Anzeigedienst, aber kein `on`-Pfad startete ihn wieder — nach dem ersten `off` (das auch der
|
||||||
|
Zeitschalter auslöst) blieb das Panel bis zum Neustart des Pi dunkel. `off` fasst den
|
||||||
|
Anzeigedienst seitdem gar nicht mehr an: er läuft durchgehend und erkennt den ausgeschalteten
|
||||||
|
Zustand selbst (`systemctl is-active ninlight-station`), zeichnet also von sich aus
|
||||||
|
**Station aus**. Der ausdrückliche Diagnosebefehl `ninlight-station display off` bleibt erhalten.
|
||||||
|
Abgesichert durch die Fälle D4 und D4b in `tools/station/tests/test_cli.sh`.
|
||||||
|
|
||||||
|
**Hardware-Lehre:** Vier Kernel-Paniken an einem Tag, alle mit derselben Fehleradresse und
|
||||||
|
unabhängig von Karte und Konfiguration, kamen von einem Mehrfach-Netzteil, das sein Budget
|
||||||
|
dynamisch neu verhandelt. Mit einem eigenen Netzteil: `throttled=0x0` auch unter Schreiblast.
|
||||||
|
Die zerschossene erste SD-Karte war die Folge, nicht die Ursache.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Laufzeitarteifakte
|
||||||
|
qcx-requests.log
|
||||||
|
hostapd.log
|
||||||
|
*.pid
|
||||||
|
images/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Ninlight Bulbs — MAC → Raum (identifiziert 2026-09-21)
|
||||||
|
f7ce Küche (grün)
|
||||||
|
fd1e Küche (rot)
|
||||||
|
fadb Küche (magenta; erster Converter vom 20.9.)
|
||||||
|
f860 Schlafzimmer
|
||||||
|
faed Schlafzimmer
|
||||||
|
fb38 Schlafzimmer (blau)
|
||||||
|
63e9 Sofa (hellblau)
|
||||||
|
10a7 unbekannt (on:false, IP .29)
|
||||||
|
1fe9 unbekannt (on:false, IP .31)
|
||||||
|
f960 Montana Black LED-Shield (28:6d:ce:3c:f9:60, ledshield_rgb, 25.9. migriert)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Logging HTTP server for the qconnex-firmware listener experiment.
|
||||||
|
|
||||||
|
Logs every request (method, path, headers, body snippet) to qcx-requests.log
|
||||||
|
and answers 404 for everything. No images served yet — this run only watches
|
||||||
|
what the original Klyqa firmware asks for after joining the AP.
|
||||||
|
"""
|
||||||
|
import http.server
|
||||||
|
import sys
|
||||||
|
|
||||||
|
LOG = sys.path[0] + "/qcx-requests.log"
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args): # keep our own log format only
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_one_request_log(self):
|
||||||
|
body = b""
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if n:
|
||||||
|
body = self.rfile.read(min(n, 2048))
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write("=== %s\n" % self.command)
|
||||||
|
f.write("url: %s\n" % self.path)
|
||||||
|
f.write("client: %s:%s\n" % self.client_address)
|
||||||
|
for k, v in self.headers.items():
|
||||||
|
f.write("%s: %s\n" % (k, v))
|
||||||
|
if body:
|
||||||
|
f.write("body: %r\n" % body)
|
||||||
|
f.flush()
|
||||||
|
print("%s %s %s" % (self.command, self.client_address[0], self.path), flush=True)
|
||||||
|
payload = b"not found\n"
|
||||||
|
self.send_response(404)
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_OPTIONS = handle_one_request_log
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
srv = http.server.ThreadingHTTPServer(("0.0.0.0", 80), Handler)
|
||||||
|
print("qcx log server on 0.0.0.0:80", flush=True)
|
||||||
|
srv.serve_forever()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Onboard a qconnex/KLYQA bulb via its softAP TLS onboarding socket (port 2223).
|
||||||
|
|
||||||
|
Usage: onboard.py <bulb-ip> <ssid> <wifi-password> [access-token]
|
||||||
|
|
||||||
|
Wire framing: every message is a 4-byte header (u16 BE payload length + 2
|
||||||
|
reserved bytes) followed by the JSON payload. Sends
|
||||||
|
{"type":"configure","ssid":"<hex>","password":"***","access_token":...}
|
||||||
|
and prints replies. Device IP is 10.42.42.10 while its KLYQA-xxxxxx softAP is up.
|
||||||
|
"""
|
||||||
|
import socket, ssl, sys, json, struct
|
||||||
|
|
||||||
|
def frame(obj):
|
||||||
|
b = json.dumps(obj).encode()
|
||||||
|
return struct.pack(">HH", len(b), 0) + b
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ip, ssid, pswd = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
token = sys.argv[4] if len(sys.argv) > 4 else "aabbccddeeff0011223344"
|
||||||
|
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT (mbedTLS server)
|
||||||
|
|
||||||
|
s = ctx.wrap_socket(socket.create_connection((ip, 2223), timeout=10))
|
||||||
|
print(f"connected {ip}:2223 {s.cipher()[0]}", file=sys.stderr)
|
||||||
|
|
||||||
|
buf = b""
|
||||||
|
def drain(sec, label):
|
||||||
|
nonlocal buf
|
||||||
|
s.settimeout(sec)
|
||||||
|
n = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = s.recv(4096)
|
||||||
|
if not chunk: break
|
||||||
|
buf += chunk
|
||||||
|
while len(buf) >= 4:
|
||||||
|
ln = struct.unpack(">H", buf[:2])[0]
|
||||||
|
if len(buf) < 4 + ln: break
|
||||||
|
msg, buf = buf[4+ln:], buf[4+ln:]
|
||||||
|
n += 1
|
||||||
|
print(f"{label}{msg.decode(errors='replace')[:400]}")
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
drain(2, "recv: ")
|
||||||
|
cfg = {"type": "configure",
|
||||||
|
"ssid": ssid.encode().hex(),
|
||||||
|
"password": pswd.encode().hex(),
|
||||||
|
"access_token": token}
|
||||||
|
s.sendall(frame(cfg))
|
||||||
|
print("sent configure", file=sys.stderr)
|
||||||
|
if not drain(10, "recv: "):
|
||||||
|
print("no reply (device may just proceed to join the WLAN)", file=sys.stderr)
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump raw bytes clients send to 192.168.8.1:3333 (what would ninlight poll?)."""
|
||||||
|
import socket, sys, datetime, binascii
|
||||||
|
s = socket.socket()
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind(("192.168.8.1", 3333))
|
||||||
|
s.listen(8)
|
||||||
|
print("raw 3333 listener", flush=True)
|
||||||
|
while True:
|
||||||
|
c, a = s.accept()
|
||||||
|
try:
|
||||||
|
c.settimeout(5)
|
||||||
|
data = b""
|
||||||
|
try:
|
||||||
|
while len(data) < 65536:
|
||||||
|
ch = c.recv(2048)
|
||||||
|
if not ch:
|
||||||
|
break
|
||||||
|
data += ch
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
print("=== %s %s %d bytes\n%s" % (datetime.datetime.now(), a[0], len(data), repr(data)), flush=True)
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""TCP 443 sniffer: accept, read first bytes, log hex+printable, close.
|
||||||
|
|
||||||
|
For an HTTPS client this captures the TLS ClientHello in plaintext — the SNI
|
||||||
|
extension names the host the bulb actually wants to talk to.
|
||||||
|
"""
|
||||||
|
import socket, sys, datetime, binascii
|
||||||
|
|
||||||
|
LOG = sys.path[0] + "/qcx-tls.log"
|
||||||
|
s = socket.socket()
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind(("0.0.0.0", 443))
|
||||||
|
s.listen(8)
|
||||||
|
print("qcx tls sniffer on 0.0.0.0:443", flush=True)
|
||||||
|
while True:
|
||||||
|
c, addr = s.accept()
|
||||||
|
try:
|
||||||
|
c.settimeout(3)
|
||||||
|
data = b""
|
||||||
|
try:
|
||||||
|
while len(data) < 4096:
|
||||||
|
chunk = c.recv(2048)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write("=== %s %s:%s %d bytes\n" % (datetime.datetime.now(), addr[0], addr[1], len(data)))
|
||||||
|
f.write(binascii.hexlify(data).decode() + "\n")
|
||||||
|
f.write(repr(data) + "\n")
|
||||||
|
print("tls from %s: %d bytes" % (addr[0], len(data)), flush=True)
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
@@ -70,6 +70,13 @@ Lib/GNU, Bin/secboot.img, Tools/GNU/link_w600_2m.ld (→ ld/) und wm_tool.c (→
|
|||||||
exportierte) `session_id`, wäre also ein größerer Eingriff für denselben Effekt. Nebenbei:
|
exportierte) `session_id`, wäre also ein größerer Eingriff für denselben Effekt. Nebenbei:
|
||||||
`nRetCode = ret` (ein lwIP-`err_t` in einer sonst `HTTP_CLIENT_*`-Codes tragenden Variable) durch
|
`nRetCode = ret` (ein lwIP-`err_t` in einer sonst `HTTP_CLIENT_*`-Codes tragenden Variable) durch
|
||||||
`HTTP_CLIENT_ERROR_BAD_STATE` ersetzt.
|
`HTTP_CLIENT_ERROR_BAD_STATE` ersetzt.
|
||||||
|
**Fix vom 2026-09-12 (am EVK gefunden):** `nl_sha256_init()` stand am Anfang von `fwup_scheduler()`,
|
||||||
|
also einmal beim Task-Start. Der Task überlebt aber jede Update-Sitzung, sodass ab dem zweiten Versuch
|
||||||
|
pro Boot ein bereits finalisierter Kontext weiterbenutzt wurde und der Trailer-Hash zwangsläufig nicht
|
||||||
|
mehr passte (`last_ota_error=hash`). Der Init sitzt jetzt dort, wo der `T_BOOTER`-Header eintrifft, also
|
||||||
|
genau einmal pro Image. Ohne diesen Fix war nach jeder Ablehnung ein Reboot nötig, bevor ein Update
|
||||||
|
überhaupt wieder gelingen konnte.
|
||||||
|
|
||||||
2. `Src/OS/RTOS/include/FreeRTOSConfig.h`: `configGENERATE_RUN_TIME_STATS 0`, QConnex-Debug-Hooks entfernt.
|
2. `Src/OS/RTOS/include/FreeRTOSConfig.h`: `configGENERATE_RUN_TIME_STATS 0`, QConnex-Debug-Hooks entfernt.
|
||||||
3. `undefined reference` beim Linken auf `EarlyInit` und `pre_gpio_config` (von
|
3. `undefined reference` beim Linken auf `EarlyInit` und `pre_gpio_config` (von
|
||||||
`sdk/Platform/Sys/wm_main.c` als `extern`-Hooks erwartet, aber nur von der App bereitgestellt):
|
`sdk/Platform/Sys/wm_main.c` als `extern`-Hooks erwartet, aber nur von der App bereitgestellt):
|
||||||
|
|||||||
@@ -190,8 +190,11 @@ static void fwup_scheduler(void *data)
|
|||||||
u32 tmplen = 0;
|
u32 tmplen = 0;
|
||||||
bool isacrossflash = FALSE;
|
bool isacrossflash = FALSE;
|
||||||
|
|
||||||
|
/* ninlight: the context is (re)initialised per image, where the T_BOOTER header arrives -
|
||||||
|
* NOT here. This task outlives every update session, so initialising once at task start
|
||||||
|
* left a finalised context behind after the first attempt and every later update in the
|
||||||
|
* same boot failed with last_ota_error=hash. Found on the EVK, 2026-09-12. */
|
||||||
nl_sha256_ctx sha;
|
nl_sha256_ctx sha;
|
||||||
nl_sha256_init(&sha);
|
|
||||||
u8 image_sha256[32] = {0};
|
u8 image_sha256[32] = {0};
|
||||||
u8 *RSA_SHA256 = NULL;
|
u8 *RSA_SHA256 = NULL;
|
||||||
|
|
||||||
@@ -264,6 +267,7 @@ static void fwup_scheduler(void *data)
|
|||||||
fwup->current_state |= TLS_FWUP_STATE_ERROR_IO;
|
fwup->current_state |= TLS_FWUP_STATE_ERROR_IO;
|
||||||
goto request_finish;
|
goto request_finish;
|
||||||
}
|
}
|
||||||
|
nl_sha256_init(&sha); /* fresh context for this image */
|
||||||
nl_sha256_update(&sha, (u8*)&booter, sizeof(T_BOOTER));
|
nl_sha256_update(&sha, (u8*)&booter, sizeof(T_BOOTER));
|
||||||
|
|
||||||
if ((IMG_TYPE_OLD_PLAIN == booter.img_type ) ||(IMG_TYPE_NEW_PLAIN == booter.img_type))
|
if ((IMG_TYPE_OLD_PLAIN == booter.img_type ) ||(IMG_TYPE_NEW_PLAIN == booter.img_type))
|
||||||
|
|||||||
+59
-11
@@ -13,10 +13,12 @@
|
|||||||
#include "drivers/nl_led.h"
|
#include "drivers/nl_led.h"
|
||||||
#include "light/nl_boot_state.h"
|
#include "light/nl_boot_state.h"
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_http_server.h"
|
#include "net/nl_http_server.h"
|
||||||
#include "net/nl_mdns.h"
|
#include "net/nl_mdns.h"
|
||||||
#include "net/nl_recovery.h"
|
#include "net/nl_recovery.h"
|
||||||
|
#include "net/nl_sacn.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
#include "sys/nl_app_api.h"
|
#include "sys/nl_app_api.h"
|
||||||
@@ -109,6 +111,19 @@ int nl_app_set_wifi_credentials(const char *ssid, const char *pass)
|
|||||||
return nl_app_save_settings();
|
return nl_app_save_settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int nl_app_clear_standalone(void)
|
||||||
|
{
|
||||||
|
bool was_set;
|
||||||
|
|
||||||
|
nl_app_lock();
|
||||||
|
was_set = s_settings.standalone != 0;
|
||||||
|
s_settings.standalone = 0;
|
||||||
|
nl_app_unlock();
|
||||||
|
if (!was_set)
|
||||||
|
return 0; /* nichts zu schreiben - der Flash-Satz steht schon richtig */
|
||||||
|
return nl_app_save_settings();
|
||||||
|
}
|
||||||
|
|
||||||
void nl_app_factory_reset(void)
|
void nl_app_factory_reset(void)
|
||||||
{
|
{
|
||||||
printf("factory reset\r\n");
|
printf("factory reset\r\n");
|
||||||
@@ -147,6 +162,7 @@ static void light_task(void *arg)
|
|||||||
now = nl_app_now_ms();
|
now = nl_app_now_ms();
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
nl_engine_tick(now - last);
|
nl_engine_tick(now - last);
|
||||||
|
nl_dmx_tick(now);
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
last = now;
|
last = now;
|
||||||
s_light_beat++;
|
s_light_beat++;
|
||||||
@@ -160,9 +176,10 @@ static void app_task(void *arg)
|
|||||||
nl_engine_cfg_t cfg;
|
nl_engine_cfg_t cfg;
|
||||||
nl_light_state_t last, initial, pending;
|
nl_light_state_t last, initial, pending;
|
||||||
bool have_last, after_ota;
|
bool have_last, after_ota;
|
||||||
bool light_alive = false, net_alive = false, http_alive = false;
|
bool light_alive = false, net_alive = false, http_alive = false, sacn_alive = false;
|
||||||
|
bool sacn_required;
|
||||||
uint8_t boots;
|
uint8_t boots;
|
||||||
uint32_t boot_ms, light_seen, net_seen, http_seen;
|
uint32_t boot_ms, light_seen, net_seen, http_seen, sacn_seen;
|
||||||
|
|
||||||
(void)arg;
|
(void)arg;
|
||||||
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
|
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
|
||||||
@@ -202,8 +219,10 @@ static void app_task(void *arg)
|
|||||||
after_ota);
|
after_ota);
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
nl_engine_init(&cfg);
|
nl_engine_init(&cfg);
|
||||||
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms);
|
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_ms,
|
||||||
|
s_settings.wake_on_change != 0);
|
||||||
nl_ctrl_boot(&initial);
|
nl_ctrl_boot(&initial);
|
||||||
|
nl_dmx_init(board);
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
|
|
||||||
tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0);
|
tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0);
|
||||||
@@ -222,17 +241,41 @@ static void app_task(void *arg)
|
|||||||
boot = s_settings;
|
boot = s_settings;
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
nl_mdns_set_name(boot.name);
|
nl_mdns_set_name(boot.name);
|
||||||
if (boot.wifi_ssid[0])
|
if (boot.standalone)
|
||||||
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD);
|
/* Kein Verbindungsversuch: die Lampe steht ausdruecklich allein. Die gespeicherten
|
||||||
|
* Zugangsdaten gehen trotzdem mit - der Standalone-Zweig der Zustandsmaschine
|
||||||
|
* verbindet nicht, aber sie zaehlen wieder, sobald der Modus ueber die API
|
||||||
|
* zurueckgenommen wird; ohne sie landete die Lampe dann im offenen
|
||||||
|
* Onboarding-AP, obwohl ihr WLAN bekannt ist. */
|
||||||
|
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass,
|
||||||
|
boot.ap_pass[0] ? boot.ap_pass : NINLIGHT_AP_PASSWORD,
|
||||||
|
boot.ap_ssid, boot.ap_channel, true, boot.standalone_pulse != 0);
|
||||||
|
else if (boot.wifi_ssid[0])
|
||||||
|
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD, "", 0, false, false);
|
||||||
else
|
else
|
||||||
nl_wifi_start(NINLIGHT_WIFI_SSID, NINLIGHT_WIFI_PASSWORD, NINLIGHT_AP_PASSWORD);
|
nl_wifi_start(NINLIGHT_WIFI_SSID, NINLIGHT_WIFI_PASSWORD, NINLIGHT_AP_PASSWORD,
|
||||||
|
"", 0, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
nl_http_server_start(nl_app_api_ctx());
|
nl_http_server_start(nl_app_api_ctx());
|
||||||
|
|
||||||
|
{
|
||||||
|
static nl_settings_t sacn_boot; /* copy: the network task may change s_settings later */
|
||||||
|
nl_app_lock();
|
||||||
|
sacn_boot = s_settings;
|
||||||
|
nl_app_unlock();
|
||||||
|
nl_sacn_apply_settings(&sacn_boot);
|
||||||
|
}
|
||||||
|
nl_sacn_start();
|
||||||
|
/* Nur ein tatsaechlich gestarteter Task darf den Wachhund mitbestimmen: ohne Task
|
||||||
|
* bewegt sich nl_sacn_beat nie, die Bedingung waere nie erfuellt und die Lampe liefe
|
||||||
|
* in eine endlose Reset-Schleife. Ein Zusatzdienst faellt dann eben aus. */
|
||||||
|
sacn_required = nl_sacn_running();
|
||||||
|
|
||||||
light_seen = s_light_beat;
|
light_seen = s_light_beat;
|
||||||
net_seen = nl_net_beat;
|
net_seen = nl_net_beat;
|
||||||
http_seen = nl_http_beat;
|
http_seen = nl_http_beat;
|
||||||
|
sacn_seen = nl_sacn_beat;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint32_t now;
|
uint32_t now;
|
||||||
bool due;
|
bool due;
|
||||||
@@ -261,9 +304,10 @@ static void app_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feed the watchdog only once the light, network and HTTP tasks have each made
|
/* Feed the watchdog only once the light, network, HTTP and sACN tasks have each
|
||||||
* progress since the last feed - a single stuck task (e.g. nl_app_lock() held
|
* made progress since the last feed - a single stuck task (e.g. nl_app_lock() held
|
||||||
* forever) must still starve the watchdog, not just the light task. */
|
* forever) must still starve the watchdog, not just the light task. Ausnahme: der
|
||||||
|
* sACN-Task, wenn er gar nicht erst angelegt werden konnte (sacn_required). */
|
||||||
if (s_light_beat != light_seen) {
|
if (s_light_beat != light_seen) {
|
||||||
light_seen = s_light_beat;
|
light_seen = s_light_beat;
|
||||||
light_alive = true;
|
light_alive = true;
|
||||||
@@ -276,8 +320,12 @@ static void app_task(void *arg)
|
|||||||
http_seen = nl_http_beat;
|
http_seen = nl_http_beat;
|
||||||
http_alive = true;
|
http_alive = true;
|
||||||
}
|
}
|
||||||
if (light_alive && net_alive && http_alive) {
|
if (nl_sacn_beat != sacn_seen) {
|
||||||
light_alive = net_alive = http_alive = false;
|
sacn_seen = nl_sacn_beat;
|
||||||
|
sacn_alive = true;
|
||||||
|
}
|
||||||
|
if (light_alive && net_alive && http_alive && (sacn_alive || !sacn_required)) {
|
||||||
|
light_alive = net_alive = http_alive = sacn_alive = false;
|
||||||
tls_watchdog_clr();
|
tls_watchdog_clr();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -21,16 +21,24 @@ const nl_board_t nl_boards[] = {
|
|||||||
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x73},
|
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x73},
|
||||||
{"gu10_rgb", "Klyqa GU10 RGB CW/WW", NL_DRV_SM2135EH, true, 2700, 6500, 0,
|
{"gu10_rgb", "Klyqa GU10 RGB CW/WW", NL_DRV_SM2135EH, true, 2700, 6500, 0,
|
||||||
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x63},
|
{NONE, NONE, NONE, NONE, NONE}, NL_PB(8), NL_PB(13), 0x63},
|
||||||
{"e27_cw", "Klyqa E27 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
{"e27_cw", "Klyqa E27 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
|
||||||
{NONE, NONE, NONE, CHA(1, NL_PB(13)), CH(4, NL_PB(8))}, NO_SM},
|
{NONE, NONE, NONE, CHA(1, NL_PB(13)), CH(4, NL_PB(8))}, NO_SM},
|
||||||
{"e14_cw", "Klyqa E14 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
{"e14_cw", "Klyqa E14 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
|
||||||
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
|
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
|
||||||
{"gu10_cw", "Klyqa GU10 CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
{"gu10_cw", "Klyqa GU10 CW/WW", NL_DRV_PWM, false, 2700, 6500, 600,
|
||||||
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
|
{NONE, NONE, NONE, CH(2, NL_PB(16)), CH(4, NL_PB(8))}, NO_SM},
|
||||||
/* G95: current table: CW=PB13, WW=PB15 (new assignment); old bulbs may use
|
/* G95: current table: CW=PB13, WW=PB15 (new assignment); old bulbs may use
|
||||||
* PB16=WW, PB15=CW - verify on a series bulb. */
|
* PB16=WW, PB15=CW - verify on a series bulb. */
|
||||||
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2500, 5000, 600,
|
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2000, 5000, 600,
|
||||||
{NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM},
|
{NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM},
|
||||||
|
/* Montana Black LED Shield (Klyqa LED-Strippen-Steuergeraet,
|
||||||
|
product_id @qcx.montana.black.ledshield.rgb): analog RGB-Streifen an
|
||||||
|
lowseitigen MOSFETs, kein CW/WW-Zweig. PIN-Kanaele und 800 Hz aus der
|
||||||
|
Original-Firmware (fw-w600 qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD);
|
||||||
|
dort invertiert init mit invertiertem Duty, effektive Wirkung wie hier
|
||||||
|
active-high. ct_min == ct_max markiert "weiss = RGB-Kombination". */
|
||||||
|
{"ledshield_rgb", "Montana Black RGB LED Shield", NL_DRV_PWM, true, 4000, 4000, 800,
|
||||||
|
{CH(1, NL_PB(13)), CH(2, NL_PB(16)), CH(3, NL_PB(15)), NONE, NONE}, NO_SM},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]);
|
const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]);
|
||||||
@@ -49,3 +57,10 @@ const nl_board_t *nl_board_get(void)
|
|||||||
return nl_board_find(NINLIGHT_DEVICE_ID);
|
return nl_board_find(NINLIGHT_DEVICE_ID);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
uint8_t nl_board_dmx_slots(const nl_board_t *b, bool rgb_layout)
|
||||||
|
{
|
||||||
|
if (rgb_layout)
|
||||||
|
return 3u; /* R,G,B - unabhaengig vom Board, damit eine Lampe ein Pixel ist */
|
||||||
|
return (b != NULL && b->has_rgb) ? 6u : 3u;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -29,15 +29,45 @@ typedef struct {
|
|||||||
|
|
||||||
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
|
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
|
||||||
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
|
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
|
||||||
* while sizeof(nl_settings_t) is a multiple of 4 (204 today). Adding a single uint16_t would move
|
* while sizeof(nl_settings_t) is a multiple of 4 (216 today, 208 before sACN). Adding a single
|
||||||
* offsetof(rec_t, crc) two bytes further than the reader looks and every stored record would
|
* uint16_t would move offsetof(rec_t, crc) two bytes further than the reader looks and every
|
||||||
* silently fail its CRC on the next boot - Wi-Fi, token and name gone on every lamp. */
|
* stored record would silently fail its CRC on the next boot - Wi-Fi, token and name gone on
|
||||||
|
* every lamp. Aus demselben Grund gilt fuer neue Felder: sie beginnen bei oder hinter dem
|
||||||
|
* vorherigen sizeof (also nicht unterhalb von 208), denn ein alter Datensatz ist genau
|
||||||
|
* sizeof lang und traegt seine Fuellbytes mit - ein Feld darunter wuerde beim Lesen von
|
||||||
|
* diesen Fuellbytes (Null) ueberschrieben statt seine Vorgabe zu behalten. */
|
||||||
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
|
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
|
||||||
|
|
||||||
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload
|
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload
|
||||||
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
|
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
|
||||||
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
|
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
|
||||||
|
|
||||||
|
/* Die Satzlaenge muss ein Vielfaches von 4 bleiben - sonst zeigt NL_SETTINGS_CRC_OFF() woanders
|
||||||
|
* hin als offsetof(rec_t, crc), und jeder gespeicherte Datensatz faellt beim naechsten Boot
|
||||||
|
* durch die CRC-Pruefung. Wer hier anstoesst, fuegt ein Fuellbyte hinzu, statt die Pruefung
|
||||||
|
* zu entfernen. */
|
||||||
|
typedef char nl_settings_size_check[(sizeof(nl_settings_t) % 4u == 0u) ? 1 : -1];
|
||||||
|
|
||||||
|
/* Das erste mit sACN angehaengte Feld muss genau dort beginnen, wo der alte Satz aufhoerte:
|
||||||
|
* bei 208, dem sizeof(nl_settings_t) der Firmware vor sACN. Saetze, die eine solche Firmware
|
||||||
|
* geschrieben hat, sind 208 Byte lang und enthalten hinter wake_on_change (Offset 206) noch
|
||||||
|
* ein Fuellbyte. read_rec() kopiert hdr.length Bytes ueber die Vorgaben - ein Feld unterhalb
|
||||||
|
* von 208 wuerde also von diesem Fuellbyte (Null) ueberschrieben und seine Vorgabe verlieren.
|
||||||
|
* Bei sacn_enabled hiesse das: sACN nach dem Update auf jeder Lampe aus. */
|
||||||
|
typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 208 ? 1 : -1];
|
||||||
|
|
||||||
|
/* Dasselbe fuer das naechste angehaengte Feld: sacn_layout muss bei 216 beginnen, dem
|
||||||
|
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
|
||||||
|
* eingefuegt - dann kaeme es aus einem aelteren Satz als Fuellbyte zurueck, nicht als
|
||||||
|
* Vorgabe. */
|
||||||
|
typedef char nl_settings_layout_offset_check[offsetof(nl_settings_t, sacn_layout) == 216 ? 1 : -1];
|
||||||
|
|
||||||
|
/* Dasselbe fuer die Standalone-Felder: standalone muss bei 220 beginnen, dem
|
||||||
|
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
|
||||||
|
* eingefuegt - dann kaeme standalone_pulse aus einem aelteren Satz als Fuellbyte (0)
|
||||||
|
* zurueck statt als Vorgabe (1), und der Startpuls waere still aus. */
|
||||||
|
typedef char nl_settings_standalone_check[offsetof(nl_settings_t, standalone) == 220 ? 1 : -1];
|
||||||
|
|
||||||
static uint32_t s_addr[2];
|
static uint32_t s_addr[2];
|
||||||
|
|
||||||
void nl_settings_defaults(nl_settings_t *s)
|
void nl_settings_defaults(nl_settings_t *s)
|
||||||
@@ -47,6 +77,14 @@ void nl_settings_defaults(nl_settings_t *s)
|
|||||||
s->power_on_behaviour = NL_POB_LAST;
|
s->power_on_behaviour = NL_POB_LAST;
|
||||||
s->fade_in_ms = 500;
|
s->fade_in_ms = 500;
|
||||||
s->fade_out_ms = 500;
|
s->fade_out_ms = 500;
|
||||||
|
s->transition_ms = 300;
|
||||||
|
s->wake_on_change = 0;
|
||||||
|
s->sacn_enabled = 1;
|
||||||
|
s->sacn_universe = 1;
|
||||||
|
s->sacn_start_slot = 1;
|
||||||
|
s->sacn_hold = 0;
|
||||||
|
s->sacn_layout = 1;
|
||||||
|
s->standalone_pulse = 1; /* standalone, ap_channel, ap_ssid, ap_pass bleiben auf 0/leer */
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
|
void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
|
||||||
|
|||||||
@@ -19,6 +19,39 @@ typedef struct {
|
|||||||
uint16_t fade_in_ms;
|
uint16_t fade_in_ms;
|
||||||
uint16_t fade_out_ms;
|
uint16_t fade_out_ms;
|
||||||
uint8_t ota_pending; /* set before an OTA; the next boot restores the last state */
|
uint8_t ota_pending; /* set before an OTA; the next boot restores the last state */
|
||||||
|
/* Appended fields: read_rec() in nl_settings.c pre-fills the struct with
|
||||||
|
* nl_settings_defaults() and only overwrites the bytes actually stored, so records
|
||||||
|
* written by an older firmware pick up the real default here, not zero. Keep adding new
|
||||||
|
* fields at the end for that to keep working. */
|
||||||
|
uint16_t transition_ms; /* default transition (ms) for a color/brightness/CT change
|
||||||
|
* while the lamp stays on; fade_in/fade_out still win when
|
||||||
|
* switching on or off. */
|
||||||
|
uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was
|
||||||
|
* off, unless the same call says "on": false. */
|
||||||
|
uint8_t _legacy_pad; /* war Fuellbyte im 208-Byte-Satz: neue Felder muessen
|
||||||
|
* bei oder hinter dem vorherigen sizeof beginnen, sonst
|
||||||
|
* ueberschreibt das Fuellbyte eines alten Datensatzes sie */
|
||||||
|
uint8_t sacn_enabled; /* Offset 208 - sACN/E1.31-Empfang aktiv (Vorgabe 1) */
|
||||||
|
uint16_t sacn_universe; /* 1..63999 (Vorgabe 1) */
|
||||||
|
uint16_t sacn_start_slot; /* 1..512 (Vorgabe 1) */
|
||||||
|
uint8_t sacn_hold; /* 0 = nach dem Timeout zurueck zum HA-Zustand, 1 = halten */
|
||||||
|
uint8_t _padding[1]; /* haelt sizeof auf einem Vielfachen von 4 */
|
||||||
|
uint8_t sacn_layout; /* Offset 216 - 0 = Dimmer voran, 1 = nur R,G,B (Vorgabe 1).
|
||||||
|
* Dieselbe Regel wie oben: neue Felder beginnen bei oder hinter
|
||||||
|
* dem vorherigen sizeof (216), sonst ueberschreibt das Fuellbyte
|
||||||
|
* eines aelteren Satzes sie. */
|
||||||
|
uint8_t _padding2[3]; /* haelt sizeof auf einem Vielfachen von 4 */
|
||||||
|
uint8_t standalone; /* Offset 220 - 1 = eigener AP statt Station. Dieselbe Regel
|
||||||
|
* wie oben: neue Felder beginnen bei oder hinter dem
|
||||||
|
* vorherigen sizeof (220), sonst ueberschreibt das
|
||||||
|
* Fuellbyte eines aelteren Satzes sie. */
|
||||||
|
uint8_t ap_channel; /* 0 = Vorgabe (6), sonst 1..13 */
|
||||||
|
uint8_t standalone_pulse; /* 1 = beim Start kurz pulsen (Vorgabe) */
|
||||||
|
uint8_t _padding3;
|
||||||
|
char ap_ssid[NL_SSID_LEN]; /* leer = "ninlight-<mac4>" */
|
||||||
|
char ap_pass[NL_PASS_LEN]; /* leer = einkompiliertes AP-Passwort. Taucht in keiner
|
||||||
|
* Antwort und in keiner Protokollzeile auf. */
|
||||||
|
uint8_t _padding4[2]; /* haelt sizeof auf einem Vielfachen von 4 (324) */
|
||||||
} nl_settings_t;
|
} nl_settings_t;
|
||||||
|
|
||||||
void nl_settings_defaults(nl_settings_t *s);
|
void nl_settings_defaults(nl_settings_t *s);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#define SM_ADDR_CURRENT 0xC0u
|
#define SM_ADDR_CURRENT 0xC0u
|
||||||
#define SM_ADDR_WY 0xC5u
|
#define SM_ADDR_WY 0xC5u
|
||||||
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
|
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
|
||||||
|
#define SM_CHANNEL_STBY 0x20u /* standby bit 5: powers the outputs down */
|
||||||
|
|
||||||
extern void delay_us(unsigned int us); /* provided by libwlan.a */
|
extern void delay_us(unsigned int us); /* provided by libwlan.a */
|
||||||
|
|
||||||
@@ -76,12 +77,35 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
|
|||||||
s_have_last = true;
|
s_have_last = true;
|
||||||
|
|
||||||
if (!white) {
|
if (!white) {
|
||||||
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, 0x00, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
/* All channels dark: put the driver into standby instead of just writing zero duty.
|
||||||
|
Without bit 5 the chip stays active and leaks enough current to keep the lamp
|
||||||
|
faintly glowing - measured on a migrated GU10 RGB, 2026-09-13. The old QConnex
|
||||||
|
firmware had this logic but left it commented out (qcl_lamp.c), which is why the
|
||||||
|
behaviour was inherited. Lamps driven straight from PWM pins (G95, the CW variants)
|
||||||
|
are unaffected. Turning back on writes 0x00 or SM_CHANNEL_WY here and clears it. */
|
||||||
|
bool dark = !ch[NL_CH_R] && !ch[NL_CH_G] && !ch[NL_CH_B];
|
||||||
|
uint8_t chan = dark ? SM_CHANNEL_STBY : 0x00u;
|
||||||
|
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||||
sm_send(d, 6);
|
sm_send(d, 6);
|
||||||
|
/* The WY data registers are cleared here too, even though this is the RGB branch:
|
||||||
|
the chip keeps whatever white value was last written, and a direct white -> colour
|
||||||
|
change never passes through the all-dark frame above. The stale white then bleeds
|
||||||
|
through as soon as the colour dims - the same defect as the colour -> white case
|
||||||
|
below (fixed in 00d5a4c, 2026-09-13), just mirrored: that fix made the WY branch
|
||||||
|
also write the (always-zero) RGB registers, this makes the RGB branch also write
|
||||||
|
the (always-zero) WY registers. Both directions had it; only one was caught first. */
|
||||||
|
uint8_t w[3] = {SM_ADDR_WY, 0, 0};
|
||||||
|
sm_send(w, 3);
|
||||||
} else {
|
} else {
|
||||||
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY};
|
/* The RGB data registers are written along with the channel select, even though they
|
||||||
|
are always zero here: the chip keeps whatever colour was last written, and a direct
|
||||||
|
colour -> white change never passes through the all-dark frame above. The stale
|
||||||
|
colour then bleeds through as soon as the white dims - visible as a coloured
|
||||||
|
fade-out on a migrated GU10 RGB, 2026-09-13. */
|
||||||
|
uint8_t h[6] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY,
|
||||||
|
ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||||
uint8_t d[3] = {SM_ADDR_WY, ch[NL_CH_WW], ch[NL_CH_CW]};
|
uint8_t d[3] = {SM_ADDR_WY, ch[NL_CH_WW], ch[NL_CH_CW]};
|
||||||
sm_send(h, 3);
|
sm_send(h, 6);
|
||||||
sm_send(d, 3);
|
sm_send(d, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_ma
|
|||||||
/* keep the lowest brightness step visible */
|
/* keep the lowest brightness step visible */
|
||||||
if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0)
|
if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0)
|
||||||
out[NL_CH_R + maxi] = 1;
|
out[NL_CH_R + maxi] = 1;
|
||||||
|
} else if (ct_min == ct_max) {
|
||||||
|
/* RGB-only board (ledshield_rgb): no white channels wired, ct range
|
||||||
|
* collapsed to a point. White shows as an equal RGB mix. */
|
||||||
|
uint8_t w = to8(level);
|
||||||
|
out[NL_CH_R] = out[NL_CH_G] = out[NL_CH_B] = w ? w : 1;
|
||||||
} else {
|
} else {
|
||||||
uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u;
|
uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u;
|
||||||
uint32_t ww = level - cw;
|
uint32_t ww = level - cw;
|
||||||
|
|||||||
+17
-4
@@ -7,23 +7,29 @@ static const nl_board_t *s_board;
|
|||||||
static nl_light_state_t s_state;
|
static nl_light_state_t s_state;
|
||||||
static uint16_t s_fade_in;
|
static uint16_t s_fade_in;
|
||||||
static uint16_t s_fade_out;
|
static uint16_t s_fade_out;
|
||||||
|
static uint16_t s_transition;
|
||||||
|
static bool s_wake_on_change;
|
||||||
static bool s_dirty;
|
static bool s_dirty;
|
||||||
static uint32_t s_changed_at;
|
static uint32_t s_changed_at;
|
||||||
|
|
||||||
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms)
|
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms,
|
||||||
|
uint16_t transition_ms, bool wake_on_change)
|
||||||
{
|
{
|
||||||
s_board = board;
|
s_board = board;
|
||||||
s_state = nl_boot_default_state(board);
|
s_state = nl_boot_default_state(board);
|
||||||
s_state.on = false;
|
s_state.on = false;
|
||||||
s_dirty = false;
|
s_dirty = false;
|
||||||
s_changed_at = 0;
|
s_changed_at = 0;
|
||||||
nl_ctrl_set_fades(fade_in_ms, fade_out_ms);
|
nl_ctrl_set_options(fade_in_ms, fade_out_ms, transition_ms, wake_on_change);
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms)
|
void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms,
|
||||||
|
bool wake_on_change)
|
||||||
{
|
{
|
||||||
s_fade_in = fade_in_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_in_ms;
|
s_fade_in = fade_in_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_in_ms;
|
||||||
s_fade_out = fade_out_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_out_ms;
|
s_fade_out = fade_out_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_out_ms;
|
||||||
|
s_transition = transition_ms > NL_MAX_TRANSITION_MS ? NL_MAX_TRANSITION_MS : transition_ms;
|
||||||
|
s_wake_on_change = wake_on_change;
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_ctrl_boot(const nl_light_state_t *initial)
|
void nl_ctrl_boot(const nl_light_state_t *initial)
|
||||||
@@ -62,6 +68,13 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms)
|
|||||||
n.mode = NL_MODE_WHITE;
|
n.mode = NL_MODE_WHITE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weckschalter: a color/CT/brightness change arriving while the lamp is off switches it
|
||||||
|
* on, unless this same call also says "on": false explicitly - that always wins. Applied
|
||||||
|
* before the duration below so an off->on transition here uses fade_in like any other. */
|
||||||
|
if (s_wake_on_change && !s_state.on && !n.on && !(c->has_on && !c->on) &&
|
||||||
|
(c->has_color || c->has_ct || c->has_brightness))
|
||||||
|
n.on = true;
|
||||||
|
|
||||||
if (c->has_transition)
|
if (c->has_transition)
|
||||||
dur = c->transition_ms;
|
dur = c->transition_ms;
|
||||||
else if (!s_state.on && n.on)
|
else if (!s_state.on && n.on)
|
||||||
@@ -69,7 +82,7 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms)
|
|||||||
else if (s_state.on && !n.on)
|
else if (s_state.on && !n.on)
|
||||||
dur = s_fade_out;
|
dur = s_fade_out;
|
||||||
else
|
else
|
||||||
dur = 0;
|
dur = s_transition;
|
||||||
|
|
||||||
s_state = n;
|
s_state = n;
|
||||||
nl_engine_set(&s_state, dur);
|
nl_engine_set(&s_state, dur);
|
||||||
|
|||||||
+11
-3
@@ -32,9 +32,17 @@ typedef enum {
|
|||||||
NL_CTRL_ERR_TRANSITION
|
NL_CTRL_ERR_TRANSITION
|
||||||
} nl_ctrl_err_t;
|
} nl_ctrl_err_t;
|
||||||
|
|
||||||
/* The engine must be initialised before. Not thread safe: callers serialise access. */
|
/* The engine must be initialised before. Not thread safe: callers serialise access.
|
||||||
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms);
|
*
|
||||||
void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms);
|
* transition_ms: default transition applied to a color/brightness/CT change while the lamp
|
||||||
|
* stays on and the caller did not send its own "transition" (fade_in/fade_out still take
|
||||||
|
* precedence when the lamp is switching on or off).
|
||||||
|
* wake_on_change: if true, a color/CT/brightness change arriving while the lamp is off
|
||||||
|
* switches it on (using fade_in), unless the same call also says "on": false. */
|
||||||
|
void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms,
|
||||||
|
uint16_t transition_ms, bool wake_on_change);
|
||||||
|
void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms,
|
||||||
|
bool wake_on_change);
|
||||||
void nl_ctrl_boot(const nl_light_state_t *initial);
|
void nl_ctrl_boot(const nl_light_state_t *initial);
|
||||||
nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms);
|
nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms);
|
||||||
void nl_ctrl_get(nl_light_state_t *out);
|
void nl_ctrl_get(nl_light_state_t *out);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+188
-3
@@ -10,6 +10,9 @@
|
|||||||
#define P_SETTINGS "/api/v1/system/settings"
|
#define P_SETTINGS "/api/v1/system/settings"
|
||||||
#define P_COMMAND "/api/v1/system/command"
|
#define P_COMMAND "/api/v1/system/command"
|
||||||
|
|
||||||
|
/* Obergrenze fuer das AP-Passwort des Standalone-Modus (WPA2-ASCII-Passphrase). */
|
||||||
|
#define NL_AP_PASS_MAX 63
|
||||||
|
|
||||||
static const char *const POB_NAMES[] = {"last", "on", "off"};
|
static const char *const POB_NAMES[] = {"last", "on", "off"};
|
||||||
|
|
||||||
static void reply(nl_api_response_t *r, int status, const char *json)
|
static void reply(nl_api_response_t *r, int status, const char *json)
|
||||||
@@ -115,6 +118,11 @@ static cJSON *state_json(const nl_api_ctx_t *ctx)
|
|||||||
POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]);
|
POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]);
|
||||||
cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms);
|
cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms);
|
||||||
cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms);
|
cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms);
|
||||||
|
/* Named "transition_default", not "transition": the latter is already the per-call field
|
||||||
|
* above the settings section of this same request/response and a second field with the
|
||||||
|
* same key in one JSON object would collide with it. */
|
||||||
|
cJSON_AddNumberToObject(o, "transition_default", s.transition_ms);
|
||||||
|
cJSON_AddBoolToObject(o, "wake_on_change", s.wake_on_change != 0);
|
||||||
cap = cJSON_AddObjectToObject(o, "capabilities");
|
cap = cJSON_AddObjectToObject(o, "capabilities");
|
||||||
cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb);
|
cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb);
|
||||||
ct[0] = ctx->board->ct_min;
|
ct[0] = ctx->board->ct_min;
|
||||||
@@ -193,6 +201,17 @@ static void handle_post_state(const nl_api_ctx_t *ctx, const nl_http_request_t *
|
|||||||
case 1: s.fade_out_ms = (uint16_t)v; settings = true; break;
|
case 1: s.fade_out_ms = (uint16_t)v; settings = true; break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
|
switch (get_int(j, "transition_default", 0, NL_MAX_TRANSITION_MS, &v)) {
|
||||||
|
case -1: err = "invalid transition_default"; goto out;
|
||||||
|
case 1: s.transition_ms = (uint16_t)v; settings = true; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
it = cJSON_GetObjectItemCaseSensitive(j, "wake_on_change");
|
||||||
|
if (it) {
|
||||||
|
if (!cJSON_IsBool(it)) { err = "invalid wake_on_change"; goto out; }
|
||||||
|
s.wake_on_change = cJSON_IsTrue(it) ? 1u : 0u;
|
||||||
|
settings = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (light) {
|
if (light) {
|
||||||
switch (ctx->apply(&ch)) {
|
switch (ctx->apply(&ch)) {
|
||||||
@@ -243,12 +262,33 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
|
|||||||
cJSON_AddStringToObject(w, "error", in.wifi_error);
|
cJSON_AddStringToObject(w, "error", in.wifi_error);
|
||||||
else
|
else
|
||||||
cJSON_AddNullToObject(w, "error");
|
cJSON_AddNullToObject(w, "error");
|
||||||
|
/* "mode" allein reicht nicht: im Standalone-Betrieb ist es "ap", genau wie beim offenen
|
||||||
|
* Onboarding-AP. Ohne dieses Feld boete die App einer fertig eingerichteten Lampe ein
|
||||||
|
* Onboarding an. */
|
||||||
|
cJSON_AddBoolToObject(w, "standalone", in.wifi_standalone);
|
||||||
cJSON_AddNumberToObject(o, "uptime", in.uptime_s);
|
cJSON_AddNumberToObject(o, "uptime", in.uptime_s);
|
||||||
cJSON_AddNumberToObject(o, "free_heap", in.free_heap);
|
cJSON_AddNumberToObject(o, "free_heap", in.free_heap);
|
||||||
if (in.last_ota_error)
|
if (in.last_ota_error)
|
||||||
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
|
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
|
||||||
else
|
else
|
||||||
cJSON_AddNullToObject(o, "last_ota_error");
|
cJSON_AddNullToObject(o, "last_ota_error");
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
|
||||||
|
ctx->get_settings(&s);
|
||||||
|
cJSON_AddBoolToObject(sa, "active", in.sacn_active);
|
||||||
|
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
|
||||||
|
/* Sagt, ob der Beitritt zur Multicast-Gruppe gelungen ist. Ohne dieses Feld war bei
|
||||||
|
* der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die
|
||||||
|
* Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */
|
||||||
|
cJSON_AddBoolToObject(sa, "multicast", in.sacn_joined);
|
||||||
|
/* Ob der Empfangs-Task ueberhaupt laeuft. Ohne dieses Feld war ein toter Task von
|
||||||
|
* "es sendet gerade niemand" nicht zu unterscheiden - beides meldete nur
|
||||||
|
* active=false, und "multicast" bleibt vom letzten gelungenen Beitritt true, auch
|
||||||
|
* wenn der Socket laengst zu ist. Das hat beim Montana-Black-Shield eine halbe
|
||||||
|
* Stunde Suche gekostet. */
|
||||||
|
cJSON_AddBoolToObject(sa, "running", in.sacn_running);
|
||||||
|
}
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +302,35 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
|
|||||||
cJSON_AddStringToObject(o, "name", s.name);
|
cJSON_AddStringToObject(o, "name", s.name);
|
||||||
w = cJSON_AddObjectToObject(o, "wifi");
|
w = cJSON_AddObjectToObject(o, "wifi");
|
||||||
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
|
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
|
||||||
|
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
|
||||||
|
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
|
||||||
|
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
|
||||||
|
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
|
||||||
|
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
|
||||||
|
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
|
||||||
|
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
|
||||||
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
||||||
|
{
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
|
||||||
|
cJSON_AddBoolToObject(sa, "enabled", s.sacn_enabled != 0);
|
||||||
|
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
|
||||||
|
cJSON_AddNumberToObject(sa, "start_slot", s.sacn_start_slot);
|
||||||
|
cJSON_AddStringToObject(sa, "on_timeout", s.sacn_hold ? "hold" : "release");
|
||||||
|
/* Informativ und bewusst aus dem Board, nicht aus dem Datensatz: so steht beim
|
||||||
|
* Adressieren direkt da, wieviel Platz diese Lampe im Universe belegt. */
|
||||||
|
cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer");
|
||||||
|
cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board, s.sacn_layout != 0));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "standalone");
|
||||||
|
cJSON_AddBoolToObject(sa, "enabled", s.standalone != 0);
|
||||||
|
/* Der WIRKSAME Name, nicht der gespeicherte: ohne eigenen Namen heisst der AP
|
||||||
|
* wie der Hostname. Wer hier einen leeren String saehe, muesste die Vorgabe
|
||||||
|
* selbst nachbilden. Das AP-Passwort steht bewusst nicht hier. */
|
||||||
|
cJSON_AddStringToObject(sa, "ssid", s.ap_ssid[0] ? s.ap_ssid : ctx->hostname);
|
||||||
|
cJSON_AddNumberToObject(sa, "channel", s.ap_channel ? s.ap_channel : 6);
|
||||||
|
cJSON_AddBoolToObject(sa, "pulse", s.standalone_pulse != 0);
|
||||||
|
}
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +362,67 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
|
|||||||
cJSON_Delete(j);
|
cJSON_Delete(j);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (strcmp(type, "standalone") == 0) {
|
||||||
|
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
|
||||||
|
const cJSON *pu = cJSON_GetObjectItemCaseSensitive(j, "pulse");
|
||||||
|
const char *as = NULL, *ap = NULL;
|
||||||
|
long ch = 0;
|
||||||
|
int asp, app, chp;
|
||||||
|
bool will_enable;
|
||||||
|
nl_settings_t prev = s; /* Stand vor der Aenderung, zum Zurueckrollen bei 503 */
|
||||||
|
|
||||||
|
will_enable = cJSON_IsBool(en) && cJSON_IsTrue(en);
|
||||||
|
asp = get_string(j, "ssid", 1, NL_SSID_LEN - 1, &as);
|
||||||
|
/* 63, nicht NL_PASS_LEN - 1 (64): eine WPA2-ASCII-Passphrase darf hoechstens 63
|
||||||
|
* Zeichen haben. Bei 64 schriebe der Treiber keyinfo.key[64] randvoll ohne
|
||||||
|
* Abschlussbyte, und ob libwlan.a das annimmt, sieht man dem Binaerobjekt nicht an -
|
||||||
|
* ein abgelehnter AP-Aufbau ist hier aber eine ueber die API erreichbare Sackgasse.
|
||||||
|
* Der "wifi"-Zweig bleibt bewusst bei 64: dort haengt kein eigener AP daran. */
|
||||||
|
app = get_string(j, "password", 8, NL_AP_PASS_MAX, &ap);
|
||||||
|
chp = get_int(j, "channel", 1, 13, &ch);
|
||||||
|
|
||||||
|
if (!cJSON_IsBool(en)) {
|
||||||
|
err = "invalid enabled";
|
||||||
|
} else if (asp < 0) {
|
||||||
|
err = "invalid ssid";
|
||||||
|
} else if (app < 0) {
|
||||||
|
/* Die Meldung nennt das Feld, nie den Wert: ein zu kurzes Passwort darf
|
||||||
|
* nicht im Protokoll des Aufrufers landen. */
|
||||||
|
err = "invalid password";
|
||||||
|
} else if (chp < 0) {
|
||||||
|
err = "invalid channel";
|
||||||
|
} else if (will_enable && app != 1 && s.ap_pass[0] == '\0') {
|
||||||
|
err = "password required";
|
||||||
|
} else if (pu && !cJSON_IsBool(pu)) {
|
||||||
|
err = "invalid pulse";
|
||||||
|
} else {
|
||||||
|
if (asp == 1)
|
||||||
|
strcpy(s.ap_ssid, as);
|
||||||
|
if (app == 1)
|
||||||
|
strcpy(s.ap_pass, ap);
|
||||||
|
if (chp == 1)
|
||||||
|
s.ap_channel = (uint8_t)ch;
|
||||||
|
if (pu)
|
||||||
|
s.standalone_pulse = cJSON_IsTrue(pu) ? 1 : 0;
|
||||||
|
s.standalone = will_enable ? 1 : 0;
|
||||||
|
/* Wie bei "wifi" kehrt dieser Zweig hier zurueck statt ans gemeinsame Ende zu
|
||||||
|
* laufen (das antwortet 200): der Wechsel zieht der Verbindung das Netz weg, die
|
||||||
|
* Antwort ist 202 und muss vorher ausgefuellt sein. */
|
||||||
|
if (ctx->save_settings(&s) != 0) {
|
||||||
|
reply_error(resp, 500, "save failed");
|
||||||
|
} else if (ctx->standalone_configure(will_enable, s.ap_ssid, s.ap_pass,
|
||||||
|
s.ap_channel, s.standalone_pulse != 0) != 0) {
|
||||||
|
/* 503 heisst "nicht uebernommen": was schon im Flash steht, muss zurueck,
|
||||||
|
* sonst startet die Lampe nach einem Neustart doch im neuen Modus. Best
|
||||||
|
* effort - scheitert auch das, bleibt nur die Meldung. */
|
||||||
|
(void)ctx->save_settings(&prev);
|
||||||
|
reply_error(resp, 503, "wifi busy");
|
||||||
|
} else {
|
||||||
|
reply(resp, 202, "{\"status\":\"standalone\"}");
|
||||||
|
}
|
||||||
|
cJSON_Delete(j);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (strcmp(type, "device_name") == 0) {
|
} else if (strcmp(type, "device_name") == 0) {
|
||||||
if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1)
|
if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1)
|
||||||
err = "invalid name";
|
err = "invalid name";
|
||||||
@@ -310,6 +439,62 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
|
|||||||
err = "invalid all";
|
err = "invalid all";
|
||||||
else
|
else
|
||||||
s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
|
s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
|
||||||
|
} else if (strcmp(type, "sacn") == 0) {
|
||||||
|
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
|
||||||
|
const char *ot = NULL, *lo = NULL;
|
||||||
|
long un = 0, st = 0;
|
||||||
|
int unp, stp, otp, lop;
|
||||||
|
bool rgb;
|
||||||
|
uint8_t slots;
|
||||||
|
uint16_t want_start;
|
||||||
|
|
||||||
|
/* Das Layout wird ZUERST ausgewertet: es bestimmt die Slotzahl und damit die
|
||||||
|
* Obergrenze fuer start_slot. Wer in derselben Anfrage beides setzt, soll gegen das
|
||||||
|
* neue Layout geprueft werden, nicht gegen das alte. */
|
||||||
|
lop = get_string(j, "layout", 1, 16, &lo);
|
||||||
|
if (lop < 0 || (lop == 1 && strcmp(lo, "dimmer") != 0 && strcmp(lo, "rgb") != 0)) {
|
||||||
|
err = "invalid layout";
|
||||||
|
lop = 0; /* die Zweige unten duerfen den Wert dann nicht mehr benutzen */
|
||||||
|
rgb = s.sacn_layout != 0;
|
||||||
|
} else {
|
||||||
|
rgb = (lop == 1) ? (strcmp(lo, "rgb") == 0) : (s.sacn_layout != 0);
|
||||||
|
}
|
||||||
|
slots = nl_board_dmx_slots(ctx->board, rgb);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
/* invalid layout steht schon fest */
|
||||||
|
} else if (en && !cJSON_IsBool(en)) {
|
||||||
|
err = "invalid enabled";
|
||||||
|
} else if ((unp = get_int(j, "universe", 1, 63999, &un)) < 0) {
|
||||||
|
err = "invalid universe";
|
||||||
|
/* Upper bound folds the board's slot count in: the highest start_slot that still
|
||||||
|
* leaves room for all of them is 512 - slots + 1. */
|
||||||
|
} else if ((stp = get_int(j, "start_slot", 1, 512 - slots + 1, &st)) < 0) {
|
||||||
|
err = "invalid start_slot";
|
||||||
|
} else if ((otp = get_string(j, "on_timeout", 1, 16, &ot)) < 0 ||
|
||||||
|
(otp == 1 && strcmp(ot, "release") != 0 && strcmp(ot, "hold") != 0)) {
|
||||||
|
err = "invalid on_timeout";
|
||||||
|
} else {
|
||||||
|
/* Ein Wechsel auf ein breiteres Layout kann einen bisher zulaessigen start_slot
|
||||||
|
* unzulaessig machen. Das wird abgelehnt statt still verschoben: eine Lampe, die
|
||||||
|
* nach einer Einstellung woanders im Universe liegt als angegeben, findet niemand
|
||||||
|
* wieder. */
|
||||||
|
want_start = (stp == 1) ? (uint16_t)st : s.sacn_start_slot;
|
||||||
|
if ((uint32_t)want_start + slots - 1u > 512u) {
|
||||||
|
err = "invalid start_slot";
|
||||||
|
} else {
|
||||||
|
if (en)
|
||||||
|
s.sacn_enabled = cJSON_IsTrue(en) ? 1 : 0;
|
||||||
|
if (unp == 1)
|
||||||
|
s.sacn_universe = (uint16_t)un;
|
||||||
|
if (stp == 1)
|
||||||
|
s.sacn_start_slot = (uint16_t)st;
|
||||||
|
if (otp == 1)
|
||||||
|
s.sacn_hold = strcmp(ot, "hold") == 0 ? 1 : 0;
|
||||||
|
if (lop == 1)
|
||||||
|
s.sacn_layout = rgb ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
err = "invalid type";
|
err = "invalid type";
|
||||||
}
|
}
|
||||||
@@ -339,9 +524,9 @@ static void handle_command(const nl_api_ctx_t *ctx, const nl_http_request_t *req
|
|||||||
else if (ctx->fw_update(url) != 0) {
|
else if (ctx->fw_update(url) != 0) {
|
||||||
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
|
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
|
||||||
* reason, not a blanket "not implemented". */
|
* reason, not a blanket "not implemented". */
|
||||||
nl_sys_info_t in;
|
/* Do not reuse last_ota_error for this message: it belongs to the running or last
|
||||||
ctx->get_info(&in);
|
* update, not to the request we are rejecting right now. */
|
||||||
reply_error(resp, 409, in.last_ota_error ? in.last_ota_error : "fw_update busy or invalid url");
|
reply_error(resp, 409, "an update is already running");
|
||||||
} else
|
} else
|
||||||
reply(resp, 202, "{\"status\":\"updating\"}");
|
reply(resp, 202, "{\"status\":\"updating\"}");
|
||||||
} else if (strcmp(type, "reboot") == 0) {
|
} else if (strcmp(type, "reboot") == 0) {
|
||||||
|
|||||||
@@ -24,18 +24,27 @@ typedef struct {
|
|||||||
uint32_t uptime_s;
|
uint32_t uptime_s;
|
||||||
uint32_t free_heap;
|
uint32_t free_heap;
|
||||||
const char *last_ota_error; /* NULL until OTA exists */
|
const char *last_ota_error; /* NULL until OTA exists */
|
||||||
|
bool sacn_active; /* true, solange die Lampe ferngesteuert wird */
|
||||||
|
bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */
|
||||||
|
bool sacn_running; /* Empfangs-Task lebt und haelt einen Socket */
|
||||||
|
bool wifi_standalone; /* eigener Dauer-AP, nicht der offene Onboarding-AP */
|
||||||
} nl_sys_info_t;
|
} nl_sys_info_t;
|
||||||
|
|
||||||
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const nl_board_t *board;
|
const nl_board_t *board;
|
||||||
const char *fw_version;
|
const char *fw_version;
|
||||||
|
const char *hostname; /* "ninlight-<mac4>", Vorgabe fuer den AP-Namen */
|
||||||
const char *build_date;
|
const char *build_date;
|
||||||
void (*get_state)(nl_light_state_t *out);
|
void (*get_state)(nl_light_state_t *out);
|
||||||
nl_ctrl_err_t (*apply)(const nl_light_change_t *c);
|
nl_ctrl_err_t (*apply)(const nl_light_change_t *c);
|
||||||
void (*get_settings)(nl_settings_t *out);
|
void (*get_settings)(nl_settings_t *out);
|
||||||
int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */
|
int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */
|
||||||
int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */
|
int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */
|
||||||
|
/* 0 = uebernommen (Antwort 202), sonst 503. Wie wifi_configure wirkt der Wechsel
|
||||||
|
erst nach der Antwort - die Verbindung bricht sonst vor dem Senden weg. */
|
||||||
|
int (*standalone_configure)(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse);
|
||||||
bool (*open_access)(void); /* unconfigured onboarding AP */
|
bool (*open_access)(void); /* unconfigured onboarding AP */
|
||||||
void (*get_info)(nl_sys_info_t *out);
|
void (*get_info)(nl_sys_info_t *out);
|
||||||
void (*factory_reset)(void); /* request only */
|
void (*factory_reset)(void); /* request only */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#ifndef NL_E131_H
|
||||||
|
#define NL_E131_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define NL_E131_PORT 5568u
|
||||||
|
#define NL_E131_MAX_PACKET 640u /* ein volles Paket ist 638 Byte; 640 laesst Luft zum Erkennen
|
||||||
|
* ueberlanger Datagramme, ohne den Puffer unnoetig aufzublaehen */
|
||||||
|
#define NL_E131_MIN_PACKET 126u
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint16_t universe;
|
||||||
|
uint8_t priority;
|
||||||
|
uint8_t sequence;
|
||||||
|
bool terminated; /* Options-Bit "stream terminated" */
|
||||||
|
uint16_t slot_count; /* Nutzdaten ohne Start-Code, 0..512 */
|
||||||
|
const uint8_t *slots; /* zeigt in den uebergebenen Puffer, wird nicht kopiert */
|
||||||
|
} nl_e131_packet_t;
|
||||||
|
|
||||||
|
/* true = gueltiges DMX-Datenpaket (Start-Code 0, kein Preview). Alles andere: false.
|
||||||
|
* `out` wird nur bei true beschrieben. */
|
||||||
|
bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out);
|
||||||
|
|
||||||
|
/* Die Multicast-Gruppe eines Universe nach E1.31 Anhang A: 239.255.<hi>.<lo>, als Zahl in
|
||||||
|
* HOST-Byte-Reihenfolge. Steht hier und nicht im Empfangs-Task, damit sie im Host-Test
|
||||||
|
* nachgerechnet werden kann - der Wert war schon einmal falsch, und man sieht es einer
|
||||||
|
* Lampe nicht an: sie tritt dann sauber einer Gruppe bei, auf die niemand sendet. */
|
||||||
|
uint32_t nl_e131_group_addr(uint16_t universe);
|
||||||
|
|
||||||
|
#endif
|
||||||
+65
-6
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "wm_include.h"
|
#include "wm_include.h"
|
||||||
#include "lwip/apps/mdns.h"
|
#include "lwip/apps/mdns.h"
|
||||||
|
#include "lwip/netif.h"
|
||||||
#include "lwip/tcpip.h"
|
#include "lwip/tcpip.h"
|
||||||
|
|
||||||
#include "config/nl_settings.h"
|
#include "config/nl_settings.h"
|
||||||
@@ -16,8 +17,13 @@
|
|||||||
#define NL_MDNS_TTL 120
|
#define NL_MDNS_TTL 120
|
||||||
|
|
||||||
static bool s_inited;
|
static bool s_inited;
|
||||||
|
static struct netif *s_nif; /* Netif, an dem Antwortdienst und Dienst haengen */
|
||||||
static bool s_netif_added;
|
static bool s_netif_added;
|
||||||
static bool s_service_added;
|
/* Vom Netz-Task gelesen, im tcpip-Thread geschrieben: volatile, und s_announce_pending deckt
|
||||||
|
* das Fenster zwischen dem Anstossen und dem Lauf von announce() ab. Ohne das meldete
|
||||||
|
* nl_mdns_service_up() beim Wechsel Station -> eigener AP noch den Dienst des alten Netifs. */
|
||||||
|
static volatile bool s_service_added;
|
||||||
|
static volatile bool s_announce_pending;
|
||||||
static char s_name[NL_NAME_LEN];
|
static char s_name[NL_NAME_LEN];
|
||||||
static char s_mac[18];
|
static char s_mac[18];
|
||||||
|
|
||||||
@@ -45,15 +51,53 @@ static void txt_cb(struct mdns_service *service, void *data)
|
|||||||
add_txt(service, "name", name);
|
add_txt(service, "name", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Das SDK fuehrt zwei Netifs (wm_ethernet.c, Tcpip_stack_init): tls_get_netif() liefert das
|
||||||
|
* der STATION; das des Softap wurde zuerst angelegt und haengt als ->next dahinter (dieselbe
|
||||||
|
* Annahme treffen DHCPS_Start/DNSS_Start im SDK mit nif->next, ethernetif_input() mit
|
||||||
|
* netif->next). Im Standalone-Modus gibt es keine Station, die Antworten muessen ueber das
|
||||||
|
* AP-Netif gehen, sonst haengt der Dienst an einem Netif ohne Adresse und Funkstrecke. */
|
||||||
|
static struct netif *pick_netif(void)
|
||||||
|
{
|
||||||
|
struct netif *sta = tls_get_netif();
|
||||||
|
struct netif *n;
|
||||||
|
|
||||||
|
if (!sta)
|
||||||
|
return NULL;
|
||||||
|
if (!nl_wifi_is_standalone())
|
||||||
|
return sta;
|
||||||
|
/* Erst der bekannte Platz, dann zur Sicherheit die lwip-Kette: das erste Netif mit
|
||||||
|
* IPv4-Adresse, das nicht die Station ist (Loopback gibt es in diesem SDK nicht). */
|
||||||
|
if (sta->next && !ip4_addr_isany_val(*netif_ip4_addr(sta->next)))
|
||||||
|
return sta->next;
|
||||||
|
for (n = netif_list; n != NULL; n = n->next)
|
||||||
|
if (n != sta && !ip4_addr_isany_val(*netif_ip4_addr(n)))
|
||||||
|
return n;
|
||||||
|
return NULL; /* AP-Netif noch ohne Adresse: lieber nichts melden als am falschen Netif */
|
||||||
|
}
|
||||||
|
|
||||||
/* tcpip thread */
|
/* tcpip thread */
|
||||||
static void announce(void *arg)
|
static void announce(void *arg)
|
||||||
{
|
{
|
||||||
struct netif *nif = tls_get_netif();
|
struct netif *nif = pick_netif();
|
||||||
bool attempted = false;
|
bool attempted = false;
|
||||||
|
|
||||||
(void)arg;
|
(void)arg;
|
||||||
if (!nif)
|
if (!nif) {
|
||||||
|
printf("mdns: no usable netif yet\r\n");
|
||||||
|
s_announce_pending = false;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anderes Netif als bisher (Station <-> eigener AP): ein blosses
|
||||||
|
* mdns_resp_netif_settings_changed() wuerde am alten haengen bleiben, denn Antwortdienst
|
||||||
|
* und Dienst sind je Netif angelegt. Also am alten Netif abmelden - remove_netif gibt auch
|
||||||
|
* dessen Dienste und die Multicast-Gruppe frei - und am neuen frisch anmelden. */
|
||||||
|
if (s_netif_added && s_nif != nif) {
|
||||||
|
mdns_resp_remove_netif(s_nif);
|
||||||
|
s_netif_added = false;
|
||||||
|
s_service_added = false;
|
||||||
|
}
|
||||||
|
s_nif = nif;
|
||||||
|
|
||||||
if (!s_inited) {
|
if (!s_inited) {
|
||||||
mdns_resp_init();
|
mdns_resp_init();
|
||||||
@@ -69,10 +113,19 @@ static void announce(void *arg)
|
|||||||
NL_MDNS_TTL, txt_cb, NULL) == ERR_OK;
|
NL_MDNS_TTL, txt_cb, NULL) == ERR_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempted)
|
if (attempted) {
|
||||||
printf("mdns: %s.local _ninlight._tcp %s\r\n", nl_wifi_hostname(), s_service_added ? "up" : "failed");
|
/* Welches Netif benutzt wurde, steht in der Zeile selbst: Namenskuerzel, Nummer, IPv4.
|
||||||
else
|
* Der rohe netif-Zeiger stand hier frueher mit (%p) dabei; waehrend der Fehlersuche war
|
||||||
|
* er nuetzlich, in einer Release-Firmware ist er Rauschen - Kuerzel und Adresse
|
||||||
|
* unterscheiden Station und eigenen AP bereits eindeutig, und eine Speicheradresse in
|
||||||
|
* einem Protokoll, das Nutzer weiterreichen, hat keinen Nutzen. */
|
||||||
|
const ip4_addr_t *ip = netif_ip4_addr(nif);
|
||||||
|
printf("mdns: %s.local _ninlight._tcp %s on %c%c%u %u.%u.%u.%u\r\n", nl_wifi_hostname(),
|
||||||
|
s_service_added ? "up" : "failed", nif->name[0], nif->name[1], (unsigned)nif->num,
|
||||||
|
ip4_addr1(ip), ip4_addr2(ip), ip4_addr3(ip), ip4_addr4(ip));
|
||||||
|
} else
|
||||||
mdns_resp_netif_settings_changed(nif); /* re-announce: new IP or TXT */
|
mdns_resp_netif_settings_changed(nif); /* re-announce: new IP or TXT */
|
||||||
|
s_announce_pending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_mdns_on_ip_up(void)
|
void nl_mdns_on_ip_up(void)
|
||||||
@@ -80,9 +133,15 @@ void nl_mdns_on_ip_up(void)
|
|||||||
uint8_t mac[6];
|
uint8_t mac[6];
|
||||||
nl_wifi_mac(mac);
|
nl_wifi_mac(mac);
|
||||||
nl_names_mac_str(mac, s_mac);
|
nl_names_mac_str(mac, s_mac);
|
||||||
|
s_announce_pending = true;
|
||||||
tcpip_callback(announce, NULL);
|
tcpip_callback(announce, NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool nl_mdns_service_up(void)
|
||||||
|
{
|
||||||
|
return !s_announce_pending && s_service_added;
|
||||||
|
}
|
||||||
|
|
||||||
void nl_mdns_set_name(const char *name)
|
void nl_mdns_set_name(const char *name)
|
||||||
{
|
{
|
||||||
u32 sr = tls_os_set_critical();
|
u32 sr = tls_os_set_critical();
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
#ifndef NL_MDNS_H
|
#ifndef NL_MDNS_H
|
||||||
#define NL_MDNS_H
|
#define NL_MDNS_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
/* Called by the network task once the station got an IP (again). */
|
/* Called by the network task once the station got an IP (again). */
|
||||||
void nl_mdns_on_ip_up(void);
|
void nl_mdns_on_ip_up(void);
|
||||||
|
/* True, sobald der Dienst wirklich angemeldet ist - und erst, nachdem die zuletzt
|
||||||
|
* angestossene Bekanntmachung im tcpip-Thread auch gelaufen ist. Der Netz-Task stoesst
|
||||||
|
* im eigenen AP so lange erneut an, bis das hier wahr ist (I5). */
|
||||||
|
bool nl_mdns_service_up(void);
|
||||||
/* Device name for the TXT record; re-announces when mDNS is running. */
|
/* Device name for the TXT record; re-announces when mDNS is running. */
|
||||||
void nl_mdns_set_name(const char *name);
|
void nl_mdns_set_name(const char *name);
|
||||||
|
|
||||||
|
|||||||
@@ -162,8 +162,14 @@ void nl_recovery_try(void)
|
|||||||
tls_os_time_delay(HZ / 2);
|
tls_os_time_delay(HZ / 2);
|
||||||
waited += 500;
|
waited += 500;
|
||||||
}
|
}
|
||||||
if (feeding)
|
if (feeding) {
|
||||||
|
/* A skipped image (same version as the running firmware) is not a failure - saying so
|
||||||
|
would leave a healthy lamp looking like it had rejected a broken image. */
|
||||||
|
if (nl_ota_skipped())
|
||||||
|
printf("recovery: image already up to date, continuing normal boot\r\n");
|
||||||
|
else
|
||||||
printf("recovery: update failed (%s), continuing normal boot\r\n",
|
printf("recovery: update failed (%s), continuing normal boot\r\n",
|
||||||
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
|
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#ifndef NL_SACN_H
|
||||||
|
#define NL_SACN_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "config/nl_settings.h"
|
||||||
|
|
||||||
|
/* Wie nl_net_beat und nl_http_beat: app_main fuettert den Watchdog erst, wenn sich jeder
|
||||||
|
* Herzschlag bewegt hat - ein haengender Empfangs-Task muss den Chip zuruecksetzen. */
|
||||||
|
extern volatile uint32_t nl_sacn_beat;
|
||||||
|
|
||||||
|
/* Uebernimmt enabled/universe/start_slot/hold und fade_in_ms. Darf jederzeit aufgerufen
|
||||||
|
* werden; der Task baut seinen Socket beim naechsten Durchlauf neu auf. */
|
||||||
|
void nl_sacn_apply_settings(const nl_settings_t *s);
|
||||||
|
void nl_sacn_start(void);
|
||||||
|
|
||||||
|
/* false, wenn der Empfangs-Task nicht angelegt werden konnte. Dann bewegt sich
|
||||||
|
* nl_sacn_beat nie und der Wachhund darf ihn nicht mehr verlangen. */
|
||||||
|
bool nl_sacn_running(void);
|
||||||
|
/* true, wenn der Beitritt zur Multicast-Gruppe des eingestellten Universe gelungen ist.
|
||||||
|
* Unicast an die Lampen-IP funktioniert unabhaengig davon. */
|
||||||
|
bool nl_sacn_joined(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
+13
-2
@@ -19,8 +19,12 @@ typedef struct {
|
|||||||
* sure the network task is still alive, not just the light task. */
|
* sure the network task is still alive, not just the light task. */
|
||||||
extern volatile uint32_t nl_net_beat;
|
extern volatile uint32_t nl_net_beat;
|
||||||
|
|
||||||
/* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied. */
|
/* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied.
|
||||||
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass);
|
* Ein leerer ap_ssid heisst "wie der Hostname", ap_channel 0 heisst Kanal 6. Mit
|
||||||
|
* standalone == true wird gar nicht erst verbunden: die Lampe oeffnet ihren eigenen,
|
||||||
|
* passwortgeschuetzten AP, pulse laesst sie dabei kurz als Lebenszeichen pulsen. */
|
||||||
|
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass,
|
||||||
|
const char *ap_ssid, uint8_t ap_channel, bool standalone, bool pulse);
|
||||||
/* Switches the SDK's own auto-connect off and drops any connection it may already have brought
|
/* Switches the SDK's own auto-connect off and drops any connection it may already have brought
|
||||||
* up on its own. nl_wifi_start() does this too, but the boot recovery path runs before that and
|
* up on its own. nl_wifi_start() does this too, but the boot recovery path runs before that and
|
||||||
* must not have the SDK joining its stored AP in parallel (I3, final review). Safe to call any
|
* must not have the SDK joining its stored AP in parallel (I3, final review). Safe to call any
|
||||||
@@ -34,6 +38,13 @@ void nl_wifi_disable_sdk_auto_connect(void);
|
|||||||
bool nl_wifi_join_blocking(const char *ssid, const char *pass, uint32_t timeout_ms);
|
bool nl_wifi_join_blocking(const char *ssid, const char *pass, uint32_t timeout_ms);
|
||||||
/* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */
|
/* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */
|
||||||
int nl_wifi_request_onboard(const char *ssid, const char *pass);
|
int nl_wifi_request_onboard(const char *ssid, const char *pass);
|
||||||
|
/* Schaltet den Standalone-Modus zur Laufzeit ein oder aus (aus dem HTTP-Task). Die Werte
|
||||||
|
* werden kopiert und erst im Netz-Task wirksam, nachdem die Antwort raus ist; ein leeres
|
||||||
|
* ssid/pass bzw. channel == 0 bedeutet jeweils die Vorgabe. 0 = uebernommen, sonst 503. */
|
||||||
|
int nl_wifi_request_standalone(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse);
|
||||||
|
/* True, solange die Lampe in ihrem eigenen Dauer-AP steht. */
|
||||||
|
bool nl_wifi_is_standalone(void);
|
||||||
/* True while the unconfigured onboarding AP is open (API without token). */
|
/* True while the unconfigured onboarding AP is open (API without token). */
|
||||||
bool nl_wifi_open_access(void);
|
bool nl_wifi_open_access(void);
|
||||||
void nl_wifi_status(nl_wifi_status_t *out);
|
void nl_wifi_status(nl_wifi_status_t *out);
|
||||||
|
|||||||
+109
-5
@@ -40,7 +40,15 @@ static void arm_ap_stop(nl_wifi_sm_t *sm, uint32_t at)
|
|||||||
static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wifi_actions_t *a)
|
static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wifi_actions_t *a)
|
||||||
{
|
{
|
||||||
sm->error = err;
|
sm->error = err;
|
||||||
if (sm->have_creds) {
|
if (sm->standalone) {
|
||||||
|
/* Zurueck in den EIGENEN AP. Der offene Onboarding-AP waere hier doppelt falsch:
|
||||||
|
* er verloere Name und Passwort des Nutzers, und open_access() machte die Lampe
|
||||||
|
* ohne Token flashbar. Ein Tippfehler im WLAN-Passwort darf sie nicht entsichern. */
|
||||||
|
sm->state = NL_WIFI_ST_STANDALONE;
|
||||||
|
sm->ap_up = true;
|
||||||
|
a->disconnect = true;
|
||||||
|
a->start_ap = true;
|
||||||
|
} else if (sm->have_creds) {
|
||||||
sm->state = NL_WIFI_ST_APSTA_FALLBACK;
|
sm->state = NL_WIFI_ST_APSTA_FALLBACK;
|
||||||
sm->since = now;
|
sm->since = now;
|
||||||
sm->backoff = NL_WIFI_BACKOFF_MIN_MS;
|
sm->backoff = NL_WIFI_BACKOFF_MIN_MS;
|
||||||
@@ -54,13 +62,26 @@ static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, uint32_t now)
|
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, bool standalone, bool pulse, uint32_t now)
|
||||||
{
|
{
|
||||||
nl_wifi_actions_t a = none();
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
memset(sm, 0, sizeof *sm);
|
memset(sm, 0, sizeof *sm);
|
||||||
sm->have_creds = have_creds;
|
sm->have_creds = have_creds;
|
||||||
if (have_creds) {
|
sm->standalone = standalone;
|
||||||
|
if (standalone) {
|
||||||
|
/* No connect attempt, not even with stored credentials: the mode is the
|
||||||
|
* user's explicit choice. */
|
||||||
|
sm->state = NL_WIFI_ST_STANDALONE;
|
||||||
|
sm->ap_up = true;
|
||||||
|
a.start_ap = true;
|
||||||
|
if (pulse) {
|
||||||
|
sm->pulsing = true;
|
||||||
|
sm->pulse_armed = true;
|
||||||
|
sm->pulse_stop_at = now + NL_WIFI_STANDALONE_PULSE_MS;
|
||||||
|
a.pulse_on = true;
|
||||||
|
}
|
||||||
|
} else if (have_creds) {
|
||||||
begin_connecting(sm, now);
|
begin_connecting(sm, now);
|
||||||
a.connect = true;
|
a.connect = true;
|
||||||
sm->awaiting = true;
|
sm->awaiting = true;
|
||||||
@@ -101,6 +122,10 @@ nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi
|
|||||||
case NL_WIFI_ST_ONBOARD_TEST:
|
case NL_WIFI_ST_ONBOARD_TEST:
|
||||||
if (ev == NL_WIFI_EV_CONNECTED) {
|
if (ev == NL_WIFI_EV_CONNECTED) {
|
||||||
a.save_test_creds = true;
|
a.save_test_creds = true;
|
||||||
|
if (sm->standalone) {
|
||||||
|
sm->standalone = false;
|
||||||
|
a.clear_standalone = true;
|
||||||
|
}
|
||||||
sm->have_creds = true;
|
sm->have_creds = true;
|
||||||
sm->error = NL_WIFI_ERR_NONE;
|
sm->error = NL_WIFI_ERR_NONE;
|
||||||
sm->state = NL_WIFI_ST_STA_CONNECTED;
|
sm->state = NL_WIFI_ST_STA_CONNECTED;
|
||||||
@@ -118,6 +143,10 @@ nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi
|
|||||||
if (ev == NL_WIFI_EV_CONNECTED)
|
if (ev == NL_WIFI_EV_CONNECTED)
|
||||||
a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */
|
a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */
|
||||||
break;
|
break;
|
||||||
|
case NL_WIFI_ST_STANDALONE:
|
||||||
|
if (ev == NL_WIFI_EV_CONNECTED)
|
||||||
|
a.disconnect = true; /* stray success of an old attempt */
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -128,6 +157,11 @@ nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now)
|
|||||||
{
|
{
|
||||||
nl_wifi_actions_t a = none();
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
|
if (sm->pulse_armed && due(now, sm->pulse_stop_at)) {
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
sm->pulsing = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
if (sm->ap_stop_armed && due(now, sm->ap_stop_at)) {
|
if (sm->ap_stop_armed && due(now, sm->ap_stop_at)) {
|
||||||
sm->ap_stop_armed = false;
|
sm->ap_stop_armed = false;
|
||||||
sm->ap_up = false;
|
sm->ap_up = false;
|
||||||
@@ -173,20 +207,90 @@ nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now)
|
|||||||
sm->deadline = now + NL_WIFI_ONBOARD_TIMEOUT_MS;
|
sm->deadline = now + NL_WIFI_ONBOARD_TIMEOUT_MS;
|
||||||
sm->retry_armed = false;
|
sm->retry_armed = false;
|
||||||
sm->ap_stop_armed = false;
|
sm->ap_stop_armed = false;
|
||||||
|
if (sm->pulse_armed) {
|
||||||
|
/* Der Startpuls gehoert nicht in den Test: sonst feuerte spaeter ein pulse_off, das zu
|
||||||
|
* keinem Puls mehr gehoert. Hier abraeumen; pulsing auch, damit er nicht haengen bleibt. */
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
sm->pulsing = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
a.disconnect = true; /* M1: drop any stale station attempt so its events don't count */
|
a.disconnect = true; /* M1: drop any stale station attempt so its events don't count */
|
||||||
a.connect = true;
|
a.connect = true;
|
||||||
a.use_test_creds = true;
|
a.use_test_creds = true;
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_standalone(nl_wifi_sm_t *sm, bool pulse, uint32_t now)
|
||||||
|
{
|
||||||
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
|
sm->standalone = true;
|
||||||
|
sm->state = NL_WIFI_ST_STANDALONE;
|
||||||
|
sm->error = NL_WIFI_ERR_NONE;
|
||||||
|
sm->retry_armed = false;
|
||||||
|
sm->ap_stop_armed = false;
|
||||||
|
sm->awaiting = false;
|
||||||
|
a.disconnect = true; /* eine laufende Stationsverbindung aufgeben */
|
||||||
|
if (!sm->ap_up) {
|
||||||
|
sm->ap_up = true;
|
||||||
|
a.start_ap = true;
|
||||||
|
}
|
||||||
|
if (pulse) {
|
||||||
|
sm->pulsing = true;
|
||||||
|
sm->pulse_armed = true;
|
||||||
|
sm->pulse_stop_at = now + NL_WIFI_STANDALONE_PULSE_MS;
|
||||||
|
a.pulse_on = true;
|
||||||
|
} else if (sm->pulsing) {
|
||||||
|
/* Ein laufender unbefristeter Puls (aus dem Onboarding-AP) hat im eigenen AP keinen
|
||||||
|
* Timer mehr, der ihn beendet; er uebermalte dauerhaft die normale Ausgabe. */
|
||||||
|
sm->pulsing = false;
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_leave_standalone(nl_wifi_sm_t *sm, bool have_creds, uint32_t now)
|
||||||
|
{
|
||||||
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
|
sm->standalone = false;
|
||||||
|
sm->have_creds = have_creds;
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
if (have_creds) {
|
||||||
|
begin_connecting(sm, now);
|
||||||
|
a.connect = true;
|
||||||
|
sm->awaiting = true;
|
||||||
|
sm->connect_at = now;
|
||||||
|
if (sm->pulsing) {
|
||||||
|
sm->pulsing = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* Ohne Zugangsdaten bliebe die Lampe sonst unerreichbar: der offene
|
||||||
|
* Onboarding-AP ist hier der gewollte Ausweg. */
|
||||||
|
sm->state = NL_WIFI_ST_AP_ONBOARDING;
|
||||||
|
sm->ap_up = true;
|
||||||
|
sm->pulsing = true;
|
||||||
|
a.start_ap = true;
|
||||||
|
a.pulse_on = true;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm)
|
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm)
|
||||||
{
|
{
|
||||||
return sm->state == NL_WIFI_ST_AP_ONBOARDING || (sm->state == NL_WIFI_ST_ONBOARD_TEST && !sm->have_creds);
|
/* !standalone is NOT redundant: during an onboarding test started from standalone mode
|
||||||
|
* the state is ONBOARD_TEST with have_creds == false, which the second clause alone would
|
||||||
|
* call open. The user's password-protected AP is up then, so the token check must stay on
|
||||||
|
* (up to NL_WIFI_ONBOARD_TIMEOUT_MS), same as in normal operation. */
|
||||||
|
return !sm->standalone &&
|
||||||
|
(sm->state == NL_WIFI_ST_AP_ONBOARDING || (sm->state == NL_WIFI_ST_ONBOARD_TEST && !sm->have_creds));
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm)
|
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm)
|
||||||
{
|
{
|
||||||
if (sm->state == NL_WIFI_ST_AP_ONBOARDING)
|
if (sm->state == NL_WIFI_ST_AP_ONBOARDING || sm->state == NL_WIFI_ST_STANDALONE)
|
||||||
return "ap";
|
return "ap";
|
||||||
return sm->ap_up ? "apsta" : "sta";
|
return sm->ap_up ? "apsta" : "sta";
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -11,13 +11,15 @@
|
|||||||
#define NL_WIFI_BACKOFF_MIN_MS 1000u
|
#define NL_WIFI_BACKOFF_MIN_MS 1000u
|
||||||
#define NL_WIFI_BACKOFF_MAX_MS 60000u
|
#define NL_WIFI_BACKOFF_MAX_MS 60000u
|
||||||
#define NL_WIFI_CONNECT_TIMEOUT_MS 30000u /* no event after a connect -> treat as failed */
|
#define NL_WIFI_CONNECT_TIMEOUT_MS 30000u /* no event after a connect -> treat as failed */
|
||||||
|
#define NL_WIFI_STANDALONE_PULSE_MS 5000u /* short sign-of-life pulse, then normal light */
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
NL_WIFI_ST_AP_ONBOARDING = 0,
|
NL_WIFI_ST_AP_ONBOARDING = 0,
|
||||||
NL_WIFI_ST_STA_CONNECTING,
|
NL_WIFI_ST_STA_CONNECTING,
|
||||||
NL_WIFI_ST_STA_CONNECTED,
|
NL_WIFI_ST_STA_CONNECTED,
|
||||||
NL_WIFI_ST_APSTA_FALLBACK,
|
NL_WIFI_ST_APSTA_FALLBACK,
|
||||||
NL_WIFI_ST_ONBOARD_TEST
|
NL_WIFI_ST_ONBOARD_TEST,
|
||||||
|
NL_WIFI_ST_STANDALONE
|
||||||
} nl_wifi_state_t;
|
} nl_wifi_state_t;
|
||||||
|
|
||||||
typedef enum { NL_WIFI_ERR_NONE = 0, NL_WIFI_ERR_AUTH, NL_WIFI_ERR_NOT_FOUND, NL_WIFI_ERR_TIMEOUT } nl_wifi_error_t;
|
typedef enum { NL_WIFI_ERR_NONE = 0, NL_WIFI_ERR_AUTH, NL_WIFI_ERR_NOT_FOUND, NL_WIFI_ERR_TIMEOUT } nl_wifi_error_t;
|
||||||
@@ -33,6 +35,7 @@ typedef struct {
|
|||||||
bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */
|
bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */
|
||||||
bool pulse_on;
|
bool pulse_on;
|
||||||
bool pulse_off;
|
bool pulse_off;
|
||||||
|
bool clear_standalone; /* Onboarding gelungen: den Standalone-Modus dauerhaft loeschen */
|
||||||
} nl_wifi_actions_t;
|
} nl_wifi_actions_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -44,18 +47,26 @@ typedef struct {
|
|||||||
bool retry_armed;
|
bool retry_armed;
|
||||||
bool ap_stop_armed;
|
bool ap_stop_armed;
|
||||||
bool awaiting; /* waiting for response to a connect */
|
bool awaiting; /* waiting for response to a connect */
|
||||||
|
bool standalone; /* stays set across an onboarding test */
|
||||||
|
bool pulse_armed; /* start pulse still running */
|
||||||
uint32_t since; /* start of the current connecting phase */
|
uint32_t since; /* start of the current connecting phase */
|
||||||
uint32_t retry_at;
|
uint32_t retry_at;
|
||||||
uint32_t backoff;
|
uint32_t backoff;
|
||||||
uint32_t deadline; /* onboarding test */
|
uint32_t deadline; /* onboarding test */
|
||||||
uint32_t ap_stop_at;
|
uint32_t ap_stop_at;
|
||||||
uint32_t connect_at; /* when the pending connect was issued */
|
uint32_t connect_at; /* when the pending connect was issued */
|
||||||
|
uint32_t pulse_stop_at;
|
||||||
} nl_wifi_sm_t;
|
} nl_wifi_sm_t;
|
||||||
|
|
||||||
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, bool standalone, bool pulse, uint32_t now);
|
||||||
nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi_error_t err, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi_error_t err, uint32_t now);
|
||||||
nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now);
|
||||||
nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now);
|
||||||
|
/* Schaltet zur Laufzeit in den Standalone-Modus (aus der API). */
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_standalone(nl_wifi_sm_t *sm, bool pulse, uint32_t now);
|
||||||
|
/* Nimmt ihn zurueck: mit gespeicherten Zugangsdaten ins WLAN, sonst in den
|
||||||
|
offenen Onboarding-AP. */
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_leave_standalone(nl_wifi_sm_t *sm, bool have_creds, uint32_t now);
|
||||||
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm);
|
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm);
|
||||||
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm);
|
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm);
|
||||||
const char *nl_wifi_error_name(nl_wifi_error_t e);
|
const char *nl_wifi_error_name(nl_wifi_error_t e);
|
||||||
|
|||||||
+306
-15
@@ -18,6 +18,10 @@
|
|||||||
#define NL_NET_QUEUE 8
|
#define NL_NET_QUEUE 8
|
||||||
#define MSG_NETIF 0x100u /* | netif status */
|
#define MSG_NETIF 0x100u /* | netif status */
|
||||||
#define MSG_ONBOARD 0x200u
|
#define MSG_ONBOARD 0x200u
|
||||||
|
/* 0x400, nicht 0x300: die netif-Meldungen werden weiter unten mit (m & MSG_NETIF) erkannt,
|
||||||
|
* und 0x300 traegt das 0x100-Bit mit - die Standalone-Meldung waere als netif-Ereignis
|
||||||
|
* protokolliert worden. */
|
||||||
|
#define MSG_STANDALONE 0x400u
|
||||||
|
|
||||||
static u32 s_stack[768];
|
static u32 s_stack[768];
|
||||||
static tls_os_queue_t *s_queue;
|
static tls_os_queue_t *s_queue;
|
||||||
@@ -29,6 +33,34 @@ static char s_hostname[24];
|
|||||||
static char s_ssid[NL_SSID_LEN], s_pass[NL_PASS_LEN];
|
static char s_ssid[NL_SSID_LEN], s_pass[NL_PASS_LEN];
|
||||||
static char s_test_ssid[NL_SSID_LEN], s_test_pass[NL_PASS_LEN];
|
static char s_test_ssid[NL_SSID_LEN], s_test_pass[NL_PASS_LEN];
|
||||||
static char s_ap_pass[NL_PASS_LEN];
|
static char s_ap_pass[NL_PASS_LEN];
|
||||||
|
static char s_ap_ssid[NL_SSID_LEN]; /* leer = Hostname */
|
||||||
|
static uint8_t s_ap_channel = 6;
|
||||||
|
/* Die Daten, mit denen der laufende AP tatsaechlich aufgebaut wurde. Nur so ist zu sehen,
|
||||||
|
* ob ein Wunsch aus der API den AP wirklich veraendert - die Zustandsmaschine liefert kein
|
||||||
|
* start_ap, wenn der AP schon steht, und die alten Daten blieben sonst still in Kraft. */
|
||||||
|
static char s_ap_live_ssid[NL_SSID_LEN], s_ap_live_pass[NL_PASS_LEN];
|
||||||
|
static uint8_t s_ap_live_channel;
|
||||||
|
static bool s_ap_live;
|
||||||
|
/* mDNS im eigenen AP haengt am ZUSTAND (Standalone + AP steht), nicht am Erzeugungsereignis:
|
||||||
|
* betritt die Lampe Standalone auf einem schon laufenden, unveraenderten AP, entsteht kein
|
||||||
|
* SOFTAP_SUCCESS mehr. Beides nur im Netz-Task angefasst. */
|
||||||
|
static bool s_ap_event_seen; /* zum aktuell stehenden AP kam SOFTAP_SUCCESS an */
|
||||||
|
static bool s_ap_mdns_done; /* fuer den aktuell stehenden AP steht der mDNS-Dienst wirklich */
|
||||||
|
/* I5: s_ap_mdns_done haengt am ERFOLG, nicht am Versuch. nl_mdns_on_ip_up() reicht die Arbeit
|
||||||
|
* nur an den tcpip-Thread weiter; findet announce() dort noch kein Netif mit Adresse, gibt es
|
||||||
|
* auf. Bis der Dienst steht, wird alle NL_AP_MDNS_RETRY_MS erneut angestossen. */
|
||||||
|
#define NL_AP_MDNS_RETRY_MS 5000u
|
||||||
|
static uint32_t s_ap_mdns_retry_at;
|
||||||
|
/* Drei Versuche, dann der Rueckfall auf Daten, die schon einmal getragen haben: einer koennte
|
||||||
|
* ein Zufall sein (das Funkteil raeumt gerade den alten AP ab), aber drei hintereinander
|
||||||
|
* heissen, dass das Funkteil genau diese Daten nicht annimmt. Und 3 x 5 s hoechstens
|
||||||
|
* 15 s ohne AP ist kuerzer als jede Fehlersuche vor Ort. */
|
||||||
|
#define NL_AP_CREATE_TRIES 3
|
||||||
|
static uint8_t s_ap_create_fails;
|
||||||
|
/* Vom HTTP-Task gefuellt, vom Netz-Task uebernommen; beides unter s_lock. */
|
||||||
|
static char s_req_ap_ssid[NL_SSID_LEN], s_req_ap_pass[NL_PASS_LEN];
|
||||||
|
static uint8_t s_req_ap_channel;
|
||||||
|
static bool s_req_enabled, s_req_pulse, s_req_pending;
|
||||||
static bool s_ap_create_pending; /* M3: tls_wifi_softap_create() failed, retry it */
|
static bool s_ap_create_pending; /* M3: tls_wifi_softap_create() failed, retry it */
|
||||||
static uint32_t s_ap_create_retry_at;
|
static uint32_t s_ap_create_retry_at;
|
||||||
|
|
||||||
@@ -88,18 +120,77 @@ void nl_wifi_disable_sdk_auto_connect(void)
|
|||||||
tls_wifi_disconnect();
|
tls_wifi_disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Setzt NUR Name und Kanal des naechsten AP auf die Vorgaben zurueck: "ninlight-<mac4>" auf
|
||||||
|
* Kanal 6. Das Passwort bleibt stehen - I1: ein Rueckfall darf das Passwort des Nutzers nicht
|
||||||
|
* durch das einkompilierte ersetzen, das in der README oeffentlich dokumentiert ist. Beruehrt
|
||||||
|
* einen LAUFENDEN AP nicht - der behaelt seine Daten, bis er abgebaut und neu aufgebaut wird. */
|
||||||
|
static void reset_ap_identity(void)
|
||||||
|
{
|
||||||
|
s_ap_ssid[0] = '\0';
|
||||||
|
s_ap_channel = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* I2: Vergisst, mit welchen Daten der zuletzt stehende AP aufgebaut wurde. Verlaesst die Lampe
|
||||||
|
* den Standalone-Modus, duerfen Name und Passwort des Nutzers nicht als "hat schon einmal
|
||||||
|
* getragen" liegenbleiben: der erste Rueckfallzweig in start_ap() holte sie sonst Minuten
|
||||||
|
* spaeter zurueck (etwa am Rueckfall-AP nach fuenf Minuten ohne Router), und die Lampe boete
|
||||||
|
* periodisch einen AP an, den der Nutzer laengst abgeschaltet hat. */
|
||||||
|
static void forget_ap_live(void)
|
||||||
|
{
|
||||||
|
s_ap_live_ssid[0] = s_ap_live_pass[0] = '\0';
|
||||||
|
s_ap_live_channel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vollstaendige Rueckstellung auf die Vorgaben, einschliesslich des einkompilierten Passworts.
|
||||||
|
* Nur fuer den Weg AUS dem Standalone-Modus heraus, wo der Wunsch des Nutzers endet. */
|
||||||
|
static void reset_ap_defaults(void)
|
||||||
|
{
|
||||||
|
reset_ap_identity();
|
||||||
|
copy(s_ap_pass, NINLIGHT_AP_PASSWORD, sizeof s_ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reisst den AP ab und raeumt dabei auch die Buchfuehrung auf. Laeuft nur im Netz-Task
|
||||||
|
* (oder vor dessen Start) und nie unter s_lock - clear_softap_param() fasst SDK-Zustand an. */
|
||||||
|
static void destroy_ap(void)
|
||||||
|
{
|
||||||
|
tls_wifi_softap_destroy();
|
||||||
|
s_ap_create_pending = false;
|
||||||
|
s_ap_live = false;
|
||||||
|
s_ap_event_seen = s_ap_mdns_done = false;
|
||||||
|
/* I3: Der Zaehler gehoert zu EINEM Datensatz. Ohne diese Nullung addieren sich die
|
||||||
|
* Fehlschlaege mit den alten Daten zu denen mit den neuen, und nach einer Korrektur
|
||||||
|
* (Kanal 13 -> Kanal 6) loeste schon ein einziger Fehlschlag den Rueckfall aus. */
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */
|
||||||
|
}
|
||||||
|
|
||||||
static void start_ap(void)
|
static void start_ap(void)
|
||||||
{
|
{
|
||||||
struct tls_softap_info_t ap;
|
struct tls_softap_info_t ap;
|
||||||
struct tls_ip_info_t ip;
|
struct tls_ip_info_t ip;
|
||||||
|
const char *ssid = s_ap_ssid[0] ? s_ap_ssid : s_hostname;
|
||||||
size_t n = strlen(s_ap_pass);
|
size_t n = strlen(s_ap_pass);
|
||||||
int r;
|
int r;
|
||||||
|
|
||||||
|
/* Steht derselbe AP schon, bleibt er stehen: die Zustandsmaschine verlangt start_ap
|
||||||
|
* auch dann, wenn sie den AP gar nicht erst abgebaut hat (fehlgeschlagener
|
||||||
|
* Onboarding-Versuch aus dem Standalone-Modus), und ein Neuaufbau wuerfe dort alle
|
||||||
|
* Clients ohne Not heraus. */
|
||||||
|
if (s_ap_live && strcmp(s_ap_live_ssid, ssid) == 0 && strcmp(s_ap_live_pass, s_ap_pass) == 0 &&
|
||||||
|
s_ap_live_channel == s_ap_channel)
|
||||||
|
return;
|
||||||
|
/* Umgekehrt: Name, Passwort oder Kanal haben sich geaendert, waehrend der AP steht.
|
||||||
|
* Ohne eigenen Abriss liefe er mit den alten Daten weiter. */
|
||||||
|
if (s_ap_live) {
|
||||||
|
printf("wifi: AP data changed, rebuilding\r\n");
|
||||||
|
destroy_ap();
|
||||||
|
}
|
||||||
|
|
||||||
memset(&ap, 0, sizeof ap);
|
memset(&ap, 0, sizeof ap);
|
||||||
memset(&ip, 0, sizeof ip);
|
memset(&ip, 0, sizeof ip);
|
||||||
copy((char *)ap.ssid, s_hostname, sizeof ap.ssid);
|
copy((char *)ap.ssid, ssid, sizeof ap.ssid);
|
||||||
ap.encrypt = IEEE80211_ENCRYT_CCMP_WPA2;
|
ap.encrypt = IEEE80211_ENCRYT_CCMP_WPA2;
|
||||||
ap.channel = 6;
|
ap.channel = s_ap_channel;
|
||||||
ap.keyinfo.format = 1; /* ascii */
|
ap.keyinfo.format = 1; /* ascii */
|
||||||
ap.keyinfo.index = 1;
|
ap.keyinfo.index = 1;
|
||||||
ap.keyinfo.key_len = (u8)n;
|
ap.keyinfo.key_len = (u8)n;
|
||||||
@@ -108,10 +199,45 @@ static void start_ap(void)
|
|||||||
ip.netmask[0] = 255; ip.netmask[1] = 255; ip.netmask[2] = 255; ip.netmask[3] = 0;
|
ip.netmask[0] = 255; ip.netmask[1] = 255; ip.netmask[2] = 255; ip.netmask[3] = 0;
|
||||||
memcpy(ip.dnsname, "local", 6);
|
memcpy(ip.dnsname, "local", 6);
|
||||||
r = tls_wifi_softap_create(&ap, &ip);
|
r = tls_wifi_softap_create(&ap, &ip);
|
||||||
printf("wifi: AP %s %s\r\n", s_hostname, r == WM_SUCCESS ? "up" : "failed");
|
printf("wifi: AP %s (ch %u) %s\r\n", ssid, (unsigned)s_ap_channel,
|
||||||
|
r == WM_SUCCESS ? "up" : "failed");
|
||||||
/* M3: retry a failed AP create instead of leaving onboarding/fallback without an AP. */
|
/* M3: retry a failed AP create instead of leaving onboarding/fallback without an AP. */
|
||||||
s_ap_create_pending = r != WM_SUCCESS;
|
s_ap_create_pending = r != WM_SUCCESS;
|
||||||
s_ap_create_retry_at = nl_app_now_ms() + 5000u;
|
s_ap_create_retry_at = nl_app_now_ms() + 5000u;
|
||||||
|
s_ap_live = r == WM_SUCCESS;
|
||||||
|
if (s_ap_live) {
|
||||||
|
/* Neuer AP: erst das Ereignis abwarten, dann bekanntmachen. */
|
||||||
|
s_ap_event_seen = s_ap_mdns_done = false;
|
||||||
|
copy(s_ap_live_ssid, ssid, sizeof s_ap_live_ssid);
|
||||||
|
copy(s_ap_live_pass, s_ap_pass, sizeof s_ap_live_pass);
|
||||||
|
s_ap_live_channel = s_ap_channel;
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Nimmt das Funkteil diese Daten dauerhaft nicht an, steht die Lampe ohne AP da - und mit
|
||||||
|
* in den Einstellungen gespeicherten Wunschdaten auch nach einem Neustart wieder. Dann
|
||||||
|
* lieber ein AP unter unerwartetem Namen: der ist sichtbar und reparierbar. */
|
||||||
|
if (++s_ap_create_fails < NL_AP_CREATE_TRIES)
|
||||||
|
return;
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
if (s_ap_live_ssid[0] && (strcmp(s_ap_live_ssid, ssid) != 0 ||
|
||||||
|
strcmp(s_ap_live_pass, s_ap_pass) != 0 ||
|
||||||
|
s_ap_live_channel != s_ap_channel)) {
|
||||||
|
printf("wifi: AP create keeps failing, falling back to the last working data (%s, ch %u)\r\n",
|
||||||
|
s_ap_live_ssid, (unsigned)s_ap_live_channel);
|
||||||
|
copy(s_ap_ssid, s_ap_live_ssid, sizeof s_ap_ssid);
|
||||||
|
copy(s_ap_pass, s_ap_live_pass, sizeof s_ap_pass);
|
||||||
|
s_ap_channel = s_ap_live_channel;
|
||||||
|
} else if (s_ap_ssid[0] || s_ap_channel != 6) {
|
||||||
|
/* I1: nur Name und Kanal. Das Passwort ist der unwahrscheinlichste Grund fuer ein
|
||||||
|
* abgelehntes tls_wifi_softap_create() - die Laenge prueft die API selbst, Kanal und
|
||||||
|
* SSID sind die plausiblen Kandidaten. Ein Rueckfall auf NINLIGHT_AP_PASSWORD stellte
|
||||||
|
* eine Standalone-Lampe mit einem oeffentlich dokumentierten Passwort ins Funkfeld,
|
||||||
|
* und sACN hoert dort unauthentifiziert mit. */
|
||||||
|
printf("wifi: AP create keeps failing, falling back to %s on ch 6\r\n", s_hostname);
|
||||||
|
reset_ap_identity();
|
||||||
|
}
|
||||||
|
/* Sonst scheitern schon die Vorgaben - dann bleibt nur, es weiter zu versuchen. */
|
||||||
}
|
}
|
||||||
|
|
||||||
static void connect_station(bool test)
|
static void connect_station(bool test)
|
||||||
@@ -149,6 +275,24 @@ static void execute(nl_wifi_actions_t a)
|
|||||||
printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid);
|
printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid);
|
||||||
nl_app_set_wifi_credentials(ssid, pass);
|
nl_app_set_wifi_credentials(ssid, pass);
|
||||||
}
|
}
|
||||||
|
if (a.clear_standalone) {
|
||||||
|
/* Sonst stuende die Lampe nach dem naechsten Neustart wieder in ihrem eigenen AP,
|
||||||
|
* obwohl sie gerade erfolgreich ins WLAN gewechselt ist. */
|
||||||
|
printf("wifi: standalone mode cleared\r\n");
|
||||||
|
/* Dieselbe Rueckstellung wie beim Zurueckschalten ueber die API: sonst kaeme ein
|
||||||
|
* spaeterer Rueckfall-AP (5 min ohne Station) mit Namen und Passwort des Nutzers
|
||||||
|
* hoch, obwohl standalone in den Einstellungen schon 0 ist. forget_ap_live() raeumt
|
||||||
|
* dazu die Buchfuehrung ueber den zuletzt tragenden AP weg (I2) - sonst holte ihn
|
||||||
|
* der erste Rueckfallzweig in start_ap() nach drei abgelehnten Aufbauten zurueck.
|
||||||
|
* Der noch laufende Standalone-AP bleibt stehen, bis die Zustandsmaschine ihn
|
||||||
|
* abbaut: dieses Aktionsbuendel enthaelt kein start_ap. */
|
||||||
|
reset_ap_defaults();
|
||||||
|
forget_ap_live();
|
||||||
|
/* Schlaegt das Speichern fehl, steht die Lampe jetzt im WLAN, nach dem naechsten
|
||||||
|
* Neustart aber wieder in ihrem eigenen AP - ohne jede Spur. Wenigstens melden. */
|
||||||
|
if (nl_app_clear_standalone() != 0)
|
||||||
|
printf("wifi: could not persist the cleared standalone flag\r\n");
|
||||||
|
}
|
||||||
if (a.pulse_on)
|
if (a.pulse_on)
|
||||||
pulse(true);
|
pulse(true);
|
||||||
if (a.pulse_off)
|
if (a.pulse_off)
|
||||||
@@ -157,9 +301,7 @@ static void execute(nl_wifi_actions_t a)
|
|||||||
start_ap();
|
start_ap();
|
||||||
if (a.stop_ap) {
|
if (a.stop_ap) {
|
||||||
printf("wifi: AP down\r\n");
|
printf("wifi: AP down\r\n");
|
||||||
tls_wifi_softap_destroy();
|
destroy_ap();
|
||||||
s_ap_create_pending = false;
|
|
||||||
clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */
|
|
||||||
}
|
}
|
||||||
if (a.disconnect) {
|
if (a.disconnect) {
|
||||||
printf("wifi: dropping stray/failed station attempt\r\n");
|
printf("wifi: dropping stray/failed station attempt\r\n");
|
||||||
@@ -185,7 +327,9 @@ static void net_task(void *arg)
|
|||||||
void *msg = NULL;
|
void *msg = NULL;
|
||||||
nl_wifi_actions_t ev, tick;
|
nl_wifi_actions_t ev, tick;
|
||||||
uint32_t m = 0, now;
|
uint32_t m = 0, now;
|
||||||
bool got_ip_event = false, connected_now, ap_wanted, unexpected_softap;
|
bool got_ip_event = false, connected_now, ap_wanted, unexpected_softap,
|
||||||
|
standalone_now, open_now;
|
||||||
|
bool recheck_ap = false, rebuild_ap = false, ignored_off = false;
|
||||||
|
|
||||||
memset(&ev, 0, sizeof ev);
|
memset(&ev, 0, sizeof ev);
|
||||||
if (tls_os_queue_receive(s_queue, &msg, 0, HZ / 10) == TLS_OS_SUCCESS)
|
if (tls_os_queue_receive(s_queue, &msg, 0, HZ / 10) == TLS_OS_SUCCESS)
|
||||||
@@ -202,6 +346,38 @@ static void net_task(void *arg)
|
|||||||
ev = nl_wifi_sm_event(&s_sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, now);
|
ev = nl_wifi_sm_event(&s_sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, now);
|
||||||
} else if (m == MSG_ONBOARD) {
|
} else if (m == MSG_ONBOARD) {
|
||||||
ev = nl_wifi_sm_onboard(&s_sm, now);
|
ev = nl_wifi_sm_onboard(&s_sm, now);
|
||||||
|
} else if (m == MSG_STANDALONE) {
|
||||||
|
/* Die Wunschwerte erst hier uebernehmen: der HTTP-Task hat sie abgelegt, der
|
||||||
|
* Netz-Task ist der einzige, der s_ap_* schreibt und start_ap() aufruft. */
|
||||||
|
if (s_req_enabled) {
|
||||||
|
copy(s_ap_ssid, s_req_ap_ssid, sizeof s_ap_ssid);
|
||||||
|
copy(s_ap_pass, s_req_ap_pass[0] ? s_req_ap_pass : NINLIGHT_AP_PASSWORD,
|
||||||
|
sizeof s_ap_pass);
|
||||||
|
s_ap_channel = s_req_ap_channel ? s_req_ap_channel : 6;
|
||||||
|
ev = nl_wifi_sm_standalone(&s_sm, s_req_pulse, now);
|
||||||
|
/* Steht der AP schon, liefert die Zustandsmaschine kein start_ap. Ob die
|
||||||
|
* neuen Daten einen Neuaufbau verlangen, entscheidet start_ap() selbst. */
|
||||||
|
recheck_ap = true;
|
||||||
|
} else if (!s_sm.standalone) {
|
||||||
|
/* War die Lampe gar nicht standalone, gibt es nichts abzuschalten. Ohne diese
|
||||||
|
* Vorpruefung riefe leave_standalone() auf einer gewoehnlichen Stationslampe
|
||||||
|
* begin_connecting() und erzwaenge eine Neuverbindung ohne Anlass. Die Antwort
|
||||||
|
* bleibt 202: der gewuenschte Zustand gilt ja bereits. (standalone bleibt auch
|
||||||
|
* waehrend eines Onboarding-Tests gesetzt, der Test faellt also nicht hierher.) */
|
||||||
|
ignored_off = true;
|
||||||
|
} else {
|
||||||
|
/* Zurueck auf die Vorgaben: ein spaeterer Rueckfall- oder Onboarding-AP ist
|
||||||
|
* wieder der gewohnte "ninlight-<mac4>" mit dem einkompilierten Passwort,
|
||||||
|
* genau wie bei einer Lampe, die nie standalone war. */
|
||||||
|
reset_ap_defaults();
|
||||||
|
forget_ap_live();
|
||||||
|
ev = nl_wifi_sm_leave_standalone(&s_sm, s_ssid[0] != '\0', now);
|
||||||
|
rebuild_ap = true;
|
||||||
|
}
|
||||||
|
/* I3: Ein neuer Wunsch ist ein neuer Datensatz - die Fehlschlaege des alten
|
||||||
|
* duerfen nicht mitzaehlen. */
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
s_req_pending = false;
|
||||||
}
|
}
|
||||||
tick = nl_wifi_sm_tick(&s_sm, now);
|
tick = nl_wifi_sm_tick(&s_sm, now);
|
||||||
/* I1: a late/stray IP-up must not announce mDNS unless the SM actually settled on STA_CONNECTED. */
|
/* I1: a late/stray IP-up must not announce mDNS unless the SM actually settled on STA_CONNECTED. */
|
||||||
@@ -213,20 +389,77 @@ static void net_task(void *arg)
|
|||||||
* clear_softap_param() touch the SDK's own state and must not run while s_lock
|
* clear_softap_param() touch the SDK's own state and must not run while s_lock
|
||||||
* (which only guards s_sm and the credential buffers) is held. */
|
* (which only guards s_sm and the credential buffers) is held. */
|
||||||
unexpected_softap = m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS) && !ap_wanted;
|
unexpected_softap = m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS) && !ap_wanted;
|
||||||
|
/* Nur die Lage merken; der Aufruf nach aussen folgt hinter der Entsperrung. */
|
||||||
|
standalone_now = s_sm.state == NL_WIFI_ST_STANDALONE;
|
||||||
|
open_now = nl_wifi_sm_open_access(&s_sm);
|
||||||
unlock();
|
unlock();
|
||||||
|
|
||||||
if (unexpected_softap) {
|
if (unexpected_softap) {
|
||||||
printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n");
|
printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n");
|
||||||
tls_wifi_softap_destroy();
|
destroy_ap();
|
||||||
clear_softap_param();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m & MSG_NETIF)
|
if (m & MSG_NETIF)
|
||||||
printf("wifi: netif event %u\r\n", (unsigned)(m & 0xFFu));
|
printf("wifi: netif event %u\r\n", (unsigned)(m & 0xFFu));
|
||||||
|
/* I6: Die 202 auf den Standalone-Wunsch schreibt der HTTP-Task erst, nachdem
|
||||||
|
* nl_api_handle() zurueck ist - die Warteschlangennachricht geht aber schon darin
|
||||||
|
* raus, und dieser Task hat die hoehere Prioritaet (35 gegen 42, kleinere Zahl heisst
|
||||||
|
* hoeher). Er verdraengt also sofort und risse das Netz ab, bevor send_all() lief.
|
||||||
|
* Beim Wechsel AUS dem WLAN ging das bisher gut, weil tls_wifi_disconnect() asynchron
|
||||||
|
* arbeitet; ueber den eigenen AP ist tls_wifi_softap_destroy() der haertere Abriss.
|
||||||
|
* Die Pause ist billige Versicherung - gemessen ist der AP-Fall damit nicht, er steht
|
||||||
|
* als offener Punkt in der Abnahmeliste. */
|
||||||
|
if (m == MSG_STANDALONE)
|
||||||
|
tls_os_time_delay(HZ / 4); /* ~250 ms */
|
||||||
|
if (ignored_off)
|
||||||
|
printf("wifi: standalone was already off, nothing to do\r\n");
|
||||||
|
/* C1: Beim Verlassen des Standalone-Modus OHNE gespeicherte WLAN-Daten geht die
|
||||||
|
* Zustandsmaschine in den Onboarding-AP - dort liefert open_access() true, waehrend
|
||||||
|
* der AP noch unveraendert mit Name und Passwort des Nutzers steht. Wer das
|
||||||
|
* AP-Passwort kennt, koennte dann ohne Token system/settings und system/command
|
||||||
|
* benutzen. Also hier hart abreissen und mit den Vorgabedaten neu aufbauen; damit
|
||||||
|
* stimmt auch die README-Zusage wieder, der AP heisse danach "ninlight-<mac4>".
|
||||||
|
* Nicht ueber die Aktionsstruktur: execute() fuehrt start_ap VOR stop_ap aus, ein
|
||||||
|
* Buendel mit beiden Flags baute erst auf und risse dann ab. Und VOR execute(ev),
|
||||||
|
* damit dessen start_ap in die Gleichheitspruefung von start_ap() faellt, statt den
|
||||||
|
* eben gebauten AP ein zweites Mal abzureissen. */
|
||||||
|
if (rebuild_ap && ap_wanted && open_now) {
|
||||||
|
printf("wifi: left standalone without credentials, rebuilding the AP with the defaults\r\n");
|
||||||
|
destroy_ap();
|
||||||
|
start_ap();
|
||||||
|
}
|
||||||
execute(ev);
|
execute(ev);
|
||||||
|
/* Nach einem Wechsel in den Standalone-Modus: start_ap() baut den AP neu auf, wenn
|
||||||
|
* Name, Passwort oder Kanal sich geaendert haben, und tut sonst nichts. */
|
||||||
|
if (recheck_ap && ap_wanted)
|
||||||
|
start_ap();
|
||||||
execute(tick);
|
execute(tick);
|
||||||
if (got_ip_event && connected_now)
|
if (got_ip_event && connected_now)
|
||||||
nl_mdns_on_ip_up();
|
nl_mdns_on_ip_up();
|
||||||
|
/* Im eigenen AP gibt es kein Stationsereignis, an dem mDNS haengen koennte.
|
||||||
|
* Ohne diesen Aufruf findet die App ein Standalone-Geraet nicht - am
|
||||||
|
* 2026-10-01 am EVK gemessen: REST auf der AP-IP lief, ninlight-681f.local
|
||||||
|
* war weder aufloesbar noch sichtbar. */
|
||||||
|
if (m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS))
|
||||||
|
s_ap_event_seen = true;
|
||||||
|
/* Zustandsgebunden: auch ein Wechsel auf einen unveraendert stehenden AP loest es aus.
|
||||||
|
* Mehrfaches Rufen waere harmlos, s_ap_mdns_done spart es trotzdem. Faellt der Zustand
|
||||||
|
* weg, gilt die Bekanntmachung beim naechsten Eintritt neu. */
|
||||||
|
if (!standalone_now) {
|
||||||
|
s_ap_mdns_done = false;
|
||||||
|
s_ap_mdns_retry_at = now; /* beim Eintritt sofort anstossen */
|
||||||
|
} else if (s_ap_live && s_ap_event_seen && !s_ap_mdns_done) {
|
||||||
|
/* I5: Erst der Erfolg beendet die Sache. Wer den blossen Versuch als erledigt
|
||||||
|
* verbucht, laesst die Lampe fuer die ganze Standzeit des AP unauffindbar,
|
||||||
|
* sobald announce() einmal "no usable netif yet" gemeldet hat. Deshalb immer
|
||||||
|
* erst anstossen und dann den gemeldeten Zustand abfragen - nl_mdns_service_up()
|
||||||
|
* ist erst wahr, wenn die Bekanntmachung im tcpip-Thread auch gelaufen ist. */
|
||||||
|
if (due_now(now, s_ap_mdns_retry_at)) {
|
||||||
|
s_ap_mdns_retry_at = now + NL_AP_MDNS_RETRY_MS;
|
||||||
|
nl_mdns_on_ip_up();
|
||||||
|
} else
|
||||||
|
s_ap_mdns_done = nl_mdns_service_up();
|
||||||
|
}
|
||||||
|
|
||||||
/* M3: retry a softap create that failed, as long as the SM still wants the AP up. */
|
/* M3: retry a softap create that failed, as long as the SM still wants the AP up. */
|
||||||
if (s_ap_create_pending && ap_wanted && due_now(now, s_ap_create_retry_at)) {
|
if (s_ap_create_pending && ap_wanted && due_now(now, s_ap_create_retry_at)) {
|
||||||
@@ -237,7 +470,8 @@ static void net_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass,
|
||||||
|
const char *ap_ssid, uint8_t ap_channel, bool standalone, bool pulse)
|
||||||
{
|
{
|
||||||
nl_wifi_actions_t a;
|
nl_wifi_actions_t a;
|
||||||
struct netif *nif;
|
struct netif *nif;
|
||||||
@@ -248,7 +482,11 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
|||||||
nl_names_hostname(s_mac, s_hostname, sizeof s_hostname);
|
nl_names_hostname(s_mac, s_hostname, sizeof s_hostname);
|
||||||
copy(s_ssid, ssid, sizeof s_ssid);
|
copy(s_ssid, ssid, sizeof s_ssid);
|
||||||
copy(s_pass, pass, sizeof s_pass);
|
copy(s_pass, pass, sizeof s_pass);
|
||||||
copy(s_ap_pass, ap_pass, sizeof s_ap_pass);
|
/* Wie in nl_wifi_request_standalone(): NULL ist hier zwar von keinem Aufrufer zu
|
||||||
|
* erwarten, aber copy() wuerde darueber stolpern. */
|
||||||
|
copy(s_ap_pass, ap_pass && ap_pass[0] ? ap_pass : NINLIGHT_AP_PASSWORD, sizeof s_ap_pass);
|
||||||
|
copy(s_ap_ssid, ap_ssid ? ap_ssid : "", sizeof s_ap_ssid); /* leer = Hostname */
|
||||||
|
s_ap_channel = ap_channel ? ap_channel : 6;
|
||||||
|
|
||||||
/* The SDK would otherwise reconnect on its own with credentials from its parameter area. */
|
/* The SDK would otherwise reconnect on its own with credentials from its parameter area. */
|
||||||
nl_wifi_disable_sdk_auto_connect();
|
nl_wifi_disable_sdk_auto_connect();
|
||||||
@@ -256,8 +494,7 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
|||||||
/* C2: the SDK already brought up its own softap (e.g. from NETIF_IP_NET_UP before
|
/* C2: the SDK already brought up its own softap (e.g. from NETIF_IP_NET_UP before
|
||||||
* we got a chance to run) - tear it down, our state machine owns AP lifecycle. */
|
* we got a chance to run) - tear it down, our state machine owns AP lifecycle. */
|
||||||
printf("wifi: destroying SDK-created softap left over from boot\r\n");
|
printf("wifi: destroying SDK-created softap left over from boot\r\n");
|
||||||
tls_wifi_softap_destroy();
|
destroy_ap();
|
||||||
clear_softap_param();
|
|
||||||
}
|
}
|
||||||
nif = tls_get_netif();
|
nif = tls_get_netif();
|
||||||
if (nif)
|
if (nif)
|
||||||
@@ -268,10 +505,11 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
|||||||
tls_netif_add_status_event(netif_event);
|
tls_netif_add_status_event(netif_event);
|
||||||
|
|
||||||
lock();
|
lock();
|
||||||
a = nl_wifi_sm_start(&s_sm, s_ssid[0] != '\0', nl_app_now_ms());
|
a = nl_wifi_sm_start(&s_sm, s_ssid[0] != '\0', standalone, pulse, nl_app_now_ms());
|
||||||
unlock();
|
unlock();
|
||||||
s_started = true;
|
s_started = true;
|
||||||
printf("wifi: %s, hostname %s\r\n", s_ssid[0] ? "station" : "onboarding AP", s_hostname);
|
printf("wifi: %s, hostname %s\r\n",
|
||||||
|
standalone ? "standalone AP" : (s_ssid[0] ? "station" : "onboarding AP"), s_hostname);
|
||||||
execute(a);
|
execute(a);
|
||||||
tls_os_task_create(NULL, "nl_net", net_task, NULL, (u8 *)s_stack, sizeof(s_stack), NL_NET_PRIO, 0);
|
tls_os_task_create(NULL, "nl_net", net_task, NULL, (u8 *)s_stack, sizeof(s_stack), NL_NET_PRIO, 0);
|
||||||
}
|
}
|
||||||
@@ -294,6 +532,50 @@ int nl_wifi_request_onboard(const char *ssid, const char *pass)
|
|||||||
return tls_os_queue_send(s_queue, (void *)MSG_ONBOARD, 0) == TLS_OS_SUCCESS ? 0 : -1;
|
return tls_os_queue_send(s_queue, (void *)MSG_ONBOARD, 0) == TLS_OS_SUCCESS ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int nl_wifi_request_standalone(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse)
|
||||||
|
{
|
||||||
|
bool busy;
|
||||||
|
|
||||||
|
if (!s_started)
|
||||||
|
return -1;
|
||||||
|
lock();
|
||||||
|
/* Wie beim Onboarding: nicht auf einen laufenden Test draufsatteln, und keinen zweiten
|
||||||
|
* Wunsch annehmen, solange der erste noch in der Warteschlange steht - er ueberschriebe
|
||||||
|
* dessen Werte, die Antwort haette aber schon 202 gesagt. */
|
||||||
|
busy = s_sm.state == NL_WIFI_ST_ONBOARD_TEST || s_req_pending;
|
||||||
|
if (!busy) {
|
||||||
|
copy(s_req_ap_ssid, ssid ? ssid : "", sizeof s_req_ap_ssid);
|
||||||
|
copy(s_req_ap_pass, pass ? pass : "", sizeof s_req_ap_pass);
|
||||||
|
s_req_ap_channel = channel;
|
||||||
|
s_req_enabled = enabled;
|
||||||
|
s_req_pulse = pulse;
|
||||||
|
s_req_pending = true;
|
||||||
|
}
|
||||||
|
unlock();
|
||||||
|
if (busy)
|
||||||
|
return -1;
|
||||||
|
if (tls_os_queue_send(s_queue, (void *)MSG_STANDALONE, 0) != TLS_OS_SUCCESS) {
|
||||||
|
lock();
|
||||||
|
s_req_pending = false;
|
||||||
|
unlock();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nl_wifi_is_standalone(void)
|
||||||
|
{
|
||||||
|
bool r;
|
||||||
|
|
||||||
|
if (!s_started)
|
||||||
|
return false;
|
||||||
|
lock();
|
||||||
|
r = s_sm.state == NL_WIFI_ST_STANDALONE;
|
||||||
|
unlock();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
bool nl_wifi_open_access(void)
|
bool nl_wifi_open_access(void)
|
||||||
{
|
{
|
||||||
bool r;
|
bool r;
|
||||||
@@ -316,6 +598,15 @@ void nl_wifi_status(nl_wifi_status_t *out)
|
|||||||
lock();
|
lock();
|
||||||
out->mode = nl_wifi_sm_mode(&s_sm);
|
out->mode = nl_wifi_sm_mode(&s_sm);
|
||||||
out->error = nl_wifi_error_name(s_sm.error);
|
out->error = nl_wifi_error_name(s_sm.error);
|
||||||
|
/* I4: Im eigenen AP (Standalone oder Onboarding) ist die Stations-SSID nicht der Name
|
||||||
|
* des Netzes, auf dem die Lampe antwortet - und ein Heimnetzname hat in einer
|
||||||
|
* tokenfrei lesbaren Antwort ohnehin nichts verloren. Gemeldet wird der WIRKSAME
|
||||||
|
* AP-Name; damit wird auch ein Rueckfall auf andere AP-Daten ueber die API sichtbar,
|
||||||
|
* ohne ein neues Feld. "apsta" bleibt bei der Station: dort ist sie die Hauptsache. */
|
||||||
|
if (strcmp(nl_wifi_sm_mode(&s_sm), "ap") == 0)
|
||||||
|
copy(out->ssid, s_ap_live_ssid[0] ? s_ap_live_ssid : (s_ap_ssid[0] ? s_ap_ssid : s_hostname),
|
||||||
|
sizeof out->ssid);
|
||||||
|
else
|
||||||
copy(out->ssid, s_ssid, sizeof out->ssid);
|
copy(out->ssid, s_ssid, sizeof out->ssid);
|
||||||
ap_up = s_sm.ap_up || s_sm.state == NL_WIFI_ST_AP_ONBOARDING;
|
ap_up = s_sm.ap_up || s_sm.state == NL_WIFI_ST_AP_ONBOARDING;
|
||||||
unlock();
|
unlock();
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ void nl_app_request_factory_reset(void);
|
|||||||
/* Stores new station credentials (from a successful onboarding test) and persists them. */
|
/* Stores new station credentials (from a successful onboarding test) and persists them. */
|
||||||
int nl_app_set_wifi_credentials(const char *ssid, const char *pass);
|
int nl_app_set_wifi_credentials(const char *ssid, const char *pass);
|
||||||
|
|
||||||
|
/* Loescht den Standalone-Modus dauerhaft (gelungenes Onboarding aus dem eigenen AP heraus).
|
||||||
|
* Name, Passwort und Kanal des AP bleiben stehen - sie zaehlen wieder, sobald der Modus
|
||||||
|
* erneut eingeschaltet wird. */
|
||||||
|
int nl_app_clear_standalone(void);
|
||||||
|
|
||||||
/* Clears the power-cycle counter ahead of the regular 5 s deadline (spec 5.5), idempotent.
|
/* Clears the power-cycle counter ahead of the regular 5 s deadline (spec 5.5), idempotent.
|
||||||
* Used by the boot recovery path once it knows this boot is a recovery boot: a successful
|
* Used by the boot recovery path once it knows this boot is a recovery boot: a successful
|
||||||
* recovery never returns to the app task, so without this the counter would keep growing
|
* recovery never returns to the app task, so without this the counter would keep growing
|
||||||
|
|||||||
+34
-2
@@ -5,9 +5,11 @@
|
|||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_mdns.h"
|
#include "net/nl_mdns.h"
|
||||||
#include "net/nl_names.h"
|
#include "net/nl_names.h"
|
||||||
|
#include "net/nl_sacn.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
#include "sys/nl_app_api.h"
|
#include "sys/nl_app_api.h"
|
||||||
@@ -29,6 +31,9 @@ static nl_ctrl_err_t apply(const nl_light_change_t *c)
|
|||||||
{
|
{
|
||||||
nl_ctrl_err_t e;
|
nl_ctrl_err_t e;
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
|
/* Ein Befehl aus HA oder per curl holt die Kontrolle zurueck - auch im Modus "hold",
|
||||||
|
* in dem der Override sonst stehen bliebe. */
|
||||||
|
nl_dmx_release();
|
||||||
e = nl_ctrl_apply(c, nl_app_now_ms());
|
e = nl_ctrl_apply(c, nl_app_now_ms());
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
return e;
|
return e;
|
||||||
@@ -46,8 +51,10 @@ static void get_settings(nl_settings_t *out)
|
|||||||
static int save_settings(const nl_settings_t *s)
|
static int save_settings(const nl_settings_t *s)
|
||||||
{
|
{
|
||||||
nl_settings_t *cur;
|
nl_settings_t *cur;
|
||||||
|
nl_settings_t applied; /* copy taken under the lock: nl_sacn_apply_settings() takes it itself */
|
||||||
char name[NL_NAME_LEN];
|
char name[NL_NAME_LEN];
|
||||||
bool name_changed;
|
bool name_changed;
|
||||||
|
int r;
|
||||||
|
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
cur = nl_app_settings();
|
cur = nl_app_settings();
|
||||||
@@ -58,17 +65,29 @@ static int save_settings(const nl_settings_t *s)
|
|||||||
memcpy(merged.wifi_pass, cur->wifi_pass, sizeof merged.wifi_pass);
|
memcpy(merged.wifi_pass, cur->wifi_pass, sizeof merged.wifi_pass);
|
||||||
*cur = merged;
|
*cur = merged;
|
||||||
}
|
}
|
||||||
nl_ctrl_set_fades(cur->fade_in_ms, cur->fade_out_ms);
|
nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0);
|
||||||
memcpy(name, cur->name, sizeof name);
|
memcpy(name, cur->name, sizeof name);
|
||||||
|
applied = *cur;
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
if (name_changed)
|
if (name_changed)
|
||||||
nl_mdns_set_name(name);
|
nl_mdns_set_name(name);
|
||||||
return nl_app_save_settings();
|
r = nl_app_save_settings();
|
||||||
|
if (r == 0)
|
||||||
|
nl_sacn_apply_settings(&applied); /* neues Universe/Startslot/hold sofort wirksam */
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); }
|
static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); }
|
||||||
static bool open_access(void) { return nl_wifi_open_access(); }
|
static bool open_access(void) { return nl_wifi_open_access(); }
|
||||||
|
|
||||||
|
/* Der Treiber nimmt den Wunsch nur entgegen; wirksam wird er im Netz-Task, nachdem die
|
||||||
|
* Antwort raus ist. 0 = uebernommen, sonst 503. */
|
||||||
|
static int standalone_configure(bool enabled, const char *ssid, const char *pass, uint8_t channel,
|
||||||
|
bool pulse)
|
||||||
|
{
|
||||||
|
return nl_wifi_request_standalone(enabled, ssid, pass, channel, pulse);
|
||||||
|
}
|
||||||
|
|
||||||
static void get_info(nl_sys_info_t *out)
|
static void get_info(nl_sys_info_t *out)
|
||||||
{
|
{
|
||||||
nl_wifi_status_t w;
|
nl_wifi_status_t w;
|
||||||
@@ -79,7 +98,14 @@ static void get_info(nl_sys_info_t *out)
|
|||||||
nl_names_mac_str(mac, out->mac);
|
nl_names_mac_str(mac, out->mac);
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
memcpy(out->name, nl_app_settings()->name, sizeof out->name);
|
memcpy(out->name, nl_app_settings()->name, sizeof out->name);
|
||||||
|
/* Gleicher Sperrabschnitt wie der Name: nl_dmx ist nicht threadsicher, und
|
||||||
|
* nl_app_lock() ist nicht rekursiv - also hier mit erledigen statt ein zweites Mal
|
||||||
|
* zu sperren. */
|
||||||
|
out->sacn_active = nl_dmx_active();
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
|
/* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */
|
||||||
|
out->sacn_joined = nl_sacn_joined();
|
||||||
|
out->sacn_running = nl_sacn_running();
|
||||||
nl_wifi_status(&w);
|
nl_wifi_status(&w);
|
||||||
out->wifi_mode = w.mode;
|
out->wifi_mode = w.mode;
|
||||||
memcpy(out->ssid, w.ssid, sizeof out->ssid);
|
memcpy(out->ssid, w.ssid, sizeof out->ssid);
|
||||||
@@ -90,6 +116,7 @@ static void get_info(nl_sys_info_t *out)
|
|||||||
out->uptime_s = nl_app_now_ms() / 1000u;
|
out->uptime_s = nl_app_now_ms() / 1000u;
|
||||||
out->free_heap = tls_mem_get_avail_heapsize();
|
out->free_heap = tls_mem_get_avail_heapsize();
|
||||||
out->last_ota_error = nl_ota_last_error();
|
out->last_ota_error = nl_ota_last_error();
|
||||||
|
out->wifi_standalone = nl_wifi_is_standalone();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void factory_reset(void) { nl_app_request_factory_reset(); }
|
static void factory_reset(void) { nl_app_request_factory_reset(); }
|
||||||
@@ -113,12 +140,17 @@ const nl_api_ctx_t *nl_app_api_ctx(void)
|
|||||||
cJSON_InitHooks(&hooks);
|
cJSON_InitHooks(&hooks);
|
||||||
ctx.board = nl_board_get();
|
ctx.board = nl_board_get();
|
||||||
ctx.fw_version = NINLIGHT_FW_VERSION;
|
ctx.fw_version = NINLIGHT_FW_VERSION;
|
||||||
|
/* Zeigerziel ist ein statischer Puffer im Treiber und gilt ab nl_wifi_start(), das im
|
||||||
|
* Startpfad vor dem HTTP-Server laeuft. Ohne diese Zeile dereferenziert der
|
||||||
|
* Einstellungs-Handler NULL, wenn kein eigener AP-Name gesetzt ist. */
|
||||||
|
ctx.hostname = nl_wifi_hostname();
|
||||||
ctx.build_date = NINLIGHT_BUILD_DATE;
|
ctx.build_date = NINLIGHT_BUILD_DATE;
|
||||||
ctx.get_state = get_state;
|
ctx.get_state = get_state;
|
||||||
ctx.apply = apply;
|
ctx.apply = apply;
|
||||||
ctx.get_settings = get_settings;
|
ctx.get_settings = get_settings;
|
||||||
ctx.save_settings = save_settings;
|
ctx.save_settings = save_settings;
|
||||||
ctx.wifi_configure = wifi_configure;
|
ctx.wifi_configure = wifi_configure;
|
||||||
|
ctx.standalone_configure = standalone_configure;
|
||||||
ctx.open_access = open_access;
|
ctx.open_access = open_access;
|
||||||
ctx.get_info = get_info;
|
ctx.get_info = get_info;
|
||||||
ctx.factory_reset = factory_reset;
|
ctx.factory_reset = factory_reset;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "wm_watchdog.h"
|
#include "wm_watchdog.h"
|
||||||
|
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
@@ -78,7 +79,7 @@ static void exec(const nl_cmd_t *c)
|
|||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
cfg->fade_in_ms = (uint16_t)c->arg[0];
|
cfg->fade_in_ms = (uint16_t)c->arg[0];
|
||||||
cfg->fade_out_ms = (uint16_t)c->arg[1];
|
cfg->fade_out_ms = (uint16_t)c->arg[1];
|
||||||
nl_ctrl_set_fades(cfg->fade_in_ms, cfg->fade_out_ms);
|
nl_ctrl_set_options(cfg->fade_in_ms, cfg->fade_out_ms, cfg->transition_ms, cfg->wake_on_change != 0);
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
printf(nl_app_save_settings() == 0 ? "ok\r\n" : "error: save failed\r\n");
|
printf(nl_app_save_settings() == 0 ? "ok\r\n" : "error: save failed\r\n");
|
||||||
print_state();
|
print_state();
|
||||||
@@ -123,6 +124,10 @@ static void exec(const nl_cmd_t *c)
|
|||||||
}
|
}
|
||||||
|
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
|
/* Wie in nl_app_api.c: ein Befehl von Hand holt die Kontrolle vom sACN-Sender zurueck.
|
||||||
|
* Die serielle Konsole ist der Rettungsweg - ohne das Freigeben bliebe eine im Modus
|
||||||
|
* "hold" dunkel geparkte Lampe dunkel und meldete trotzdem "ok". */
|
||||||
|
nl_dmx_release();
|
||||||
err = nl_ctrl_apply(&ch, nl_app_now_ms());
|
err = nl_ctrl_apply(&ch, nl_app_now_ms());
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
if (err == NL_CTRL_OK)
|
if (err == NL_CTRL_OK)
|
||||||
|
|||||||
+23
-3
@@ -23,6 +23,7 @@ static char s_url[NL_OTA_URL_MAX];
|
|||||||
static volatile bool s_busy;
|
static volatile bool s_busy;
|
||||||
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
|
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
|
||||||
static const char * volatile s_error;
|
static const char * volatile s_error;
|
||||||
|
static volatile bool s_skipped;
|
||||||
static u32 s_stack[NL_OTA_STACK];
|
static u32 s_stack[NL_OTA_STACK];
|
||||||
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
|
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ void nl_ota_init(void)
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool nl_ota_busy(void) { return s_busy; }
|
bool nl_ota_busy(void) { return s_busy; }
|
||||||
|
bool nl_ota_skipped(void) { return s_skipped; }
|
||||||
const char *nl_ota_last_error(void) { return s_error; }
|
const char *nl_ota_last_error(void) { return s_error; }
|
||||||
void nl_ota_set_error(const char *e) { s_error = e; }
|
void nl_ota_set_error(const char *e) { s_error = e; }
|
||||||
|
|
||||||
@@ -61,7 +63,10 @@ bool nl_fwup_verify_tail(const uint8_t sha256[32], const uint8_t *tail, uint32_t
|
|||||||
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
|
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
|
||||||
printf("ota: recovery image already carries the running version %s, not flashing\r\n",
|
printf("ota: recovery image already carries the running version %s, not flashing\r\n",
|
||||||
s_tail_version);
|
s_tail_version);
|
||||||
s_error = "trailer";
|
/* Deliberately skipped, not failed: leave last_ota_error alone. Reporting "trailer"
|
||||||
|
* here used to make a perfectly healthy lamp look like it had rejected a malformed
|
||||||
|
* image, which is exactly the wrong thing to read off a lamp in the field. */
|
||||||
|
s_skipped = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
|
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
|
||||||
@@ -89,10 +94,19 @@ static void ota_task(void *arg)
|
|||||||
(void)arg;
|
(void)arg;
|
||||||
log_url(s_url);
|
log_url(s_url);
|
||||||
rc = t_http_fwup(s_url);
|
rc = t_http_fwup(s_url);
|
||||||
/* Only reached on failure - the SDK resets the chip itself after a good image. */
|
/* Only reached on failure or a deliberate skip - the SDK resets the chip itself after a good
|
||||||
|
* image. rc is not a reliable success/failure signal here: the SDK aborts the transfer as
|
||||||
|
* soon as the trailer hook returns false, which happens both for a real rejection and for the
|
||||||
|
* deliberate "recovery image already up to date" skip. s_error/s_skipped are what actually
|
||||||
|
* distinguish the two, so branch on those instead of folding everything into one printf that
|
||||||
|
* would risk passing a NULL s_error to %s. */
|
||||||
|
if (s_skipped) {
|
||||||
|
printf("ota: skipped (rc=%d, image already up to date)\r\n", rc);
|
||||||
|
} else {
|
||||||
if (s_error == NULL)
|
if (s_error == NULL)
|
||||||
s_error = "download";
|
s_error = "download";
|
||||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
nl_settings_t *s;
|
nl_settings_t *s;
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
@@ -115,11 +129,17 @@ static int ota_start(const char *url, bool reject_running_version)
|
|||||||
if (n <= 7 || n >= sizeof s_url) /* n == 7 is bare "http://" with no host */
|
if (n <= 7 || n >= sizeof s_url) /* n == 7 is bare "http://" with no host */
|
||||||
return -1;
|
return -1;
|
||||||
if (s_busy) {
|
if (s_busy) {
|
||||||
s_error = "busy";
|
/* Do NOT touch s_error here. last_ota_error describes the outcome of the *running or last*
|
||||||
|
* update; a request we reject because one is already in flight has no outcome of its own.
|
||||||
|
* Writing "busy" into it used to poison the running attempt's slot: ota_task() only falls
|
||||||
|
* back to "download" when s_error is still NULL, so a failed transfer was afterwards
|
||||||
|
* reported as `busy` instead of `download`. Found on the EVK, 2026-09-12. The REST layer
|
||||||
|
* reports the rejection to its own caller with 409. */
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
s_busy = true;
|
s_busy = true;
|
||||||
s_error = NULL;
|
s_error = NULL;
|
||||||
|
s_skipped = false;
|
||||||
s_reject_running_version = reject_running_version;
|
s_reject_running_version = reject_running_version;
|
||||||
memcpy(s_url, url, n + 1);
|
memcpy(s_url, url, n + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ int nl_ota_start(const char *url);
|
|||||||
* forever. A deliberate re-flash of the same version stays possible over the REST API. */
|
* forever. A deliberate re-flash of the same version stays possible over the REST API. */
|
||||||
int nl_ota_start_recovery(const char *url);
|
int nl_ota_start_recovery(const char *url);
|
||||||
bool nl_ota_busy(void);
|
bool nl_ota_busy(void);
|
||||||
|
/* True when the last recovery attempt deliberately skipped an image whose version already
|
||||||
|
matches the running firmware - that is not a failure and sets no last_ota_error. */
|
||||||
|
bool nl_ota_skipped(void);
|
||||||
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
|
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
|
||||||
const char *nl_ota_last_error(void);
|
const char *nl_ota_last_error(void);
|
||||||
void nl_ota_set_error(const char *e);
|
void nl_ota_set_error(const char *e);
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ uint8_t nl_crc8(const void *data, size_t len)
|
|||||||
while (len--) {
|
while (len--) {
|
||||||
crc ^= *p++;
|
crc ^= *p++;
|
||||||
for (int k = 0; k < 8; k++)
|
for (int k = 0; k < 8; k++)
|
||||||
crc = (uint8_t)((crc & 0x80u) ? (crc << 1) ^ 0x07u : (crc << 1));
|
crc = (uint8_t)((crc & 0x80u) ? (int)(((unsigned)crc << 1) ^ 0x07u) : (int)((unsigned)crc << 1));
|
||||||
}
|
}
|
||||||
return crc;
|
return crc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "fake_api_ctx.h"
|
#include "fake_api_ctx.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "light/nl_boot_state.h"
|
#include "light/nl_boot_state.h"
|
||||||
@@ -12,6 +13,10 @@ nl_ctrl_err_t fake_apply_result;
|
|||||||
int fake_apply_calls, fake_save_calls, fake_save_result;
|
int fake_apply_calls, fake_save_calls, fake_save_result;
|
||||||
int fake_wifi_calls, fake_wifi_result;
|
int fake_wifi_calls, fake_wifi_result;
|
||||||
char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
||||||
|
int fake_standalone_calls, fake_standalone_result;
|
||||||
|
bool fake_standalone_enabled, fake_standalone_pulse;
|
||||||
|
char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
|
||||||
|
uint8_t fake_standalone_channel;
|
||||||
int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
||||||
char fake_fw_url[256];
|
char fake_fw_url[256];
|
||||||
bool fake_open;
|
bool fake_open;
|
||||||
@@ -49,6 +54,18 @@ static int wifi_configure(const char *ssid, const char *pass)
|
|||||||
return fake_wifi_result;
|
return fake_wifi_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int fake_standalone_configure(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse)
|
||||||
|
{
|
||||||
|
fake_standalone_calls++;
|
||||||
|
fake_standalone_enabled = enabled;
|
||||||
|
fake_standalone_pulse = pulse;
|
||||||
|
fake_standalone_channel = channel;
|
||||||
|
snprintf(fake_standalone_ssid, sizeof fake_standalone_ssid, "%s", ssid ? ssid : "");
|
||||||
|
snprintf(fake_standalone_pass, sizeof fake_standalone_pass, "%s", pass ? pass : "");
|
||||||
|
return fake_standalone_result;
|
||||||
|
}
|
||||||
|
|
||||||
static bool open_access(void) { return fake_open; }
|
static bool open_access(void) { return fake_open; }
|
||||||
static void get_info(nl_sys_info_t *out) { *out = fake_info; }
|
static void get_info(nl_sys_info_t *out) { *out = fake_info; }
|
||||||
static void factory_reset(void) { fake_factory_calls++; }
|
static void factory_reset(void) { fake_factory_calls++; }
|
||||||
@@ -83,6 +100,10 @@ void fake_api_reset(const char *board_id)
|
|||||||
fake_apply_calls = fake_save_calls = fake_save_result = 0;
|
fake_apply_calls = fake_save_calls = fake_save_result = 0;
|
||||||
fake_wifi_calls = fake_wifi_result = 0;
|
fake_wifi_calls = fake_wifi_result = 0;
|
||||||
fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0';
|
fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0';
|
||||||
|
fake_standalone_calls = fake_standalone_result = 0;
|
||||||
|
fake_standalone_enabled = fake_standalone_pulse = false;
|
||||||
|
fake_standalone_ssid[0] = fake_standalone_pass[0] = '\0';
|
||||||
|
fake_standalone_channel = 0;
|
||||||
fake_factory_calls = fake_ping_calls = 0;
|
fake_factory_calls = fake_ping_calls = 0;
|
||||||
fake_fw_result = -1;
|
fake_fw_result = -1;
|
||||||
fake_fw_url[0] = '\0';
|
fake_fw_url[0] = '\0';
|
||||||
@@ -96,6 +117,8 @@ void fake_api_reset(const char *board_id)
|
|||||||
fake_ctx.get_settings = get_settings;
|
fake_ctx.get_settings = get_settings;
|
||||||
fake_ctx.save_settings = save_settings;
|
fake_ctx.save_settings = save_settings;
|
||||||
fake_ctx.wifi_configure = wifi_configure;
|
fake_ctx.wifi_configure = wifi_configure;
|
||||||
|
fake_ctx.standalone_configure = fake_standalone_configure;
|
||||||
|
fake_ctx.hostname = "ninlight-681f";
|
||||||
fake_ctx.open_access = open_access;
|
fake_ctx.open_access = open_access;
|
||||||
fake_ctx.get_info = get_info;
|
fake_ctx.get_info = get_info;
|
||||||
fake_ctx.factory_reset = factory_reset;
|
fake_ctx.factory_reset = factory_reset;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ extern nl_ctrl_err_t fake_apply_result;
|
|||||||
extern int fake_apply_calls, fake_save_calls, fake_save_result;
|
extern int fake_apply_calls, fake_save_calls, fake_save_result;
|
||||||
extern int fake_wifi_calls, fake_wifi_result;
|
extern int fake_wifi_calls, fake_wifi_result;
|
||||||
extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
||||||
|
extern int fake_standalone_calls, fake_standalone_result;
|
||||||
|
extern bool fake_standalone_enabled, fake_standalone_pulse;
|
||||||
|
extern char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
|
||||||
|
extern uint8_t fake_standalone_channel;
|
||||||
extern int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
extern int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
||||||
extern char fake_fw_url[256];
|
extern char fake_fw_url[256];
|
||||||
extern bool fake_open;
|
extern bool fake_open;
|
||||||
|
|||||||
+539
-1
@@ -55,6 +55,8 @@ static void test_get_state(void)
|
|||||||
TEST_ASSERT_EQUAL_STRING("last", cJSON_GetStringValue(field("power_on_behaviour")));
|
TEST_ASSERT_EQUAL_STRING("last", cJSON_GetStringValue(field("power_on_behaviour")));
|
||||||
TEST_ASSERT_EQUAL_INT(700, field("fade_in")->valueint);
|
TEST_ASSERT_EQUAL_INT(700, field("fade_in")->valueint);
|
||||||
TEST_ASSERT_EQUAL_INT(500, field("fade_out")->valueint);
|
TEST_ASSERT_EQUAL_INT(500, field("fade_out")->valueint);
|
||||||
|
TEST_ASSERT_EQUAL_INT(300, field("transition_default")->valueint);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsFalse(field("wake_on_change")));
|
||||||
cap = field("capabilities");
|
cap = field("capabilities");
|
||||||
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(cap, "color")));
|
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(cap, "color")));
|
||||||
TEST_ASSERT_EQUAL_INT(2700, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 0)->valueint);
|
TEST_ASSERT_EQUAL_INT(2700, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 0)->valueint);
|
||||||
@@ -114,6 +116,8 @@ static void test_post_validation(void)
|
|||||||
{"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"},
|
{"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"},
|
||||||
{"{\"fade_in\":10001}", "invalid fade_in"},
|
{"{\"fade_in\":10001}", "invalid fade_in"},
|
||||||
{"{\"fade_out\":-1}", "invalid fade_out"},
|
{"{\"fade_out\":-1}", "invalid fade_out"},
|
||||||
|
{"{\"transition_default\":60001}", "invalid transition_default"},
|
||||||
|
{"{\"wake_on_change\":\"ja\"}", "invalid wake_on_change"},
|
||||||
};
|
};
|
||||||
for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) {
|
for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) {
|
||||||
call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body);
|
call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body);
|
||||||
@@ -151,6 +155,33 @@ static void test_post_settings_fields(void)
|
|||||||
TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour")));
|
TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_post_transition_default_and_wake(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_POST, "/api/v1/device/state", NULL,
|
||||||
|
"{\"transition_default\":1200,\"wake_on_change\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1200, fake_settings.transition_ms);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.wake_on_change);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1200, field("transition_default")->valueint);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(field("wake_on_change")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A colour and a per-call transition travel in the same request - the light change is
|
||||||
|
applied, and nothing is treated as a settings write. */
|
||||||
|
static void test_post_color_with_transition(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_POST, "/api/v1/device/state", NULL,
|
||||||
|
"{\"color\":{\"r\":255,\"g\":0,\"b\":0},\"transition\":2000}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_apply_calls);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
TEST_ASSERT_TRUE(fake_last_change.has_transition);
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(2000, fake_last_change.transition_ms);
|
||||||
|
TEST_ASSERT_TRUE(fake_last_change.has_color);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(255, fake_last_change.r);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_post_save_failure(void)
|
static void test_post_save_failure(void)
|
||||||
{
|
{
|
||||||
fake_save_result = -1;
|
fake_save_result = -1;
|
||||||
@@ -233,6 +264,7 @@ static void test_settings_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
|
||||||
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||||
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
||||||
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
||||||
fake_open = true;
|
fake_open = true;
|
||||||
@@ -240,6 +272,19 @@ static void test_settings_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* F1: nie onboardet -> gespeicherte ssid ist leer, die Lampe laeuft auf den
|
||||||
|
* einkompilierten Zugangsdaten (siehe app_main.c). "source" muss das additiv
|
||||||
|
* sichtbar machen, ohne "ssid" umzudeuten - sonst widerspricht die Antwort
|
||||||
|
* scheinbar system/info, das die tatsaechlich verbundene SSID zeigt. */
|
||||||
|
static void test_settings_wifi_source_reflects_compiled_fallback(void)
|
||||||
|
{
|
||||||
|
fake_settings.wifi_ssid[0] = '\0';
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("compiled", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||||
|
}
|
||||||
|
|
||||||
static void test_settings_wifi(void)
|
static void test_settings_wifi(void)
|
||||||
{
|
{
|
||||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
|
||||||
@@ -301,6 +346,149 @@ static void test_settings_name_token_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- system/settings: sacn --- */
|
||||||
|
static const cJSON *sacn_field(const char *name)
|
||||||
|
{
|
||||||
|
const cJSON *s = cJSON_GetObjectItemCaseSensitive(doc, "sacn");
|
||||||
|
return cJSON_GetObjectItemCaseSensitive(s, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_settings_exposes_sacn_block(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("release", cJSON_GetStringValue(sacn_field("on_timeout")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_settings_sacn_block_reflects_stored_values(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_enabled = 0;
|
||||||
|
fake_settings.sacn_universe = 12;
|
||||||
|
fake_settings.sacn_start_slot = 7;
|
||||||
|
fake_settings.sacn_hold = 1;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(12, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(7, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("hold", cJSON_GetStringValue(sacn_field("on_timeout")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_all_fields(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"enabled\":false,\"universe\":300,"
|
||||||
|
"\"start_slot\":100,\"on_timeout\":\"hold\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, fake_settings.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(100, fake_settings.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_single_field_leaves_rest(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_universe = 9;
|
||||||
|
fake_settings.sacn_hold = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"start_slot\":50}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(50, fake_settings.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(9, fake_settings.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_rejects_bad_values(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"enabled\":\"ja\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":64000}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"on_timeout\":\"halten\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid on_timeout", error_text());
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls); /* nichts davon darf gespeichert werden */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* universe/start_slot muessen ueber get_int() laufen wie jedes andere Zahlenfeld in dieser
|
||||||
|
* Datei - ein Bruchwert darf nicht klammheimlich auf einen Integer abgeschnitten werden. */
|
||||||
|
static void test_put_sacn_rejects_fractional_numbers(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":12.5}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":2.5}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Board mit RGB braucht 6 Slots: ab 508 passt der Satz nicht mehr ins Universe. */
|
||||||
|
static void test_put_sacn_start_slot_must_leave_room_for_all_slots(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_layout = 0; /* die Vorgabe ist rgb mit 3 Slots - hier geht es um 6 */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":507}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":508}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_start_slot_limit_follows_the_board(void)
|
||||||
|
{
|
||||||
|
fake_api_reset("g95_cw");
|
||||||
|
fake_settings.sacn_layout = 0; /* im dimmer-Layout haengt die Slotzahl am Board */
|
||||||
|
/* Ein Board ohne Farbe hat auch mit Dimmer nur drei Slots: 510 passt noch. */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":511}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_info_exposes_sacn_state(void)
|
||||||
|
{
|
||||||
|
fake_info.sacn_active = true;
|
||||||
|
fake_info.sacn_joined = true;
|
||||||
|
fake_info.sacn_running = true;
|
||||||
|
fake_settings.sacn_universe = 5;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(5, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("multicast")));
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("running")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein toter Empfangs-Task sah von aussen genauso aus wie "es sendet gerade niemand":
|
||||||
|
* beides meldete nur active=false. Beim Montana-Black-Shield (2026-09-26) hat das eine
|
||||||
|
* halbe Stunde Suche gekostet - "multicast" half nicht weiter, weil es den gemerkten
|
||||||
|
* Erfolg des LETZTEN Beitritts zeigt und auch dann true bleibt, wenn der Socket laengst
|
||||||
|
* zu ist. */
|
||||||
|
static void test_info_reports_a_dead_sacn_task(void)
|
||||||
|
{
|
||||||
|
fake_info.sacn_running = false;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("running")));
|
||||||
|
}
|
||||||
|
|
||||||
/* --- system/command --- */
|
/* --- system/command --- */
|
||||||
static void test_commands(void)
|
static void test_commands(void)
|
||||||
{
|
{
|
||||||
@@ -324,7 +512,7 @@ static void test_commands(void)
|
|||||||
|
|
||||||
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
||||||
TEST_ASSERT_EQUAL_INT(409, resp.status);
|
TEST_ASSERT_EQUAL_INT(409, resp.status);
|
||||||
TEST_ASSERT_EQUAL_STRING("fw_update busy or invalid url", error_text());
|
TEST_ASSERT_EQUAL_STRING("an update is already running", error_text());
|
||||||
TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url);
|
TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url);
|
||||||
fake_fw_result = 0;
|
fake_fw_result = 0;
|
||||||
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
|
||||||
@@ -339,9 +527,330 @@ static void test_commands(void)
|
|||||||
TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
|
TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- sacn: Belegung --- */
|
||||||
|
|
||||||
|
static void test_settings_reports_the_layout_and_matching_slots(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
/* Vorgabe: rgb - und darin belegt auch ein Board mit Farbe nur drei Slots. */
|
||||||
|
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
|
||||||
|
fake_settings.sacn_layout = 0;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("dimmer", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_layout(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"rgb\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_rejects_an_unknown_layout(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"pixel\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid layout", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Obergrenze fuer start_slot haengt am Layout. Im rgb-Layout (3 Slots) ist 510 erlaubt,
|
||||||
|
* mit Dimmer (6 Slots) nicht mehr - ein Wechsel darf einen unzulaessigen Zustand also nicht
|
||||||
|
* still herstellen. */
|
||||||
|
static void test_put_sacn_start_slot_limit_follows_the_layout(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_layout = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||||
|
|
||||||
|
/* Zurueck auf dimmer wuerde 510 + 6 - 1 = 515 bedeuten: abgelehnt. */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout); /* nichts uebernommen */
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_layout_and_start_slot_together(void)
|
||||||
|
{
|
||||||
|
/* Beides in einer Anfrage: geprueft wird gegen das NEUE Layout. */
|
||||||
|
fake_settings.sacn_layout = 0;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"layout\":\"rgb\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- standalone --- */
|
||||||
|
|
||||||
|
static void test_standalone_requires_a_password(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("password required", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein gespeichertes Passwort genuegt - man soll den Kanal aendern koennen, ohne das
|
||||||
|
* Passwort erneut zu schicken. */
|
||||||
|
static void test_standalone_keeps_a_stored_password(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":11}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(11, fake_standalone_channel);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rejects_a_short_password(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"1234567\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid password", error_text());
|
||||||
|
/* Die Fehlermeldung nennt das Feld, nie den Wert. */
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "1234567"));
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rejects_a_channel_outside_the_band(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":14}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_accepts_a_full_request(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\","
|
||||||
|
"\"ssid\":\"Wohnzimmerlampe\",\"channel\":6,\"pulse\":false}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("standalone", cJSON_GetStringValue(field("status")));
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
|
||||||
|
TEST_ASSERT_TRUE(fake_standalone_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", fake_standalone_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, fake_standalone_channel);
|
||||||
|
TEST_ASSERT_FALSE(fake_standalone_pulse);
|
||||||
|
/* Gespeichert wird auch: sonst ist der Modus nach dem Neustart weg. */
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", fake_settings.ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Abschalten braucht kein Passwort. */
|
||||||
|
static void test_standalone_can_be_switched_off(void)
|
||||||
|
{
|
||||||
|
fake_settings.standalone = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":false}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
|
||||||
|
TEST_ASSERT_FALSE(fake_standalone_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_busy_radio_answers_503_and_rolls_back(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
fake_settings.standalone = 0;
|
||||||
|
fake_standalone_result = -1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(503, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("wifi busy", error_text());
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
/* "nicht uebernommen" gilt auch im Flash. */
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_INT(2, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rollback_restores_every_changed_field(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "altpass123");
|
||||||
|
strcpy(fake_settings.ap_ssid, "alt");
|
||||||
|
fake_settings.ap_channel = 3;
|
||||||
|
fake_settings.standalone_pulse = 1;
|
||||||
|
fake_standalone_result = -1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"neupass123\","
|
||||||
|
"\"ssid\":\"neu\",\"channel\":11,\"pulse\":false}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(503, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("altpass123", fake_settings.ap_pass);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", fake_settings.ap_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, fake_settings.ap_channel);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone_pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_failed_save_answers_500_without_switching(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
fake_save_result = -1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nicht angegebene Felder behalten ihren gespeicherten Wert. */
|
||||||
|
static void test_standalone_unspecified_fields_keep_their_stored_values(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
strcpy(fake_settings.ap_ssid, "Gespeichert");
|
||||||
|
fake_settings.ap_channel = 11;
|
||||||
|
fake_settings.standalone_pulse = 0;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Gespeichert", fake_standalone_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(11, fake_standalone_channel);
|
||||||
|
TEST_ASSERT_FALSE(fake_standalone_pulse);
|
||||||
|
|
||||||
|
fake_settings.standalone_pulse = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_TRUE(fake_standalone_pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_length_limits(void)
|
||||||
|
{
|
||||||
|
char body[256], pw[80], ss[48];
|
||||||
|
|
||||||
|
/* 63 ist die Obergrenze, nicht 64: eine WPA2-ASCII-Passphrase ist hoechstens 63 Zeichen
|
||||||
|
* lang, und ein vom Funkteil abgelehnter AP-Aufbau waere hier eine Sackgasse. */
|
||||||
|
memset(pw, 'p', 63); pw[63] = '\0';
|
||||||
|
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING(pw, fake_standalone_pass);
|
||||||
|
memset(pw, 'p', 64); pw[64] = '\0';
|
||||||
|
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, pw));
|
||||||
|
memset(pw, 'p', 65); pw[65] = '\0';
|
||||||
|
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, pw));
|
||||||
|
|
||||||
|
memset(ss, 's', 32); ss[32] = '\0';
|
||||||
|
snprintf(body, sizeof body,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"ssid\":\"%s\"}", ss);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING(ss, fake_standalone_ssid);
|
||||||
|
memset(ss, 's', 33); ss[33] = '\0';
|
||||||
|
snprintf(body, sizeof body,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"ssid\":\"%s\"}", ss);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_accepts_the_band_edges(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":13}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(13, fake_standalone_channel);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":1}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_standalone_channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rejects_bad_types_of_the_other_fields(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":\"yes\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"pulse\":1}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid pulse", error_text());
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"ssid\":\"\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid ssid", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das AP-Passwort darf nirgends in der Antwort auftauchen. */
|
||||||
|
static void test_settings_show_standalone_without_the_password(void)
|
||||||
|
{
|
||||||
|
cJSON *o, *sa;
|
||||||
|
fake_settings.standalone = 1;
|
||||||
|
fake_settings.ap_channel = 11;
|
||||||
|
fake_settings.standalone_pulse = 0;
|
||||||
|
strcpy(fake_settings.ap_ssid, "Wohnzimmerlampe");
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe",
|
||||||
|
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(11, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "pulse")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ohne eigenen Namen zeigt die Antwort den WIRKSAMEN, nicht einen leeren String -
|
||||||
|
* sonst muesste jeder Aufrufer die Vorgabe selbst nachbilden. */
|
||||||
|
static void test_settings_show_the_effective_ap_name(void)
|
||||||
|
{
|
||||||
|
cJSON *o, *sa;
|
||||||
|
fake_settings.ap_ssid[0] = '\0';
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ninlight-681f",
|
||||||
|
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
|
||||||
|
/* Kanal 0 im Datensatz heisst 6. */
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "ap" allein reicht nicht: denselben Wert meldet der Onboarding-AP. Ohne dieses Feld
|
||||||
|
* boete die App einer fertig eingerichteten Lampe ein Onboarding an. */
|
||||||
|
static void test_info_tells_standalone_from_onboarding(void)
|
||||||
|
{
|
||||||
|
cJSON *o, *w;
|
||||||
|
fake_info.wifi_mode = "ap";
|
||||||
|
fake_info.wifi_standalone = true;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
|
||||||
|
fake_info.wifi_standalone = false;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_settings_reports_the_layout_and_matching_slots);
|
||||||
|
RUN_TEST(test_put_sacn_layout);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_an_unknown_layout);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_layout);
|
||||||
|
RUN_TEST(test_put_sacn_layout_and_start_slot_together);
|
||||||
RUN_TEST(test_unknown_path_and_method);
|
RUN_TEST(test_unknown_path_and_method);
|
||||||
RUN_TEST(test_get_state);
|
RUN_TEST(test_get_state);
|
||||||
RUN_TEST(test_white_board_capabilities);
|
RUN_TEST(test_white_board_capabilities);
|
||||||
@@ -350,14 +859,43 @@ int main(void)
|
|||||||
RUN_TEST(test_post_validation);
|
RUN_TEST(test_post_validation);
|
||||||
RUN_TEST(test_post_control_errors);
|
RUN_TEST(test_post_control_errors);
|
||||||
RUN_TEST(test_post_settings_fields);
|
RUN_TEST(test_post_settings_fields);
|
||||||
|
RUN_TEST(test_post_transition_default_and_wake);
|
||||||
|
RUN_TEST(test_post_color_with_transition);
|
||||||
RUN_TEST(test_post_save_failure);
|
RUN_TEST(test_post_save_failure);
|
||||||
RUN_TEST(test_auth_all_protects_device_state);
|
RUN_TEST(test_auth_all_protects_device_state);
|
||||||
RUN_TEST(test_auth_too_long_is_unauthenticated);
|
RUN_TEST(test_auth_too_long_is_unauthenticated);
|
||||||
RUN_TEST(test_info_is_open);
|
RUN_TEST(test_info_is_open);
|
||||||
RUN_TEST(test_info_with_sta_ip_and_error);
|
RUN_TEST(test_info_with_sta_ip_and_error);
|
||||||
RUN_TEST(test_settings_auth);
|
RUN_TEST(test_settings_auth);
|
||||||
|
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
||||||
RUN_TEST(test_settings_wifi);
|
RUN_TEST(test_settings_wifi);
|
||||||
RUN_TEST(test_settings_name_token_auth);
|
RUN_TEST(test_settings_name_token_auth);
|
||||||
|
RUN_TEST(test_settings_exposes_sacn_block);
|
||||||
|
RUN_TEST(test_settings_sacn_block_reflects_stored_values);
|
||||||
|
RUN_TEST(test_put_sacn_all_fields);
|
||||||
|
RUN_TEST(test_put_sacn_single_field_leaves_rest);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_bad_values);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_fractional_numbers);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_must_leave_room_for_all_slots);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_board);
|
||||||
|
RUN_TEST(test_info_exposes_sacn_state);
|
||||||
|
RUN_TEST(test_info_reports_a_dead_sacn_task);
|
||||||
RUN_TEST(test_commands);
|
RUN_TEST(test_commands);
|
||||||
|
RUN_TEST(test_standalone_requires_a_password);
|
||||||
|
RUN_TEST(test_standalone_keeps_a_stored_password);
|
||||||
|
RUN_TEST(test_standalone_rejects_a_short_password);
|
||||||
|
RUN_TEST(test_standalone_rejects_a_channel_outside_the_band);
|
||||||
|
RUN_TEST(test_standalone_accepts_a_full_request);
|
||||||
|
RUN_TEST(test_standalone_can_be_switched_off);
|
||||||
|
RUN_TEST(test_standalone_busy_radio_answers_503_and_rolls_back);
|
||||||
|
RUN_TEST(test_standalone_rollback_restores_every_changed_field);
|
||||||
|
RUN_TEST(test_standalone_failed_save_answers_500_without_switching);
|
||||||
|
RUN_TEST(test_standalone_unspecified_fields_keep_their_stored_values);
|
||||||
|
RUN_TEST(test_standalone_length_limits);
|
||||||
|
RUN_TEST(test_standalone_accepts_the_band_edges);
|
||||||
|
RUN_TEST(test_standalone_rejects_bad_types_of_the_other_fields);
|
||||||
|
RUN_TEST(test_settings_show_standalone_without_the_password);
|
||||||
|
RUN_TEST(test_settings_show_the_effective_ap_name);
|
||||||
|
RUN_TEST(test_info_tells_standalone_from_onboarding);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-5
@@ -5,12 +5,12 @@
|
|||||||
void setUp(void) {}
|
void setUp(void) {}
|
||||||
void tearDown(void) {}
|
void tearDown(void) {}
|
||||||
|
|
||||||
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw"};
|
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"};
|
||||||
|
|
||||||
static void test_all_devices_present(void)
|
static void test_all_devices_present(void)
|
||||||
{
|
{
|
||||||
TEST_ASSERT_EQUAL_UINT(8, nl_boards_count);
|
TEST_ASSERT_EQUAL_UINT(9, nl_boards_count);
|
||||||
for (size_t i = 0; i < 8; i++) {
|
for (size_t i = 0; i < 9; i++) {
|
||||||
const nl_board_t *b = nl_board_find(IDS[i]);
|
const nl_board_t *b = nl_board_find(IDS[i]);
|
||||||
TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]);
|
TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]);
|
||||||
TEST_ASSERT_EQUAL_STRING(IDS[i], b->id);
|
TEST_ASSERT_EQUAL_STRING(IDS[i], b->id);
|
||||||
@@ -29,9 +29,17 @@ static void test_rgb_flags_and_ct_ranges(void)
|
|||||||
for (size_t i = 0; i < nl_boards_count; i++) {
|
for (size_t i = 0; i < nl_boards_count; i++) {
|
||||||
const nl_board_t *b = &nl_boards[i];
|
const nl_board_t *b = &nl_boards[i];
|
||||||
bool rgb = strstr(b->id, "rgb") != NULL || strcmp(b->id, "evk") == 0;
|
bool rgb = strstr(b->id, "rgb") != NULL || strcmp(b->id, "evk") == 0;
|
||||||
|
/* Measured on real bulbs, 2026-09-13: every lamp but the G95 covers 2700..6500 K.
|
||||||
|
The G95 Vintage is a warm bulb and reaches 2000 K at the bottom but stops at
|
||||||
|
5000 K - hard-coded per board rather than derived, because "warm variant" is a
|
||||||
|
property of that one product, not of a class of them. */
|
||||||
|
bool g95 = strcmp(b->id, "g95_cw") == 0;
|
||||||
|
/* ledshield_rgb has no white LEDs at all: ct_min == ct_max is the
|
||||||
|
sentinel "white mode renders as an RGB mix" (see nl_color.c). */
|
||||||
|
bool shield = strcmp(b->id, "ledshield_rgb") == 0;
|
||||||
TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id);
|
TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id);
|
||||||
TEST_ASSERT_EQUAL_UINT16(rgb ? 2700 : 2500, b->ct_min);
|
TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 2000 : 2700), b->ct_min, b->id);
|
||||||
TEST_ASSERT_EQUAL_UINT16(rgb ? 6500 : 5000, b->ct_max);
|
TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 5000 : 6500), b->ct_max, b->id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +50,15 @@ static void test_pwm_boards_consistent(void)
|
|||||||
if (b->driver != NL_DRV_PWM)
|
if (b->driver != NL_DRV_PWM)
|
||||||
continue;
|
continue;
|
||||||
TEST_ASSERT_TRUE(b->pwm_freq_hz > 0);
|
TEST_ASSERT_TRUE(b->pwm_freq_hz > 0);
|
||||||
|
if (b->ct_min == b->ct_max) {
|
||||||
|
/* RGB-only board: white is an RGB mix, no white channels wired. */
|
||||||
|
TEST_ASSERT_TRUE(b->has_rgb);
|
||||||
|
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_CW].pwm_ch);
|
||||||
|
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_WW].pwm_ch);
|
||||||
|
} else {
|
||||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
||||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
||||||
|
}
|
||||||
for (int c = NL_CH_R; c <= NL_CH_B; c++)
|
for (int c = NL_CH_R; c <= NL_CH_B; c++)
|
||||||
TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id);
|
TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id);
|
||||||
for (int c = 0; c < NL_CH_COUNT; c++) {
|
for (int c = 0; c < NL_CH_COUNT; c++) {
|
||||||
@@ -101,6 +116,16 @@ static void test_known_values_from_old_firmware(void)
|
|||||||
TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch);
|
TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_dmx_slots(void)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("gu10_rgb"), false));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"), false));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("evk"), false));
|
||||||
|
/* Im rgb-Layout belegt JEDES Board drei Slots - eine Lampe ist ein Pixel. */
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("gu10_rgb"), true));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"), true));
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -110,5 +135,6 @@ int main(void)
|
|||||||
RUN_TEST(test_pwm_boards_consistent);
|
RUN_TEST(test_pwm_boards_consistent);
|
||||||
RUN_TEST(test_sm_boards_consistent);
|
RUN_TEST(test_sm_boards_consistent);
|
||||||
RUN_TEST(test_known_values_from_old_firmware);
|
RUN_TEST(test_known_values_from_old_firmware);
|
||||||
|
RUN_TEST(test_dmx_slots);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,15 @@ static void test_off_forces_off(void)
|
|||||||
|
|
||||||
static void test_sanitizes_stored_values(void)
|
static void test_sanitizes_stored_values(void)
|
||||||
{
|
{
|
||||||
|
const nl_board_t *b = nl_board_find("e27_cw");
|
||||||
nl_light_state_t last = {true, 0, NL_MODE_COLOR, 1, 2, 3, 9000};
|
nl_light_state_t last = {true, 0, NL_MODE_COLOR, 1, 2, 3, 9000};
|
||||||
nl_light_state_t s = nl_boot_state(NL_POB_LAST, &last, nl_board_find("e27_cw"), false);
|
nl_light_state_t s = nl_boot_state(NL_POB_LAST, &last, b, false);
|
||||||
TEST_ASSERT_EQUAL_UINT8(100, s.brightness); /* invalid 0 -> 100 */
|
TEST_ASSERT_EQUAL_UINT8(100, s.brightness); /* invalid 0 -> 100 */
|
||||||
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode); /* no RGB on this board */
|
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode); /* no RGB on this board */
|
||||||
TEST_ASSERT_EQUAL_UINT16(5000, s.ct); /* clamped */
|
/* Clamped to whatever this board can do. Deliberately read from the board instead of
|
||||||
|
repeating the number: the range itself is nailed down in test_board.c, and this test
|
||||||
|
is about the clamping, not about the constant. */
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(b->ct_max, s.ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Spec 8.2: a reboot caused by an OTA is not a power cut - the light comes back effectively as
|
/* Spec 8.2: a reboot caused by an OTA is not a power cut - the light comes back effectively as
|
||||||
@@ -84,12 +88,13 @@ static void test_after_ota_ignores_pob_on(void)
|
|||||||
|
|
||||||
static void test_after_ota_still_sanitizes(void)
|
static void test_after_ota_still_sanitizes(void)
|
||||||
{
|
{
|
||||||
|
const nl_board_t *b = nl_board_find("e27_cw");
|
||||||
nl_light_state_t last = {true, 200, NL_MODE_COLOR, 1, 2, 3, 9000};
|
nl_light_state_t last = {true, 200, NL_MODE_COLOR, 1, 2, 3, 9000};
|
||||||
nl_light_state_t s = nl_boot_state(NL_POB_OFF, &last, nl_board_find("e27_cw"), true);
|
nl_light_state_t s = nl_boot_state(NL_POB_OFF, &last, b, true);
|
||||||
TEST_ASSERT_TRUE(s.on);
|
TEST_ASSERT_TRUE(s.on);
|
||||||
TEST_ASSERT_EQUAL_UINT8(100, s.brightness);
|
TEST_ASSERT_EQUAL_UINT8(100, s.brightness);
|
||||||
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode);
|
TEST_ASSERT_EQUAL(NL_MODE_WHITE, s.mode);
|
||||||
TEST_ASSERT_EQUAL_UINT16(5000, s.ct);
|
TEST_ASSERT_EQUAL_UINT16(b->ct_max, s.ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nothing stored (first boot after a migration): there is no "last" to restore, so the normal
|
/* Nothing stored (first boot after a migration): there is no "last" to restore, so the normal
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ static void test_one_percent_is_visible(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]);
|
TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_rgb_only_white_mode(void)
|
||||||
|
{
|
||||||
|
uint8_t ch[NL_CH_COUNT];
|
||||||
|
/* ct_min == ct_max: RGB-only board, white = equal RGB mix, no division. */
|
||||||
|
nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 4000);
|
||||||
|
nl_visual_to_channels(&v, 4000, 4000, ch);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_R]);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_G]);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_B]);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]);
|
||||||
|
v.bri = 100; /* 1 % stays visible */
|
||||||
|
nl_visual_to_channels(&v, 4000, 4000, ch);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, ch[NL_CH_R]);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_zero_brightness_is_dark(void)
|
static void test_zero_brightness_is_dark(void)
|
||||||
{
|
{
|
||||||
uint8_t ch[NL_CH_COUNT];
|
uint8_t ch[NL_CH_COUNT];
|
||||||
@@ -107,6 +123,7 @@ int main(void)
|
|||||||
RUN_TEST(test_white_mix_keeps_total);
|
RUN_TEST(test_white_mix_keeps_total);
|
||||||
RUN_TEST(test_color_mode);
|
RUN_TEST(test_color_mode);
|
||||||
RUN_TEST(test_one_percent_is_visible);
|
RUN_TEST(test_one_percent_is_visible);
|
||||||
|
RUN_TEST(test_rgb_only_white_mode);
|
||||||
RUN_TEST(test_zero_brightness_is_dark);
|
RUN_TEST(test_zero_brightness_is_dark);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-3
@@ -10,18 +10,24 @@ static void out(const uint8_t ch[NL_CH_COUNT], void *ctx)
|
|||||||
(void)ctx;
|
(void)ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void start(const char *board_id, bool on)
|
static void start_opts(const char *board_id, bool on, uint16_t transition_ms, bool wake)
|
||||||
{
|
{
|
||||||
const nl_board_t *b = nl_board_find(board_id);
|
const nl_board_t *b = nl_board_find(board_id);
|
||||||
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
|
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
|
||||||
nl_engine_init(&cfg);
|
nl_engine_init(&cfg);
|
||||||
nl_ctrl_init(b, 500, 800);
|
nl_ctrl_init(b, 500, 800, transition_ms, wake);
|
||||||
nl_light_state_t s = nl_boot_default_state(b);
|
nl_light_state_t s = nl_boot_default_state(b);
|
||||||
s.on = on;
|
s.on = on;
|
||||||
nl_ctrl_boot(&s);
|
nl_ctrl_boot(&s);
|
||||||
nl_engine_tick(10000); /* finish a possible boot fade */
|
nl_engine_tick(10000); /* finish a possible boot fade */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Most tests want the historical behaviour: no default transition, no wake switch. */
|
||||||
|
static void start(const char *board_id, bool on)
|
||||||
|
{
|
||||||
|
start_opts(board_id, on, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
static uint16_t bri(void)
|
static uint16_t bri(void)
|
||||||
{
|
{
|
||||||
nl_visual_t v;
|
nl_visual_t v;
|
||||||
@@ -188,13 +194,112 @@ static void test_boot_fades_in_when_on(void)
|
|||||||
const nl_board_t *b = nl_board_find("evk");
|
const nl_board_t *b = nl_board_find("evk");
|
||||||
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
|
nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL};
|
||||||
nl_engine_init(&cfg);
|
nl_engine_init(&cfg);
|
||||||
nl_ctrl_init(b, 500, 800);
|
nl_ctrl_init(b, 500, 800, 0, false);
|
||||||
nl_light_state_t s = nl_boot_default_state(b);
|
nl_light_state_t s = nl_boot_default_state(b);
|
||||||
nl_ctrl_boot(&s);
|
nl_ctrl_boot(&s);
|
||||||
nl_engine_tick(250);
|
nl_engine_tick(250);
|
||||||
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_default_transition_applies_while_on(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", true, 400, false);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_brightness = true;
|
||||||
|
c.brightness = 50;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(10000, bri()); /* not instant any more */
|
||||||
|
nl_engine_tick(200);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(7500, bri());
|
||||||
|
nl_engine_tick(200);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_explicit_transition_beats_default(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", true, 5000, false);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_brightness = true;
|
||||||
|
c.brightness = 50;
|
||||||
|
c.has_transition = true;
|
||||||
|
c.transition_ms = 100;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
nl_engine_tick(100);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_fades_beat_default_transition_when_switching(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", false, 5000, false);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_on = true;
|
||||||
|
c.on = true;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
nl_engine_tick(500); /* fade_in, not the 5 s default */
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(10000, bri());
|
||||||
|
|
||||||
|
c = chg();
|
||||||
|
c.has_on = true;
|
||||||
|
c.on = false;
|
||||||
|
nl_ctrl_apply(&c, 1000);
|
||||||
|
nl_engine_tick(800); /* fade_out */
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(0, bri());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_wake_switch_off_leaves_lamp_off(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", false, 300, false);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_ct = true;
|
||||||
|
c.ct = 3000;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
nl_light_state_t s;
|
||||||
|
nl_ctrl_get(&s);
|
||||||
|
TEST_ASSERT_FALSE(s.on);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_wake_switch_turns_lamp_on_with_fade_in(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", false, 300, true);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_ct = true;
|
||||||
|
c.ct = 3000;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
nl_light_state_t s;
|
||||||
|
nl_ctrl_get(&s);
|
||||||
|
TEST_ASSERT_TRUE(s.on);
|
||||||
|
nl_engine_tick(250); /* fade_in = 500, not the 300 ms default transition */
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(5000, bri());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_wake_switch_also_reacts_to_brightness(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", false, 300, true);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_brightness = true;
|
||||||
|
c.brightness = 40;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
nl_light_state_t s;
|
||||||
|
nl_ctrl_get(&s);
|
||||||
|
TEST_ASSERT_TRUE(s.on);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(40, s.brightness);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_explicit_off_beats_wake_switch(void)
|
||||||
|
{
|
||||||
|
start_opts("evk", false, 300, true);
|
||||||
|
nl_light_change_t c = chg();
|
||||||
|
c.has_on = true;
|
||||||
|
c.on = false;
|
||||||
|
c.has_brightness = true;
|
||||||
|
c.brightness = 40;
|
||||||
|
nl_ctrl_apply(&c, 0);
|
||||||
|
nl_light_state_t s;
|
||||||
|
nl_ctrl_get(&s);
|
||||||
|
TEST_ASSERT_FALSE(s.on);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(40, s.brightness);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -209,5 +314,12 @@ int main(void)
|
|||||||
RUN_TEST(test_persist_debounce);
|
RUN_TEST(test_persist_debounce);
|
||||||
RUN_TEST(test_change_during_write_stays_dirty);
|
RUN_TEST(test_change_during_write_stays_dirty);
|
||||||
RUN_TEST(test_boot_fades_in_when_on);
|
RUN_TEST(test_boot_fades_in_when_on);
|
||||||
|
RUN_TEST(test_default_transition_applies_while_on);
|
||||||
|
RUN_TEST(test_explicit_transition_beats_default);
|
||||||
|
RUN_TEST(test_fades_beat_default_transition_when_switching);
|
||||||
|
RUN_TEST(test_wake_switch_off_leaves_lamp_off);
|
||||||
|
RUN_TEST(test_wake_switch_turns_lamp_on_with_fade_in);
|
||||||
|
RUN_TEST(test_wake_switch_also_reacts_to_brightness);
|
||||||
|
RUN_TEST(test_explicit_off_beats_wake_switch);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+412
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ static void test_defaults(void)
|
|||||||
TEST_ASSERT_EQUAL_UINT8(NL_POB_LAST, s.power_on_behaviour);
|
TEST_ASSERT_EQUAL_UINT8(NL_POB_LAST, s.power_on_behaviour);
|
||||||
TEST_ASSERT_EQUAL_UINT16(500, s.fade_in_ms);
|
TEST_ASSERT_EQUAL_UINT16(500, s.fade_in_ms);
|
||||||
TEST_ASSERT_EQUAL_UINT16(500, s.fade_out_ms);
|
TEST_ASSERT_EQUAL_UINT16(500, s.fade_out_ms);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, s.transition_ms);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.wake_on_change);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_save_and_load(void)
|
static void test_save_and_load(void)
|
||||||
@@ -108,6 +110,19 @@ static void test_load_accepts_shorter_record(void)
|
|||||||
TEST_ASSERT_EQUAL_UINT16(500, out.fade_in_ms);
|
TEST_ASSERT_EQUAL_UINT16(500, out.fade_in_ms);
|
||||||
TEST_ASSERT_EQUAL_UINT16(500, out.fade_out_ms);
|
TEST_ASSERT_EQUAL_UINT16(500, out.fade_out_ms);
|
||||||
TEST_ASSERT_EQUAL_UINT8(0, out.ota_pending);
|
TEST_ASSERT_EQUAL_UINT8(0, out.ota_pending);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, out.transition_ms);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.wake_on_change);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_transition_and_wake_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("neu"), out;
|
||||||
|
in.transition_ms = 1500;
|
||||||
|
in.wake_on_change = 1;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1500, out.transition_ms);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.wake_on_change);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_load_rejects_longer_record(void)
|
static void test_load_rejects_longer_record(void)
|
||||||
@@ -167,10 +182,158 @@ static void test_erase(void)
|
|||||||
TEST_ASSERT_FALSE(nl_settings_load(&out));
|
TEST_ASSERT_FALSE(nl_settings_load(&out));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_sacn_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sacn_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("dmx"), out;
|
||||||
|
in.sacn_enabled = 0;
|
||||||
|
in.sacn_universe = 4711;
|
||||||
|
in.sacn_start_slot = 300;
|
||||||
|
in.sacn_hold = 1;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(4711, out.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, out.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Datensatz aus einer Firmware VOR sACN: die vier Felder muessen als echte Vorgaben
|
||||||
|
* zurueckkommen, nicht als Null - sonst waere sACN auf jeder aktualisierten Lampe aus.
|
||||||
|
* Die Laenge ist bewusst die Literalzahl 208: das war sizeof(nl_settings_t) vor diesem
|
||||||
|
* Zweig und damit die Laenge, die auf jeder ausgelieferten Lampe im Flash steht. Ein
|
||||||
|
* offsetof() auf ein heutiges Feld wuerde mit der Struktur mitwandern und genau den Fall
|
||||||
|
* verfehlen, um den es hier geht. Das letzte Byte (207) war im 208-Byte-Satz reines
|
||||||
|
* Fuellbyte und daher 0 - es wird hier nachgestellt, denn genau dieses Byte legt sich
|
||||||
|
* beim Lesen ueber das erste neue Feld. */
|
||||||
|
static void test_record_without_sacn_gets_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("alt"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 208u; /* sizeof(nl_settings_t) vor sACN */
|
||||||
|
fake_flash_mem[NL_FL_SETTINGS_A + 12 + 207] = 0; /* Fuellbyte des alten Satzes */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ninHome", out.wifi_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Satzlaenge muss durch 4 teilbar bleiben, sonst laufen Schreiber und Leser beim CRC
|
||||||
|
* auseinander (siehe NL_SETTINGS_CRC_OFF in nl_settings.c). */
|
||||||
|
static void test_sacn_layout_defaults_to_rgb(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sacn_layout_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("layout"), out;
|
||||||
|
in.sacn_layout = 0; /* weg von der Vorgabe, sonst prueft der Test nur diese */
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Derselbe Fallstrick wie bei sacn_enabled, eine Feldgeneration weiter: ein Satz, der von
|
||||||
|
* einer Firmware MIT sACN, aber OHNE layout geschrieben wurde, ist 216 Byte lang. Laege
|
||||||
|
* sacn_layout darunter, kaeme es als Fuellbyte dieses Satzes zurueck statt als Vorgabe. */
|
||||||
|
static void test_record_without_layout_gets_the_default(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("vor-layout"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
in.sacn_layout = 0; /* der abgeschnittene Satz darf diese 0 nicht zurueckliefern */
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 216u; /* sizeof(nl_settings_t) vor dem Layout-Feld */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("vor-layout", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.ap_channel); /* 0 heisst "Vorgabe 6" */
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.standalone_pulse);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", s.ap_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", s.ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("ap"), out;
|
||||||
|
in.standalone = 1;
|
||||||
|
in.ap_channel = 11;
|
||||||
|
in.standalone_pulse = 0;
|
||||||
|
strcpy(in.ap_ssid, "Wohnzimmerlampe");
|
||||||
|
strcpy(in.ap_pass, "geheim123");
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(11, out.ap_channel);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.standalone_pulse);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", out.ap_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", out.ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Datensatz aus einer Firmware VOR dem Standalone-Modus: die neuen Felder muessen
|
||||||
|
* als echte Vorgaben zurueckkommen, nicht als Null. Sonst stuende standalone_pulse nach
|
||||||
|
* dem Update auf jeder Lampe auf 0 - der Startpuls waere still abgeschaltet. */
|
||||||
|
static void test_record_without_standalone_gets_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("alt"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 220u; /* sizeof(nl_settings_t) vor diesem Zweig */
|
||||||
|
memset(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + 217], 0, 3); /* Fuellbytes */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.standalone_pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_record_size_stays_aligned(void)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
RUN_TEST(test_defaults);
|
RUN_TEST(test_defaults);
|
||||||
|
RUN_TEST(test_transition_and_wake_roundtrip);
|
||||||
RUN_TEST(test_save_and_load);
|
RUN_TEST(test_save_and_load);
|
||||||
RUN_TEST(test_alternates_slots);
|
RUN_TEST(test_alternates_slots);
|
||||||
RUN_TEST(test_power_loss_keeps_previous);
|
RUN_TEST(test_power_loss_keeps_previous);
|
||||||
@@ -180,5 +343,15 @@ int main(void)
|
|||||||
RUN_TEST(test_ota_pending_default_and_roundtrip);
|
RUN_TEST(test_ota_pending_default_and_roundtrip);
|
||||||
RUN_TEST(test_crc_offset_matches_reader);
|
RUN_TEST(test_crc_offset_matches_reader);
|
||||||
RUN_TEST(test_erase);
|
RUN_TEST(test_erase);
|
||||||
|
RUN_TEST(test_sacn_defaults);
|
||||||
|
RUN_TEST(test_sacn_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_sacn_gets_defaults);
|
||||||
|
RUN_TEST(test_sacn_layout_defaults_to_rgb);
|
||||||
|
RUN_TEST(test_sacn_layout_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_layout_gets_the_default);
|
||||||
|
RUN_TEST(test_record_size_stays_aligned);
|
||||||
|
RUN_TEST(test_standalone_defaults);
|
||||||
|
RUN_TEST(test_standalone_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_standalone_gets_defaults);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+284
-12
@@ -10,7 +10,7 @@ void tearDown(void) {}
|
|||||||
|
|
||||||
static void test_start_without_credentials_opens_ap(void)
|
static void test_start_without_credentials_opens_ap(void)
|
||||||
{
|
{
|
||||||
a = nl_wifi_sm_start(&sm, false, 0);
|
a = nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
TEST_ASSERT_TRUE(a.start_ap);
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
TEST_ASSERT_TRUE(a.pulse_on);
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
TEST_ASSERT_FALSE(a.connect);
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
@@ -21,7 +21,7 @@ static void test_start_without_credentials_opens_ap(void)
|
|||||||
|
|
||||||
static void test_start_with_credentials_connects(void)
|
static void test_start_with_credentials_connects(void)
|
||||||
{
|
{
|
||||||
a = nl_wifi_sm_start(&sm, true, 0);
|
a = nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
TEST_ASSERT_TRUE(a.connect);
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
TEST_ASSERT_FALSE(a.use_test_creds);
|
TEST_ASSERT_FALSE(a.use_test_creds);
|
||||||
TEST_ASSERT_FALSE(a.start_ap);
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
@@ -33,7 +33,7 @@ static void test_start_with_credentials_connects(void)
|
|||||||
|
|
||||||
static void test_retry_with_backoff(void)
|
static void test_retry_with_backoff(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 1000);
|
||||||
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).connect);
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).connect);
|
||||||
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000).connect);
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000).connect);
|
||||||
@@ -50,7 +50,7 @@ static void test_retry_with_backoff(void)
|
|||||||
|
|
||||||
static void test_fallback_after_five_minutes(void)
|
static void test_fallback_after_five_minutes(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_NOT_FOUND, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_NOT_FOUND, 1000);
|
||||||
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1);
|
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1);
|
||||||
TEST_ASSERT_FALSE(a.start_ap);
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
@@ -74,7 +74,7 @@ static void test_fallback_after_five_minutes(void)
|
|||||||
|
|
||||||
static void test_disconnect_while_connected(void)
|
static void test_disconnect_while_connected(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, 100000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, 100000);
|
||||||
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
|
||||||
@@ -86,7 +86,7 @@ static void test_disconnect_while_connected(void)
|
|||||||
|
|
||||||
static void test_onboarding_success(void)
|
static void test_onboarding_success(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
a = nl_wifi_sm_onboard(&sm, 5000);
|
a = nl_wifi_sm_onboard(&sm, 5000);
|
||||||
TEST_ASSERT_FALSE(a.start_ap); /* already up */
|
TEST_ASSERT_FALSE(a.start_ap); /* already up */
|
||||||
TEST_ASSERT_TRUE(a.connect);
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
@@ -106,7 +106,7 @@ static void test_onboarding_success(void)
|
|||||||
|
|
||||||
static void test_onboarding_failure_without_credentials(void)
|
static void test_onboarding_failure_without_credentials(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000);
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000);
|
||||||
TEST_ASSERT_FALSE(a.pulse_off);
|
TEST_ASSERT_FALSE(a.pulse_off);
|
||||||
@@ -121,7 +121,7 @@ static void test_onboarding_failure_without_credentials(void)
|
|||||||
|
|
||||||
static void test_onboarding_timeout(void)
|
static void test_onboarding_timeout(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
||||||
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS - 1);
|
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS - 1);
|
||||||
@@ -136,7 +136,7 @@ static void test_onboarding_connected_event_while_ap_onboarding_is_stray(void)
|
|||||||
{
|
{
|
||||||
/* I1: fail_test() returns to AP_ONBOARDING; a late CONNECTED for the timed-out
|
/* I1: fail_test() returns to AP_ONBOARDING; a late CONNECTED for the timed-out
|
||||||
* attempt must be treated as stray and disconnected, not accepted. */
|
* attempt must be treated as stray and disconnected, not accepted. */
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS); /* -> AP_ONBOARDING */
|
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS); /* -> AP_ONBOARDING */
|
||||||
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 40000);
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 40000);
|
||||||
@@ -146,7 +146,7 @@ static void test_onboarding_connected_event_while_ap_onboarding_is_stray(void)
|
|||||||
|
|
||||||
static void test_onboarding_while_connected_falls_back_to_old_credentials(void)
|
static void test_onboarding_while_connected_falls_back_to_old_credentials(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
a = nl_wifi_sm_onboard(&sm, 2000);
|
a = nl_wifi_sm_onboard(&sm, 2000);
|
||||||
TEST_ASSERT_TRUE(a.start_ap);
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
@@ -167,7 +167,7 @@ static void test_onboard_while_test_running_disconnects_stale_attempt(void)
|
|||||||
{
|
{
|
||||||
/* M1: a repeated onboarding request while a test connect is already in flight
|
/* M1: a repeated onboarding request while a test connect is already in flight
|
||||||
* must disconnect first so stale events from the previous attempt don't count. */
|
* must disconnect first so stale events from the previous attempt don't count. */
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
||||||
a = nl_wifi_sm_onboard(&sm, 6000);
|
a = nl_wifi_sm_onboard(&sm, 6000);
|
||||||
@@ -185,7 +185,7 @@ static void test_error_names(void)
|
|||||||
|
|
||||||
static void test_connect_without_any_event_retries(void)
|
static void test_connect_without_any_event_retries(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 29999).connect);
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 29999).connect);
|
||||||
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 30000).connect); /* retry scheduled at 31000 */
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 30000).connect); /* retry scheduled at 31000 */
|
||||||
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 31000).connect);
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 31000).connect);
|
||||||
@@ -202,6 +202,257 @@ static void test_connect_without_any_event_retries(void)
|
|||||||
TEST_ASSERT_TRUE(a.connect);
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_standalone_opens_ap_without_connecting(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ap", nl_wifi_sm_mode(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Standalone-AP ist KEIN offener Onboarding-AP. Haengt diese Zusicherung nicht,
|
||||||
|
* koennte jeder in Funkreichweite die Lampe ohne Token flashen oder zuruecksetzen. */
|
||||||
|
static void test_standalone_is_not_open_access(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das Fenster, das der Ruhezustand nicht abdeckt: Onboarding-Test aus dem Standalone-Modus.
|
||||||
|
* Dann ist state == ONBOARD_TEST und have_creds == false - ohne !standalone in open_access()
|
||||||
|
* fiele der Tokenschutz bis zu 30 s lang, waehrend der passwortgeschuetzte AP oben steht. */
|
||||||
|
static void test_standalone_onboard_test_is_not_open_access(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
||||||
|
TEST_ASSERT_FALSE(sm.have_creds);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gespeicherte WLAN-Daten aendern nichts: wer standalone gewaehlt hat, will keinen
|
||||||
|
* Verbindungsversuch, auch wenn von frueher noch Zugangsdaten herumliegen. */
|
||||||
|
static void test_standalone_wins_over_stored_credentials(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kein Rueckfall und kein Wiederholungsversuch: die Maschine darf im Standalone-Modus
|
||||||
|
* nie von selbst anfangen, ein WLAN zu suchen. */
|
||||||
|
static void test_standalone_never_falls_back(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS + 60000);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(a.stop_ap);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein verirrter Erfolg eines alten Versuchs darf die Lampe nicht aus ihrem AP holen. */
|
||||||
|
static void test_standalone_drops_a_stray_connect(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Puls ist zeitlich begrenzt, und das ist keine Kosmetik: nl_engine_pulse() legt sich
|
||||||
|
* ueber die normale Ausgabe und uebermalt auch sACN. Am 2026-10-01 am EVK beobachtet -
|
||||||
|
* ein gueltiger sACN-Strom kam an, waehrend die Lampe weiter den Puls zeigte. Ein
|
||||||
|
* dauerhafter Puls machte sACN im Standalone-Modus unbrauchbar. */
|
||||||
|
static void test_standalone_pulse_stops_by_itself(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, false, true, true, 0);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS - 1).pulse_off);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
/* und danach nicht noch einmal */
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS + 5000).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_without_pulse_stays_dark(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_on);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erfolg: die Lampe wechselt ins WLAN UND verlaesst den Standalone-Modus dauerhaft.
|
||||||
|
* Ohne clear_standalone staende sie nach dem naechsten Neustart wieder im eigenen AP. */
|
||||||
|
static void test_onboarding_from_standalone_clears_the_mode(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 2000);
|
||||||
|
TEST_ASSERT_TRUE(a.save_test_creds);
|
||||||
|
TEST_ASSERT_TRUE(a.clear_standalone);
|
||||||
|
TEST_ASSERT_FALSE(sm.standalone);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTED, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fehlschlag: zurueck in den EIGENEN AP, nicht in den offenen Onboarding-AP. Sonst
|
||||||
|
* verloere ein Tippfehler im WLAN-Passwort den vom Nutzer gesetzten AP-Namen samt
|
||||||
|
* Passwort und entsicherte die Lampe, weil open_access() dann wieder true waere. */
|
||||||
|
static void test_failed_onboarding_returns_to_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(sm.standalone);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
TEST_ASSERT_FALSE(a.clear_standalone);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ERR_AUTH, sm.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auch der Zeitablauf des Tests fuehrt zurueck in den eigenen AP. */
|
||||||
|
static void test_onboarding_timeout_returns_to_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_ONBOARD_TIMEOUT_MS);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ERR_TIMEOUT, sm.error);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der bisherige Weg bleibt unberuehrt: ohne Standalone und ohne gespeicherte Daten
|
||||||
|
* landet ein gescheiterter Test weiterhin im offenen Onboarding-AP. */
|
||||||
|
static void test_failed_onboarding_without_standalone_opens_ap(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_AP_ONBOARDING, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aus dem laufenden Stationsbetrieb in den eigenen AP. */
|
||||||
|
static void test_switching_to_standalone_at_runtime(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
|
a = nl_wifi_sm_standalone(&sm, true, 2000);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(sm.standalone);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000 + NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zuruecknehmen MIT gespeicherten Zugangsdaten: ab ins WLAN, nicht in den offenen AP. */
|
||||||
|
static void test_leaving_standalone_with_credentials_connects(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
a = nl_wifi_sm_leave_standalone(&sm, true, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
TEST_ASSERT_FALSE(sm.standalone);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zuruecknehmen OHNE Zugangsdaten: offener Onboarding-AP, damit man wieder herankommt. */
|
||||||
|
static void test_leaving_standalone_without_credentials_opens_onboarding(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
a = nl_wifi_sm_leave_standalone(&sm, false, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_AP_ONBOARDING, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Laeuft beim Start eines Onboarding-Tests noch der Startpuls, raeumt onboard() ihn ab:
|
||||||
|
* pulse_off sofort, danach kein verwaister pulse_off mehr -- weder nach einem
|
||||||
|
* gescheiterten noch nach einem gelungenen Test. */
|
||||||
|
static void test_onboard_cancels_running_start_pulse(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, true, 0);
|
||||||
|
a = nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_off);
|
||||||
|
TEST_ASSERT_FALSE(sm.pulse_armed);
|
||||||
|
TEST_ASSERT_FALSE(sm.pulsing);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 6000);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_off);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 20000).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der eigentliche Spec-Fall: gespeicherte Zugangsdaten UND Standalone, der Test scheitert.
|
||||||
|
* Die Lampe darf nicht in den Rueckfall (APSTA_FALLBACK) mit den alten Daten gehen und
|
||||||
|
* damit ihren eigenen AP verlassen, sondern bleibt im Standalone-AP. */
|
||||||
|
static void test_failed_onboarding_with_credentials_stays_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_onboarding_timeout_with_credentials_stays_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_ONBOARD_TIMEOUT_MS);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nach gelungenem Onboarding laeuft noch der AP-Stopptimer. Schaltet man jetzt auf
|
||||||
|
* Standalone, muss er weg sein, sonst schaltet tick() den AP ab und die Lampe ist
|
||||||
|
* ueber nichts mehr erreichbar. */
|
||||||
|
static void test_standalone_cancels_pending_ap_stop(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 2000);
|
||||||
|
nl_wifi_sm_standalone(&sm, false, 3000);
|
||||||
|
a = nl_wifi_sm_tick(&sm, 2000 + NL_WIFI_AP_CLOSE_ONBOARD_MS);
|
||||||
|
TEST_ASSERT_FALSE(a.stop_ap);
|
||||||
|
TEST_ASSERT_TRUE(sm.ap_up);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein unbefristeter Puls aus dem Onboarding-AP muss beim Wechsel ohne Puls enden, denn
|
||||||
|
* im Standalone-Modus gibt es keinen Timer mehr, der ihn beendete. */
|
||||||
|
static void test_standalone_without_pulse_ends_running_pulse(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
TEST_ASSERT_TRUE(sm.pulsing);
|
||||||
|
a = nl_wifi_sm_standalone(&sm, false, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_off);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_on);
|
||||||
|
TEST_ASSERT_FALSE(sm.pulsing);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* onboard() aus dem Onboarding-AP laesst den unbefristeten Puls in Ruhe. */
|
||||||
|
static void test_onboard_from_ap_onboarding_keeps_pulse(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
a = nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_off);
|
||||||
|
TEST_ASSERT_TRUE(sm.pulsing);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -218,5 +469,26 @@ int main(void)
|
|||||||
RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt);
|
RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt);
|
||||||
RUN_TEST(test_error_names);
|
RUN_TEST(test_error_names);
|
||||||
RUN_TEST(test_connect_without_any_event_retries);
|
RUN_TEST(test_connect_without_any_event_retries);
|
||||||
|
RUN_TEST(test_standalone_opens_ap_without_connecting);
|
||||||
|
RUN_TEST(test_standalone_is_not_open_access);
|
||||||
|
RUN_TEST(test_standalone_onboard_test_is_not_open_access);
|
||||||
|
RUN_TEST(test_standalone_wins_over_stored_credentials);
|
||||||
|
RUN_TEST(test_standalone_never_falls_back);
|
||||||
|
RUN_TEST(test_standalone_drops_a_stray_connect);
|
||||||
|
RUN_TEST(test_standalone_pulse_stops_by_itself);
|
||||||
|
RUN_TEST(test_standalone_without_pulse_stays_dark);
|
||||||
|
RUN_TEST(test_onboarding_from_standalone_clears_the_mode);
|
||||||
|
RUN_TEST(test_failed_onboarding_returns_to_standalone);
|
||||||
|
RUN_TEST(test_onboarding_timeout_returns_to_standalone);
|
||||||
|
RUN_TEST(test_failed_onboarding_without_standalone_opens_ap);
|
||||||
|
RUN_TEST(test_switching_to_standalone_at_runtime);
|
||||||
|
RUN_TEST(test_leaving_standalone_with_credentials_connects);
|
||||||
|
RUN_TEST(test_leaving_standalone_without_credentials_opens_onboarding);
|
||||||
|
RUN_TEST(test_onboard_cancels_running_start_pulse);
|
||||||
|
RUN_TEST(test_failed_onboarding_with_credentials_stays_standalone);
|
||||||
|
RUN_TEST(test_onboarding_timeout_with_credentials_stays_standalone);
|
||||||
|
RUN_TEST(test_standalone_cancels_pending_ap_stop);
|
||||||
|
RUN_TEST(test_standalone_without_pulse_ends_running_pulse);
|
||||||
|
RUN_TEST(test_onboard_from_ap_onboarding_keeps_pulse);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
OUT=${BUILD_ROOT:-$ROOT/build}
|
OUT=${BUILD_ROOT:-$ROOT/build}
|
||||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||||
for d in "${DEVICES[@]}"; do
|
for d in "${DEVICES[@]}"; do
|
||||||
echo "== $d"
|
echo "== $d"
|
||||||
cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \
|
cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \
|
||||||
|
|||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit
|
||||||
|
# im Onboarding-AP (ninlight-<mac4>), statt sich in ein fremdes WLAN einzuwaehlen.
|
||||||
|
#
|
||||||
|
# Aufruf: tools/build_nocreds.sh [ziel ...] (ohne Argumente: alle Ziele)
|
||||||
|
#
|
||||||
|
# Ergebnis: je Ziel ein Build-Verzeichnis unter $NINLIGHT_NOCREDS_BUILD_REL (Vorgabe
|
||||||
|
# "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten
|
||||||
|
# (ninlight_<ziel>_gz.img, ninlight_<ziel>_ota.img, ...). Bewusst NICHT build/: die
|
||||||
|
# dortigen Abzuege sind die des Entwicklers - mit seinem WLAN - und bleiben unangetastet.
|
||||||
|
#
|
||||||
|
# Dieses Skript ist ausdruecklich zum Wiederverwenden da: tools/release.sh baut damit das
|
||||||
|
# Auslieferungsarchiv, und die Migrationsstation wird damit die Wahl "Lampe startet im
|
||||||
|
# Onboarding-AP" bedienen. Deshalb steht die Logik hier und nicht in release.sh.
|
||||||
|
#
|
||||||
|
# Umgebungsvariablen:
|
||||||
|
# NINLIGHT_DOCKER Build-Vorspann (Vorgabe tools/docker.sh); Tests stubben ihn
|
||||||
|
# NINLIGHT_NOCREDS_BUILD_REL Ausgabeverzeichnis relativ zur Repo-Wurzel
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh}
|
||||||
|
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||||
|
|
||||||
|
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
TARGETS=("$@")
|
||||||
|
else
|
||||||
|
TARGETS=("${DEVICES[@]}")
|
||||||
|
fi
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
ok=0
|
||||||
|
for d in "${DEVICES[@]}"; do [ "$t" = "$d" ] && ok=1; done
|
||||||
|
[ "$ok" -eq 1 ] || { echo "Fehler: unbekanntes Ziel '$t' (bekannt: ${DEVICES[*]})" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# config.local.cmake wird in CMakeLists.txt mit gewoehnlichem set() eingebunden und
|
||||||
|
# SCHLAEGT damit jedes -D auf der Kommandozeile. Ein Bau ohne Zugangsdaten ist deshalb
|
||||||
|
# nur moeglich, wenn die Datei fuer die Dauer des Baus beiseite liegt. In ihr stehen die
|
||||||
|
# echte SSID, das WLAN-Passwort und das API-Token - sie muss auch dann zurueckkommen,
|
||||||
|
# wenn der Bau abbricht oder jemand Strg-C drueckt. Darum ein trap auf EXIT UND auf
|
||||||
|
# INT/TERM (EXIT allein laeuft bei einem Signal zwar auch, aber der Exit-Code soll das
|
||||||
|
# Signal widerspiegeln, statt als Erfolg durchzugehen).
|
||||||
|
LOCAL="$ROOT/config.local.cmake"
|
||||||
|
STASH_DIR=""
|
||||||
|
restore_local() {
|
||||||
|
[ -n "$STASH_DIR" ] || return 0
|
||||||
|
if [ -f "$STASH_DIR/config.local.cmake" ]; then
|
||||||
|
cp -p "$STASH_DIR/config.local.cmake" "$LOCAL" || {
|
||||||
|
echo "ACHTUNG: config.local.cmake konnte nicht zurueckgelegt werden - Kopie liegt unter $STASH_DIR" >&2
|
||||||
|
STASH_DIR=""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
rm -rf "$STASH_DIR"
|
||||||
|
STASH_DIR=""
|
||||||
|
}
|
||||||
|
if [ -f "$LOCAL" ]; then
|
||||||
|
STASH_DIR=$(mktemp -d)
|
||||||
|
cp -p "$LOCAL" "$STASH_DIR/config.local.cmake"
|
||||||
|
trap 'restore_local' EXIT
|
||||||
|
trap 'restore_local; trap - INT; kill -INT $$' INT
|
||||||
|
trap 'restore_local; exit 143' TERM
|
||||||
|
rm -f "$LOCAL"
|
||||||
|
echo "config.local.cmake liegt fuer diesen Bau beiseite (keine WLAN-Zugangsdaten im Abzug)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
echo "== $t (ohne Zugangsdaten)"
|
||||||
|
# Relative Pfade, weil der Bau ueblicherweise im Container laeuft, der das Repo unter
|
||||||
|
# /work einhaengt - absolute Hostpfade gaebe es dort nicht.
|
||||||
|
"$DOCKER" cmake -S . -B "$BUILD_REL/$t" -G Ninja \
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE="$t" >/dev/null
|
||||||
|
"$DOCKER" cmake --build "$BUILD_REL/$t"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "fertig - Abzuege ohne Zugangsdaten unter $ROOT/$BUILD_REL"
|
||||||
+1
-1
@@ -2,6 +2,6 @@
|
|||||||
# Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4).
|
# Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4).
|
||||||
# Usage: tools/docker.sh cmake --build build/evk
|
# Usage: tools/docker.sh cmake --build build/evk
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
|
||||||
docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
|
docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
|
||||||
exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
|
exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ set -euo pipefail
|
|||||||
DEV=${1:?usage: tools/flash.sh <device> <port> [full]}
|
DEV=${1:?usage: tools/flash.sh <device> <port> [full]}
|
||||||
PORT=${2:?usage: tools/flash.sh <device> <port> [full]}
|
PORT=${2:?usage: tools/flash.sh <device> <port> [full]}
|
||||||
MODE=${3:-app}
|
MODE=${3:-app}
|
||||||
|
# wm_tool prepends /dev/ itself - accept either spelling.
|
||||||
|
PORT=${PORT#/dev/}
|
||||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
TOOL="$ROOT/build/host-tools/wm_tool"
|
TOOL="$ROOT/build/host-tools/wm_tool"
|
||||||
if [ ! -x "$TOOL" ] || [ "$ROOT/sdk/tools/wm_tool.c" -nt "$TOOL" ]; then
|
if [ ! -x "$TOOL" ] || [ "$ROOT/sdk/tools/wm_tool.c" -nt "$TOOL" ]; then
|
||||||
|
|||||||
@@ -4,7 +4,19 @@
|
|||||||
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
|
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
|
||||||
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
|
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
|
||||||
firmware hashes while writing the image to flash.
|
firmware hashes while writing the image to flash.
|
||||||
|
|
||||||
|
Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the
|
||||||
|
x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware
|
||||||
|
build, and that container has not been upgraded past 3.8. This is independent of the
|
||||||
|
"Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that
|
||||||
|
runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this
|
||||||
|
file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`)
|
||||||
|
in annotations stay text and are never evaluated - without it, that syntax raises
|
||||||
|
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See
|
||||||
|
test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
import sys
|
||||||
@@ -19,18 +31,75 @@ def field(value: str, name: str) -> bytes:
|
|||||||
raw = value.encode()
|
raw = value.encode()
|
||||||
if len(raw) >= FIELD:
|
if len(raw) >= FIELD:
|
||||||
sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)")
|
sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)")
|
||||||
|
if any(c.isspace() for c in value):
|
||||||
|
sys.exit(f"error: {name} '{value}' contains whitespace")
|
||||||
return raw.ljust(FIELD, b"\x00")
|
return raw.ljust(FIELD, b"\x00")
|
||||||
|
|
||||||
|
|
||||||
|
def verify(path: Path, expect_device: str | None) -> int:
|
||||||
|
"""Checks an _ota.img the way the firmware does before accepting it."""
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if len(data) <= TRAILER_SIZE:
|
||||||
|
print(f"error: {path} is too short to carry a trailer", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:]
|
||||||
|
if trailer[0:4] != b"NINL":
|
||||||
|
print(f"error: {path} has no ninlight trailer", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if trailer[4] != TRAILER_VERSION:
|
||||||
|
print(f"error: {path} has trailer version {trailer[4]}, expected {TRAILER_VERSION}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# Device field must be null-terminated within its 16 bytes (same as firmware check)
|
||||||
|
device_field = trailer[5:21]
|
||||||
|
if b"\x00" not in device_field:
|
||||||
|
print(f"error: {path} has unterminated device field", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
device = device_field.rstrip(b"\x00").decode("ascii", "replace")
|
||||||
|
version = trailer[21:37].rstrip(b"\x00").decode("ascii", "replace")
|
||||||
|
if expect_device is not None and device != expect_device:
|
||||||
|
print(f"error: {path} carries device '{device}', expected '{expect_device}'",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if trailer[37:69] != hashlib.sha256(payload).digest():
|
||||||
|
print(f"error: {path} fails its sha256 - the payload does not match the trailer",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"{device} {version} {len(data)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
p = argparse.ArgumentParser(description="append the ninlight OTA trailer")
|
p = argparse.ArgumentParser(description="append the ninlight OTA trailer")
|
||||||
p.add_argument("--image", required=True, type=Path, help="input _gz.img")
|
p.add_argument("--image", type=Path, help="input _gz.img")
|
||||||
p.add_argument("--device", required=True, help="device id, e.g. e27_rgb")
|
p.add_argument("--device", help="device id, e.g. e27_rgb")
|
||||||
p.add_argument("--version", required=True, help="firmware version")
|
p.add_argument("--version", help="firmware version")
|
||||||
p.add_argument("--out", required=True, type=Path, help="output _ota.img")
|
p.add_argument("--out", type=Path, help="output _ota.img")
|
||||||
|
p.add_argument("--verify", type=Path, help="check an existing _ota.img instead of building one")
|
||||||
|
p.add_argument("--expect-device", help="with --verify: require this device id")
|
||||||
a = p.parse_args()
|
a = p.parse_args()
|
||||||
|
|
||||||
|
if a.verify is not None:
|
||||||
|
# Verify mode: check that build arguments are not provided
|
||||||
|
build_args = ("image", "device", "version", "out")
|
||||||
|
if any(getattr(a, name) is not None for name in build_args):
|
||||||
|
p.error("--verify cannot be combined with build mode arguments (--image, --device, --version, --out)")
|
||||||
|
raise SystemExit(verify(a.verify, a.expect_device))
|
||||||
|
|
||||||
|
# Build mode: require all arguments
|
||||||
|
for name in ("image", "device", "version", "out"):
|
||||||
|
if getattr(a, name) is None:
|
||||||
|
p.error(f"--{name} is required unless --verify is given")
|
||||||
|
|
||||||
|
try:
|
||||||
data = a.image.read_bytes()
|
data = a.image.read_bytes()
|
||||||
|
except OSError as e:
|
||||||
|
sys.exit(f"error: {e}")
|
||||||
|
|
||||||
if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL":
|
if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL":
|
||||||
sys.exit("error: image already carries a trailer")
|
sys.exit("error: image already carries a trailer")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Signiert ein W600-Firmwareimage so, wie es die alte Klyqa-Firmware erwartet.
|
||||||
|
|
||||||
|
Format: RSA-4096 PKCS#1 v1.5 ueber den SHA256 der GESAMTEN Datei, angehaengt als
|
||||||
|
exakt 512 Byte. Belegt durch Check_OTA_Image() in qc_ssl_layer.c der alten Firmware
|
||||||
|
und durch sign_w600_fw.py im Schluesselverzeichnis.
|
||||||
|
|
||||||
|
Die 512 Byte belegen denselben Block wie der ninlight-Trailer. Ein Migrationsimage
|
||||||
|
traegt deshalb KEINEN Trailer - die alte Firmware prueft die Signatur, die neue
|
||||||
|
prueft an dieser Stelle nichts mehr, weil sie dann schon laeuft.
|
||||||
|
|
||||||
|
Muss unter Python 3.8.10 laufen (siehe nl_mkimage.py fuer den Hintergrund): keine
|
||||||
|
`str | None`-Annotationen ohne `from __future__ import annotations`, kein
|
||||||
|
`list[str]` zur Laufzeit.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
SIG_LEN = 512
|
||||||
|
TRAILER_MAGIC = b"NINL"
|
||||||
|
KEY_BITS = 4096
|
||||||
|
|
||||||
|
|
||||||
|
class SignError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestKey(NamedTuple):
|
||||||
|
private_pem: bytes
|
||||||
|
public_der: bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _backend():
|
||||||
|
"""Liefert ein einheitliches (sign, verify, keygen)-Tripel, egal ob `cryptography`
|
||||||
|
oder `pycryptodome`/`pycryptodomex` installiert ist - auf keinem Rechner ist sicher
|
||||||
|
dieselbe Bibliothek vorhanden."""
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
|
||||||
|
def sign(private_pem: bytes, data: bytes) -> bytes:
|
||||||
|
try:
|
||||||
|
key = serialization.load_pem_private_key(private_pem, password=None)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise SignError(
|
||||||
|
"privater Schluessel ist kein gueltiger PEM-Schluessel") from None
|
||||||
|
if key.key_size != KEY_BITS:
|
||||||
|
raise SignError(
|
||||||
|
f"Schluessel hat {key.key_size} Bit, benoetigt werden {KEY_BITS}")
|
||||||
|
return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
|
||||||
|
def verify(public_key_bytes: bytes, data: bytes, signature: bytes) -> bool:
|
||||||
|
# akzeptiert sowohl DER als auch PEM, wie im CLI-Interface vorgesehen
|
||||||
|
try:
|
||||||
|
key = serialization.load_der_public_key(public_key_bytes)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = serialization.load_pem_public_key(public_key_bytes)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise SignError(
|
||||||
|
"oeffentlicher Schluessel ist weder gueltiges DER noch PEM") from None
|
||||||
|
try:
|
||||||
|
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
return True
|
||||||
|
except InvalidSignature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def keygen() -> TestKey:
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||||
|
private_pem = key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
public_der = key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
return TestKey(private_pem, public_der)
|
||||||
|
|
||||||
|
return sign, verify, keygen
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from Cryptodome.Signature import pkcs1_15
|
||||||
|
from Cryptodome.Hash import SHA256
|
||||||
|
from Cryptodome.PublicKey import RSA
|
||||||
|
except ImportError:
|
||||||
|
from Crypto.Signature import pkcs1_15
|
||||||
|
from Crypto.Hash import SHA256
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
|
||||||
|
def sign(private_pem: bytes, data: bytes) -> bytes:
|
||||||
|
try:
|
||||||
|
key = RSA.import_key(private_pem)
|
||||||
|
except (ValueError, TypeError, IndexError):
|
||||||
|
raise SignError(
|
||||||
|
"privater Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
|
||||||
|
if key.size_in_bits() != KEY_BITS:
|
||||||
|
raise SignError(
|
||||||
|
f"Schluessel hat {key.size_in_bits()} Bit, benoetigt werden {KEY_BITS}")
|
||||||
|
digest = SHA256.new(data)
|
||||||
|
return pkcs1_15.new(key).sign(digest)
|
||||||
|
|
||||||
|
def verify(public_der: bytes, data: bytes, signature: bytes) -> bool:
|
||||||
|
try:
|
||||||
|
key = RSA.import_key(public_der)
|
||||||
|
except (ValueError, TypeError, IndexError):
|
||||||
|
raise SignError(
|
||||||
|
"oeffentlicher Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
|
||||||
|
digest = SHA256.new(data)
|
||||||
|
try:
|
||||||
|
pkcs1_15.new(key).verify(digest, signature)
|
||||||
|
return True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def keygen() -> TestKey:
|
||||||
|
key = RSA.generate(KEY_BITS)
|
||||||
|
private_pem = key.export_key("PEM")
|
||||||
|
public_der = key.publickey().export_key("DER")
|
||||||
|
return TestKey(private_pem, public_der)
|
||||||
|
|
||||||
|
return sign, verify, keygen
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise SignError(
|
||||||
|
"weder 'cryptography' noch 'pycryptodome'/'pycryptodomex' ist installiert")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test_key() -> TestKey:
|
||||||
|
"""Nur fuer Tests: erzeugt einen frischen 4096-Bit-Schluessel im Speicher.
|
||||||
|
Schreibt niemals eine Datei."""
|
||||||
|
_sign, _verify, keygen = _backend()
|
||||||
|
return keygen()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_raw(raw: bytes) -> None:
|
||||||
|
if len(raw) < 1024:
|
||||||
|
raise SignError(f"Eingabe ist mit {len(raw)} Byte kuerzer als die Mindestlaenge 1024")
|
||||||
|
if len(raw) % 4 != 0:
|
||||||
|
raise SignError(f"Eingabe ist mit {len(raw)} Byte nicht durch 4 teilbar")
|
||||||
|
if raw[-SIG_LEN:][:4] == TRAILER_MAGIC:
|
||||||
|
raise SignError(
|
||||||
|
"Eingabe traegt bereits einen ninlight-Trailer - das ist ein OTA-Image, "
|
||||||
|
"kein Rohbild")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_image(raw: bytes, key_pem: bytes) -> bytes:
|
||||||
|
"""Haengt eine 512-Byte-PKCS#1-v1.5-Signatur ueber den SHA256 von `raw` an."""
|
||||||
|
_check_raw(raw)
|
||||||
|
sign, _verify, _keygen = _backend()
|
||||||
|
signature = sign(key_pem, raw)
|
||||||
|
if len(signature) != SIG_LEN:
|
||||||
|
raise SignError(
|
||||||
|
f"Signatur ist {len(signature)} Byte lang, erwartet werden {SIG_LEN}")
|
||||||
|
return raw + signature
|
||||||
|
|
||||||
|
|
||||||
|
def verify_image(signed: bytes, pub_der: bytes) -> bool:
|
||||||
|
"""Prueft eine mit `sign_image` erzeugte Signatur. Gibt bei ungueltiger Signatur
|
||||||
|
False zurueck; wirft nur bei echten Formatfehlern."""
|
||||||
|
if len(signed) <= SIG_LEN:
|
||||||
|
raise SignError(f"Eingabe ist mit {len(signed)} Byte zu kurz fuer eine Signatur")
|
||||||
|
payload, signature = signed[:-SIG_LEN], signed[-SIG_LEN:]
|
||||||
|
_sign, verify, _keygen = _backend()
|
||||||
|
return verify(pub_der, payload, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_sign(args: argparse.Namespace) -> int:
|
||||||
|
key_pem = Path(args.key).read_bytes()
|
||||||
|
raw = Path(args.in_).read_bytes()
|
||||||
|
try:
|
||||||
|
signed = sign_image(raw, key_pem)
|
||||||
|
except SignError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
Path(args.out).write_bytes(signed)
|
||||||
|
print(f"{args.out} ({len(signed)} bytes)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_verify(args: argparse.Namespace) -> int:
|
||||||
|
pub_der = Path(args.pub).read_bytes()
|
||||||
|
signed = Path(args.in_).read_bytes()
|
||||||
|
try:
|
||||||
|
ok = verify_image(signed, pub_der)
|
||||||
|
except SignError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if ok:
|
||||||
|
print(f"{args.in_}: signature valid")
|
||||||
|
return 0
|
||||||
|
print(f"error: {args.in_}: signature invalid", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="signs/verifies a W600 image the way the old Klyqa firmware expects")
|
||||||
|
sub = p.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_sign = sub.add_parser("sign", help="sign a raw _gz.img")
|
||||||
|
p_sign.add_argument("--key", required=True, help="private key, PEM")
|
||||||
|
p_sign.add_argument("--in", dest="in_", required=True, help="input _gz.img")
|
||||||
|
p_sign.add_argument("--out", required=True, help="output signed image")
|
||||||
|
p_sign.set_defaults(func=_cmd_sign)
|
||||||
|
|
||||||
|
p_verify = sub.add_parser("verify", help="verify a signed image")
|
||||||
|
p_verify.add_argument("--pub", required=True, help="public key, PEM or DER")
|
||||||
|
p_verify.add_argument("--in", dest="in_", required=True, help="signed image to check")
|
||||||
|
p_verify.set_defaults(func=_cmd_verify)
|
||||||
|
|
||||||
|
args = p.parse_args()
|
||||||
|
raise SystemExit(args.func(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,4 +4,4 @@ version = "0.1.0"
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8"]
|
dev = ["pytest>=8", "vermin>=1.6"]
|
||||||
|
|||||||
@@ -53,3 +53,226 @@ def test_refuses_image_that_already_has_a_trailer(tmp_path):
|
|||||||
)
|
)
|
||||||
assert r.returncode != 0
|
assert r.returncode != 0
|
||||||
assert "trailer" in r.stderr
|
assert "trailer" in r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def verify(path, *extra):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--verify", str(path), *extra],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_accepts_a_good_image(tmp_path):
|
||||||
|
_, out = build(tmp_path, device="e27_rgb", version="1.2.3")
|
||||||
|
r = verify(out)
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
assert r.stdout.split()[:2] == ["e27_rgb", "1.2.3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_a_file_without_trailer(tmp_path):
|
||||||
|
p = tmp_path / "plain.img"
|
||||||
|
p.write_bytes(b"x" * 5000)
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "trailer" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_a_corrupted_payload(tmp_path):
|
||||||
|
_, out = build(tmp_path)
|
||||||
|
d = bytearray(out.read_bytes())
|
||||||
|
d[100] ^= 0x01
|
||||||
|
out.write_bytes(d)
|
||||||
|
r = verify(out)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "sha256" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_checks_the_expected_device(tmp_path):
|
||||||
|
_, out = build(tmp_path, device="evk")
|
||||||
|
assert verify(out, "--expect-device", "evk").returncode == 0
|
||||||
|
r = verify(out, "--expect-device", "g95_cw")
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "evk" in r.stderr and "g95_cw" in r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_a_file_shorter_than_the_trailer(tmp_path):
|
||||||
|
p = tmp_path / "tiny.img"
|
||||||
|
p.write_bytes(b"NINL" + b"\x00" * 10)
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_nonexistent_file(tmp_path):
|
||||||
|
p = tmp_path / "does_not_exist.img"
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert r.stdout == ""
|
||||||
|
assert len(r.stderr.splitlines()) == 1
|
||||||
|
assert "error:" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_directory(tmp_path):
|
||||||
|
d = tmp_path / "dir"
|
||||||
|
d.mkdir()
|
||||||
|
r = verify(d)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert r.stdout == ""
|
||||||
|
assert len(r.stderr.splitlines()) == 1
|
||||||
|
assert "error:" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_empty_file(tmp_path):
|
||||||
|
p = tmp_path / "empty.img"
|
||||||
|
p.write_bytes(b"")
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "trailer" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_file_with_only_trailer_no_payload(tmp_path):
|
||||||
|
p = tmp_path / "only_trailer.img"
|
||||||
|
trailer = bytearray(b"\xff" * 512)
|
||||||
|
trailer[0:4] = b"NINL"
|
||||||
|
trailer[4] = 1
|
||||||
|
trailer[5:21] = b"test\x00" + b"\x00" * 11
|
||||||
|
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
|
||||||
|
trailer[37:69] = hashlib.sha256(b"").digest()
|
||||||
|
p.write_bytes(bytes(trailer))
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "trailer" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_rejects_device_with_whitespace(tmp_path):
|
||||||
|
src = tmp_path / "fw_gz.img"
|
||||||
|
src.write_bytes(b"x")
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "dev ice",
|
||||||
|
"--version", "1.0", "--out", str(tmp_path / "o.img")],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "whitespace" in r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_rejects_version_with_whitespace(tmp_path):
|
||||||
|
src = tmp_path / "fw_gz.img"
|
||||||
|
src.write_bytes(b"x")
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "device",
|
||||||
|
"--version", "1.0 beta", "--out", str(tmp_path / "o.img")],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "whitespace" in r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_unterminated_device_field(tmp_path):
|
||||||
|
p = tmp_path / "bad_device.img"
|
||||||
|
payload = b"x" * 1000
|
||||||
|
trailer = bytearray(b"\xff" * 512)
|
||||||
|
trailer[0:4] = b"NINL"
|
||||||
|
trailer[4] = 1
|
||||||
|
# Fill entire device field with no null terminator
|
||||||
|
trailer[5:21] = b"a" * 16
|
||||||
|
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
|
||||||
|
trailer[37:69] = hashlib.sha256(payload).digest()
|
||||||
|
p.write_bytes(payload + bytes(trailer))
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "unterminated" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_unterminated_device_field_without_expect_device(tmp_path):
|
||||||
|
# Same as above but without --expect-device to ensure the check happens regardless
|
||||||
|
p = tmp_path / "bad_device2.img"
|
||||||
|
payload = b"x" * 1000
|
||||||
|
trailer = bytearray(b"\xff" * 512)
|
||||||
|
trailer[0:4] = b"NINL"
|
||||||
|
trailer[4] = 1
|
||||||
|
# Fill entire device field with no null terminator
|
||||||
|
trailer[5:21] = b"b" * 16
|
||||||
|
trailer[21:37] = b"2.0.0\x00" + b"\x00" * 10
|
||||||
|
trailer[37:69] = hashlib.sha256(payload).digest()
|
||||||
|
p.write_bytes(payload + bytes(trailer))
|
||||||
|
r = verify(p)
|
||||||
|
assert r.returncode == 1
|
||||||
|
assert "unterminated" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_with_build_arguments_is_an_error(tmp_path):
|
||||||
|
_, out = build(tmp_path)
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--verify", str(out), "--device", "x"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "cannot be combined" in r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
# --- Python 3.8 compatibility -------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# CMake calls this script via plain `python3` inside the build container on every firmware
|
||||||
|
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
|
||||||
|
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
|
||||||
|
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
|
||||||
|
# git history around the "unsupported operand type(s) for |" break for what happens when it
|
||||||
|
# regresses.
|
||||||
|
#
|
||||||
|
# Two checks, because neither alone covers the failure mode we actually hit:
|
||||||
|
# 1. `from __future__ import annotations` must be the first statement after the module
|
||||||
|
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
|
||||||
|
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
|
||||||
|
# them get added later.
|
||||||
|
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
|
||||||
|
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
|
||||||
|
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
|
||||||
|
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
|
||||||
|
# accounting for `from __future__ import annotations` deferring evaluation - which would
|
||||||
|
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
|
||||||
|
# what actually guards against that pattern.
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_annotations_import_is_present():
|
||||||
|
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
tree = ast.parse(SCRIPT.read_text())
|
||||||
|
body = tree.body
|
||||||
|
# Skip a leading module docstring, if any.
|
||||||
|
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
||||||
|
and isinstance(body[0].value.value, str):
|
||||||
|
body = body[1:]
|
||||||
|
assert body, "nl_mkimage.py is unexpectedly empty"
|
||||||
|
first = body[0]
|
||||||
|
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
|
||||||
|
and any(alias.name == "annotations" for alias in first.names), (
|
||||||
|
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
|
||||||
|
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
|
||||||
|
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
|
||||||
|
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stays_python38_compatible():
|
||||||
|
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
|
||||||
|
|
||||||
|
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
|
||||||
|
version from syntax/stdlib usage instead of actually executing the file.
|
||||||
|
"""
|
||||||
|
vermin = __import__("vermin")
|
||||||
|
|
||||||
|
config = vermin.Config()
|
||||||
|
config.set_quiet(True)
|
||||||
|
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
|
||||||
|
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
|
||||||
|
# they need runtime support either. This is vermin's default, spelled out here so it's
|
||||||
|
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
|
||||||
|
config.set_eval_annotations(False)
|
||||||
|
|
||||||
|
source = SCRIPT.read_text()
|
||||||
|
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
|
||||||
|
assert py3_min is not None and py3_min <= (3, 8), (
|
||||||
|
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
|
||||||
|
"Python 3.8.10 in the Docker build container (see module docstring)"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
TOOL = HERE.parent / "nl_sign.py"
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
|
||||||
|
import nl_sign # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def key():
|
||||||
|
"""Ein eigener 4096-Bit-Schluessel, damit kein echter Signierschluessel in die Tests
|
||||||
|
gerät. Einmal pro Modul, weil das Erzeugen ein paar Sekunden dauert."""
|
||||||
|
return nl_sign.generate_test_key()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_is_512_bytes(key, tmp_path):
|
||||||
|
raw = b"\xa5" * 4096
|
||||||
|
signed = nl_sign.sign_image(raw, key.private_pem)
|
||||||
|
assert len(signed) == len(raw) + 512
|
||||||
|
assert signed[: len(raw)] == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_verifies(key):
|
||||||
|
raw = b"firmware" * 512
|
||||||
|
signed = nl_sign.sign_image(raw, key.private_pem)
|
||||||
|
assert nl_sign.verify_image(signed, key.public_der) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_modified_payload_fails_verification(key):
|
||||||
|
raw = bytearray(b"firmware" * 512)
|
||||||
|
signed = bytearray(nl_sign.sign_image(bytes(raw), key.private_pem))
|
||||||
|
signed[17] ^= 0xFF
|
||||||
|
assert nl_sign.verify_image(bytes(signed), key.public_der) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_signing_is_deterministic(key):
|
||||||
|
raw = b"x" * 1024
|
||||||
|
assert nl_sign.sign_image(raw, key.private_pem) == nl_sign.sign_image(raw, key.private_pem)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_roundtrip(key, tmp_path):
|
||||||
|
src = tmp_path / "in_gz.img"
|
||||||
|
dst = tmp_path / "out.img"
|
||||||
|
pub = tmp_path / "pub.der"
|
||||||
|
priv = tmp_path / "key.pem"
|
||||||
|
src.write_bytes(b"\x01\x02\x03" * 1000)
|
||||||
|
priv.write_bytes(key.private_pem)
|
||||||
|
pub.write_bytes(key.public_der)
|
||||||
|
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||||
|
"--in", str(src), "--out", str(dst)], check=True)
|
||||||
|
assert dst.stat().st_size == src.stat().st_size + 512
|
||||||
|
subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
|
||||||
|
"--in", str(dst)], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_verify_rejects_tampered(key, tmp_path):
|
||||||
|
src = tmp_path / "in_gz.img"
|
||||||
|
dst = tmp_path / "out.img"
|
||||||
|
pub = tmp_path / "pub.der"
|
||||||
|
priv = tmp_path / "key.pem"
|
||||||
|
src.write_bytes(b"\x07" * 2000)
|
||||||
|
priv.write_bytes(key.private_pem)
|
||||||
|
pub.write_bytes(key.public_der)
|
||||||
|
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||||
|
"--in", str(src), "--out", str(dst)], check=True)
|
||||||
|
data = bytearray(dst.read_bytes())
|
||||||
|
data[0] ^= 0x01
|
||||||
|
dst.write_bytes(bytes(data))
|
||||||
|
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
|
||||||
|
"--in", str(dst)])
|
||||||
|
assert r.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refuses_already_signed_looking_input(key, tmp_path):
|
||||||
|
"""Ein Image mit ninlight-Trailer ist ein OTA-Image, kein Rohbild - zweimal 512 Byte
|
||||||
|
anzuhaengen ergaebe eine Datei, die die alte Firmware ablehnt."""
|
||||||
|
raw = b"\x00" * 1024 + b"NINL" + b"\x01" + b"\x00" * 507
|
||||||
|
with pytest.raises(nl_sign.SignError):
|
||||||
|
nl_sign.sign_image(raw, key.private_pem)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_garbage_pub_key(key):
|
||||||
|
"""Ein kaputter --pub-Inhalt (kein gueltiges DER/PEM) muss als SignError gemeldet
|
||||||
|
werden, nicht als roher ValueError/Traceback - und darf niemals True ergeben."""
|
||||||
|
raw = b"x" * 1024
|
||||||
|
signed = nl_sign.sign_image(raw, key.private_pem)
|
||||||
|
with pytest.raises(nl_sign.SignError):
|
||||||
|
nl_sign.verify_image(signed, b"das ist definitiv kein Schluessel")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_rejects_garbage_private_key():
|
||||||
|
"""Ein kaputter --key-Inhalt muss ebenso als SignError gemeldet werden."""
|
||||||
|
raw = b"x" * 1024
|
||||||
|
with pytest.raises(nl_sign.SignError):
|
||||||
|
nl_sign.sign_image(raw, b"das ist definitiv kein Schluessel")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_verify_rejects_garbage_pub_key(key, tmp_path):
|
||||||
|
src = tmp_path / "in_gz.img"
|
||||||
|
dst = tmp_path / "out.img"
|
||||||
|
pub = tmp_path / "pub.der"
|
||||||
|
priv = tmp_path / "key.pem"
|
||||||
|
src.write_bytes(b"\x01\x02\x03" * 1000)
|
||||||
|
priv.write_bytes(key.private_pem)
|
||||||
|
pub.write_bytes(b"das ist definitiv kein Schluessel")
|
||||||
|
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||||
|
"--in", str(src), "--out", str(dst)], check=True)
|
||||||
|
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
|
||||||
|
"--in", str(dst)], capture_output=True, text=True)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "Traceback" not in r.stderr
|
||||||
|
assert "error:" in r.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sign_rejects_garbage_private_key(tmp_path):
|
||||||
|
src = tmp_path / "in_gz.img"
|
||||||
|
dst = tmp_path / "out.img"
|
||||||
|
priv = tmp_path / "key.pem"
|
||||||
|
src.write_bytes(b"\x01\x02\x03" * 1000)
|
||||||
|
priv.write_bytes(b"das ist definitiv kein Schluessel")
|
||||||
|
r = subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
|
||||||
|
"--in", str(src), "--out", str(dst)], capture_output=True, text=True)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "Traceback" not in r.stderr
|
||||||
|
assert "error:" in r.stderr.lower()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Wache dagegen, dass eine Testdatei aus allen Suiten herausfaellt.
|
||||||
|
|
||||||
|
Genau das war schon einmal passiert: tools/test_host.sh rief pytest auf
|
||||||
|
tools/mkimage/tests und tools/station/tests auf - tools/mkimage/test_nl_mkimage.py
|
||||||
|
liegt aber DIREKT in tools/mkimage/ und lief damit in keiner Suite mehr mit. 19 Tests
|
||||||
|
waren still verschwunden, darunter test_stays_python38_compatible, die Wache fuer die
|
||||||
|
bindende 3.8-Randbedingung des Docker-Build-Containers (`str | None` in nl_mkimage.py
|
||||||
|
hatte den Build schon einmal gebrochen).
|
||||||
|
|
||||||
|
Eine stumm ausgefallene Testdatei ist schlimmer als eine fehlende: die Suite bleibt
|
||||||
|
gruen und behauptet damit eine Deckung, die es nicht gibt. Deshalb wird hier nicht ein
|
||||||
|
einzelner Pfad festgenagelt, sondern die Regel selbst - JEDE test_*.py unter tools/
|
||||||
|
muss von einem der Pfade erfasst sein, die tools/test_host.sh an pytest uebergibt.
|
||||||
|
Eine neue Testdatei an einer neuen Stelle faellt damit sofort auf, statt erst dann,
|
||||||
|
wenn jemand sie vermisst.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
REPO_ROOT = TOOLS_DIR.parent
|
||||||
|
TEST_HOST = TOOLS_DIR / "test_host.sh"
|
||||||
|
|
||||||
|
# Verzeichnisse, die keine Tests DIESES Projekts enthalten: virtuelle Umgebungen und
|
||||||
|
# installierte Fremdpakete bringen eigene test_*.py mit, die hier nichts zu suchen haben.
|
||||||
|
IGNORED_PARTS = (".venv", "__pycache__", "site-packages", "node_modules", ".git")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_paths():
|
||||||
|
"""Die Pfade, die tools/test_host.sh an pytest uebergibt, als aufgeloeste Pfade."""
|
||||||
|
text = TEST_HOST.read_text()
|
||||||
|
match = re.search(r"^\s*python3 -m pytest (.+?)(?:\s+-\S+)*\s*$", text, re.M)
|
||||||
|
assert match, "in tools/test_host.sh ist kein 'python3 -m pytest'-Aufruf zu finden"
|
||||||
|
paths = []
|
||||||
|
for raw in match.group(1).split():
|
||||||
|
arg = raw.strip('"')
|
||||||
|
if arg.startswith("-"):
|
||||||
|
continue
|
||||||
|
paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve())
|
||||||
|
assert paths, "der pytest-Aufruf in tools/test_host.sh uebergibt keine Pfade"
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def own_test_files(suffix=".py"):
|
||||||
|
return sorted(
|
||||||
|
p.resolve()
|
||||||
|
for p in TOOLS_DIR.rglob("test_*" + suffix)
|
||||||
|
if not any(part in IGNORED_PARTS for part in p.parts)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def listed_shell_suites():
|
||||||
|
"""Die Eintraege des SHELL_SUITES-Feldes in tools/test_host.sh, aufgeloest.
|
||||||
|
|
||||||
|
Ausdruecklich gegen das FELD geprueft und nicht per Textsuche nach Dateinamen: eine
|
||||||
|
Textsuche ist schon durch einen nachgestellten Kommentar ("echo x # test_deploy.sh"),
|
||||||
|
ein ": \"test_deploy.sh\"" oder einen Heredoc-Inhalt erfuellt - sie belegt nur, dass
|
||||||
|
der Name irgendwo vorkommt, nicht dass die Suite laeuft (Task 6b F2).
|
||||||
|
"""
|
||||||
|
text = TEST_HOST.read_text()
|
||||||
|
block = re.search(r"^SHELL_SUITES=\((.*?)^\)", text, re.M | re.S)
|
||||||
|
assert block, (
|
||||||
|
"in tools/test_host.sh gibt es kein 'SHELL_SUITES=(...)'-Feld mehr - die "
|
||||||
|
"Vollstaendigkeitspruefung der Shell-Suiten haengt daran"
|
||||||
|
)
|
||||||
|
# Und das Feld muss auch wirklich abgearbeitet werden, sonst ist es nur Deko.
|
||||||
|
assert re.search(r'for\s+\w+\s+in\s+"\$\{SHELL_SUITES\[@\]\}"', text), (
|
||||||
|
"tools/test_host.sh arbeitet SHELL_SUITES nicht in einer Schleife ab - die "
|
||||||
|
"aufgefuehrten Suiten wuerden gar nicht laufen"
|
||||||
|
)
|
||||||
|
paths = []
|
||||||
|
for raw in block.group(1).split():
|
||||||
|
arg = raw.strip('"')
|
||||||
|
if not arg or arg.startswith("#"):
|
||||||
|
continue
|
||||||
|
paths.append(Path(arg.replace("$ROOT", str(REPO_ROOT))).resolve())
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_test_file_is_reached_by_test_host():
|
||||||
|
roots = pytest_paths()
|
||||||
|
orphans = []
|
||||||
|
for test_file in own_test_files():
|
||||||
|
if not any(test_file == r or r in test_file.parents for r in roots):
|
||||||
|
orphans.append(test_file.relative_to(REPO_ROOT))
|
||||||
|
assert not orphans, (
|
||||||
|
"diese Testdateien laufen in keiner Suite mit - tools/test_host.sh sammelt sie "
|
||||||
|
"nicht ein: {}. Entweder den Pfad in test_host.sh erweitern oder die Datei "
|
||||||
|
"dorthin verschieben, wo sie eingesammelt wird.".format(
|
||||||
|
", ".join(str(o) for o in orphans)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_shell_suite_is_called_by_test_host():
|
||||||
|
"""Dieselbe Regel fuer die Shell-Suiten (Task 6b F1, I3).
|
||||||
|
|
||||||
|
test_deploy.sh war in tools/test_host.sh nicht aufgefuehrt und lief damit nur bei
|
||||||
|
Handaufruf - 95 Zusagen, die niemand mehr sah. Die .py-Regel oben deckt genau diese
|
||||||
|
Haelfte nicht ab, weil die Shell-Suiten nicht ueber pytest laufen, sondern einzeln
|
||||||
|
aufgerufen werden.
|
||||||
|
"""
|
||||||
|
listed = listed_shell_suites()
|
||||||
|
# Symmetrisch zur .py-Regel: das GANZE tools/ wird durchsucht, nicht nur
|
||||||
|
# tools/station/tests - eine kuenftige Shell-Suite an einer neuen Stelle faellt sonst
|
||||||
|
# weiterhin durch (Task 6b F2).
|
||||||
|
# tools/test_host.sh heisst selbst "test_*.sh", ist aber der Aufrufer und keine
|
||||||
|
# Suite - es kann sich schlecht selbst auffuehren.
|
||||||
|
missing = [
|
||||||
|
s.relative_to(REPO_ROOT)
|
||||||
|
for s in own_test_files(".sh")
|
||||||
|
if s != TEST_HOST.resolve() and s not in listed
|
||||||
|
]
|
||||||
|
assert not missing, (
|
||||||
|
"diese Shell-Suiten stehen nicht in SHELL_SUITES von tools/test_host.sh, sie "
|
||||||
|
"laufen also nur bei Handaufruf: {}".format(", ".join(str(m) for m in missing))
|
||||||
|
)
|
||||||
|
# Und andersherum: ein Eintrag, den es nicht mehr gibt, laesst test_host.sh beim
|
||||||
|
# naechsten Lauf hart scheitern - das faellt hier schon vorher auf.
|
||||||
|
verschwunden = [str(p) for p in listed if not p.exists()]
|
||||||
|
assert not verschwunden, (
|
||||||
|
"SHELL_SUITES in tools/test_host.sh nennt Dateien, die es nicht gibt: {}".format(
|
||||||
|
", ".join(verschwunden)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_python38_guard_is_among_them():
|
||||||
|
"""Die konkrete Datei, deren Ausfall den Build-Bruch damals durchgelassen haette."""
|
||||||
|
guard = (TOOLS_DIR / "mkimage" / "test_nl_mkimage.py").resolve()
|
||||||
|
assert guard.exists(), "tools/mkimage/test_nl_mkimage.py fehlt"
|
||||||
|
roots = pytest_paths()
|
||||||
|
assert any(guard == r or r in guard.parents for r in roots), (
|
||||||
|
"tools/mkimage/test_nl_mkimage.py wird von tools/test_host.sh nicht eingesammelt "
|
||||||
|
"- darin steht test_stays_python38_compatible, die einzige Wache gegen erneute "
|
||||||
|
"3.10-Syntax in nl_mkimage.py (der Docker-Build-Container hat Python 3.8.10)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Kein eigener "ist vermin installiert"-Test hier: test_nl_mkimage.py importiert vermin
|
||||||
|
# ohnehin hart (kein Skip), und ein fehlendes Paket faellt dort mit derselben Deutlichkeit
|
||||||
|
# auf. Zwei Tests fuer dieselbe Zusage sind nur zwei Stellen zum Pflegen.
|
||||||
Generated
+14
-1
@@ -28,12 +28,16 @@ source = { virtual = "." }
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "vermin" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8" },
|
||||||
|
{ name = "vermin", specifier = ">=1.6" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
@@ -77,3 +81,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vermin"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/7b/2c1b403f2a844e1acb36694fc336e323df742f7f752edb4188311ad43f9e/vermin-1.8.0.tar.gz", hash = "sha256:3621955ac2a2950175c5b4a9b2fc3bd24bd416da0388893c9eb6971264e4ca1f", size = 99913, upload-time = "2025-11-20T17:29:31.509Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/fd/6d2167767488e162efd01300a679c7f7d913dbb65baa0b15760ebb369854/vermin-1.8.0-py3-none-any.whl", hash = "sha256:6f2b98ad697a16ed3442e81a0f0baabfc38c185b78aaab1b1fecd87bdd6640b8", size = 98030, upload-time = "2025-11-20T17:29:29.931Z" },
|
||||||
|
]
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker.sh equivalent using podman (same image, same mount, no emulation flag needed if native)
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
|
||||||
|
podman image exists ninlight-build || podman build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
|
||||||
|
exec podman run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
|
||||||
Executable
+367
@@ -0,0 +1,367 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut ein Auslieferungsarchiv: alle Ziele, OHNE einkompilierte WLAN-Zugangsdaten
|
||||||
|
# (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration
|
||||||
|
# (dev und prod signiert) und serielle Erstbespielung.
|
||||||
|
#
|
||||||
|
# Aufruf: tools/release.sh <version>
|
||||||
|
# Ergebnis: dist/ninlight-<version>.tar.gz
|
||||||
|
#
|
||||||
|
# Umgebungsvariablen:
|
||||||
|
# NINLIGHT_SIGN_DIR Schluesselverzeichnis (Vorgabe ~/workspace/fw-sign)
|
||||||
|
# NINLIGHT_NOCREDS_BUILD_REL Build-Verzeichnis relativ zur Repo-Wurzel (s. build_nocreds.sh)
|
||||||
|
# NINLIGHT_DOCKER Build-Vorspann (s. build_nocreds.sh); Tests stubben ihn
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION=${1:?Aufruf: tools/release.sh <version>}
|
||||||
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||||
|
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||||
|
BUILD_DIR="$ROOT/$BUILD_REL"
|
||||||
|
NAME="ninlight-$VERSION"
|
||||||
|
TGZ="$ROOT/dist/$NAME.tar.gz"
|
||||||
|
|
||||||
|
# Ein vorhandenes Archiv wird NICHT ueberschrieben: eine schon weitergegebene Version
|
||||||
|
# still durch einen anderen Inhalt zu ersetzen, ist der Fehler, den man am spaetesten
|
||||||
|
# bemerkt.
|
||||||
|
[ -e "$TGZ" ] && { echo "Fehler: $TGZ existiert bereits - Version erhoehen oder Datei entfernen" >&2; exit 1; }
|
||||||
|
|
||||||
|
# sha256: GNU zuerst (Debian/Pi), BSD/macOS als Rueckfall.
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
SHA_CMD="sha256sum"
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
SHA_CMD="shasum -a 256"
|
||||||
|
else
|
||||||
|
echo "Fehler: weder sha256sum noch shasum gefunden" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Firmware ohne Zugangsdaten bauen =="
|
||||||
|
# Der Bauweg liegt bewusst in einem eigenen, wiederverwendbaren Skript (die
|
||||||
|
# Migrationsstation braucht dieselben Abzuege) - inklusive des Beiseitelegens von
|
||||||
|
# config.local.cmake, das jedes -D schlaegt.
|
||||||
|
"$ROOT/tools/build_nocreds.sh" "${TARGETS[@]}"
|
||||||
|
|
||||||
|
STAGE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
OUT="$STAGE/$NAME"
|
||||||
|
mkdir -p "$OUT/ota" "$OUT/flash"
|
||||||
|
|
||||||
|
echo "== Images einsammeln =="
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
ota="$BUILD_DIR/$t/ninlight_${t}_ota.img"
|
||||||
|
gz="$BUILD_DIR/$t/ninlight_${t}_gz.img"
|
||||||
|
[ -f "$ota" ] || { echo "Fehler: $ota fehlt" >&2; exit 1; }
|
||||||
|
[ -f "$gz" ] || { echo "Fehler: $gz fehlt" >&2; exit 1; }
|
||||||
|
cp "$ota" "$OUT/ota/"
|
||||||
|
cp "$gz" "$OUT/flash/"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== Migrationsimages signieren =="
|
||||||
|
# Wie tools/station/deploy.sh: die Schluessel werden nur gelesen, sie gehoeren nie ins
|
||||||
|
# Repo, nie auf den Pi und schon gar nicht ins Archiv. Fehlt das Verzeichnis oder ein
|
||||||
|
# einzelner Schluessel, entfaellt die jeweilige Variante mit einer Meldung - das Archiv
|
||||||
|
# entsteht trotzdem, und README.md sagt dann, warum migrate/ fehlt.
|
||||||
|
# ${HOME:-} statt $HOME, damit `set -u` unter env -i/cron nicht unnoetig abbricht.
|
||||||
|
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
|
||||||
|
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
|
||||||
|
KEY_DEV="$SIGN_DIR/fw_sign.pem"
|
||||||
|
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
|
||||||
|
MIGRATE_NOTE=""
|
||||||
|
HAVE_PROD=0
|
||||||
|
HAVE_DEV=0
|
||||||
|
if [ -d "$SIGN_DIR" ]; then
|
||||||
|
if [ -f "$KEY_PROD" ]; then HAVE_PROD=1; else
|
||||||
|
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img" >&2
|
||||||
|
fi
|
||||||
|
if [ -f "$KEY_DEV" ]; then HAVE_DEV=1; else
|
||||||
|
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
||||||
|
mkdir -p "$OUT/migrate"
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide belegen
|
||||||
|
# denselben 512-Byte-Block am Dateiende (Signatur bzw. Trailer).
|
||||||
|
gz="$BUILD_DIR/$t/ninlight_${t}_gz.img"
|
||||||
|
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||||
|
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$OUT/migrate/${t}_prod.img" \
|
||||||
|
|| { echo "Fehler: Signieren von $t (prod) fehlgeschlagen" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||||
|
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$OUT/migrate/${t}_dev.img" \
|
||||||
|
|| { echo "Fehler: Signieren von $t (dev) fehlgeschlagen" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_PROD" -eq 0 ] && [ "$HAVE_DEV" -eq 0 ]; then
|
||||||
|
MIGRATE_NOTE="**\`migrate/\` fehlt in diesem Archiv.** Beim Bauen war kein Signierschluessel
|
||||||
|
erreichbar (\`$SIGN_DIR\`). Ohne Signatur nimmt die alte Klyqa-Firmware kein Image an;
|
||||||
|
alte Lampen lassen sich mit diesem Archiv also nicht migrieren. Die Schluessel gehoeren
|
||||||
|
nie in ein Archiv - wer sie hat, baut sich das Archiv mit \`tools/release.sh\` neu."
|
||||||
|
elif [ "$HAVE_PROD" -eq 0 ]; then
|
||||||
|
MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_prod.img\`.** Beim Bauen war nur der
|
||||||
|
Entwicklungsschluessel erreichbar. Lampen mit Produktionssignatur lassen sich damit nicht migrieren."
|
||||||
|
elif [ "$HAVE_DEV" -eq 0 ]; then
|
||||||
|
MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_dev.img\`.** Beim Bauen war nur der
|
||||||
|
Produktionsschluessel erreichbar. Lampen mit Entwicklungssignatur lassen sich damit nicht migrieren."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Bruno-Collection, Onboarding-Skript und README =="
|
||||||
|
mkdir -p "$OUT/bruno"
|
||||||
|
cp "$ROOT/bruno/ninlight.yml" "$OUT/bruno/"
|
||||||
|
|
||||||
|
cat >"$OUT/onboard.sh" <<'ONBOARD'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bringt eine frisch geflashte ninlight-Lampe ins WLAN.
|
||||||
|
#
|
||||||
|
# Aufruf: ./onboard.sh <ssid> [wlan-passwort]
|
||||||
|
#
|
||||||
|
# VORHER VON HAND (das kann dir kein Skript portabel abnehmen - sich per Skript mit
|
||||||
|
# einem WLAN zu verbinden ist auf jedem Betriebssystem anders):
|
||||||
|
# 1. Lampe einschalten. Ohne gespeicherte Zugangsdaten pulsiert sie warmweiss und
|
||||||
|
# spannt den Zugangspunkt "ninlight-<mac4>" auf (Passwort: ninlight).
|
||||||
|
# 2. Diesen Rechner mit genau diesem WLAN verbinden.
|
||||||
|
# 3. Dieses Skript aufrufen.
|
||||||
|
# 4. Danach wieder ins normale WLAN wechseln; die Lampe ist per mDNS
|
||||||
|
# (ninlight-<mac4>.local, Port 3333) erreichbar.
|
||||||
|
#
|
||||||
|
# Optionen:
|
||||||
|
# --host <ip> Adresse der Lampe im AP (Vorgabe 192.168.4.1)
|
||||||
|
# --port <port> REST-Port (Vorgabe 3333)
|
||||||
|
# --token <token> API-Token. Im unkonfigurierten Onboarding-AP NICHT noetig (der
|
||||||
|
# Zugang ist dort offen). Noetig im Fallback-AP, den eine Lampe
|
||||||
|
# aufspannt, die bereits Zugangsdaten hat (Router > 5 min weg) -
|
||||||
|
# dort ist system/settings wie ueberall tokengeschuetzt.
|
||||||
|
# Vorgabewert der Firmware: aabbccddeeff0011223344
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || { echo "Fehler: curl wird gebraucht" >&2; exit 1; }
|
||||||
|
command -v python3 >/dev/null 2>&1 || { echo "Fehler: python3 wird gebraucht (JSON-Body)" >&2; exit 1; }
|
||||||
|
|
||||||
|
HOST=192.168.4.1
|
||||||
|
PORT=3333
|
||||||
|
TOKEN=""
|
||||||
|
SSID=""
|
||||||
|
PASSWORD=""
|
||||||
|
# Hilfetext ist der Kommentarblock oben - keine feste Zeilennummer, die beim naechsten
|
||||||
|
# Satz im Kopf stillschweigend falsch wird.
|
||||||
|
usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--host) HOST=${2:?--host braucht einen Wert}; shift 2 ;;
|
||||||
|
--port) PORT=${2:?--port braucht einen Wert}; shift 2 ;;
|
||||||
|
--token) TOKEN=${2:?--token braucht einen Wert}; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-*) echo "Unbekannte Option: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
*) if [ -z "$SSID" ]; then SSID=$1; else PASSWORD=$1; fi; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$SSID" ] || { echo "Fehler: SSID fehlt" >&2; usage >&2; exit 2; }
|
||||||
|
|
||||||
|
URL="http://$HOST:$PORT/api/v1/system/settings"
|
||||||
|
BODY=$(SSID="$SSID" PASSWORD="$PASSWORD" python3 -c '
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({"type": "wifi", "ssid": os.environ["SSID"], "password": os.environ["PASSWORD"]}))')
|
||||||
|
|
||||||
|
set -- -X PUT "$URL" -H "Content-Type: application/json" -d "$BODY" --max-time 15
|
||||||
|
[ -n "$TOKEN" ] && set -- "$@" -H "Authorization: $TOKEN"
|
||||||
|
echo "== WLAN setzen ($URL) =="
|
||||||
|
curl -fsS "$@" || {
|
||||||
|
echo "Fehler: die Lampe hat die Zugangsdaten nicht angenommen." >&2
|
||||||
|
echo " 401 -> Fallback-AP: --token <token> mitgeben." >&2
|
||||||
|
echo " keine Verbindung -> bist du wirklich im WLAN ninlight-<mac4>?" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo
|
||||||
|
echo "== Verbindung pruefen (system/info) =="
|
||||||
|
# Die Lampe braucht ein paar Sekunden, bis sie im neuen WLAN haengt; danach schliesst
|
||||||
|
# der AP nach 60 s. wifi.sta_ip zeigt die neue Adresse, wifi.error den Grund, falls es
|
||||||
|
# nicht geklappt hat (z.B. falsches Passwort). Wartezeit fuer Tests ueberschreibbar.
|
||||||
|
sleep "${NINLIGHT_ONBOARD_WAIT:-8}"
|
||||||
|
curl -fsS "http://$HOST:$PORT/api/v1/system/info" --max-time 15 || {
|
||||||
|
echo "Hinweis: system/info nicht mehr erreichbar - meist ein gutes Zeichen (der AP ist" >&2
|
||||||
|
echo " zu, die Lampe haengt im neuen WLAN). Im normalen WLAN nachsehen." >&2
|
||||||
|
}
|
||||||
|
echo
|
||||||
|
ONBOARD
|
||||||
|
chmod +x "$OUT/onboard.sh"
|
||||||
|
|
||||||
|
cat >"$OUT/README.md" <<README
|
||||||
|
# ninlight $VERSION
|
||||||
|
|
||||||
|
Ersatzfirmware fuer die abgekuendigten Klyqa-Lampen (WinnerMicro W600). Alle Images in
|
||||||
|
diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit bespielte
|
||||||
|
Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (Passwort \`ninlight\`) und
|
||||||
|
wird mit \`onboard.sh\` ins WLAN gebracht.
|
||||||
|
|
||||||
|
Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb.
|
||||||
|
|
||||||
|
## Was liegt wo
|
||||||
|
|
||||||
|
| Verzeichnis | Inhalt | wofuer |
|
||||||
|
|---|---|---|
|
||||||
|
| \`ota/\` | \`ninlight_<ziel>_ota.img\` | Update einer Lampe, auf der ninlight schon laeuft (REST \`fw_update\`), und die Auslieferung an die Rettungsstation |
|
||||||
|
| \`migrate/\` | \`<ziel>_prod.img\`, \`<ziel>_dev.img\` | Erstmigration einer Lampe, auf der noch die alte Klyqa-Firmware laeuft |
|
||||||
|
| \`flash/\` | \`ninlight_<ziel>_gz.img\` | serielle Erstbespielung ueber die Platine |
|
||||||
|
| \`bruno/\` | \`ninlight.yml\` | REST-Schnittstelle als Bruno-Collection |
|
||||||
|
|
||||||
|
\`SHA256SUMS\` deckt jede Datei ab: \`sha256sum -c SHA256SUMS\` (macOS: \`shasum -a 256 -c SHA256SUMS\`).
|
||||||
|
|
||||||
|
$MIGRATE_NOTE
|
||||||
|
|
||||||
|
## Alte Klyqa-Lampe migrieren
|
||||||
|
|
||||||
|
Die alte Firmware nimmt nur signierte Images an; welche Signatur (\`_prod\` oder \`_dev\`)
|
||||||
|
gilt, haengt an Produkt-ID und Version der Lampe. Der Weg laeuft ueber die
|
||||||
|
Migrationsstation (\`tools/station/\` im Repo, ein Raspberry Pi mit zwei Zugangspunkten):
|
||||||
|
|
||||||
|
1. Station einschalten und \`ninlight-station migrate on\` (oder den Taster druecken).
|
||||||
|
2. Lampe einschalten - sie waehlt sich in den Klyqa-Zugangspunkt ein und holt das
|
||||||
|
passende Image aus \`migrate/\`.
|
||||||
|
3. Nach dem Neustart laeuft ninlight; die Lampe spannt ihren Onboarding-AP auf.
|
||||||
|
|
||||||
|
## Frisch geflashte Lampe onboarden
|
||||||
|
|
||||||
|
./onboard.sh "MeinWLAN" "meinpasswort"
|
||||||
|
|
||||||
|
Vorher von Hand: Lampe einschalten (sie pulsiert warmweiss), diesen Rechner mit dem WLAN
|
||||||
|
\`ninlight-<mac4>\` verbinden (Passwort \`ninlight\`). \`./onboard.sh --help\` zeigt die
|
||||||
|
Schritte und die Optionen (\`--host\`, \`--port\`, \`--token\`). Im unkonfigurierten
|
||||||
|
Onboarding-AP ist der Zugang offen, im Fallback-AP einer bereits onboardeten Lampe ist das
|
||||||
|
Token noetig.
|
||||||
|
|
||||||
|
Serielle Erstbespielung: \`flash/ninlight_<ziel>_gz.img\` mit \`tools/flash.sh\` aus dem Repo
|
||||||
|
bzw. \`wm_tool\` auf die Platine schreiben.
|
||||||
|
|
||||||
|
## Vertrauensgrenze
|
||||||
|
|
||||||
|
Der 512-Byte-Trailer der \`_ota.img\` prueft Geraetetyp und SHA256 - das ist eine
|
||||||
|
Integritaets- und Geraetetyppruefung, **kein kryptografisches Signaturverfahren**. Der
|
||||||
|
Download laeuft ueber \`http://\` ohne TLS, und der Rettungskanal ist nur durch ein WLAN-
|
||||||
|
Passwort geschuetzt, das mit einem oeffentlichen Default im Repo steht. Wer im selben WLAN
|
||||||
|
ist und das API-Token kennt (bzw. beim Recovery in Funkreichweite ist und das Passwort
|
||||||
|
kennt), kann der Lampe beliebige Firmware unterschieben. Das ist eine bewusste
|
||||||
|
Entscheidung dieses Projekts: Ziel ist, die Signaturpruefung der alten, abgekuendigten
|
||||||
|
Original-Firmware zu umgehen, damit die Lampen ueberhaupt weiter nutzbar bleiben - nicht,
|
||||||
|
ein neues, gehaertetes Sicherheitsmodell zu bauen.
|
||||||
|
|
||||||
|
Die RSA-Signatur der \`migrate/\`-Images ist die der ALTEN Firmware und existiert nur, damit
|
||||||
|
die alte Firmware das Image ueberhaupt annimmt. Die privaten Schluessel liegen nie in
|
||||||
|
diesem Archiv.
|
||||||
|
README
|
||||||
|
|
||||||
|
echo "== Auf Zugangsdaten pruefen =="
|
||||||
|
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
|
||||||
|
# wird, wird JEDE Datei des Archivs gegen die echten Werte aus config.local.cmake
|
||||||
|
# geprueft. Lieber ein abgebrochener Release als ein Archiv mit Michaels WLAN-Passwort
|
||||||
|
# bei Dritten - dieses Archiv geht an Fremde, und wiederholen laesst sich das nicht.
|
||||||
|
#
|
||||||
|
# Gesucht wird in ZWEI Sichten:
|
||||||
|
# roh - Kopf, Trailer und die Nicht-Abbilder (README.md, onboard.sh, bruno/) sind
|
||||||
|
# unkomprimiert; eine versehentlich notierte Zeile faellt nur so auf.
|
||||||
|
# entpackt- jedes Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann (beim _ota.img
|
||||||
|
# bzw. den signierten migrate/-Bildern) 512 Byte Trailer bzw. Signatur. Im
|
||||||
|
# komprimierten Strom steht kein Klartext. Die rohe Suche allein meldete
|
||||||
|
# deshalb IMMER "sauber" - auch fuer Abzuege aus build/, die die
|
||||||
|
# Zugangsdaten nachweislich tragen. Sie hat nie etwas geprueft.
|
||||||
|
#
|
||||||
|
# Entpackt wird ab Byte 56 OHNE das Dateiende abzuschneiden: die drei Bildarten im
|
||||||
|
# Archiv haben unterschiedliche Enden (flash/_gz.img traegt gar keinen Trailer,
|
||||||
|
# ota/_ota.img einen 512-Byte-Trailer, migrate/*.img eine 512-Byte-Signatur). Ein
|
||||||
|
# festes raw[56:-512] wie in tools/station/deploy.sh - wo nur Abbilder MIT Trailer
|
||||||
|
# vorkommen - wuerde hier jedes _gz.img fuer unentpackbar halten und jeden Release
|
||||||
|
# abbrechen. decompressobj hoert am Ende des gzip-Stroms von selbst auf; was danach
|
||||||
|
# kommt, ist ohnehin schon roh geprueft.
|
||||||
|
#
|
||||||
|
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
|
||||||
|
# "dann eben ungeprueft durchlassen". Das gilt auch fuer einen abgeschnittenen
|
||||||
|
# gzip-Strom, den decompressobj sonst stillschweigend annimmt.
|
||||||
|
LOCAL="$ROOT/config.local.cmake"
|
||||||
|
if [ -f "$LOCAL" ]; then
|
||||||
|
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
stage, local = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(local, encoding="utf-8", errors="replace").read()
|
||||||
|
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
||||||
|
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
||||||
|
# haette nur falsche Alarme erzeugt.
|
||||||
|
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||||
|
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||||
|
# Kopflaenge des vom wm_tool erzeugten Abbilds; danach beginnt der gzip-Strom.
|
||||||
|
HEADER_LEN = 56
|
||||||
|
needles = []
|
||||||
|
for name in SECRETS:
|
||||||
|
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||||
|
if m and m.group(1) not in PUBLIC:
|
||||||
|
needles.append((name, m.group(1).encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(raw):
|
||||||
|
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
|
||||||
|
if len(raw) <= HEADER_LEN:
|
||||||
|
raise ValueError("Datei ist kuerzer als der Kopf")
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
body = dec.decompress(raw[HEADER_LEN:])
|
||||||
|
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
|
||||||
|
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
|
||||||
|
# ungeprueft - und genau dort koennte das Passwort stehen.
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom ist unvollstaendig")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
broken = []
|
||||||
|
checked = 0
|
||||||
|
images = 0
|
||||||
|
for root, _dirs, files in os.walk(stage):
|
||||||
|
for fn in sorted(files):
|
||||||
|
path = os.path.join(root, fn)
|
||||||
|
rel = os.path.relpath(path, stage)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
checked += 1
|
||||||
|
views = [data]
|
||||||
|
if fn.endswith(".img"):
|
||||||
|
try:
|
||||||
|
views.append(unpack(data))
|
||||||
|
except Exception as exc:
|
||||||
|
broken.append("%s (%s)" % (rel, exc))
|
||||||
|
continue
|
||||||
|
images += 1
|
||||||
|
for name, needle in needles:
|
||||||
|
if any(needle in view for view in views):
|
||||||
|
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
|
||||||
|
hits.append("%s in %s" % (name, rel))
|
||||||
|
if broken:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Abbild laesst sich nicht entpacken, also auch nicht auf Zugangsdaten "
|
||||||
|
"pruefen - nichts gepackt:\n " + "\n ".join(broken) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
if hits:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
|
||||||
|
+ "\n ".join(hits) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
print(" sauber (%d Dateien geprueft, davon %d Abbilder entpackt)" % (checked, images))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== SHA256SUMS =="
|
||||||
|
(
|
||||||
|
cd "$OUT"
|
||||||
|
# $SHA_CMD absichtlich ohne Anfuehrungszeichen: "shasum -a 256" sind drei Woerter.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort | xargs $SHA_CMD >SHA256SUMS
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "== packen =="
|
||||||
|
mkdir -p "$ROOT/dist"
|
||||||
|
tar czf "$TGZ" -C "$STAGE" "$NAME"
|
||||||
|
echo "fertig: $TGZ"
|
||||||
Executable
+84
@@ -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()
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# ninlight-Recovery-Station
|
||||||
|
|
||||||
|
Ein Raspberry Pi spannt den Zugangspunkt `ninlight-recovery` auf und liefert unter
|
||||||
|
`http://192.168.8.1/ninlight_<target>_ota.img` Firmware-Images aus.
|
||||||
|
|
||||||
|
Jede ninlight-Lampe sucht beim Booten bis zu drei Sekunden nach dieser Station. Findet sie
|
||||||
|
sie, lädt sie das Image für ihren Gerätetyp und flasht es — ohne dass die Lampe im WLAN
|
||||||
|
erreichbar sein muss. Das ist der Rettungsweg für Lampen, an die man sonst nur noch mit dem
|
||||||
|
Lötkolben käme.
|
||||||
|
|
||||||
|
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
||||||
|
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
||||||
|
|
||||||
|
## Voraussetzung
|
||||||
|
|
||||||
|
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
|
||||||
|
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
|
||||||
|
würde dort nur mit einer Fehlermeldung enden.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
||||||
|
tools/station/deploy.sh <pi-host> [target] Images ausliefern
|
||||||
|
ssh <pi-host> ninlight-station on einschalten
|
||||||
|
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
|
||||||
|
ssh <pi-host> ninlight-station bildsatz welcher Migrations-Bildsatz gilt
|
||||||
|
ssh <pi-host> ninlight-station off ausschalten
|
||||||
|
|
||||||
|
## Bildsatz: mit oder ohne WLAN-Zugangsdaten
|
||||||
|
|
||||||
|
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es
|
||||||
|
gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor — **für beide
|
||||||
|
Betriebsarten**, Migration **und Rettung**:
|
||||||
|
|
||||||
|
| Umschalterstellung | Migration | Rettung | gebaut mit | die Lampe … |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `images/rescue-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
|
||||||
|
| `mit-wlan` | `images/migrate-wlan/` | `images/rescue-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` |
|
||||||
|
|
||||||
|
Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
|
||||||
|
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration/
|
||||||
|
Rettung**: der Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts
|
||||||
|
und bleibt, bis ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der
|
||||||
|
Fußzeile des Panels** — in jedem Modus, auch bei ausgeschalteter Station: man soll vor
|
||||||
|
jedem Einschalten sehen, was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu
|
||||||
|
flashen, weil die Einstellung von vorletzter Woche noch stand.
|
||||||
|
|
||||||
|
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
|
||||||
|
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
|
||||||
|
Klyqa-Lampe in Funkreichweite migriert, auch fremde. Mit dem Abzug ohne Zugangsdaten ist
|
||||||
|
ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und wartet. Mit
|
||||||
|
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden
|
||||||
|
Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||||
|
|
||||||
|
**Der Umschalter galt bisher nur für die Migration — jetzt auch für die Rettung, und das
|
||||||
|
hat eine spürbare Folge.** Bis zu dieser Erweiterung bekam eine Rettung *immer* ein
|
||||||
|
Abbild **mit** Zugangsdaten (der flache Bestand aus `build/`). Seitdem gilt für die
|
||||||
|
Rettung dieselbe Vorgabe `ohne-wlan` wie für die Migration: **eine gerettete Lampe steht
|
||||||
|
danach im eigenen Onboarding-Zugangspunkt statt im eigenen WLAN**, bis jemand ihr eines
|
||||||
|
nennt. Das ist gewollt (dieselbe Abwägung wie oben — der teure Fehler darf nie der sein,
|
||||||
|
der ohne Zutun passiert), soll aber niemanden überraschen: die Fußzeile des Panels zeigt
|
||||||
|
den Bildsatz deshalb ausdrücklich auch im Rettungsmodus, und stellt man bewusst auf
|
||||||
|
`mit-wlan` um, gilt das für beide Betriebsarten gemeinsam.
|
||||||
|
|
||||||
|
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie
|
||||||
|
gelaufen), wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz,
|
||||||
|
und auch nicht auf einen alten, flachen Bestand direkt in `images/` (siehe unten). Der
|
||||||
|
Server antwortet mit 404 (Migration zusätzlich mit `status: noimageset`), und auf dem
|
||||||
|
Panel steht „SATZ FEHLT" — in JEDEM Modus. „Vorhanden" heißt dabei überall dasselbe:
|
||||||
|
Verzeichnis da **und** mindestens ein Image darin.
|
||||||
|
|
||||||
|
`deploy.sh` liefert alle vier Sätze aus (migrate-wlan/migrate-nocreds für die Migration,
|
||||||
|
rescue-wlan/rescue-nocreds für die Rettung). Fehlt `build-nocreds/` oder ist es für ein
|
||||||
|
Ziel unvollständig, bleibt der jeweils betroffene Satz `ohne-wlan` auf dem Pi unangetastet
|
||||||
|
(statt geleert zu werden) und der Lauf meldet, womit er entsteht.
|
||||||
|
|
||||||
|
**Rückwärtskompatibilität bewusst nicht gegeben:** vor dieser Erweiterung lagen
|
||||||
|
Rettungsabbilder flach in `images/` (immer mit Zugangsdaten). Ein Pi mit diesem alten
|
||||||
|
Bestand muss **einmal neu per `deploy.sh` bestückt werden** — der Deploy räumt den
|
||||||
|
flachen Altbestand dabei automatisch weg. Bis dahin liefert die Rettung in der Vorgabe
|
||||||
|
`ohne-wlan` sichtbar nichts aus (statt den alten Bestand mit Zugangsdaten stillschweigend
|
||||||
|
weiter zu bedienen, was den ganzen Sinn dieser Erweiterung unterlaufen würde).
|
||||||
|
|
||||||
|
## Taster am Gerät
|
||||||
|
|
||||||
|
Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**.
|
||||||
|
Für genau diesen Zustand gibt es einen Taster auf **GPIO23**; die Rückmeldung kommt aufs
|
||||||
|
E-Paper. Ein Taster, zwei Zusammenhänge:
|
||||||
|
|
||||||
|
| | kurz (unter 1 s) | gehalten |
|
||||||
|
|----------------------|------------------|-------------------------------------------------------|
|
||||||
|
| Ruhe (Statusbild) | Menü öffnen | 3 s → Abbruch (Station aus), weiter 10 s → Herunterfahren |
|
||||||
|
| Menü offen | nächster Eintrag | 1,5 s → Eintrag auswählen |
|
||||||
|
| Rückfrage | abbrechen | 1,5 s → bestätigen |
|
||||||
|
|
||||||
|
Der Abbruch feuert **bei 3 s**, nicht erst beim Loslassen — danach läuft auf dem Panel ein
|
||||||
|
Countdown bis zum Herunterfahren, den Loslassen jederzeit abbricht. Das Menü schließt nach
|
||||||
|
20 s ohne Eingabe, eine Rückfrage verfällt nach 10 s als Abbruch. Die Einträge hängen vom
|
||||||
|
Zustand ab (Station aus / Rettung läuft / Migration läuft); jede verändernde Auswahl
|
||||||
|
bekommt eine Rückfrage.
|
||||||
|
|
||||||
|
**Die beiden Migrationsarten sind getrennte Einträge.** „Migration: horchen"
|
||||||
|
(`migrate on --listen`) steht zuerst und ist der naheliegende: der Zugangspunkt steht,
|
||||||
|
aber es geht nichts raus, bis du eine erkannte Lampe freigibst. „Migration: AUTOMATIK"
|
||||||
|
(`migrate on`) folgt danach und bekommt eine **andere, deutlichere Rückfrage** — der
|
||||||
|
Migrations-Zugangspunkt hat ein öffentlich bekanntes Passwort (es steht in der alten
|
||||||
|
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||||
|
ungefragt migriert, auch fremde. Die Rückfrage benennt genau das, statt nur „Wirklich?"
|
||||||
|
zu fragen.
|
||||||
|
|
||||||
|
Der Taster ist nirgends Voraussetzung: fehlt `gpiozero` oder der Taster, protokolliert der
|
||||||
|
Dienst eine Zeile und beendet sich ohne Fehler. Er fasst den SPI-Bus **nicht** an (zwei
|
||||||
|
Prozesse auf denselben Leitungen ergeben `GPIO busy`), sondern schreibt seinen
|
||||||
|
Bedienzustand nach `/run/ninlight-button/ui.json`; der Anzeigedienst liest ihn und
|
||||||
|
zeichnet. Ausgeführt wird ausschließlich über das CLI.
|
||||||
|
|
||||||
|
Dienst und Unit liegen in `tools/station/button/` bzw.
|
||||||
|
`tools/station/ninlight-button.service`. `setup_pi.sh` installiert, aktiviert und startet
|
||||||
|
den Tasterdienst wie den Anzeigedienst; `deploy.sh` liefert `button/` bei jedem Deploy mit
|
||||||
|
aus. Ein fehlgeschlagenes Aktivieren/Starten ist dabei nur eine Warnung — eine Station
|
||||||
|
ohne Taster muss vollständig benutzbar bleiben.
|
||||||
|
|
||||||
|
## Abschlussbildschirm beim Herunterfahren und beim Neustart
|
||||||
|
|
||||||
|
E-Paper behält sein Bild **dauerhaft**, auch stromlos. Fährt der Pi herunter, wird der
|
||||||
|
Anzeigedienst mit abgeräumt, und das zuletzt gezeichnete Bild bleibt für immer stehen —
|
||||||
|
bei der Abnahme stand dort die **Rückfrage** des Tastermenüs, und der ausgeschaltete Pi
|
||||||
|
sah aus, als warte er noch auf eine Eingabe.
|
||||||
|
|
||||||
|
Dafür gibt es zwei systemd-Units, `ninlight-poweroff.service` (`WantedBy=poweroff.target`)
|
||||||
|
und `ninlight-reboot.service` (`WantedBy=reboot.target`). Sie hängen am **System**, nicht
|
||||||
|
am Tasterdienst: so greifen sie bei jedem Herunterfahren — über das Menü, über `poweroff`
|
||||||
|
per SSH, über `reboot` — und nicht nur auf einem von drei Wegen.
|
||||||
|
|
||||||
|
**Die Texte unterscheiden sich, und das ist der Punkt:**
|
||||||
|
|
||||||
|
| | Panel |
|
||||||
|
|---|---|
|
||||||
|
| Herunterfahren | „AUSGESCHALTET — Strom kann getrennt werden" |
|
||||||
|
| Neustart | „NEUSTART — Strom NICHT trennen!" plus der Hinweis, dass es rund 2 min dauert |
|
||||||
|
|
||||||
|
Ein Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder zeichnet. Stünde
|
||||||
|
dort währenddessen „Strom kann getrennt werden", wäre das eine Aufforderung zu genau dem
|
||||||
|
Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
|
||||||
|
|
||||||
|
**Reihenfolge:** der Anzeigedienst hält die GPIO-/SPI-Leitungen; zwei Prozesse darauf
|
||||||
|
ergeben `lgpio.error: 'GPIO busy'`. Die Units ordnen sich deshalb per
|
||||||
|
`After=ninlight-display.service` hinter dessen Stop ein, und `ninlight-station display
|
||||||
|
final <poweroff|reboot>` wartet zusätzlich, bis `systemctl is-active ninlight-display`
|
||||||
|
nicht mehr anschlägt — dasselbe Muster wie `display_handover()` bei `display test`, nur
|
||||||
|
ohne Wiederanfahren. Gibt der Dienst die Leitungen nicht frei, wird **nicht** gezeichnet,
|
||||||
|
sondern eine Zeile ins Journal geschrieben: ein Fehler an der Anzeige darf das
|
||||||
|
Herunterfahren nie aufhalten.
|
||||||
|
|
||||||
|
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
|
||||||
|
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
|
||||||
|
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
|
||||||
|
|
||||||
|
## Vertrauensgrenze
|
||||||
|
|
||||||
|
Der Trailer eines Images prüft Gerätetyp und SHA256. Er ist **keine** kryptografische
|
||||||
|
Signatur, und der Download läuft über `http://` ohne TLS. Die Station schützt allein ein
|
||||||
|
WPA2-Passwort, dessen Default im Repo steht. Wer in Funkreichweite ist und es kennt, kann
|
||||||
|
jeder Lampe beim nächsten Boot beliebige Firmware unterschieben.
|
||||||
|
|
||||||
|
Das ist gewollt: Die Signaturprüfung der alten Firmware auszuhebeln ist der Zweck dieses
|
||||||
|
Projekts. Daraus folgen zwei Regeln.
|
||||||
|
|
||||||
|
**Setz ein eigenes Passwort.** In `config.local.cmake` und in
|
||||||
|
`/etc/ninlight-station.conf` — an beiden Stellen denselben Wert.
|
||||||
|
|
||||||
|
**Lass die Station nur an, solange du sie brauchst.** Nicht nur wegen des Fremdzugriffs:
|
||||||
|
Die Firmware löscht den Power-Cycle-Zähler, bevor sie einen Recovery-Versuch startet.
|
||||||
|
Solange eine Station in Reichweite ist, funktioniert der Werksreset per fünf Stromzyklen
|
||||||
|
bei **keiner** Lampe in Reichweite mehr.
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tasterdienst der ninlight-Station: EIN Taster auf GPIO23 (active low, interner
|
||||||
|
Pull-up), Rueckmeldung ueber das E-Paper.
|
||||||
|
|
||||||
|
Wozu das gut ist: "ninlight-station on"/"migrate on" macht wlan0 zum Zugangspunkt und
|
||||||
|
reisst damit JEDE SSH-Sitzung ueber WLAN ab. Genau in diesem Zustand hatte der Bediener
|
||||||
|
am Geraet bisher keinerlei Eingriff - es blieb warten oder Stecker ziehen, und Letzteres
|
||||||
|
hat an diesem Pi schon eine SD-Karte gekostet. Der Taster ist der Ausweg: Abbruch
|
||||||
|
(Station aus) und, ueber eine sichtbare Zwischenstufe, ein sauberes Herunterfahren.
|
||||||
|
|
||||||
|
Bedienung (ein Taster, zwei Zusammenhaenge - die Bedeutung haengt daran, ob das Menue
|
||||||
|
offen ist):
|
||||||
|
|
||||||
|
kurz (< 1 s) gehalten
|
||||||
|
Ruhe Menue oeffnen 3 s -> Abbruch, weiter 10 s -> Herunterfahren
|
||||||
|
Menue offen naechster Eintrag 1,5 s -> Eintrag auswaehlen
|
||||||
|
Rueckfrage abbrechen 1,5 s -> bestaetigen
|
||||||
|
|
||||||
|
Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen: der Bediener soll die Wirkung am
|
||||||
|
Panel sehen. Haelt er weiter, laeuft dort ein Countdown bis 10 s (Herunterfahren);
|
||||||
|
Loslassen bricht diese zweite Stufe jederzeit ab. Das Herunterfahren ist damit nur ueber
|
||||||
|
eine sichtbare Zustandsaenderung hinweg erreichbar - nicht aus Versehen.
|
||||||
|
|
||||||
|
DER TASTERDIENST FASST DEN SPI-BUS NICHT AN. Zwei Prozesse auf denselben GPIO-/SPI-
|
||||||
|
Leitungen enden in "lgpio.error: 'GPIO busy'" (am Geraet passiert, als
|
||||||
|
"ninlight-station display test" neben dem laufenden Anzeigedienst lief). Deshalb schreibt
|
||||||
|
dieser Dienst nur seinen Bedienzustand nach UI_FILE, und der Anzeigedienst liest ihn und
|
||||||
|
zeichnet (siehe display.read_ui()/render.blocks()).
|
||||||
|
|
||||||
|
Alle veraendernden Aktionen laufen ueber das vorhandene CLI (/usr/local/bin/
|
||||||
|
ninlight-station), nie ueber nachgebaute Logik: nur das CLI kennt die Profilumschaltung,
|
||||||
|
die Nachpruefungen ("es darf nie mehr als ein Stationsprofil aktiv sein") und den
|
||||||
|
Zeitschalter.
|
||||||
|
|
||||||
|
SICHERHEITSREGEL: die beiden Migrationseintraege sind GETRENNT und namentlich
|
||||||
|
unterscheidbar. "Migration: horchen" ruft "migrate on --listen" - der Zugangspunkt steht,
|
||||||
|
aber es geht nichts raus, bis der Bediener eine erkannte Lampe freigibt; das ist der
|
||||||
|
erste und naheliegende Eintrag. "Migration: AUTOMATIK" ruft "migrate on" ohne "--listen"
|
||||||
|
und bekommt eine ANDERE, deutlichere Rueckfrage (siehe action_migrate_auto()): der
|
||||||
|
Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten
|
||||||
|
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||||
|
ungefragt migriert, auch fremde. Die bequeme Geste bleibt damit die sichere, ohne dass
|
||||||
|
der Automatikmodus - fuer eine ganze Kiste eigener Lampen am Stueck - nur noch ueber die
|
||||||
|
Kommandozeile erreichbar waere, die im Zugangspunktbetrieb gerade nicht zur Verfuegung
|
||||||
|
steht.
|
||||||
|
|
||||||
|
Der Taster ist NIRGENDS Voraussetzung: fehlt gpiozero, fehlt der Taster oder ist GPIO23
|
||||||
|
belegt, schreibt main() GENAU EINE Protokollzeile und beendet sich mit sys.exit(0) -
|
||||||
|
dieselbe Zusage (und derselbe Grund: Restart=on-failure darf daraus keine Neustartschleife
|
||||||
|
machen) wie bei der Anzeige, siehe display.py's Modul-Docstring. Und ein Fehler am Taster
|
||||||
|
darf niemals eine laufende Migration stoeren oder das Ein-/Ausschalten aufhalten - deshalb
|
||||||
|
faengt jeder Schreib- und Ausfuehrungspfad hier seine Fehler selbst ab.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger("ninlight-button")
|
||||||
|
|
||||||
|
# --- Zeiten und Pfade ---------------------------------------------------------------
|
||||||
|
|
||||||
|
BUTTON_PIN = int(os.environ.get("NINLIGHT_BUTTON_PIN", "23"))
|
||||||
|
# 50 ms statt der naheliegenden 20: ein sehr kurzer Tipper kam am Geraet durch eine
|
||||||
|
# 20-ms-Entprellung noch durch und oeffnete ungewollt das Menue. Die Entprellung macht
|
||||||
|
# gpiozero selbst (bounce_time), nicht diese Schleife - siehe open_button().
|
||||||
|
DEBOUNCE_S = 0.05
|
||||||
|
# Abtastrate der Hauptschleife. Deutlich feiner als die Entprellung, damit eine Flanke
|
||||||
|
# nicht erst eine halbe Entprellzeit spaeter gesehen wird; ein Durchlauf ohne Aenderung
|
||||||
|
# kostet nur einen gpiozero-Registerzugriff und einen Woerterbuchvergleich (UiWriter
|
||||||
|
# schreibt nur bei Aenderung).
|
||||||
|
TICK_S = 0.02
|
||||||
|
|
||||||
|
SHORT_PRESS_MAX_S = 1.0
|
||||||
|
SELECT_HOLD_S = 1.5 # Menue: Eintrag waehlen / Rueckfrage: bestaetigen
|
||||||
|
CANCEL_HOLD_S = 3.0 # Ruhe: Abbruch (Station aus)
|
||||||
|
SHUTDOWN_HOLD_S = 10.0 # Ruhe: Herunterfahren (nur ueber die Abbruchstufe hinweg)
|
||||||
|
MENU_TIMEOUT_S = 20.0 # ohne Eingabe zurueck aufs Statusbild
|
||||||
|
CONFIRM_TIMEOUT_S = 10.0 # Rueckfrage verfaellt als Abbruch
|
||||||
|
RESULT_S = 4.0 # wie lange die Rueckmeldung einer Aktion stehen bleibt
|
||||||
|
|
||||||
|
# Dieselben Umgebungsvariablen wie ninlight-station/display.py, damit Tests und
|
||||||
|
# Handbetrieb ohne echtes /run bzw. /etc auskommen.
|
||||||
|
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
|
||||||
|
# BEWUSST NICHT /run/ninlight-station/ui.json (das war der Vorschlag): dieses Verzeichnis
|
||||||
|
# ist das RuntimeDirectory der Hauptunit, und systemd raeumt es beim regulaeren Stop von
|
||||||
|
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue am
|
||||||
|
# dringendsten gebraucht wird (dort liegen "Rettung starten"/"Migration starten"). Die
|
||||||
|
# Datei liegt deshalb im eigenen RuntimeDirectory dieses Dienstes; sie verschwindet damit
|
||||||
|
# genau dann, wenn dieser Dienst endet - und ein stehengebliebenes Menue kann den
|
||||||
|
# Anzeigedienst nicht ueberdauern.
|
||||||
|
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
|
||||||
|
STATION_CLI = os.environ.get("NINLIGHT_STATION_BIN", "/usr/local/bin/ninlight-station")
|
||||||
|
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" - dieselbe Datei, die
|
||||||
|
# das CLI schreibt, der Server liest und der Anzeigedienst auf dem Panel zeigt. Hier wird
|
||||||
|
# sie NUR GELESEN (fuer die Beschriftung des Menueeintrags); umgeschaltet wird wie jede
|
||||||
|
# andere Aktion ueber das CLI.
|
||||||
|
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
||||||
|
"/var/lib/ninlight-station/imageset"))
|
||||||
|
IMAGESET_NAMES = ("ohne-wlan", "mit-wlan")
|
||||||
|
IMAGESET_DEFAULT = "ohne-wlan"
|
||||||
|
STATION_SERVICE = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
|
||||||
|
# Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige
|
||||||
|
# Aktion dieses Dienstes, die an systemd selbst geht.
|
||||||
|
SHUTDOWN_ARGV = ("systemctl", "poweroff")
|
||||||
|
# Obergrenze fuer einen CLI-Aufruf. "recovery on"/"migrate on" schalten NetworkManager-
|
||||||
|
# Profile um und pruefen danach nach; das dauert Sekunden, nicht Minuten. Ohne Deckel
|
||||||
|
# wuerde ein haengender NetworkManager die Bedienung dauerhaft einfrieren - mit ihm
|
||||||
|
# bleibt der Taster spaetestens danach wieder bedienbar.
|
||||||
|
ACTION_TIMEOUT_S = 120
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonUnavailable(Exception):
|
||||||
|
"""Kein Taster da (keine Bibliothek, kein Pin, Pin belegt) - main() macht daraus
|
||||||
|
genau eine Protokollzeile und sys.exit(0)."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aktionen -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Action:
|
||||||
|
"""Was ein Menueeintrag bzw. die Haltegeste ausloest.
|
||||||
|
|
||||||
|
`confirm` = braucht eine Rueckfrage. Zusage: JEDE veraendernde Auswahl bekommt eine
|
||||||
|
("Anzeige auffrischen" veraendert nichts und braucht keine).
|
||||||
|
|
||||||
|
`confirm_title`/`confirm_details` machen die Rueckfrage bei Bedarf DEUTLICHER als die
|
||||||
|
uebliche. Die Vorgabe ("RUECKFRAGE" + die zwei Bedienhinweise) reicht fuer eine
|
||||||
|
Auswahl, deren Tragweite im Eintragsnamen steht ("Station aus", "Herunterfahren").
|
||||||
|
Sie reicht NICHT fuer den Automatikmodus: was dort passiert - jede alte Klyqa-Lampe
|
||||||
|
in Funkreichweite wird ungefragt migriert, auch fremde - steht in keinem
|
||||||
|
Eintragsnamen, und ein blosses "Wirklich?" fragt nach etwas, das der Bediener in
|
||||||
|
diesem Moment gar nicht vor Augen hat."""
|
||||||
|
|
||||||
|
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True,
|
||||||
|
done_text: str | None = None, confirm_title: str = "CONFIRM",
|
||||||
|
confirm_details=None) -> None:
|
||||||
|
self.label = label
|
||||||
|
self.argv = tuple(argv)
|
||||||
|
self.kind = kind
|
||||||
|
self.confirm = confirm
|
||||||
|
self.done_text = done_text or label
|
||||||
|
self.confirm_title = confirm_title
|
||||||
|
self.confirm_details = list(confirm_details) if confirm_details else None
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # nur fuer Testausgaben
|
||||||
|
return "Action({!r}, {!r}, kind={!r})".format(self.label, self.argv, self.kind)
|
||||||
|
|
||||||
|
|
||||||
|
def action_shutdown() -> Action:
|
||||||
|
return Action("Shut down", kind="shutdown", confirm=True,
|
||||||
|
done_text="Shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
def action_migrate_listen() -> Action:
|
||||||
|
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus,
|
||||||
|
bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
|
||||||
|
vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
|
||||||
|
return Action("Migration: listen", ("migrate", "on", "--listen"),
|
||||||
|
done_text="Migration: listening")
|
||||||
|
|
||||||
|
|
||||||
|
def action_migrate_auto() -> Action:
|
||||||
|
"""Der Automatikmodus - fuer die eigene Kiste Lampen am Stueck, ohne jede einzeln
|
||||||
|
freizugeben. Er steht hier, weil das Verabreden ueber die Kommandozeile genau dann
|
||||||
|
nicht geht, wenn man ihn braucht: "on" macht wlan0 zum Zugangspunkt und reisst jede
|
||||||
|
SSH-Sitzung ueber WLAN ab.
|
||||||
|
|
||||||
|
Seine Rueckfrage ist ABSICHTLICH eine andere als die der uebrigen Eintraege. Der
|
||||||
|
Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten
|
||||||
|
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||||
|
ungefragt migriert - auch die des Nachbarn, der davon nichts weiss und dessen Lampe
|
||||||
|
danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der
|
||||||
|
Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
|
||||||
|
Rueckfrage selbst."""
|
||||||
|
return Action("Migration: AUTOMATIC", ("migrate", "on"),
|
||||||
|
done_text="Migration: automatic",
|
||||||
|
confirm_title="WARNING",
|
||||||
|
confirm_details=["Flashes EVERY old Klyqa lamp",
|
||||||
|
"in range - other people's too"])
|
||||||
|
|
||||||
|
|
||||||
|
def action_imageset(current: str) -> Action:
|
||||||
|
"""Der Umschalter zwischen den beiden Migrations-Bildsaetzen. Der Eintrag nennt, WOHIN
|
||||||
|
umgeschaltet wird, nicht was gerade gilt: waehrend das Menue offen ist, ersetzt es das
|
||||||
|
Statusbild samt Fusszeile - "Bildsatz: mit WLAN" waere dann nicht zu entscheiden.
|
||||||
|
|
||||||
|
Der Weg nach "mit-wlan" bekommt die deutlichere Rueckfrage, der Weg zurueck die
|
||||||
|
uebliche: nur die eine Richtung schreibt das WLAN-Passwort des Bedieners in jede
|
||||||
|
Lampe, die sie erwischt."""
|
||||||
|
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
|
||||||
|
if target == "mit-wlan":
|
||||||
|
return Action("Switch to: with Wi-Fi", ("bildsatz", "mit-wlan"),
|
||||||
|
done_text="Image set: with Wi-Fi",
|
||||||
|
confirm_title="WARNING",
|
||||||
|
confirm_details=["Every migrated lamp receives",
|
||||||
|
"your Wi-Fi password"])
|
||||||
|
return Action("Switch to: no Wi-Fi", ("bildsatz", "ohne-wlan"),
|
||||||
|
done_text="Image set: no Wi-Fi")
|
||||||
|
|
||||||
|
|
||||||
|
def action_cancel() -> Action:
|
||||||
|
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die
|
||||||
|
Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
|
||||||
|
return Action("Station off", ("off",), confirm=False, done_text="Station off")
|
||||||
|
|
||||||
|
|
||||||
|
def menu_actions(state: dict) -> list[Action]:
|
||||||
|
"""Die Menueeintraege fuer den gegebenen Stationszustand - nur das, was gerade
|
||||||
|
sinnvoll ist. Reine Funktion ueber dem von station_state() gelesenen Zustand."""
|
||||||
|
running = bool(state.get("running"))
|
||||||
|
mode = state.get("mode")
|
||||||
|
target = state.get("target")
|
||||||
|
imageset = state.get("imageset") or IMAGESET_DEFAULT
|
||||||
|
|
||||||
|
if not running:
|
||||||
|
return [
|
||||||
|
Action("Start recovery", ("recovery", "on"), done_text="Recovery running"),
|
||||||
|
# Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
|
||||||
|
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo
|
||||||
|
# loslaesst, landet nicht im Automatikmodus.
|
||||||
|
action_migrate_listen(),
|
||||||
|
action_migrate_auto(),
|
||||||
|
# Der beste Zeitpunkt zum Umschalten ist VOR dem Einschalten - deshalb steht
|
||||||
|
# der Eintrag auch (und gerade) hier.
|
||||||
|
action_imageset(imageset),
|
||||||
|
action_shutdown(),
|
||||||
|
]
|
||||||
|
|
||||||
|
items: list[Action] = []
|
||||||
|
if mode == "migration" and target:
|
||||||
|
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
|
||||||
|
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
|
||||||
|
items.append(Action("Arm: {}".format(target), ("arm", str(target)),
|
||||||
|
done_text="Armed: {}".format(target)))
|
||||||
|
items.append(Action("Station off", ("off",), done_text="Station off"))
|
||||||
|
items.append(action_imageset(imageset))
|
||||||
|
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
|
||||||
|
items.append(Action("Refresh display", ("display", "test"), confirm=False,
|
||||||
|
done_text="Display refreshed"))
|
||||||
|
items.append(action_shutdown())
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class CliActions:
|
||||||
|
"""Fuehrt eine Action aus - ueber das CLI bzw. systemctl, nie durch Nachbauen deren
|
||||||
|
Logik. Gibt zurueck, ob es geklappt hat; wirft nie: ein Fehler hier darf weder den
|
||||||
|
Tasterdienst beenden noch eine laufende Migration stoeren."""
|
||||||
|
|
||||||
|
def __init__(self, cli: str = STATION_CLI, shutdown_argv=SHUTDOWN_ARGV,
|
||||||
|
runner=subprocess.run) -> None:
|
||||||
|
self.cli = cli
|
||||||
|
self.shutdown_argv = tuple(shutdown_argv)
|
||||||
|
self.runner = runner
|
||||||
|
|
||||||
|
def argv_for(self, action: Action) -> list[str]:
|
||||||
|
if action.kind == "shutdown":
|
||||||
|
return list(self.shutdown_argv)
|
||||||
|
return [self.cli] + list(action.argv)
|
||||||
|
|
||||||
|
def run(self, action: Action) -> bool:
|
||||||
|
argv = self.argv_for(action)
|
||||||
|
log.info("Taster fuehrt aus: %s", " ".join(argv))
|
||||||
|
try:
|
||||||
|
result = self.runner(argv, check=False, timeout=ACTION_TIMEOUT_S)
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
log.warning("Aktion '%s' fehlgeschlagen: %s", action.label, e)
|
||||||
|
return False
|
||||||
|
rc = getattr(result, "returncode", 0)
|
||||||
|
if rc != 0:
|
||||||
|
log.warning("Aktion '%s' endete mit Rueckgabewert %s", action.label, rc)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stationszustand (nur lesen) ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _service_active(service_name: str = STATION_SERVICE) -> bool:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["systemctl", "is-active", "--quiet", service_name],
|
||||||
|
timeout=5, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
# Ohne systemd (Handlauf) lieber "laeuft" annehmen als das Menue mit
|
||||||
|
# "Rettung starten" anzubieten, waehrend die Station in Wahrheit laeuft.
|
||||||
|
return True
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _read_imageset(path: Path = IMAGESET_FILE) -> str:
|
||||||
|
"""Der eingestellte Bildsatz, oder IMAGESET_DEFAULT bei fehlender/unlesbarer Datei
|
||||||
|
bzw. unbekanntem Inhalt - dieselbe Vorgabe wie in server.py und im CLI."""
|
||||||
|
try:
|
||||||
|
value = path.read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
return IMAGESET_DEFAULT
|
||||||
|
return value if value in IMAGESET_NAMES else IMAGESET_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE,
|
||||||
|
imageset_path: Path = IMAGESET_FILE) -> dict:
|
||||||
|
"""Der Zustand, von dem die Menueeintraege abhaengen. Liest dieselben Quellen wie
|
||||||
|
display.build_state() - RUN_DIR/mode (nicht status.json's "mode"-Feld, siehe dort)
|
||||||
|
und status.json fuer das zuletzt erkannte Ziel - dupliziert aber keine Logik des
|
||||||
|
CLI: geschaltet wird ausschliesslich ueber das CLI."""
|
||||||
|
running = _service_active(service_name)
|
||||||
|
try:
|
||||||
|
mode = (run_dir / "mode").read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
mode = "recovery"
|
||||||
|
if mode not in ("recovery", "migration"):
|
||||||
|
mode = "recovery"
|
||||||
|
status = _read_json(run_dir / "status.json")
|
||||||
|
last_seen = status.get("last_seen")
|
||||||
|
target = last_seen.get("target") if isinstance(last_seen, dict) else None
|
||||||
|
return {"running": running, "mode": mode,
|
||||||
|
"target": target if isinstance(target, str) and target else None,
|
||||||
|
"imageset": _read_imageset(imageset_path)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bedienzustand in eine Datei schreiben ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class UiWriter:
|
||||||
|
"""Schreibt den Bedienzustand atomar mit 0644 nach UI_FILE - dasselbe Muster und
|
||||||
|
dieselben Rechte wie status.json/control.json (Temp-Datei im selben Verzeichnis,
|
||||||
|
Rechte setzen, dann umbenennen). Beides sind Muster, deren Verletzung in diesem
|
||||||
|
Projekt schon mehrfach Fehler verursacht hat: ohne 0644 kann der Anzeigedienst die
|
||||||
|
Datei nicht lesen, ohne das Umbenennen sieht er sie halb geschrieben.
|
||||||
|
|
||||||
|
Schreibt nur bei Aenderung (die Schleife laeuft alle TICK_S) und wirft nie."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path = UI_FILE) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._last: dict | None = None
|
||||||
|
self._failed_logged = False
|
||||||
|
|
||||||
|
def write(self, view: dict) -> bool:
|
||||||
|
"""True, wenn tatsaechlich geschrieben wurde."""
|
||||||
|
if view == self._last:
|
||||||
|
return False
|
||||||
|
payload = json.dumps(view, ensure_ascii=False, sort_keys=True)
|
||||||
|
tmp = self.path.with_name("{}.tmp-{}".format(self.path.name, os.getpid()))
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.write_text(payload)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, self.path)
|
||||||
|
except OSError as e:
|
||||||
|
# Nur einmal protokollieren: die Schleife kaeme sonst alle TICK_S wieder
|
||||||
|
# hierher und wuerde das Journal fluten. Der Taster bleibt trotzdem
|
||||||
|
# bedienbar, nur die Rueckmeldung am Panel fehlt.
|
||||||
|
if not self._failed_logged:
|
||||||
|
log.warning("Bedienzustand konnte nicht geschrieben werden (%s): %s",
|
||||||
|
self.path, e)
|
||||||
|
self._failed_logged = True
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
self._last = dict(view)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die Zustandsfuehrung -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _countdown(total_s: float, held_s: float) -> int:
|
||||||
|
"""Ganze Sekunden bis `total_s`, aufgerundet und nie negativ - "noch 6" solange auch
|
||||||
|
nur eine Zehntelsekunde davon uebrig ist."""
|
||||||
|
return max(0, int(math.ceil(total_s - held_s)))
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonUI:
|
||||||
|
"""Die gesamte Bedienlogik - welcher Druck was bedeutet, welche Zeitschwelle was
|
||||||
|
ausloest, was auf dem Panel steht - als reine Zustandsmaschine ohne Schleife, ohne
|
||||||
|
time.sleep() und ohne GPIO. Dasselbe Muster wie display.RefreshPlanner, aus demselben
|
||||||
|
Grund: so laesst sich ein kompletter Bedienverlauf in Millisekunden durchspielen.
|
||||||
|
|
||||||
|
Benutzung pro Runde: update(now, pressed) -> Action oder None; eine zurueckgegebene
|
||||||
|
Action fuehrt der Aufrufer aus und meldet das Ergebnis per done() zurueck. view(now)
|
||||||
|
liefert jederzeit den Bedienzustand fuer die Anzeige.
|
||||||
|
|
||||||
|
`pressed` ist der ENTPRELLTE Pegel (gpiozero, bounce_time), nicht eine Flanke - alle
|
||||||
|
Schwellen rechnen deshalb gegen den Zeitpunkt der letzten steigenden Flanke. Das ist
|
||||||
|
auch der Grund, warum ein blockierender CLI-Aufruf mitten in einer Haltegeste nichts
|
||||||
|
kaputtmacht: nach der Rueckkehr wird derselbe Pegel wieder gegen dieselbe Uhr
|
||||||
|
gemessen."""
|
||||||
|
|
||||||
|
def __init__(self, now: float = 0.0, read_state=station_state) -> None:
|
||||||
|
self._read_state = read_state
|
||||||
|
self._pressed = False
|
||||||
|
self._press_t = now
|
||||||
|
# Diese Haltegeste hat schon etwas ausgeloest - ein spaeteres Loslassen darf
|
||||||
|
# dann nicht zusaetzlich als "kurzer Druck" gelten.
|
||||||
|
self._consumed = False
|
||||||
|
# 0 = nichts, 1 = Abbruch gefeuert, zweite Stufe (Herunterfahren) laeuft.
|
||||||
|
self._hold_stage = 0
|
||||||
|
# In welchem Zusammenhang diese Haltegeste BEGONNEN hat ("idle", "menu",
|
||||||
|
# "confirm"). Die Haltegeste wird danach beurteilt, NICHT nach dem inzwischen
|
||||||
|
# erreichten Bildschirm: waehlt man im Menue einen Eintrag (1,5 s), steht danach
|
||||||
|
# "Bitte warten"/die Rueckfrage - haelt der Bediener einfach weiter, duerfte das
|
||||||
|
# nicht plotzlich als die Ruhe-Haltegeste gelten und bei 3 s die Station
|
||||||
|
# abschalten. Genau das tat eine fruehere Fassung.
|
||||||
|
self._press_screen = "idle"
|
||||||
|
self.screen = "idle"
|
||||||
|
self.items: list[Action] = []
|
||||||
|
self.index = 0
|
||||||
|
self.pending: Action | None = None
|
||||||
|
self.deadline: float | None = None
|
||||||
|
self.busy: Action | None = None
|
||||||
|
self.result: tuple[str, bool] | None = None
|
||||||
|
self.result_until: float | None = None
|
||||||
|
|
||||||
|
# -- Eingabe ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update(self, now: float, pressed: bool) -> Action | None:
|
||||||
|
action = None
|
||||||
|
if pressed and not self._pressed:
|
||||||
|
self._pressed = True
|
||||||
|
self._press_t = now
|
||||||
|
self._consumed = False
|
||||||
|
self._press_screen = self.screen if self.screen in ("menu", "confirm") else "idle"
|
||||||
|
elif pressed and self._pressed:
|
||||||
|
action = self._held(now)
|
||||||
|
elif self._pressed and not pressed:
|
||||||
|
self._pressed = False
|
||||||
|
held = now - self._press_t
|
||||||
|
if not self._consumed and held < SHORT_PRESS_MAX_S:
|
||||||
|
action = self._short(now)
|
||||||
|
# Loslassen bricht die zweite Stufe (Herunterfahren) jederzeit ab.
|
||||||
|
self._hold_stage = 0
|
||||||
|
self._expire(now)
|
||||||
|
else:
|
||||||
|
self._expire(now)
|
||||||
|
if action is not None:
|
||||||
|
self.busy = action
|
||||||
|
self.screen = "busy"
|
||||||
|
self.pending = None
|
||||||
|
self.deadline = None
|
||||||
|
return action
|
||||||
|
|
||||||
|
def _short(self, now: float) -> Action | None:
|
||||||
|
self.result = None
|
||||||
|
self.result_until = None
|
||||||
|
if self.screen == "menu":
|
||||||
|
if self.items:
|
||||||
|
self.index = (self.index + 1) % len(self.items)
|
||||||
|
self.deadline = now + MENU_TIMEOUT_S
|
||||||
|
return None
|
||||||
|
if self.screen == "confirm":
|
||||||
|
# Abbrechen - zurueck ins Menue, nicht aufs Statusbild: der Bediener ist
|
||||||
|
# noch am Bedienen und will meist einen anderen Eintrag.
|
||||||
|
self.pending = None
|
||||||
|
self.screen = "menu"
|
||||||
|
self.deadline = now + MENU_TIMEOUT_S
|
||||||
|
return None
|
||||||
|
# Ruhe (auch waehrend eine Rueckmeldung steht): Menue oeffnen.
|
||||||
|
self._open_menu(now)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _open_menu(self, now: float) -> None:
|
||||||
|
try:
|
||||||
|
state = self._read_state()
|
||||||
|
except Exception as e: # noqa: BLE001 - siehe Modul-Docstring: nie stoeren
|
||||||
|
log.warning("Stationszustand nicht lesbar, Menue bleibt zu: %s", e)
|
||||||
|
return
|
||||||
|
self.items = menu_actions(state)
|
||||||
|
if not self.items:
|
||||||
|
return
|
||||||
|
self.index = 0
|
||||||
|
self.screen = "menu"
|
||||||
|
self.deadline = now + MENU_TIMEOUT_S
|
||||||
|
|
||||||
|
def _held(self, now: float) -> Action | None:
|
||||||
|
held = now - self._press_t
|
||||||
|
# Nach dem Zusammenhang BEIM DRUCK beurteilt, nicht nach dem aktuellen
|
||||||
|
# Bildschirm - siehe _press_screen.
|
||||||
|
if self._press_screen == "menu":
|
||||||
|
if held >= SELECT_HOLD_S and not self._consumed and self.items:
|
||||||
|
self._consumed = True
|
||||||
|
return self._select(now, self.items[self.index])
|
||||||
|
return None
|
||||||
|
if self._press_screen == "confirm":
|
||||||
|
if held >= SELECT_HOLD_S and not self._consumed:
|
||||||
|
self._consumed = True
|
||||||
|
return self.pending
|
||||||
|
return None
|
||||||
|
# Ruhe (oder eine stehende Rueckmeldung/ein gerade gelaufener Abbruch):
|
||||||
|
# zweistufige Haltegeste.
|
||||||
|
if self._hold_stage == 1 and held >= SHUTDOWN_HOLD_S and not self._consumed:
|
||||||
|
self._consumed = True
|
||||||
|
return action_shutdown()
|
||||||
|
if self._hold_stage == 0 and held >= CANCEL_HOLD_S:
|
||||||
|
# Feuert BEI 3 s, nicht erst beim Loslassen - und setzt _consumed NICHT:
|
||||||
|
# die zweite Stufe muss aus derselben Geste noch erreichbar bleiben.
|
||||||
|
self._hold_stage = 1
|
||||||
|
self.result = None
|
||||||
|
self.result_until = None
|
||||||
|
return action_cancel()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _select(self, now: float, action: Action) -> Action | None:
|
||||||
|
if not action.confirm:
|
||||||
|
return action
|
||||||
|
self.pending = action
|
||||||
|
self.screen = "confirm"
|
||||||
|
self.deadline = now + CONFIRM_TIMEOUT_S
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _expire(self, now: float) -> None:
|
||||||
|
if self.result_until is not None and now >= self.result_until:
|
||||||
|
self.result = None
|
||||||
|
self.result_until = None
|
||||||
|
if self.deadline is None or now < self.deadline:
|
||||||
|
return
|
||||||
|
if self.screen == "menu":
|
||||||
|
# Ein Geraet, das im Menue stehenbleibt, soll nicht aussehen, als sei es
|
||||||
|
# beschaeftigt - zurueck aufs Statusbild.
|
||||||
|
self.screen = "idle"
|
||||||
|
self.items = []
|
||||||
|
self.deadline = None
|
||||||
|
elif self.screen == "confirm":
|
||||||
|
# Verfaellt als ABBRUCH - eine Rueckfrage, die von selbst zustimmt, waere
|
||||||
|
# das Gegenteil einer Rueckfrage.
|
||||||
|
self.pending = None
|
||||||
|
self.screen = "idle"
|
||||||
|
self.items = []
|
||||||
|
self.deadline = None
|
||||||
|
|
||||||
|
def done(self, now: float, action: Action, ok: bool) -> None:
|
||||||
|
"""Ergebnis einer ausgefuehrten Aktion - setzt die Rueckmeldung fuers Panel."""
|
||||||
|
self.busy = None
|
||||||
|
self.screen = "idle"
|
||||||
|
self.items = []
|
||||||
|
self.pending = None
|
||||||
|
self.deadline = None
|
||||||
|
self.result = (action.done_text if ok else "{}: Fehler".format(action.label), ok)
|
||||||
|
self.result_until = now + RESULT_S
|
||||||
|
|
||||||
|
# -- Ausgabe ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def view(self, now: float) -> dict:
|
||||||
|
"""Der Bedienzustand, wie ihn der Anzeigedienst zeichnet. "active" sagt ihm, dass
|
||||||
|
gerade eine Bedienung laeuft (er sieht dann schneller nach)."""
|
||||||
|
if (self._pressed and self._press_screen == "idle"
|
||||||
|
and self.screen in ("idle", "busy")):
|
||||||
|
held = now - self._press_t
|
||||||
|
if self._hold_stage == 1:
|
||||||
|
return _view("hold", "STATION OFF",
|
||||||
|
"Shutting down in {} s".format(
|
||||||
|
_countdown(SHUTDOWN_HOLD_S, held)),
|
||||||
|
["Keep holding - release to cancel"])
|
||||||
|
if held >= SHORT_PRESS_MAX_S:
|
||||||
|
return _view("hold", "HOLD",
|
||||||
|
"Cancel in {} s".format(_countdown(CANCEL_HOLD_S, held)),
|
||||||
|
["Keep holding - release to cancel"])
|
||||||
|
if self.screen == "menu" and self.items:
|
||||||
|
selected = self.items[self.index]
|
||||||
|
rest = self.items[self.index + 1:] + self.items[:self.index]
|
||||||
|
return _view("menu", "MENU", "> " + selected.label,
|
||||||
|
[a.label for a in rest] + ["tap: next, hold: select"])
|
||||||
|
if self.screen == "confirm" and self.pending is not None:
|
||||||
|
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
|
||||||
|
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
|
||||||
|
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
|
||||||
|
details = list(self.pending.confirm_details or [])
|
||||||
|
details += ["hold (1.5 s): yes", "tap: no"]
|
||||||
|
return _view("confirm", self.pending.confirm_title, self.pending.label,
|
||||||
|
details)
|
||||||
|
if self.screen == "busy" and self.busy is not None:
|
||||||
|
return _view("busy", "PLEASE WAIT", self.busy.label, [])
|
||||||
|
if self.result is not None:
|
||||||
|
text, ok = self.result
|
||||||
|
return _view("result", "DONE" if ok else "ERROR", text, [])
|
||||||
|
return {"view": "idle", "active": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _view(view: str, title: str, hero: str, details: list[str]) -> dict:
|
||||||
|
return {"view": view, "title": title, "hero": hero, "details": list(details),
|
||||||
|
"active": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hardware und Hauptschleife -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def open_button(pin: int = BUTTON_PIN):
|
||||||
|
"""Der einzige Hardwarezugriff dieses Dienstes: ein Eingangspin. KEIN SPI, kein
|
||||||
|
Zeichenpin - siehe Modul-Docstring ("GPIO busy").
|
||||||
|
|
||||||
|
Entprellt gpiozero selbst (bounce_time): ein Tipper, der kuerzer ist als
|
||||||
|
DEBOUNCE_S, kommt gar nicht erst als Druck an."""
|
||||||
|
try:
|
||||||
|
import gpiozero
|
||||||
|
except ImportError as e:
|
||||||
|
raise ButtonUnavailable("gpiozero fehlt ({})".format(e))
|
||||||
|
try:
|
||||||
|
return gpiozero.Button(pin, pull_up=True, bounce_time=DEBOUNCE_S)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# Bewusst breit: ein belegter Pin kommt je nach Pin-Factory als
|
||||||
|
# gpiozero.GPIOPinInUse, als roher lgpio.error ('GPIO busy') oder als OSError
|
||||||
|
# zurueck - keine gemeinsame Oberklasse, die hier greifen wuerde. Alles davon
|
||||||
|
# heisst dasselbe: kein Taster, also lautlos ohne Fehler beenden, statt eine
|
||||||
|
# Neustartschleife auszuloesen.
|
||||||
|
raise ButtonUnavailable("GPIO{} nicht verfuegbar ({})".format(pin, e))
|
||||||
|
|
||||||
|
|
||||||
|
def run_daemon(button=None, ui: "ButtonUI | None" = None, writer: "UiWriter | None" = None,
|
||||||
|
actions: "CliActions | None" = None, clock=time,
|
||||||
|
read_state=station_state) -> None:
|
||||||
|
"""Endlosschleife: Pegel lesen, ButtonUI fragen, eine zurueckgegebene Aktion
|
||||||
|
ausfuehren, Ergebnis zurueckmelden, Bedienzustand schreiben.
|
||||||
|
|
||||||
|
`button`, `ui`, `writer`, `actions` und `clock` sind einschleusbar, damit die
|
||||||
|
VERDRAHTUNG hier selbst pruefbar ist und nicht nur eine Kopie davon (display.py hatte
|
||||||
|
genau diesen Fehler: die Tests trieben eine nachgebaute Schleife, und Mutationen an
|
||||||
|
den echten Zeilen blieben gruen). Im Betrieb bleiben die Vorgaben.
|
||||||
|
|
||||||
|
Die Reihenfolge ist wesentlich: der Bedienzustand wird VOR dem (sekundenlangen)
|
||||||
|
CLI-Aufruf geschrieben, sonst steht auf dem Panel bis zu dessen Ende noch das Menue
|
||||||
|
und der Bediener haelt das Geraet fuer eingefroren."""
|
||||||
|
if button is None:
|
||||||
|
button = open_button()
|
||||||
|
if writer is None:
|
||||||
|
writer = UiWriter()
|
||||||
|
if actions is None:
|
||||||
|
actions = CliActions()
|
||||||
|
if ui is None:
|
||||||
|
ui = ButtonUI(clock.monotonic(), read_state=read_state)
|
||||||
|
|
||||||
|
log.info("Taster bereit (GPIO%s), Bedienzustand in %s", BUTTON_PIN, writer.path)
|
||||||
|
writer.write(ui.view(clock.monotonic()))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = clock.monotonic()
|
||||||
|
action = ui.update(now, bool(button.is_pressed))
|
||||||
|
if action is not None:
|
||||||
|
writer.write(ui.view(now))
|
||||||
|
ok = actions.run(action)
|
||||||
|
ui.done(clock.monotonic(), action, ok)
|
||||||
|
writer.write(ui.view(clock.monotonic()))
|
||||||
|
clock.sleep(TICK_S)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
try:
|
||||||
|
run_daemon()
|
||||||
|
except ButtonUnavailable as e:
|
||||||
|
# GENAU EINE Zeile, dann sys.exit(0) - siehe Modul-Docstring. Kein Fehler, damit
|
||||||
|
# Restart=on-failure daraus keine Neustartschleife macht und das Journal flutet.
|
||||||
|
log.info("Taster nicht verfuegbar, beende ohne Fehler: %s", e)
|
||||||
|
sys.exit(0)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+722
@@ -0,0 +1,722 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Liefert Firmware-Images und den Stationscode an den Pi aus.
|
||||||
|
#
|
||||||
|
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
|
||||||
|
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
|
||||||
|
shift || true
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
ROOT=$(cd "$HERE/../.." && pwd)
|
||||||
|
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
||||||
|
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
|
||||||
|
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
||||||
|
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
||||||
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||||
|
# 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 RESCUE_NOCREDS_FILES=()
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
||||||
|
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
||||||
|
# unten).
|
||||||
|
FULL_SYNC=0
|
||||||
|
for t in "$@"; do
|
||||||
|
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
||||||
|
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
||||||
|
FILES+=("$f")
|
||||||
|
# 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
|
||||||
|
else
|
||||||
|
FULL_SYNC=1
|
||||||
|
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
||||||
|
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
||||||
|
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
||||||
|
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
||||||
|
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
||||||
|
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
||||||
|
# kein Zufall.
|
||||||
|
declare -A SEEN_AT=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
||||||
|
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEEN_AT[$base]=$f
|
||||||
|
FILES+=("$f")
|
||||||
|
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||||
|
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
||||||
|
# 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
|
||||||
|
|
||||||
|
echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) =="
|
||||||
|
STAGE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
||||||
|
declare -A NINL_VERSION=()
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
|
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
|
||||||
|
# das faellt sonst erst am Geraet auf.
|
||||||
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
||||||
|
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $out
|
||||||
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||||
|
NINL_VERSION[$target]="$2"
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten
|
||||||
|
# Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den
|
||||||
|
# gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine
|
||||||
|
# Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie,
|
||||||
|
# ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer
|
||||||
|
# jedes Ziel gelaufen" (siehe oben).
|
||||||
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then
|
||||||
|
echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) =="
|
||||||
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
||||||
|
echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $out
|
||||||
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||||
|
mkdir -p "$STAGE/rescue-nocreds"
|
||||||
|
cp "$f" "$STAGE/rescue-nocreds/$base"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
||||||
|
fi
|
||||||
|
# "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz
|
||||||
|
# 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf
|
||||||
|
# dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die
|
||||||
|
# Migrationsimages weiter unten, nur ohne Signierschritt dazwischen).
|
||||||
|
RESCUE_NOCREDS_COMPLETE=1
|
||||||
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then
|
||||||
|
RESCUE_NOCREDS_COMPLETE=0
|
||||||
|
else
|
||||||
|
declare -A RESCUE_NOCREDS_TARGETS=()
|
||||||
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
||||||
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
||||||
|
RESCUE_NOCREDS_TARGETS[$t]=1
|
||||||
|
done
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
||||||
|
[ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Migrationsimages signieren =="
|
||||||
|
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
||||||
|
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
||||||
|
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
|
||||||
|
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
|
||||||
|
# ausgeliefert (Exit-Code bleibt 0).
|
||||||
|
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
|
||||||
|
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
|
||||||
|
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
|
||||||
|
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
|
||||||
|
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
|
||||||
|
KEY_DEV="$SIGN_DIR/fw_sign.pem"
|
||||||
|
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
|
||||||
|
# MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
|
||||||
|
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
|
||||||
|
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
|
||||||
|
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
|
||||||
|
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
|
||||||
|
MIGRATE_COMPLETE=0
|
||||||
|
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
|
||||||
|
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
||||||
|
# einer, darf --delete auf dem Pi nicht laufen.
|
||||||
|
NOCREDS_COMPLETE=0
|
||||||
|
# NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
|
||||||
|
|
||||||
|
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
||||||
|
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
||||||
|
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
|
||||||
|
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
|
||||||
|
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
|
||||||
|
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
|
||||||
|
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
|
||||||
|
sign_set() {
|
||||||
|
local src_root=$1 stage_sub=$2 strictness=$3
|
||||||
|
local complete=1 produced=0
|
||||||
|
local f base target gz
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
|
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
|
||||||
|
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
|
||||||
|
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
|
||||||
|
# annimmt.
|
||||||
|
gz="$src_root/$target/ninlight_${target}_gz.img"
|
||||||
|
if [ ! -f "$gz" ]; then
|
||||||
|
if [ "$strictness" = "strict" ]; then
|
||||||
|
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
|
||||||
|
complete=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
mkdir -p "$STAGE/$stage_sub"
|
||||||
|
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||||
|
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|
||||||
|
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
||||||
|
produced=1
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||||
|
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|
||||||
|
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
||||||
|
produced=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$produced" -eq 1 ] || return 1
|
||||||
|
local VERSION_ARGS=()
|
||||||
|
local t
|
||||||
|
for t in "${!NINL_VERSION[@]}"; do
|
||||||
|
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
|
||||||
|
done
|
||||||
|
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
|
||||||
|
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
|
||||||
|
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
|
||||||
|
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
|
||||||
|
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
|
||||||
|
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
|
||||||
|
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
|
||||||
|
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
|
||||||
|
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
|
||||||
|
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
|
||||||
|
python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
migrate_dir = sys.argv[1]
|
||||||
|
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
|
||||||
|
entries = []
|
||||||
|
for name in sorted(os.listdir(migrate_dir)):
|
||||||
|
if name.endswith("_prod.img"):
|
||||||
|
target, variant = name[: -len("_prod.img")], "prod"
|
||||||
|
elif name.endswith("_dev.img"):
|
||||||
|
target, variant = name[: -len("_dev.img")], "dev"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
with open(os.path.join(migrate_dir, name), "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
entries.append({
|
||||||
|
"target": target,
|
||||||
|
"variant": variant,
|
||||||
|
"file": name,
|
||||||
|
"bytes": len(data),
|
||||||
|
"sha256": hashlib.sha256(data).hexdigest(),
|
||||||
|
"ninlight_version": version_map.get(target, ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"images": entries,
|
||||||
|
}
|
||||||
|
versions = {e["ninlight_version"] for e in entries}
|
||||||
|
if len(versions) == 1:
|
||||||
|
(version,) = versions
|
||||||
|
if version:
|
||||||
|
manifest["ninlight_version"] = version
|
||||||
|
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
||||||
|
json.dump(manifest, fh, indent=2, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
PY
|
||||||
|
[ "$complete" -eq 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
HAVE_PROD=0
|
||||||
|
HAVE_DEV=0
|
||||||
|
if [ -d "$SIGN_DIR" ]; then
|
||||||
|
if [ -f "$KEY_PROD" ]; then
|
||||||
|
HAVE_PROD=1
|
||||||
|
else
|
||||||
|
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2
|
||||||
|
fi
|
||||||
|
if [ -f "$KEY_DEV" ]; then
|
||||||
|
HAVE_DEV=1
|
||||||
|
else
|
||||||
|
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
||||||
|
BOTH_KEYS=0
|
||||||
|
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
|
||||||
|
|
||||||
|
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
|
||||||
|
# und die Station beide vorhalten soll (der Bediener schaltet per
|
||||||
|
# "ninlight-station bildsatz" bzw. am Taster um):
|
||||||
|
#
|
||||||
|
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
|
||||||
|
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
|
||||||
|
#
|
||||||
|
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
|
||||||
|
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
|
||||||
|
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
|
||||||
|
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
|
||||||
|
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
|
||||||
|
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
|
||||||
|
echo " Bildsatz migrate-wlan (aus build/)"
|
||||||
|
if sign_set "$ROOT/build" migrate-wlan strict; then
|
||||||
|
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
|
||||||
|
fi
|
||||||
|
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
|
||||||
|
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
|
||||||
|
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
|
||||||
|
else
|
||||||
|
if [ ! -d "$STAGE/migrate-nocreds" ]; then
|
||||||
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
|
||||||
|
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
|
||||||
|
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
|
||||||
|
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
|
||||||
|
# dagegen ungeprueft, was in build-nocreds/ lag.
|
||||||
|
#
|
||||||
|
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
|
||||||
|
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
|
||||||
|
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
|
||||||
|
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
||||||
|
# Fehler. Lieber ein abgebrochener Deploy.
|
||||||
|
#
|
||||||
|
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen.
|
||||||
|
# Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der
|
||||||
|
# Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden:
|
||||||
|
# er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an
|
||||||
|
# migrate-nocreds/, der Rettungssatz lief ungeprueft durch.
|
||||||
|
# migrate-wlan/ und rescue-wlan/ bleiben ungeprueft: die SOLLEN die Zugangsdaten
|
||||||
|
# enthalten, das ist ihr Zweck.
|
||||||
|
#
|
||||||
|
# Gesucht wird im ENTPACKTEN Rumpf, nicht in der Datei, wie sie auf der Platte liegt:
|
||||||
|
# jedes Abbild (_ota.img wie die signierten _prod.img/_dev.img) ist 56 Byte Kopf, dann
|
||||||
|
# ein gzip-Strom, dann 512 Byte Trailer bzw. Signatur. Im komprimierten Strom steht der
|
||||||
|
# Klartext des Passworts nicht - eine Suche im Rohbyteinhalt meldete deshalb IMMER
|
||||||
|
# "sauber", auch fuer den Satz aus build/, der die Zugangsdaten nachweislich traegt. Die
|
||||||
|
# Pruefung hat bis hierhin also nie irgendetwas geprueft.
|
||||||
|
#
|
||||||
|
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
|
||||||
|
# "dann eben ungeprueft durchlassen". Genau diese Nachsicht waere der Weg, auf dem das
|
||||||
|
# Passwort am Ende doch hinausgeht.
|
||||||
|
#
|
||||||
|
# Der Pruefkoerper in tools/release.sh ist verwandt, aber nicht mehr identisch: dort
|
||||||
|
# wird ein fertiges Archiv aus lauter verschiedenen Dateiarten geprueft, hier ein
|
||||||
|
# Bildsatz aus Firmware-Abbildern.
|
||||||
|
# Als if-Zweige und nicht als "[ -d … ] && CHECK_SETS+=(…)": unter "set -e" waere eine
|
||||||
|
# solche Zeile, wenn das Verzeichnis fehlt, ein fehlgeschlagenes letztes Kommando und
|
||||||
|
# risse den Deploy ab.
|
||||||
|
declare -a CHECK_SETS=()
|
||||||
|
if [ -d "$STAGE/migrate-nocreds" ]; then CHECK_SETS+=(migrate-nocreds); fi
|
||||||
|
if [ -d "$STAGE/rescue-nocreds" ]; then CHECK_SETS+=(rescue-nocreds); fi
|
||||||
|
if [ ${#CHECK_SETS[@]} -gt 0 ]; then
|
||||||
|
echo "== Bildsaetze 'ohne-wlan' auf Zugangsdaten pruefen =="
|
||||||
|
LOCAL="$ROOT/config.local.cmake"
|
||||||
|
if [ -f "$LOCAL" ]; then
|
||||||
|
python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:]
|
||||||
|
text = open(local, encoding="utf-8", errors="replace").read()
|
||||||
|
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
||||||
|
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
||||||
|
# haette nur falsche Alarme erzeugt.
|
||||||
|
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||||
|
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||||
|
# Aufbau eines Abbilds: 56 Byte Kopf (vom wm_tool erzeugt), dann der gzip-Strom mit der
|
||||||
|
# eigentlichen Firmware, dann 512 Byte ninlight-Trailer bzw. RSA-Signatur.
|
||||||
|
HEADER_LEN = 56
|
||||||
|
TRAILER_LEN = 512
|
||||||
|
needles = []
|
||||||
|
for name in SECRETS:
|
||||||
|
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||||
|
if m and m.group(1) not in PUBLIC:
|
||||||
|
needles.append((name, m.group(1).encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(raw):
|
||||||
|
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
|
||||||
|
if len(raw) <= HEADER_LEN + TRAILER_LEN:
|
||||||
|
raise ValueError("Datei ist kuerzer als Kopf + Trailer")
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
body = dec.decompress(raw[HEADER_LEN:-TRAILER_LEN])
|
||||||
|
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
|
||||||
|
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
|
||||||
|
# ungeprueft - und genau dort koennte das Passwort stehen.
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom ist unvollstaendig")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
broken = []
|
||||||
|
images = 0
|
||||||
|
for image_set in sets:
|
||||||
|
root_dir = os.path.join(stage, image_set)
|
||||||
|
for root, _dirs, files in os.walk(root_dir):
|
||||||
|
for fn in sorted(files):
|
||||||
|
path = os.path.join(root, fn)
|
||||||
|
rel = os.path.relpath(path, stage)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
# Durchsucht werden beide Sichten: der entpackte Rumpf (dort steht die
|
||||||
|
# Firmware) UND die Rohbytes (Kopf und Trailer werden nicht komprimiert,
|
||||||
|
# und Beidateien wie manifest.json gibt es nur roh).
|
||||||
|
views = [raw]
|
||||||
|
if fn.endswith(".img"):
|
||||||
|
images += 1
|
||||||
|
try:
|
||||||
|
views.append(unpack(raw))
|
||||||
|
except Exception as exc:
|
||||||
|
broken.append("%s (%s)" % (rel, exc))
|
||||||
|
continue
|
||||||
|
for name, needle in needles:
|
||||||
|
if any(needle in view for view in views):
|
||||||
|
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
|
||||||
|
hits.append("%s in %s" % (name, rel))
|
||||||
|
if broken:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Abbild im Bildsatz 'ohne-wlan' laesst sich nicht entpacken, also auch "
|
||||||
|
"nicht auf Zugangsdaten pruefen - nichts uebertragen:\n "
|
||||||
|
+ "\n ".join(broken) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
if hits:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
|
||||||
|
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
print(" sauber (%d Abbilder in %d Saetzen geprueft: %s)"
|
||||||
|
% (images, len(sets), ", ".join(sets)))
|
||||||
|
CHECKPY
|
||||||
|
else
|
||||||
|
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== uebertragen =="
|
||||||
|
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
||||||
|
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
||||||
|
# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer
|
||||||
|
# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde
|
||||||
|
# rsync ein solches Update dann stillschweigend ueberspringen.
|
||||||
|
#
|
||||||
|
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
|
||||||
|
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
|
||||||
|
# ausgelieferte Verzeichnis.
|
||||||
|
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
|
||||||
|
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
||||||
|
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
|
||||||
|
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
||||||
|
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
||||||
|
"$HOST:/tmp/"
|
||||||
|
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
|
||||||
|
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
|
||||||
|
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
|
||||||
|
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
|
||||||
|
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
|
||||||
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
|
||||||
|
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
|
||||||
|
# __pycache__ dieses Rechners.
|
||||||
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
||||||
|
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
||||||
|
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
FULL_SYNC=$1
|
||||||
|
MIGRATE_COMPLETE=$2
|
||||||
|
NOCREDS_COMPLETE=$3
|
||||||
|
RESCUE_NOCREDS_COMPLETE=$4
|
||||||
|
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||||
|
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
||||||
|
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||||
|
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
||||||
|
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
||||||
|
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
||||||
|
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
|
||||||
|
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/images"
|
||||||
|
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||||
|
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
||||||
|
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
||||||
|
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
||||||
|
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
||||||
|
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
||||||
|
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
||||||
|
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
||||||
|
REMOVED=""
|
||||||
|
if [ -n "$OLD_LIST" ]; then
|
||||||
|
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
||||||
|
fi
|
||||||
|
if [ -n "$REMOVED" ]; then
|
||||||
|
echo " voller Abgleich - folgende Images werden entfernt:"
|
||||||
|
echo "$REMOVED" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " voller Abgleich - keine Images werden entfernt"
|
||||||
|
fi
|
||||||
|
# migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
|
||||||
|
# hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
|
||||||
|
# Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
|
||||||
|
# unter /tmp/ninlight-images deshalb gar nicht existiert.
|
||||||
|
#
|
||||||
|
# Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die
|
||||||
|
# Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die
|
||||||
|
# Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem
|
||||||
|
# Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle
|
||||||
|
# unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt
|
||||||
|
# noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt.
|
||||||
|
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
||||||
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
|
else
|
||||||
|
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||||
|
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
||||||
|
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
||||||
|
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
||||||
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
|
fi
|
||||||
|
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
||||||
|
# beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
|
||||||
|
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
|
||||||
|
#
|
||||||
|
# Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
|
||||||
|
# seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
|
||||||
|
# fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
|
||||||
|
# tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
|
||||||
|
# geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
|
||||||
|
# frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
|
||||||
|
# diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten
|
||||||
|
# betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die
|
||||||
|
# Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren
|
||||||
|
# greift dort also einfach nicht.
|
||||||
|
sync_image_set() {
|
||||||
|
set=$1
|
||||||
|
complete=$2
|
||||||
|
if [ -d "/tmp/ninlight-images/$set" ]; then
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
|
||||||
|
if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then
|
||||||
|
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
|
||||||
|
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
|
||||||
|
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
|
||||||
|
sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
||||||
|
else
|
||||||
|
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
|
||||||
|
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
|
||||||
|
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
|
||||||
|
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
|
||||||
|
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
|
||||||
|
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
|
||||||
|
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
||||||
|
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
|
||||||
|
sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
||||||
|
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
|
||||||
|
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
||||||
|
with open(new_manifest_path) as fh:
|
||||||
|
new_manifest = json.load(fh)
|
||||||
|
if not isinstance(new_manifest, dict):
|
||||||
|
new_manifest = {}
|
||||||
|
try:
|
||||||
|
with open(manifest_path) as fh:
|
||||||
|
old_manifest = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
old_manifest = {}
|
||||||
|
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
|
||||||
|
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
|
||||||
|
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
|
||||||
|
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
|
||||||
|
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
|
||||||
|
# Stationscode zurueck.
|
||||||
|
if not isinstance(old_manifest, dict):
|
||||||
|
old_manifest = {}
|
||||||
|
old_images = old_manifest.get("images")
|
||||||
|
if not isinstance(old_images, list):
|
||||||
|
old_images = []
|
||||||
|
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
|
||||||
|
new_images = new_manifest.get("images")
|
||||||
|
if not isinstance(new_images, list):
|
||||||
|
new_images = []
|
||||||
|
for e in new_images:
|
||||||
|
if isinstance(e, dict):
|
||||||
|
by_key[(e.get("target"), e.get("variant"))] = e
|
||||||
|
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
|
||||||
|
merged = {
|
||||||
|
"built": new_manifest.get("built"),
|
||||||
|
"images": merged_images,
|
||||||
|
}
|
||||||
|
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
|
||||||
|
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
|
||||||
|
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
|
||||||
|
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
|
||||||
|
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
|
||||||
|
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
|
||||||
|
# Fall, dass wirklich alle Eintraege uebereinstimmen.
|
||||||
|
versions = {e.get("ninlight_version") for e in merged_images}
|
||||||
|
if len(versions) == 1:
|
||||||
|
(version,) = versions
|
||||||
|
if isinstance(version, str) and version:
|
||||||
|
merged["ninlight_version"] = version
|
||||||
|
with open(manifest_path, "w") as fh:
|
||||||
|
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
MERGE
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis
|
||||||
|
# vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
|
||||||
|
# ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
|
||||||
|
# ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
|
||||||
|
# (Fixrunde 2, Kleinigkeit).
|
||||||
|
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem
|
||||||
|
# Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der
|
||||||
|
# andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der
|
||||||
|
# fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus.
|
||||||
|
sync_image_set migrate-wlan "$MIGRATE_COMPLETE"
|
||||||
|
sync_image_set migrate-nocreds "$NOCREDS_COMPLETE"
|
||||||
|
# rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht
|
||||||
|
# 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie
|
||||||
|
# das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein
|
||||||
|
# Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen
|
||||||
|
# Schluesseln.
|
||||||
|
sync_image_set rescue-wlan 1
|
||||||
|
sync_image_set rescue-nocreds "$RESCUE_NOCREDS_COMPLETE"
|
||||||
|
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
||||||
|
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
||||||
|
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
||||||
|
if [ -d "$ROOT_DIR/images/migrate" ]; then
|
||||||
|
sudo rm -rf "$ROOT_DIR/images/migrate"
|
||||||
|
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
||||||
|
fi
|
||||||
|
# Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege
|
||||||
|
# FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest
|
||||||
|
# dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung,
|
||||||
|
# warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die
|
||||||
|
# normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja
|
||||||
|
# nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht
|
||||||
|
# uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser
|
||||||
|
# Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv
|
||||||
|
# (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen
|
||||||
|
# gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten.
|
||||||
|
OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null)
|
||||||
|
if [ -n "$OLD_FLAT_RESCUE" ]; then
|
||||||
|
echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f
|
||||||
|
echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)"
|
||||||
|
fi
|
||||||
|
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
||||||
|
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
||||||
|
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
|
||||||
|
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
|
||||||
|
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
|
||||||
|
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/display"
|
||||||
|
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
|
||||||
|
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
|
||||||
|
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/button"
|
||||||
|
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
|
||||||
|
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
|
||||||
|
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
|
||||||
|
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
|
||||||
|
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
|
||||||
|
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
|
||||||
|
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
if systemctl is-active --quiet ninlight-station; then
|
||||||
|
if sudo systemctl restart ninlight-station; then
|
||||||
|
echo " Dienst neu gestartet"
|
||||||
|
else
|
||||||
|
echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Dienst laeuft nicht (Station ist aus)"
|
||||||
|
fi
|
||||||
|
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
|
||||||
|
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
|
||||||
|
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
|
||||||
|
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
|
||||||
|
# uebersehen.
|
||||||
|
if systemctl is-active --quiet ninlight-display; then
|
||||||
|
if sudo systemctl restart ninlight-display; then
|
||||||
|
echo " Anzeigedienst neu gestartet"
|
||||||
|
else
|
||||||
|
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Anzeigedienst laeuft nicht"
|
||||||
|
fi
|
||||||
|
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
|
||||||
|
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
|
||||||
|
if systemctl is-active --quiet ninlight-button; then
|
||||||
|
if sudo systemctl restart ninlight-button; then
|
||||||
|
echo " Tasterdienst neu gestartet"
|
||||||
|
else
|
||||||
|
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Tasterdienst laeuft nicht"
|
||||||
|
fi
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "fertig."
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Herkunft der Panel-Treiber
|
||||||
|
|
||||||
|
`epd2in13_V2.py` und `epd2in13_V3.py` sind unveraendert aus dem offiziellen
|
||||||
|
Waveshare-Repository uebernommen:
|
||||||
|
|
||||||
|
- Quelle: https://github.com/waveshareteam/e-Paper
|
||||||
|
- Pfad: `RaspberryPi_JetsonNano/python/lib/waveshare_epd/epd2in13_V2.py` bzw.
|
||||||
|
`.../epd2in13_V3.py`
|
||||||
|
- Commit: `85802dee4b938212b0ae069a34492c54a3d99878` (2023-02-15), abgerufen am
|
||||||
|
2026-09-13 per `git log --follow` auf diesen beiden Dateien im Repository.
|
||||||
|
- `epd2in13_V2.py`: Treiber-Version laut Dateikopf V4.0 (2019-06-20).
|
||||||
|
- `epd2in13_V3.py`: Treiber-Version laut Dateikopf V1.2 (2022-08-09).
|
||||||
|
|
||||||
|
## Was geaendert wurde
|
||||||
|
|
||||||
|
Ausschliesslich die Importzeile:
|
||||||
|
|
||||||
|
```
|
||||||
|
from . import epdconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
wurde zu
|
||||||
|
|
||||||
|
```
|
||||||
|
import epdconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
weil `display/` in diesem Repository kein Python-Package ist (kein `__init__.py`,
|
||||||
|
`sys.path`-Import wie bei `products.py` in `server.py`), sondern flach importiert wird -
|
||||||
|
sowohl von `display.py` als auch von `tools/station/tests/test_render.py`. Ein relativer
|
||||||
|
Import wuerde in dieser Umgebung mit `ImportError: attempted relative import with no known
|
||||||
|
parent package` fehlschlagen.
|
||||||
|
|
||||||
|
Init-Sequenz, LUT-Tabellen, Timings und der Urheberrechtsvermerk der Originaldateien
|
||||||
|
bleiben unangetastet. Ein kurzer Herkunftsvermerk mit denselben Angaben wie oben steht
|
||||||
|
zusaetzlich im Kopf jeder der beiden Dateien.
|
||||||
|
|
||||||
|
## Was NICHT uebernommen wurde
|
||||||
|
|
||||||
|
`epdconfig.py` in diesem Verzeichnis ist eine eigene Neuimplementierung (siehe deren
|
||||||
|
Modul-Docstring), keine Uebernahme aus dem Waveshare-Repository: Waveshares eigenes
|
||||||
|
`epdconfig.py` fuer den Raspberry Pi setzt `RPi.GPIO` voraus, das auf Debian 13 (trixie)
|
||||||
|
mit Python 3.13 nicht mehr baut/importiert. Unser `epdconfig.py` implementiert dieselbe
|
||||||
|
von den beiden Treibern erwartete Schnittstelle (Pin-Konstanten, `digital_write`,
|
||||||
|
`digital_read`, `delay_ms`, `spi_writebyte`, `spi_writebyte2`, `module_init`,
|
||||||
|
`module_exit`) gegen `gpiozero`/`spidev`, die auf dem Ziel-Pi vorhanden sind.
|
||||||
@@ -0,0 +1,873 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Daemon fuer die E-Paper-Anzeige der ninlight-Station: alle 5 s status.json,
|
||||||
|
control.json und die Konfigurationsdatei lesen, daraus einen Text zeichnen und - nur bei
|
||||||
|
Aenderung - aufs Panel bringen.
|
||||||
|
|
||||||
|
Panel: Waveshare 2,13" (250x122 px), SPI. Ob das verbaute Panel Revision V2 oder V3 ist,
|
||||||
|
laesst sich am Geraet selbst nicht auslesen - die Revision kommt deshalb aus der
|
||||||
|
Umgebungsvariable EPD_REVISION (Vorgabe "V3", von setup_pi.sh in
|
||||||
|
/etc/ninlight-station.conf geschrieben und von der systemd-Unit als Environment=
|
||||||
|
weitergereicht).
|
||||||
|
|
||||||
|
So bestimmst du die richtige Revision empirisch: erst
|
||||||
|
EPD_REVISION=V3 python3 display.py --test
|
||||||
|
laufen lassen und das Panel ansehen. Zeigt es ein sauberes, seitenrichtiges Testbild ohne
|
||||||
|
Geisterbilder/Streifen, ist V3 richtig. Zeigt es nichts, ein invertiertes/verschobenes Bild
|
||||||
|
oder haengt beim SWRESET (ReadBusy() kehrt nie zurueck), mit
|
||||||
|
EPD_REVISION=V2 python3 display.py --test
|
||||||
|
wiederholen. Zwischen den beiden Versuchen kurz die Stromversorgung des Panels trennen
|
||||||
|
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
|
||||||
|
lassen).
|
||||||
|
|
||||||
|
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
|
||||||
|
Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
|
||||||
|
ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
|
||||||
|
einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
|
||||||
|
das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
|
||||||
|
# display.py kann aber von ueberall aus gestartet werden (die systemd-Unit ruft es mit
|
||||||
|
# vollem Pfad auf).
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import render # noqa: E402
|
||||||
|
from epdconfig import DisplayUnavailable # noqa: E402
|
||||||
|
|
||||||
|
log = logging.getLogger("ninlight-display")
|
||||||
|
|
||||||
|
# Dieselben Umgebungsvariablen wie tools/station/ninlight-station verwendet, damit Tests
|
||||||
|
# und Handbetrieb ohne echtes /run bzw. /etc auskommen.
|
||||||
|
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
|
||||||
|
CONF_PATH = Path(os.environ.get("NINLIGHT_STATION_CONF", "/etc/ninlight-station.conf"))
|
||||||
|
SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
|
||||||
|
# Bedienzustand des Tasterdienstes (tools/station/button/button.py). NUR GELESEN: der
|
||||||
|
# Tasterdienst fasst den SPI-Bus nicht an und dieser Dienst kein GPIO23 - zwei Prozesse
|
||||||
|
# auf denselben Leitungen enden in "lgpio.error: 'GPIO busy'". Die Datei liegt im
|
||||||
|
# RuntimeDirectory DES TASTERDIENSTES (nicht in RUN_DIR): RUN_DIR ist das
|
||||||
|
# RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also
|
||||||
|
# ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet.
|
||||||
|
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
|
||||||
|
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" (siehe
|
||||||
|
# ninlight-station, bildsatz_cmd, und server.py:read_image_set). NUR GELESEN, und
|
||||||
|
# absichtlich aus derselben Datei wie der Server - nicht aus status.json: der Zustand
|
||||||
|
# muss auch dann auf dem Panel stehen, wenn die Station AUS ist. Genau davor soll er
|
||||||
|
# schuetzen ("man soll vor jedem Einschalten sehen, was gilt"), und ausgeschaltet
|
||||||
|
# schreibt server.py nichts mehr.
|
||||||
|
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
||||||
|
"/var/lib/ninlight-station/imageset"))
|
||||||
|
# Der Modus, den "recovery on"/"migrate on" zuletzt angefordert haben - fuer die
|
||||||
|
# Fusszeile GENAUSO dauerhaft gebraucht wie der Bildsatz-Umschalter oben, aus demselben
|
||||||
|
# Grund und nach demselben Muster (eigene Datei unter /var/lib, NICHT unter RUN_DIR).
|
||||||
|
#
|
||||||
|
# Fixrunde 1, Befund 3: RUN_DIR/mode taugt dafuer NICHT. Die Unit setzt
|
||||||
|
# RuntimeDirectoryPreserve=restart - das erhaelt /run/ninlight-station nur ueber einen
|
||||||
|
# "systemctl restart" hinweg (das, was "recovery on"/"migrate on" fuer einen bereits
|
||||||
|
# laufenden Dienst ausloesen). "ninlight-station off" aber STOPPT den Dienst
|
||||||
|
# (systemctl stop), und GENAU DANN raeumt systemd das RuntimeDirectory leer, "restart"
|
||||||
|
# deckt einen echten Stop nicht ab. Ein frueherer Docstring hier behauptete das
|
||||||
|
# Gegenteil ("die Datei ueberlebt das Abschalten") - das war schlicht falsch, und der
|
||||||
|
# Fehler war unauffaellig, weil eine fehlende Datei auf "recovery" faellt: genau der
|
||||||
|
# Modus, den viele Tests ohnehin erwarteten, ohne dass die Faelligkeit der Datei
|
||||||
|
# je geprueft wurde. Die Folge am Geraet waere die falsche Beruhigung, gegen die die
|
||||||
|
# Fusszeile ueberhaupt gebaut ist - "in Ordnung" vor einer geplanten Migration, obwohl
|
||||||
|
# der Migrationssatz leer ist, weil das Panel mangels Datei den Rettungssatz geprueft
|
||||||
|
# haette.
|
||||||
|
#
|
||||||
|
# write_mode() im CLI schreibt deshalb NEBEN der ephemeren RUN_DIR/mode (die
|
||||||
|
# ausschliesslich server.py's naechsten Start betrifft) auch hierher - dieselbe
|
||||||
|
# Zeichenkette, zwei Ablagen fuer zwei verschiedene Leser mit verschiedenen
|
||||||
|
# Haltbarkeitsanforderungen.
|
||||||
|
LAST_MODE_FILE = Path(os.environ.get("NINLIGHT_LAST_MODE_FILE",
|
||||||
|
"/var/lib/ninlight-station/mode"))
|
||||||
|
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
|
||||||
|
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
|
||||||
|
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||||
|
IMAGESET_DEFAULT = "ohne-wlan"
|
||||||
|
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (server.py: rescue_dir()) - mit
|
||||||
|
# eigenen Verzeichnissen, denn Rettungs- und Migrationsabzuege sind verschieden hergestellt
|
||||||
|
# (siehe server.py fuer die Begruendung). Welches Paar am Panel zu pruefen ist, haengt vom
|
||||||
|
# Modus ab: _read_imageset() bekommt ihn deshalb als Parameter statt fest verdrahtet.
|
||||||
|
RESCUE_IMAGESET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
|
||||||
|
|
||||||
|
POLL_INTERVAL_S = 5
|
||||||
|
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
|
||||||
|
# bis zu 5 s ohne Reaktion). Die Schleife tickt deshalb in diesem Takt und sieht bei
|
||||||
|
# JEDEM Tick in der Bedienzustandsdatei nach - auch im Ruhezustand, sonst waere
|
||||||
|
# ausgerechnet der erste Druck (der das Menue oeffnet) der langsamste. Das kostet eine
|
||||||
|
# kleine Dateilesung je halber Sekunde; der teure Teil des Zustands (build_state() mit
|
||||||
|
# seinem "systemctl is-active"-Fork) bleibt beim 5-Sekunden-Takt.
|
||||||
|
#
|
||||||
|
# Am Panel gemessen kommt zu dieser halben Sekunde noch der Teilaufbau (rund 0,3 s) -
|
||||||
|
# also grob 0,5-0,8 s je Druck. Das ist spuerbar, aber bedienbar: die Geste ist
|
||||||
|
# "druecken und hinsehen", nicht "tippen wie auf einer Tastatur", und die Haltegesten
|
||||||
|
# (1,5/3/10 s) liegen alle weit ueber dieser Verzoegerung.
|
||||||
|
UI_POLL_INTERVAL_S = 0.5
|
||||||
|
# E-Paper altert mit jedem Bildaufbau - ein Vollaufbau nur bei Moduswechsel und
|
||||||
|
# mindestens alle 30 Minuten, sonst Teilaktualisierung (siehe Modul-Docstring der
|
||||||
|
# Panel-Treiber: haeufige Teilaktualisierungen ohne gelegentlichen Vollaufbau lassen
|
||||||
|
# Geisterbilder stehen).
|
||||||
|
FULL_REFRESH_INTERVAL_S = 30 * 60
|
||||||
|
|
||||||
|
# Panel erst schlafen legen, wenn seit dem letzten Bildaufbau schon eine Weile nichts
|
||||||
|
# mehr passiert ist - NICHT nach jedem einzelnen Bild (Fixrunde 2, N1/N2/N3: das liess
|
||||||
|
# V3 beim naechsten Teilaufbau abstuerzen und machte V2s Deep-Sleep-Aufwachsequenz
|
||||||
|
# unbrauchbar, siehe Panel-Klasse). Migrationsereignisse kommen oft in kurzen Buendeln
|
||||||
|
# (mehrere Lampen kurz nacheinander) - das Panel dafuer jedesmal schlafen zu legen und
|
||||||
|
# wieder aufzuwecken waere nur zusaetzlicher Verschleiss ohne Nutzen fuer die
|
||||||
|
# Bias-Alterung, die I4 eigentlich vermeiden soll. 10 Minuten ohne Aenderung sind lang
|
||||||
|
# genug, dass ein Buendel zusammenhaengender Ereignisse das Panel nicht staendig
|
||||||
|
# schlafen und wieder aufwachen laesst, aber kurz genug, dass es zwischen zwei
|
||||||
|
# tatsaechlich seltenen Ereignissen (z.B. stundenlanger Rettungsmodus ohne Anfrage)
|
||||||
|
# nicht dauerhaft unter Bias steht.
|
||||||
|
SLEEP_AFTER_IDLE_S = 10 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict:
|
||||||
|
"""Geparster Inhalt von `path`, oder {} wenn er fehlt/unlesbar/kaputt/kein Objekt
|
||||||
|
ist - status.json und control.json koennen jederzeit in genau diesem Zustand sein
|
||||||
|
(frisch aufgesetzter Pi, Schreibfehler mitten im Schreiben)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_conf(path: Path) -> dict[str, str]:
|
||||||
|
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
|
||||||
|
setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
|
||||||
|
beim Nachtragen fehlender Schluessel ausdruecklich auf "^(export[[:space:]]+)?KEY=" -
|
||||||
|
ein bereits vorhandenes "export SSID=..." muss also erkannt werden, nicht nur die
|
||||||
|
unpraefigierte Form). Kein echtes Shell-Sourcing (das CLI braucht das fuer $- und
|
||||||
|
`-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
|
||||||
|
SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
text = path.read_text()
|
||||||
|
except OSError:
|
||||||
|
return values
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
if line.startswith("export ") or line.startswith("export\t"):
|
||||||
|
line = line[len("export"):].lstrip()
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
if key:
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _read_autooff_minutes(run_dir: Path, now: float | None = None):
|
||||||
|
"""Restzeit des optionalen Zeitschalters in ganzen Minuten (aufgerundet), oder None,
|
||||||
|
wenn gerade keiner laeuft.
|
||||||
|
|
||||||
|
Quelle ist RUN_DIR/autooff.json, die "ninlight-station --for/renew" schreibt und
|
||||||
|
"hold"/"off" wieder loescht (siehe dort). Ein Zeitschalter, den man am Panel nicht
|
||||||
|
sieht, waere schlimmer als keiner: man glaubt, die Station laeuft, und sie ist
|
||||||
|
laengst aus.
|
||||||
|
|
||||||
|
"deadline" ist eine Unix-Zeit (der Zeitschalter selbst haengt an systemds monotoner
|
||||||
|
Uhr, die Anzeige rechnet in Wanduhrzeit) - eine Zeitverstellung verschiebt also die
|
||||||
|
angezeigte Restzeit, nicht das tatsaechliche Abschalten. Rundet auf: solange noch
|
||||||
|
eine Sekunde uebrig ist, steht dort "1 min" und nicht "0 min"."""
|
||||||
|
data = _read_json(run_dir / "autooff.json")
|
||||||
|
deadline = data.get("deadline")
|
||||||
|
if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
|
||||||
|
return None
|
||||||
|
remaining_s = deadline - (time.time() if now is None else now)
|
||||||
|
if remaining_s <= 0:
|
||||||
|
# Der Termin ist da (oder der Lauf verschiebt gerade) - "0 min" ist hier die
|
||||||
|
# ehrliche Auskunft, nicht das Verschwinden der Zeile.
|
||||||
|
return 0
|
||||||
|
whole, rest = divmod(remaining_s, 60)
|
||||||
|
return int(whole) + (1 if rest else 0)
|
||||||
|
|
||||||
|
|
||||||
|
# Lebenszeichen: ein kleiner Punkt, der erscheint, sobald irgendeine Anfrage hereinkommt
|
||||||
|
# (auch eine erfolglose - "notfound"/"listen"/"unknown"/"noimageset" zaehlen genauso wie
|
||||||
|
# eine erfolgreiche Auslieferung), und nach ACTIVITY_WINDOW_S Sekunden von selbst wieder
|
||||||
|
# verschwindet. Der Wunsch dahinter (Rueckmeldung eines Bedieners, 2026-09-23): bisher war
|
||||||
|
# nur am Servierzaehler ("Served: N") zu sehen, ob es geklappt HAT - bis dahin war offen,
|
||||||
|
# ob ueberhaupt jemand anklopft. server.py schreibt den Zeitpunkt der letzten Anfrage
|
||||||
|
# jeder Art schon nach status.json (Station.last_activity); hier wird daraus nur noch ein
|
||||||
|
# Ja/Nein fuer "gerade eben".
|
||||||
|
#
|
||||||
|
# Bewusst KEIN eigener Zeittakt/Timer: das Panel pollt status.json ohnehin schon alle
|
||||||
|
# POLL_INTERVAL_S Sekunden, und render.blocks() haengt den Punkt an eine bestehende
|
||||||
|
# Textzeile an (siehe dort) - er faehrt also ueber genau denselben Textvergleich
|
||||||
|
# (RefreshPlanner.decide()) mit, der auch jede andere Aenderung erkennt. Ein Erscheinen
|
||||||
|
# und ein Verschwinden kosten damit je einen gewoehnlichen Teilaufbau, keinen eigenen
|
||||||
|
# Mechanismus und kein zusaetzliches Wachhalten des E-Papers - fuegt sich also in die
|
||||||
|
# vorhandene, fuer E-Paper sparsame Taktung ein, statt eine eigene, potenziell haeufigere
|
||||||
|
# aufzumachen.
|
||||||
|
ACTIVITY_WINDOW_S = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_request(status: dict, now: float | None = None) -> bool:
|
||||||
|
"""War die letzte Anfrage (jeder Art) hoechstens ACTIVITY_WINDOW_S Sekunden her?
|
||||||
|
|
||||||
|
"last_request_ts" fehlt bei einer aelteren server.py-Fassung oder wenn noch nie eine
|
||||||
|
Anfrage einging - dann gilt False, kein Punkt erfunden. Ein Zeitstempel aus der
|
||||||
|
Zukunft (Uhr verstellt, oder Server- und Anzeigerechner-Uhr laufen leicht auseinander)
|
||||||
|
zaehlt ebenfalls als "nicht mehr aktuell" statt als potenziell dauerhaft aktiv."""
|
||||||
|
ts = status.get("last_request_ts")
|
||||||
|
if not isinstance(ts, str):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
now_s = time.time() if now is None else now
|
||||||
|
age_s = now_s - parsed.timestamp()
|
||||||
|
return 0 <= age_s <= ACTIVITY_WINDOW_S
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mode_file(path: Path) -> str:
|
||||||
|
"""Zuletzt angeforderter Modus laut `path`. Fehlt die Datei, ist sie unlesbar
|
||||||
|
(inklusive ungueltigem UTF-8 - eine halb geschriebene/beschaedigte Datei ist nicht
|
||||||
|
nur per fehlendem Inhalt kaputt), oder ist ihr Inhalt kein bekannter Modus, gilt
|
||||||
|
"recovery" - derselbe Vorgabewert wie in server.py:resolve_mode(). Wirft nie: eine
|
||||||
|
kaputte Datei darf weder das Panel noch "off" aufhalten.
|
||||||
|
|
||||||
|
`path` ist bewusst eine Datei, kein Verzeichnis (anders als frueher, wo hier
|
||||||
|
`run_dir / "mode"` gelesen wurde) - siehe LAST_MODE_FILE oben, warum die Fusszeile
|
||||||
|
NICHT mehr aus RUN_DIR liest."""
|
||||||
|
try:
|
||||||
|
value = path.read_text().strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return "recovery"
|
||||||
|
return value if value in ("recovery", "migration") else "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
# Nur beim ersten Mal protokollieren, dass systemctl selbst nicht erreichbar war (siehe
|
||||||
|
# _service_active) - das passiert bei jedem der Aufrufe alle 5 s erneut, und eine Zeile
|
||||||
|
# alle 5 s wuerde das Journal fluten, obwohl es sich nie aendert (Fixrunde 1, Kleinigkeit).
|
||||||
|
_service_active_fallback_logged = False
|
||||||
|
|
||||||
|
|
||||||
|
def _service_active(service_name: str) -> bool:
|
||||||
|
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
|
||||||
|
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen -
|
||||||
|
ein reines Lesen von status.json wuerde nach "off" also faelschlich weiter den letzten
|
||||||
|
Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
|
||||||
|
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
|
||||||
|
global _service_active_fallback_logged
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "is-active", "--quiet", service_name],
|
||||||
|
timeout=5, check=False)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
|
||||||
|
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen.
|
||||||
|
if not _service_active_fallback_logged:
|
||||||
|
log.warning("systemctl nicht erreichbar - Anzeige raet den Modus anhand von "
|
||||||
|
"RUN_DIR/mode statt der echten Dienstaktivitaet")
|
||||||
|
_service_active_fallback_logged = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tuple[str, bool]:
|
||||||
|
"""(Name des eingestellten Bildsatzes, liegt der fuer `mode` PASSENDE Satz ueberhaupt
|
||||||
|
auf dem Pi?).
|
||||||
|
|
||||||
|
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
|
||||||
|
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
|
||||||
|
Panel etwas anderes behaupten als der Server tut.
|
||||||
|
|
||||||
|
`ok` ist False, wenn der GEWAEHLTE Satz fuer den GERADE GELTENDEN Modus nicht auf dem
|
||||||
|
Pi liegt - nach derselben Regel wie server.py:migrate_set_present()/
|
||||||
|
rescue_set_present(): Verzeichnis vorhanden UND mindestens ein Image darin. Welches
|
||||||
|
Verzeichnispaar das ist (migrate-*/rescue-*), entscheidet `mode` - Rettung und
|
||||||
|
Migration liegen seit der Erweiterung des Umschalters auf die Rettung in getrennten
|
||||||
|
Verzeichnissen (server.py begruendet das). Eine falsche Zuordnung hier wuerde das
|
||||||
|
Panel in GENAU DEM Modus luegen lassen, fuer den es gerade steht: "ok", obwohl die
|
||||||
|
Rettung nichts ausliefern kann, oder "FEHLT", obwohl sie es koennte.
|
||||||
|
|
||||||
|
Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts behauptet:
|
||||||
|
dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
|
||||||
|
try:
|
||||||
|
value = imageset_path.read_text().strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
value = ""
|
||||||
|
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
|
||||||
|
image_dir = conf.get("IMAGE_DIR")
|
||||||
|
if not image_dir:
|
||||||
|
return name, True
|
||||||
|
dirs = RESCUE_IMAGESET_DIRS if mode == "recovery" else IMAGESET_DIRS
|
||||||
|
set_dir = Path(image_dir) / dirs[name]
|
||||||
|
try:
|
||||||
|
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
|
||||||
|
except OSError:
|
||||||
|
return name, False
|
||||||
|
|
||||||
|
|
||||||
|
def _route_lookup() -> str:
|
||||||
|
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt.
|
||||||
|
|
||||||
|
Eigene Funktion, damit die Tests sie ersetzen koennen, ohne einen Unterprozess zu
|
||||||
|
starten - und damit local_address() nur noch das Auswerten macht."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
fertig = subprocess.run(
|
||||||
|
# Bewusst eine dokumentierte Testadresse (RFC 5737) statt eines echten Ziels
|
||||||
|
# wie 8.8.8.8: "route get" fragt nur den Kernel, es geht kein Paket hinaus,
|
||||||
|
# aber es soll auch beim Mitlesen nichts Fremdes anklingen.
|
||||||
|
["ip", "-4", "route", "get", "192.0.2.1"],
|
||||||
|
capture_output=True, text=True, timeout=2, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
return fertig.stdout if fertig.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def local_address() -> tuple[str, str]:
|
||||||
|
"""(Geraet, Adresse), unter der der Pi im Heimnetz erreichbar ist - ("", "") ohne Netz.
|
||||||
|
|
||||||
|
Ueber "ip route get", NICHT ueber einen verbundenen UDP-Socket: die Unit erlaubt dem
|
||||||
|
Anzeigedienst nur AF_UNIX und AF_NETLINK (ninlight-display.service:
|
||||||
|
RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort mit EAFNOSUPPORT, und
|
||||||
|
genau daran ist die Adresszeile am echten Panel gescheitert, waehrend derselbe
|
||||||
|
Aufruf von Hand - ohne Sandbox - die Adresse lieferte (Rueckmeldung 2026-09-23).
|
||||||
|
"ip" spricht Netlink und laeuft damit innerhalb der Erlaubnis. Der Unterprozess
|
||||||
|
kostet einen fork alle POLL_INTERVAL_S Sekunden und faellt nur im Aus-Zustand an,
|
||||||
|
dem einzigen Statusbild mit dieser Zeile.
|
||||||
|
|
||||||
|
Das Geraet kommt mit, weil "LAN" am Panel eine Falschauskunft war: dieser Pi haengt
|
||||||
|
per WLAN im Netz, nicht am Kabel. Der Kernel nennt in derselben Auskunft beides -
|
||||||
|
"192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000".
|
||||||
|
|
||||||
|
Wirft nie: ohne Route gibt es nichts auszuwerten, dann liefert der Aufruf ("", "")
|
||||||
|
und die Anzeige sagt das - eine Ausnahme aus der Adressermittlung darf das Panel
|
||||||
|
nicht aufhalten."""
|
||||||
|
worte = _route_lookup().split()
|
||||||
|
geraet = adresse = ""
|
||||||
|
for i, w in enumerate(worte):
|
||||||
|
if w == "dev" and i + 1 < len(worte) and not geraet:
|
||||||
|
geraet = worte[i + 1]
|
||||||
|
elif w == "src" and i + 1 < len(worte) and not adresse:
|
||||||
|
adresse = worte[i + 1]
|
||||||
|
# Ohne Adresse ist auch der Geraetename wertlos - er wuerde ein Netz vortaeuschen,
|
||||||
|
# das nicht steht.
|
||||||
|
return (geraet, adresse) if adresse else ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||||
|
service_name: str = SERVICE_NAME,
|
||||||
|
imageset_path: Path = IMAGESET_FILE,
|
||||||
|
last_mode_path: Path = LAST_MODE_FILE) -> dict:
|
||||||
|
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
||||||
|
|
||||||
|
Modus kommt vorrangig aus `last_mode_path` (Vorgabe "recovery", wie
|
||||||
|
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
|
||||||
|
zuletzt geschrieben haben, dauerhaft (siehe LAST_MODE_FILE oben - NICHT die ephemere
|
||||||
|
RUN_DIR/mode, die server.py fuer den naechsten Start liest) und unabhaengig davon
|
||||||
|
verfuegbar, ob/wann der Serverprozess seit dem letzten Start schon einmal
|
||||||
|
status.json geschrieben hat. status.json selbst liefert nur served/recent/
|
||||||
|
last_seen; sein "mode"-Feld wird bewusst NICHT als Modusquelle verwendet
|
||||||
|
(Fixrunde 1: die urspruengliche Fassung hatte den Vorrang umgekehrt).
|
||||||
|
|
||||||
|
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
|
||||||
|
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
|
||||||
|
alles, wenn der Hauptdienst gar nicht laeuft.
|
||||||
|
|
||||||
|
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
|
||||||
|
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
|
||||||
|
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
|
||||||
|
Menue auffaellt, hat genau den Zweck verfehlt.
|
||||||
|
|
||||||
|
Ob "imageset_ok" den Rettungs- oder den Migrations-Bildsatz prueft, richtet sich nach
|
||||||
|
dem MODUS, in dem die Station beim naechsten Einschalten anlaufen wuerde - also
|
||||||
|
`last_mode_path`, nicht danach, ob sie gerade laeuft. Deshalb wird der Modus HIER,
|
||||||
|
vor der "ist der Dienst aktiv"-Abfrage, gelesen: er ist auch im Zustand "off"
|
||||||
|
verfuegbar (die Datei ist dauerhaft, siehe LAST_MODE_FILE) und genau dort soll die
|
||||||
|
Vorschau ja gelten - "off" zeigt ja noch keinen Modus im engeren Sinn, aber der
|
||||||
|
naechste Start faehrt in genau diesen Modus, und die Fusszeile soll VORHER zeigen,
|
||||||
|
ob dessen Bildsatz bereitliegt.
|
||||||
|
|
||||||
|
"activity" (nur in Recovery/Migration, siehe _recent_request()) sagt, ob die letzte
|
||||||
|
Anfrage jeder Art hoechstens ACTIVITY_WINDOW_S Sekunden her ist - das Lebenszeichen,
|
||||||
|
das render.blocks() an die Servierzeile anhaengt."""
|
||||||
|
conf = _read_conf(conf_path)
|
||||||
|
mode = _read_mode_file(last_mode_path)
|
||||||
|
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
|
||||||
|
|
||||||
|
if not _service_active(service_name):
|
||||||
|
# Die IP-Zeile gibt es NUR hier: ist die Station aus, ist der Pi ein gewoehnlicher
|
||||||
|
# Rechner im Heimnetz, und die einzige Auskunft, die man dann am Panel braucht,
|
||||||
|
# ist die Adresse, unter der man per SSH drankommt. Im Betrieb waere sie sinnlos
|
||||||
|
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
|
||||||
|
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
|
||||||
|
geraet, adresse = local_address()
|
||||||
|
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
|
||||||
|
"ip": adresse, "iface": geraet}
|
||||||
|
|
||||||
|
status = _read_json(run_dir / "status.json")
|
||||||
|
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
||||||
|
"imageset": imageset, "imageset_ok": imageset_ok,
|
||||||
|
"activity": _recent_request(status)}
|
||||||
|
|
||||||
|
if mode == "recovery":
|
||||||
|
state["ssid"] = conf.get("SSID")
|
||||||
|
else: # migration
|
||||||
|
state["ssid"] = conf.get("MIGRATE_SSID")
|
||||||
|
state["last_seen"] = status.get("last_seen")
|
||||||
|
control = _read_json(run_dir / "control.json")
|
||||||
|
phase = control.get("phase")
|
||||||
|
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
|
||||||
|
state["armed_target"] = control.get("armed_target")
|
||||||
|
|
||||||
|
# Der Zeitschalter ist optional und gilt in beiden Modi gleichermassen - ohne ihn
|
||||||
|
# bleibt das Feld weg und render.blocks() zeichnet keine Zeile dafuer.
|
||||||
|
autooff_minutes = _read_autooff_minutes(run_dir)
|
||||||
|
if autooff_minutes is not None:
|
||||||
|
state["autooff_minutes"] = autooff_minutes
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def read_ui(ui_path: Path = UI_FILE) -> dict:
|
||||||
|
"""Der Bedienzustand des Tasterdienstes, oder {} wenn gerade keine Bedienung laeuft
|
||||||
|
(Datei fehlt, ist unlesbar, halb geschrieben, aus einer aelteren Fassung oder meldet
|
||||||
|
ausdruecklich "idle").
|
||||||
|
|
||||||
|
Jedes Feld wird defensiv gelesen: die Datei kommt aus einem FREMDEN Prozess, der
|
||||||
|
abgestuerzt, aelter oder neuer sein kann - ein Fehler hier darf die Anzeige nicht
|
||||||
|
aufhalten und schon gar nicht eine Migration stoeren."""
|
||||||
|
data = _read_json(ui_path)
|
||||||
|
view = data.get("view")
|
||||||
|
if not data.get("active") or not isinstance(view, str) or view == "idle":
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _load_epd_module(revision: str):
|
||||||
|
"""Laedt den passenden Panel-Treiber. Nicht ueber ein Dict aus Modulnamen, damit ein
|
||||||
|
statischer Import-Checker (und ein Mensch, der `grep -n epd2in13` tippt) beide
|
||||||
|
moeglichen Importe findet."""
|
||||||
|
if revision == "V2":
|
||||||
|
import epd2in13_V2 as epd_module
|
||||||
|
elif revision == "V3":
|
||||||
|
import epd2in13_V3 as epd_module
|
||||||
|
else:
|
||||||
|
raise DisplayUnavailable(
|
||||||
|
f"unbekannte EPD_REVISION '{revision}' - erwartet 'V2' oder 'V3'")
|
||||||
|
return epd_module
|
||||||
|
|
||||||
|
|
||||||
|
class Panel:
|
||||||
|
"""Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
|
||||||
|
Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
|
||||||
|
(FULL_UPDATE/PART_UPDATE), epd2in13_V3.init() braucht keins.
|
||||||
|
|
||||||
|
Verfolgt zusaetzlich selbst, ob das Panel gerade "wach" ist (Register initialisiert,
|
||||||
|
Referenzpuffer fuer Teilaufbauten bekannt) oder zuletzt sleep() gerufen wurde
|
||||||
|
(Fixrunde 2, N1/N3): epd.sleep() schickt das Panel in einen Zustand, aus dem heraus
|
||||||
|
KEINE Revision direkt einen Teilaufbau vertraegt - V2s sleep() ist Deep Sleep Mode 2
|
||||||
|
(RAM/Register verworfen; ein nachfolgendes init(PART_UPDATE) setzt nur einen Teil der
|
||||||
|
Register, die ein Teilaufbau braucht - 0x01/0x11/0x44/0x45/0x4E/0x4F setzt
|
||||||
|
ausschliesslich der FULL_UPDATE-Zweig), V3s sleep() ruft epdconfig.module_exit() und
|
||||||
|
schliesst damit die GPIO-Pins, die epd2in13_V3.displayPartial() als Erstes anfasst
|
||||||
|
(roher AttributeError auf ein geschlossenes Pin-Objekt). Ein Teilaufbau nach dem
|
||||||
|
Schlafen wird deshalb hier, nicht beim Aufrufer, transparent zu einem Vollaufbau
|
||||||
|
aufgewertet - unabhaengig davon, in welcher Reihenfolge draw_full()/draw_partial()/
|
||||||
|
sleep() aufgerufen werden.
|
||||||
|
|
||||||
|
draw_full() (und clear(), Fixrunde 3, F2) verwendet fuer BEIDE Revisionen
|
||||||
|
displayPartBaseImage() statt display()/Clear() (Fixrunde 2, N2): das schreibt beide
|
||||||
|
RAM-Puffer des Controllers (0x24 UND 0x26) und legt damit erst die Referenz an, gegen
|
||||||
|
die ein nachfolgender Teilaufbau diffen kann - display() allein haette nur 0x24
|
||||||
|
beschrieben (und Clear() ebenso - bei V2 ist das 0x26-Schreiben in Clear() sogar
|
||||||
|
auskommentiert) und den ersten Teilaufbau danach gegen einen veralteten/undefinierten
|
||||||
|
Puffer laufen lassen. self._awake wird deshalb nur dort auf True gesetzt, wo
|
||||||
|
tatsaechlich ein gueltiger Referenzpuffer entstanden ist - vorher (Fixrunde 2) setzte
|
||||||
|
clear() es, obwohl es nur epd.Clear() (kein 0x26) rief, und die im Klassen-Docstring
|
||||||
|
behauptete Invariante stimmte nach clear() nicht."""
|
||||||
|
|
||||||
|
def __init__(self, revision: str) -> None:
|
||||||
|
self.revision = revision
|
||||||
|
self._epd_module = _load_epd_module(revision)
|
||||||
|
self.epd = self._epd_module.EPD()
|
||||||
|
# Vor dem ersten clear()/draw_full() ist nichts initialisiert - siehe Klassen-
|
||||||
|
# Docstring, warum draw_partial() das genauso wie ein sleep() davor behandeln
|
||||||
|
# muss (beides: kein gueltiger Referenzpuffer, keine vollstaendige Registerlage).
|
||||||
|
self._awake = False
|
||||||
|
|
||||||
|
def _full_init(self) -> None:
|
||||||
|
if self.revision == "V2":
|
||||||
|
self.epd.init(self.epd.FULL_UPDATE)
|
||||||
|
else:
|
||||||
|
self.epd.init()
|
||||||
|
|
||||||
|
def _blank_buffer(self) -> list[int]:
|
||||||
|
"""Ein reiner Weiss-Puffer in der Groesse, die epd.getbuffer() fuer ein
|
||||||
|
vollformatiges Bild liefern wuerde - dieselbe Formel wie in epd.Clear() selbst
|
||||||
|
(siehe epd2in13_V2.py/epd2in13_V3.py), nur ohne ueber Pillow ein echtes weisses
|
||||||
|
Bild zu erzeugen."""
|
||||||
|
width, height = self.epd.width, self.epd.height
|
||||||
|
linewidth = width // 8 if width % 8 == 0 else width // 8 + 1
|
||||||
|
return [0xFF] * (linewidth * height)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._full_init()
|
||||||
|
# displayPartBaseImage() statt epd.Clear() (Fixrunde 3, F2): dieselbe sichtbare
|
||||||
|
# Wirkung (Panel wird weiss), aber schreibt zusaetzlich den 0x26-Referenzpuffer,
|
||||||
|
# den ein nachfolgender draw_partial() braucht - epd.Clear() allein wuerde
|
||||||
|
# self._awake=True vortaeuschen, ohne die im Klassen-Docstring beschriebene
|
||||||
|
# Invariante wirklich herzustellen.
|
||||||
|
self.epd.displayPartBaseImage(self._blank_buffer())
|
||||||
|
self._awake = True
|
||||||
|
|
||||||
|
def draw_full(self, image) -> None:
|
||||||
|
buf = self.epd.getbuffer(image)
|
||||||
|
self._full_init()
|
||||||
|
self.epd.displayPartBaseImage(buf)
|
||||||
|
self._awake = True
|
||||||
|
|
||||||
|
def draw_partial(self, image) -> bool:
|
||||||
|
"""Zeichnet teilweise - oder, falls das Panel nicht wach ist, vollstaendig.
|
||||||
|
Gibt zurueck, ob intern zu einem Vollaufbau aufgewertet wurde (Fixrunde 3, F4):
|
||||||
|
der Aufrufer muss das wie einen regulaeren Vollaufbau behandeln (z.B. fuer die
|
||||||
|
30-Minuten-Uhr in run_daemon()), sonst kommt der naechste dortige Vollaufbau zu
|
||||||
|
frueh und altert das Panel zusaetzlich, ohne dass ein Teilaufbau ueberhaupt
|
||||||
|
stattgefunden haette."""
|
||||||
|
if not self._awake:
|
||||||
|
# Siehe Klassen-Docstring (Fixrunde 2, N1/N3): ein Teilaufbau direkt nach dem
|
||||||
|
# Schlafen (oder vor dem allerersten Vollaufbau) ist auf keiner Revision eine
|
||||||
|
# gueltige Sequenz - stattdessen einen vollwertigen Vollaufbau ausfuehren.
|
||||||
|
self.draw_full(image)
|
||||||
|
return True
|
||||||
|
buf = self.epd.getbuffer(image)
|
||||||
|
if self.revision == "V2":
|
||||||
|
self.epd.init(self.epd.PART_UPDATE)
|
||||||
|
self.epd.displayPartial(buf)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def sleep(self) -> None:
|
||||||
|
# Idempotent: ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft,
|
||||||
|
# wuerde sonst versuchen, ueber bereits von module_exit() geschlossene GPIO-Pins
|
||||||
|
# SPI-Befehle zu schicken (derselbe AttributeError-Mechanismus wie N1, nur
|
||||||
|
# selbst herbeigefuehrt statt durch einen Teilaufbau).
|
||||||
|
if not self._awake:
|
||||||
|
return
|
||||||
|
self.epd.sleep()
|
||||||
|
self._awake = False
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshPlanner:
|
||||||
|
"""Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann
|
||||||
|
geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne
|
||||||
|
Hardware.
|
||||||
|
|
||||||
|
Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch
|
||||||
|
ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die
|
||||||
|
Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung
|
||||||
|
in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde.
|
||||||
|
Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen
|
||||||
|
(siehe test_render.py, _run_schedule()).
|
||||||
|
|
||||||
|
Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und
|
||||||
|
das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert
|
||||||
|
nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob
|
||||||
|
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat).
|
||||||
|
|
||||||
|
Aktionen:
|
||||||
|
"full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich)
|
||||||
|
"partial" - Teilaktualisierung (Inhalt hat sich geaendert)
|
||||||
|
"sleep" - Panel schlafen legen (laenger nichts passiert)
|
||||||
|
"idle" - nichts tun
|
||||||
|
"""
|
||||||
|
|
||||||
|
_NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
||||||
|
|
||||||
|
def __init__(self, now: float,
|
||||||
|
full_refresh_interval_s: float | None = None,
|
||||||
|
sleep_after_idle_s: float | None = None) -> None:
|
||||||
|
# Bewusst None statt der Modulkonstanten als Vorgabewert (Fixrunde 5): ein
|
||||||
|
# Default-Argument wird zur Importzeit ausgewertet, ein spaeteres
|
||||||
|
# monkeypatch.setattr(display, "FULL_REFRESH_INTERVAL_S", ...) haette dann keine
|
||||||
|
# Wirkung mehr auf neu gebaute Planer - die alte Schleife las die Konstanten pro
|
||||||
|
# Runde. Die Properties unten lesen sie weiterhin bei jeder Entscheidung.
|
||||||
|
self._full_refresh_interval_s = full_refresh_interval_s
|
||||||
|
self._sleep_after_idle_s = sleep_after_idle_s
|
||||||
|
self.last_lines: list[str] | None = None
|
||||||
|
self.last_mode: object = self._NO_MODE
|
||||||
|
self.last_full = now
|
||||||
|
self.last_draw = now
|
||||||
|
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. full_refresh_interval_s
|
||||||
|
# existiert nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten
|
||||||
|
# auszugleichen - ohne mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt
|
||||||
|
# es nichts auszugleichen, und ein Vollaufbau des UNVERAENDERTEN Bildes waere
|
||||||
|
# reine, nutzlose Alterung (Fixrunde 3, F3): ohne diese Absicherung entstand im
|
||||||
|
# eingeschwungenen Ruhezustand ein endloser 10-Minuten-schlafen/10-Minuten-wach-
|
||||||
|
# Zyklus (33% Bias-Anteil) rein aus der 30-Minuten-Uhr, obwohl sich der Inhalt nie
|
||||||
|
# aenderte.
|
||||||
|
#
|
||||||
|
# Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1):
|
||||||
|
# das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich
|
||||||
|
# damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in
|
||||||
|
# 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild
|
||||||
|
# ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen
|
||||||
|
# partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich
|
||||||
|
# ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal
|
||||||
|
# kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt
|
||||||
|
# wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen
|
||||||
|
# einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal,
|
||||||
|
# wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die
|
||||||
|
# das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll
|
||||||
|
# aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht
|
||||||
|
# weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen
|
||||||
|
# Termin.
|
||||||
|
self.partials_since_full = 0
|
||||||
|
self.asleep = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_refresh_interval_s(self) -> float:
|
||||||
|
return (FULL_REFRESH_INTERVAL_S if self._full_refresh_interval_s is None
|
||||||
|
else self._full_refresh_interval_s)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sleep_after_idle_s(self) -> float:
|
||||||
|
return (SLEEP_AFTER_IDLE_S if self._sleep_after_idle_s is None
|
||||||
|
else self._sleep_after_idle_s)
|
||||||
|
|
||||||
|
def decide(self, now: float, lines: list[str], mode: object) -> str:
|
||||||
|
"""Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*()
|
||||||
|
zurueckgemeldet."""
|
||||||
|
mode_changed = mode != self.last_mode
|
||||||
|
stale = (self.partials_since_full > 0
|
||||||
|
and (now - self.last_full) >= self.full_refresh_interval_s)
|
||||||
|
|
||||||
|
if lines != self.last_lines or mode_changed or stale:
|
||||||
|
return "full" if (mode_changed or stale) else "partial"
|
||||||
|
if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s:
|
||||||
|
# Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben -
|
||||||
|
# e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel
|
||||||
|
# deutlich weniger).
|
||||||
|
return "sleep"
|
||||||
|
return "idle"
|
||||||
|
|
||||||
|
def record_draw(self, now: float, lines: list[str], mode: object,
|
||||||
|
was_full: bool) -> None:
|
||||||
|
"""`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn
|
||||||
|
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3,
|
||||||
|
F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel
|
||||||
|
zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde."""
|
||||||
|
self.last_lines = lines
|
||||||
|
self.last_mode = mode
|
||||||
|
self.last_draw = now
|
||||||
|
self.asleep = False
|
||||||
|
if was_full:
|
||||||
|
self.last_full = now
|
||||||
|
self.partials_since_full = 0
|
||||||
|
else:
|
||||||
|
self.partials_since_full += 1
|
||||||
|
|
||||||
|
def record_sleep(self) -> None:
|
||||||
|
"""Kein `now`-Parameter (Fixrunde 5): das Einschlafen verschiebt weder die
|
||||||
|
30-Minuten-Uhr noch last_draw - es merkt sich nur, dass ein weiteres
|
||||||
|
panel.sleep() nichts mehr zu tun haette."""
|
||||||
|
self.asleep = True
|
||||||
|
|
||||||
|
|
||||||
|
def _test_image(revision: str):
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
width, height = 250, 122
|
||||||
|
image = Image.new("1", (width, height), 255)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
draw.rectangle((0, 0, width - 1, height - 1), outline=0)
|
||||||
|
draw.text((4, 4), "ninlight-display Test", font=ImageFont.load_default(), fill=0)
|
||||||
|
draw.text((4, 20), f"EPD_REVISION={revision}", font=ImageFont.load_default(), fill=0)
|
||||||
|
draw.line((4, 40, width - 4, 40), fill=0)
|
||||||
|
# Vier Kaestchen, abwechselnd gefuellt/leer - hilft, Kontrast/Inversion am
|
||||||
|
# angeschlossenen Panel auf einen Blick zu beurteilen.
|
||||||
|
for i in range(4):
|
||||||
|
x0 = 4 + i * 20
|
||||||
|
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def run_test(revision: str) -> None:
|
||||||
|
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
|
||||||
|
von V3 unterscheidet."""
|
||||||
|
panel = Panel(revision)
|
||||||
|
panel.clear()
|
||||||
|
panel.draw_full(_test_image(revision))
|
||||||
|
panel.sleep()
|
||||||
|
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
|
||||||
|
|
||||||
|
|
||||||
|
def run_final(revision: str, kind: str) -> None:
|
||||||
|
"""Zeichnet den Abschlussbildschirm ("poweroff"/"reboot") und legt das Panel schlafen.
|
||||||
|
|
||||||
|
Das ist das Bild, das nach dem Herunterfahren DAUERHAFT stehen bleibt - E-Paper ist
|
||||||
|
bistabil, es haelt sein Bild auch ohne Strom. Bewusst ein VOLLAUFBAU: ein Teilaufbau
|
||||||
|
liesse Reste des vorigen Bildes stehen, und dieses eine Bild ueberdauert alle anderen.
|
||||||
|
|
||||||
|
Aufgerufen wird das ausschliesslich ueber "ninlight-station display final <kind>" aus
|
||||||
|
einer systemd-Unit heraus, und zwar ERST, nachdem der Anzeigedienst gestoppt ist - er
|
||||||
|
haelt sonst die GPIO-/SPI-Leitungen ("lgpio.error: 'GPIO busy'"). Diese Uebergabe
|
||||||
|
macht das CLI (display_final()), nicht diese Funktion."""
|
||||||
|
panel = Panel(revision)
|
||||||
|
panel.clear()
|
||||||
|
panel.draw_full(render.compose({"mode": "final", "final": kind}))
|
||||||
|
panel.sleep()
|
||||||
|
log.info("Abschlussbild gezeichnet (%s, EPD_REVISION=%s)", kind, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def run_clear(revision: str) -> None:
|
||||||
|
panel = Panel(revision)
|
||||||
|
panel.clear()
|
||||||
|
panel.sleep()
|
||||||
|
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_read_state(read_state, fallback: dict) -> dict:
|
||||||
|
"""Ruft `read_state()` auf und faengt JEDE Ausnahme ab - nicht nur die, die die
|
||||||
|
einzelnen build_state()-Bausteine schon selbst abfangen (siehe _read_json() etc.
|
||||||
|
oben). run_daemon() laeuft als Endlosschleife, und main() fängt darum nur
|
||||||
|
(DisplayUnavailable, ImportError) ab; ein einzelner, unvorhergesehener Fehler beim
|
||||||
|
Erheben wuerde diese Schleife sonst beenden - und weil E-Paper sein zuletzt
|
||||||
|
gezeichnetes Bild dauerhaft behaelt, saehe das Panel einfach so aus, als sei nichts
|
||||||
|
passiert, nur eben fuer immer eingefroren. `fallback` (der vorherige Zustand, oder
|
||||||
|
beim allerersten Aufruf ein neutraler Platzhalter) gilt dann fuer diesen einen Takt;
|
||||||
|
beim naechsten wird es erneut versucht."""
|
||||||
|
try:
|
||||||
|
return read_state()
|
||||||
|
except Exception as e: # noqa: BLE001 - bewusst pauschal, siehe Docstring
|
||||||
|
log.warning("Zustand konnte nicht gelesen werden, vorheriger Stand bleibt: %s", e)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
||||||
|
read_state=build_state, read_ui=read_ui) -> None:
|
||||||
|
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
|
||||||
|
POLL_INTERVAL_S Sekunden den Stationszustand einlesen und die von RefreshPlanner
|
||||||
|
entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik zur Panelalterung
|
||||||
|
mehr (Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne
|
||||||
|
Schleife, ohne time.sleep() und ohne Hardware pruefbar.
|
||||||
|
|
||||||
|
Die zwei Takte: der teure Teil (build_state() forkt fuer "systemctl is-active")
|
||||||
|
bleibt beim 5-Sekunden-Takt, der billige (eine kleine Datei lesen) laeuft in
|
||||||
|
UI_POLL_INTERVAL_S, damit eine Bedienung am Taster ueberhaupt benutzbar ist. Ein
|
||||||
|
laufender Bedienzustand ERSETZT das Statusbild (render.blocks() zweigt dafuer ganz
|
||||||
|
vorne ab); der Modus bleibt derselbe, sodass das Bild ueber Teilaufbauten kommt und
|
||||||
|
ein Tastendruck dem Panel keinen Vollaufbau kostet.
|
||||||
|
|
||||||
|
`clock` (braucht monotonic() und sleep()), `panel`, `read_state` und `read_ui` sind
|
||||||
|
einschleusbar, damit die VERDRAHTUNG hier - welche Aktion welche Panel-Methode ruft,
|
||||||
|
dass die Selbst-Aufwertung von draw_partial() zurueckgemeldet wird, dass das
|
||||||
|
Einschlafen zurueckgemeldet wird, und die Reihenfolge decide -> ausfuehren ->
|
||||||
|
zurueckmelden - selbst pruefbar ist und nicht nur eine Kopie davon (Fixrunde 5, N2:
|
||||||
|
alle drei Mutationen an genau diesen Zeilen blieben vorher gruen, weil der Test eine
|
||||||
|
nachgebaute Schleife statt dieser hier trieb). Im Betrieb bleiben die Vorgaben: echte
|
||||||
|
Uhr, echtes Panel, echtes build_state()/read_ui()."""
|
||||||
|
if panel is None:
|
||||||
|
panel = Panel(revision)
|
||||||
|
panel.clear()
|
||||||
|
planner = RefreshPlanner(clock.monotonic())
|
||||||
|
base_state = _safe_read_state(read_state, {"mode": None})
|
||||||
|
last_state_poll = clock.monotonic()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = clock.monotonic()
|
||||||
|
if now - last_state_poll >= POLL_INTERVAL_S:
|
||||||
|
# Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler
|
||||||
|
# schon ab (siehe _read_json/_read_conf/_service_active/local_address oben) - aber
|
||||||
|
# main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError)
|
||||||
|
# ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim
|
||||||
|
# Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese
|
||||||
|
# Endlosschleife sonst ganz beenden - und E-Paper behaelt sein zuletzt
|
||||||
|
# gezeichnetes Bild fuer immer (siehe Modul-Docstring): die Anzeige wuerde
|
||||||
|
# dann OHNE jede Fehlermeldung am Geraet einfach stehen bleiben, ununterscheidbar
|
||||||
|
# von einem echten, andauernden Zustand. read_state() bleibt deshalb hier
|
||||||
|
# abgesichert - ein einzelner Fehlschlag ueberspringt genau diese Erhebung
|
||||||
|
# (der vorherige Zustand bleibt stehen) statt den ganzen Dienst zu beenden;
|
||||||
|
# beim naechsten Takt in POLL_INTERVAL_S wird es erneut versucht.
|
||||||
|
base_state = _safe_read_state(read_state, base_state)
|
||||||
|
last_state_poll = now
|
||||||
|
ui = read_ui()
|
||||||
|
if ui:
|
||||||
|
state = dict(base_state)
|
||||||
|
state["ui"] = ui
|
||||||
|
else:
|
||||||
|
state = base_state
|
||||||
|
current_lines = render.lines(state)
|
||||||
|
mode = state.get("mode")
|
||||||
|
|
||||||
|
action = planner.decide(now, current_lines, mode)
|
||||||
|
if action == "full":
|
||||||
|
panel.draw_full(render.compose(state))
|
||||||
|
planner.record_draw(now, current_lines, mode, was_full=True)
|
||||||
|
elif action == "partial":
|
||||||
|
# Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das
|
||||||
|
# Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck.
|
||||||
|
upgraded = panel.draw_partial(render.compose(state))
|
||||||
|
planner.record_draw(now, current_lines, mode, was_full=upgraded)
|
||||||
|
elif action == "sleep":
|
||||||
|
panel.sleep()
|
||||||
|
planner.record_sleep()
|
||||||
|
|
||||||
|
clock.sleep(UI_POLL_INTERVAL_S)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="ninlight E-Paper-Anzeige")
|
||||||
|
group = parser.add_mutually_exclusive_group()
|
||||||
|
group.add_argument("--test", action="store_true", help="Testbild zeichnen und beenden")
|
||||||
|
group.add_argument("--clear", action="store_true", help="Panel leeren und beenden")
|
||||||
|
group.add_argument("--final", choices=sorted(render.FINAL_LABELS),
|
||||||
|
help="Abschlussbild zeichnen und beenden (beim Herunterfahren "
|
||||||
|
"bzw. Neustart; die Texte unterscheiden sich)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
revision = os.environ.get("EPD_REVISION", "V3")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.test:
|
||||||
|
run_test(revision)
|
||||||
|
elif args.clear:
|
||||||
|
run_clear(revision)
|
||||||
|
elif args.final:
|
||||||
|
run_final(revision, args.final)
|
||||||
|
else:
|
||||||
|
run_daemon(revision)
|
||||||
|
except (DisplayUnavailable, ImportError) as e:
|
||||||
|
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
|
||||||
|
# Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
|
||||||
|
# Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
|
||||||
|
# erst per "apt install python3-pil" nachkommt (Fixrunde 1, C4): render.compose()
|
||||||
|
# importiert Pillow erst hier, ein fehlendes Paket zeigt sich also erst als
|
||||||
|
# ModuleNotFoundError (eine ImportError-Unterklasse) beim ersten Zeichnen, nicht
|
||||||
|
# schon beim Start. Restart=on-failure (statt always) in der Unit greift hier
|
||||||
|
# nicht, weil 0 kein Fehlschlag ist; ein spaeter angestecktes Panel bzw.
|
||||||
|
# nachinstalliertes Paket braucht also einen manuellen/zeitgesteuerten Neustart
|
||||||
|
# der Unit, nicht eine Neustartschleife.
|
||||||
|
log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
|
||||||
|
sys.exit(0)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# *****************************************************************************
|
||||||
|
# * | File : epd2in13_V2.py
|
||||||
|
# * | Author : Waveshare team
|
||||||
|
# * | Function : Electronic paper driver
|
||||||
|
# * | Info :
|
||||||
|
# *----------------
|
||||||
|
# * | This version: V4.0
|
||||||
|
# * | Date : 2019-06-20
|
||||||
|
# # | Info : python demo
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documnetation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
#
|
||||||
|
|
||||||
|
# --- Herkunftsvermerk (ninlight-firmware) ---------------------------------
|
||||||
|
# Unveraendert uebernommen aus waveshareteam/e-Paper (RaspberryPi_JetsonNano/
|
||||||
|
# python/lib/waveshare_epd/epd2in13_V2.py), Commit 85802dee (2023-02-15).
|
||||||
|
# Einzige Aenderung: 'from . import epdconfig' -> 'import epdconfig', damit das
|
||||||
|
# Modul flach importierbar ist (kein Package). Alles andere - Init-Sequenz, LUTs,
|
||||||
|
# Timings - unveraendert. Details: tools/station/display/HERKUNFT.md
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import epdconfig
|
||||||
|
|
||||||
|
# Display resolution
|
||||||
|
EPD_WIDTH = 122
|
||||||
|
EPD_HEIGHT = 250
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class EPD:
|
||||||
|
def __init__(self):
|
||||||
|
self.reset_pin = epdconfig.RST_PIN
|
||||||
|
self.dc_pin = epdconfig.DC_PIN
|
||||||
|
self.busy_pin = epdconfig.BUSY_PIN
|
||||||
|
self.cs_pin = epdconfig.CS_PIN
|
||||||
|
self.width = EPD_WIDTH
|
||||||
|
self.height = EPD_HEIGHT
|
||||||
|
|
||||||
|
FULL_UPDATE = 0
|
||||||
|
PART_UPDATE = 1
|
||||||
|
lut_full_update= [
|
||||||
|
0x80,0x60,0x40,0x00,0x00,0x00,0x00, #LUT0: BB: VS 0 ~7
|
||||||
|
0x10,0x60,0x20,0x00,0x00,0x00,0x00, #LUT1: BW: VS 0 ~7
|
||||||
|
0x80,0x60,0x40,0x00,0x00,0x00,0x00, #LUT2: WB: VS 0 ~7
|
||||||
|
0x10,0x60,0x20,0x00,0x00,0x00,0x00, #LUT3: WW: VS 0 ~7
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT4: VCOM: VS 0 ~7
|
||||||
|
|
||||||
|
0x03,0x03,0x00,0x00,0x02, # TP0 A~D RP0
|
||||||
|
0x09,0x09,0x00,0x00,0x02, # TP1 A~D RP1
|
||||||
|
0x03,0x03,0x00,0x00,0x02, # TP2 A~D RP2
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP3 A~D RP3
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP4 A~D RP4
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP5 A~D RP5
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP6 A~D RP6
|
||||||
|
|
||||||
|
0x15,0x41,0xA8,0x32,0x30,0x0A,
|
||||||
|
]
|
||||||
|
|
||||||
|
lut_partial_update = [ #20 bytes
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT0: BB: VS 0 ~7
|
||||||
|
0x80,0x00,0x00,0x00,0x00,0x00,0x00, #LUT1: BW: VS 0 ~7
|
||||||
|
0x40,0x00,0x00,0x00,0x00,0x00,0x00, #LUT2: WB: VS 0 ~7
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT3: WW: VS 0 ~7
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00, #LUT4: VCOM: VS 0 ~7
|
||||||
|
|
||||||
|
0x0A,0x00,0x00,0x00,0x00, # TP0 A~D RP0
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP1 A~D RP1
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP2 A~D RP2
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP3 A~D RP3
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP4 A~D RP4
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP5 A~D RP5
|
||||||
|
0x00,0x00,0x00,0x00,0x00, # TP6 A~D RP6
|
||||||
|
|
||||||
|
0x15,0x41,0xA8,0x32,0x30,0x0A,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Hardware reset
|
||||||
|
def reset(self):
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
epdconfig.delay_ms(200)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 0)
|
||||||
|
epdconfig.delay_ms(5)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
epdconfig.delay_ms(200)
|
||||||
|
|
||||||
|
def send_command(self, command):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 0)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte([command])
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
def send_data(self, data):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 1)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte([data])
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
# send a lot of data
|
||||||
|
def send_data2(self, data):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 1)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte2(data)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
def ReadBusy(self):
|
||||||
|
while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy
|
||||||
|
epdconfig.delay_ms(100)
|
||||||
|
|
||||||
|
def TurnOnDisplay(self):
|
||||||
|
self.send_command(0x22)
|
||||||
|
self.send_data(0xC7)
|
||||||
|
self.send_command(0x20)
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
def TurnOnDisplayPart(self):
|
||||||
|
self.send_command(0x22)
|
||||||
|
self.send_data(0x0c)
|
||||||
|
self.send_command(0x20)
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
def init(self, update):
|
||||||
|
if (epdconfig.module_init() != 0):
|
||||||
|
return -1
|
||||||
|
# EPD hardware init start
|
||||||
|
self.reset()
|
||||||
|
if(update == self.FULL_UPDATE):
|
||||||
|
self.ReadBusy()
|
||||||
|
self.send_command(0x12) # soft reset
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
self.send_command(0x74) #set analog block control
|
||||||
|
self.send_data(0x54)
|
||||||
|
self.send_command(0x7E) #set digital block control
|
||||||
|
self.send_data(0x3B)
|
||||||
|
|
||||||
|
self.send_command(0x01) #Driver output control
|
||||||
|
self.send_data(0xF9)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
|
||||||
|
self.send_command(0x11) #data entry mode
|
||||||
|
self.send_data(0x01)
|
||||||
|
|
||||||
|
self.send_command(0x44) #set Ram-X address start/end position
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x0F) #0x0C-->(15+1)*8=128
|
||||||
|
|
||||||
|
self.send_command(0x45) #set Ram-Y address start/end position
|
||||||
|
self.send_data(0xF9) #0xF9-->(249+1)=250
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
|
||||||
|
self.send_command(0x3C) #BorderWavefrom
|
||||||
|
self.send_data(0x03)
|
||||||
|
|
||||||
|
self.send_command(0x2C) #VCOM Voltage
|
||||||
|
self.send_data(0x55) #
|
||||||
|
|
||||||
|
self.send_command(0x03)
|
||||||
|
self.send_data(self.lut_full_update[70])
|
||||||
|
|
||||||
|
self.send_command(0x04) #
|
||||||
|
self.send_data(self.lut_full_update[71])
|
||||||
|
self.send_data(self.lut_full_update[72])
|
||||||
|
self.send_data(self.lut_full_update[73])
|
||||||
|
|
||||||
|
self.send_command(0x3A) #Dummy Line
|
||||||
|
self.send_data(self.lut_full_update[74])
|
||||||
|
self.send_command(0x3B) #Gate time
|
||||||
|
self.send_data(self.lut_full_update[75])
|
||||||
|
|
||||||
|
self.send_command(0x32)
|
||||||
|
for count in range(70):
|
||||||
|
self.send_data(self.lut_full_update[count])
|
||||||
|
|
||||||
|
self.send_command(0x4E) # set RAM x address count to 0
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_command(0x4F) # set RAM y address count to 0X127
|
||||||
|
self.send_data(0xF9)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.ReadBusy()
|
||||||
|
else:
|
||||||
|
self.send_command(0x2C) #VCOM Voltage
|
||||||
|
self.send_data(0x26)
|
||||||
|
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
self.send_command(0x32)
|
||||||
|
for count in range(70):
|
||||||
|
self.send_data(self.lut_partial_update[count])
|
||||||
|
|
||||||
|
self.send_command(0x37)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x40)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
|
||||||
|
self.send_command(0x22)
|
||||||
|
self.send_data(0xC0)
|
||||||
|
self.send_command(0x20)
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
self.send_command(0x3C) #BorderWavefrom
|
||||||
|
self.send_data(0x01)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getbuffer(self, image):
|
||||||
|
if self.width%8 == 0:
|
||||||
|
linewidth = int(self.width/8)
|
||||||
|
else:
|
||||||
|
linewidth = int(self.width/8) + 1
|
||||||
|
|
||||||
|
buf = [0xFF] * (linewidth * self.height)
|
||||||
|
image_monocolor = image.convert('1')
|
||||||
|
imwidth, imheight = image_monocolor.size
|
||||||
|
pixels = image_monocolor.load()
|
||||||
|
|
||||||
|
if(imwidth == self.width and imheight == self.height):
|
||||||
|
logger.debug("Vertical")
|
||||||
|
for y in range(imheight):
|
||||||
|
for x in range(imwidth):
|
||||||
|
if pixels[x, y] == 0:
|
||||||
|
x = imwidth - x
|
||||||
|
buf[int(x / 8) + y * linewidth] &= ~(0x80 >> (x % 8))
|
||||||
|
elif(imwidth == self.height and imheight == self.width):
|
||||||
|
logger.debug("Horizontal")
|
||||||
|
for y in range(imheight):
|
||||||
|
for x in range(imwidth):
|
||||||
|
newx = y
|
||||||
|
newy = self.height - x - 1
|
||||||
|
if pixels[x, y] == 0:
|
||||||
|
newy = imwidth - newy - 1
|
||||||
|
buf[int(newx / 8) + newy*linewidth] &= ~(0x80 >> (y % 8))
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def display(self, image):
|
||||||
|
self.send_command(0x24)
|
||||||
|
self.send_data2(image)
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
def displayPartial(self, image):
|
||||||
|
if self.width%8 == 0:
|
||||||
|
linewidth = int(self.width/8)
|
||||||
|
else:
|
||||||
|
linewidth = int(self.width/8) + 1
|
||||||
|
|
||||||
|
buf = [0x00] * self.height * linewidth
|
||||||
|
for j in range(0, self.height):
|
||||||
|
for i in range(0, linewidth):
|
||||||
|
buf[i + j * linewidth] = ~image[i + j * linewidth]
|
||||||
|
|
||||||
|
self.send_command(0x24)
|
||||||
|
self.send_data2(image)
|
||||||
|
|
||||||
|
|
||||||
|
self.send_command(0x26)
|
||||||
|
self.send_data2(buf)
|
||||||
|
self.TurnOnDisplayPart()
|
||||||
|
|
||||||
|
def displayPartBaseImage(self, image):
|
||||||
|
self.send_command(0x24)
|
||||||
|
self.send_data2(image)
|
||||||
|
|
||||||
|
self.send_command(0x26)
|
||||||
|
self.send_data2(image)
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
def Clear(self, color=0xFF):
|
||||||
|
if self.width%8 == 0:
|
||||||
|
linewidth = int(self.width/8)
|
||||||
|
else:
|
||||||
|
linewidth = int(self.width/8) + 1
|
||||||
|
# logger.debug(linewidth)
|
||||||
|
|
||||||
|
buf = [0x00] * self.height * linewidth
|
||||||
|
for j in range(0, self.height):
|
||||||
|
for i in range(0, linewidth):
|
||||||
|
buf[i + j * linewidth] = color
|
||||||
|
|
||||||
|
self.send_command(0x24)
|
||||||
|
self.send_data2(buf)
|
||||||
|
|
||||||
|
# self.send_command(0x26)
|
||||||
|
# for j in range(0, self.height):
|
||||||
|
# for i in range(0, linewidth):
|
||||||
|
# self.send_data(color)
|
||||||
|
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
def sleep(self):
|
||||||
|
# self.send_command(0x22) #POWER OFF
|
||||||
|
# self.send_data(0xC3)
|
||||||
|
# self.send_command(0x20)
|
||||||
|
|
||||||
|
self.send_command(0x10) #enter deep sleep
|
||||||
|
self.send_data(0x03)
|
||||||
|
epdconfig.delay_ms(2000)
|
||||||
|
epdconfig.module_exit()
|
||||||
|
|
||||||
|
### END OF FILE ###
|
||||||
|
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# *****************************************************************************
|
||||||
|
# * | File : epd2in13_V3.py
|
||||||
|
# * | Author : Waveshare team
|
||||||
|
# * | Function : Electronic paper driver
|
||||||
|
# * | Info :
|
||||||
|
# *----------------
|
||||||
|
# * | This version: V1.2
|
||||||
|
# * | Date : 2022-08-9
|
||||||
|
# # | Info : python demo
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documnetation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
#
|
||||||
|
|
||||||
|
# --- Herkunftsvermerk (ninlight-firmware) ---------------------------------
|
||||||
|
# Unveraendert uebernommen aus waveshareteam/e-Paper (RaspberryPi_JetsonNano/
|
||||||
|
# python/lib/waveshare_epd/epd2in13_V3.py), Commit 85802dee (2023-02-15).
|
||||||
|
# Einzige Aenderung: 'from . import epdconfig' -> 'import epdconfig', damit das
|
||||||
|
# Modul flach importierbar ist (kein Package). Alles andere - Init-Sequenz, LUTs,
|
||||||
|
# Timings - unveraendert. Details: tools/station/display/HERKUNFT.md
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import epdconfig
|
||||||
|
|
||||||
|
# Display resolution
|
||||||
|
EPD_WIDTH = 122
|
||||||
|
EPD_HEIGHT = 250
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class EPD:
|
||||||
|
def __init__(self):
|
||||||
|
self.reset_pin = epdconfig.RST_PIN
|
||||||
|
self.dc_pin = epdconfig.DC_PIN
|
||||||
|
self.busy_pin = epdconfig.BUSY_PIN
|
||||||
|
self.cs_pin = epdconfig.CS_PIN
|
||||||
|
self.width = EPD_WIDTH
|
||||||
|
self.height = EPD_HEIGHT
|
||||||
|
|
||||||
|
lut_partial_update= [
|
||||||
|
0x0,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x80,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x40,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x14,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0,
|
||||||
|
0x22,0x17,0x41,0x00,0x32,0x36,
|
||||||
|
]
|
||||||
|
|
||||||
|
lut_full_update = [
|
||||||
|
0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x80,0x4A,0x40,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x40,0x4A,0x80,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0xF,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0xF,0x0,0x0,0xF,0x0,0x0,0x2,
|
||||||
|
0xF,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x1,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x0,0x0,0x0,0x0,0x0,0x0,0x0,
|
||||||
|
0x22,0x22,0x22,0x22,0x22,0x22,0x0,0x0,0x0,
|
||||||
|
0x22,0x17,0x41,0x0,0x32,0x36,
|
||||||
|
]
|
||||||
|
|
||||||
|
'''
|
||||||
|
function :Hardware reset
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def reset(self):
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
epdconfig.delay_ms(20)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 0)
|
||||||
|
epdconfig.delay_ms(2)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
epdconfig.delay_ms(20)
|
||||||
|
|
||||||
|
'''
|
||||||
|
function :send command
|
||||||
|
parameter:
|
||||||
|
command : Command register
|
||||||
|
'''
|
||||||
|
def send_command(self, command):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 0)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte([command])
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
'''
|
||||||
|
function :send data
|
||||||
|
parameter:
|
||||||
|
data : Write data
|
||||||
|
'''
|
||||||
|
def send_data(self, data):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 1)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte([data])
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
# send a lot of data
|
||||||
|
def send_data2(self, data):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 1)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte2(data)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
'''
|
||||||
|
function :Wait until the busy_pin goes LOW
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def ReadBusy(self):
|
||||||
|
logger.debug("e-Paper busy")
|
||||||
|
while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy
|
||||||
|
epdconfig.delay_ms(10)
|
||||||
|
logger.debug("e-Paper busy release")
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Turn On Display
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def TurnOnDisplay(self):
|
||||||
|
self.send_command(0x22) # Display Update Control
|
||||||
|
self.send_data(0xC7)
|
||||||
|
self.send_command(0x20) # Activate Display Update Sequence
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Turn On Display Part
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def TurnOnDisplayPart(self):
|
||||||
|
self.send_command(0x22) # Display Update Control
|
||||||
|
self.send_data(0x0f) # fast:0x0c, quality:0x0f, 0xcf
|
||||||
|
self.send_command(0x20) # Activate Display Update Sequence
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Set lut
|
||||||
|
parameter:
|
||||||
|
lut : lut data
|
||||||
|
'''
|
||||||
|
def Lut(self, lut):
|
||||||
|
self.send_command(0x32)
|
||||||
|
for i in range(0, 153):
|
||||||
|
self.send_data(lut[i])
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Send lut data and configuration
|
||||||
|
parameter:
|
||||||
|
lut : lut data
|
||||||
|
'''
|
||||||
|
def SetLut(self, lut):
|
||||||
|
self.Lut(lut)
|
||||||
|
self.send_command(0x3f)
|
||||||
|
self.send_data(lut[153])
|
||||||
|
self.send_command(0x03) # gate voltage
|
||||||
|
self.send_data(lut[154])
|
||||||
|
self.send_command(0x04) # source voltage
|
||||||
|
self.send_data(lut[155]) # VSH
|
||||||
|
self.send_data(lut[156]) # VSH2
|
||||||
|
self.send_data(lut[157]) # VSL
|
||||||
|
self.send_command(0x2c) # VCOM
|
||||||
|
self.send_data(lut[158])
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Setting the display window
|
||||||
|
parameter:
|
||||||
|
xstart : X-axis starting position
|
||||||
|
ystart : Y-axis starting position
|
||||||
|
xend : End position of X-axis
|
||||||
|
yend : End position of Y-axis
|
||||||
|
'''
|
||||||
|
def SetWindow(self, x_start, y_start, x_end, y_end):
|
||||||
|
self.send_command(0x44) # SET_RAM_X_ADDRESS_START_END_POSITION
|
||||||
|
# x point must be the multiple of 8 or the last 3 bits will be ignored
|
||||||
|
self.send_data((x_start>>3) & 0xFF)
|
||||||
|
self.send_data((x_end>>3) & 0xFF)
|
||||||
|
|
||||||
|
self.send_command(0x45) # SET_RAM_Y_ADDRESS_START_END_POSITION
|
||||||
|
self.send_data(y_start & 0xFF)
|
||||||
|
self.send_data((y_start >> 8) & 0xFF)
|
||||||
|
self.send_data(y_end & 0xFF)
|
||||||
|
self.send_data((y_end >> 8) & 0xFF)
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Set Cursor
|
||||||
|
parameter:
|
||||||
|
x : X-axis starting position
|
||||||
|
y : Y-axis starting position
|
||||||
|
'''
|
||||||
|
def SetCursor(self, x, y):
|
||||||
|
self.send_command(0x4E) # SET_RAM_X_ADDRESS_COUNTER
|
||||||
|
# x point must be the multiple of 8 or the last 3 bits will be ignored
|
||||||
|
self.send_data(x & 0xFF)
|
||||||
|
|
||||||
|
self.send_command(0x4F) # SET_RAM_Y_ADDRESS_COUNTER
|
||||||
|
self.send_data(y & 0xFF)
|
||||||
|
self.send_data((y >> 8) & 0xFF)
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Initialize the e-Paper register
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def init(self):
|
||||||
|
if (epdconfig.module_init() != 0):
|
||||||
|
return -1
|
||||||
|
# EPD hardware init start
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
self.ReadBusy()
|
||||||
|
self.send_command(0x12) #SWRESET
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
self.send_command(0x01) #Driver output control
|
||||||
|
self.send_data(0xf9)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
|
||||||
|
self.send_command(0x11) #data entry mode
|
||||||
|
self.send_data(0x03)
|
||||||
|
|
||||||
|
self.SetWindow(0, 0, self.width-1, self.height-1)
|
||||||
|
self.SetCursor(0, 0)
|
||||||
|
|
||||||
|
self.send_command(0x3c)
|
||||||
|
self.send_data(0x05)
|
||||||
|
|
||||||
|
self.send_command(0x21) # Display update control
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x80)
|
||||||
|
|
||||||
|
self.send_command(0x18)
|
||||||
|
self.send_data(0x80)
|
||||||
|
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
self.SetLut(self.lut_full_update)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Display images
|
||||||
|
parameter:
|
||||||
|
image : Image data
|
||||||
|
'''
|
||||||
|
def getbuffer(self, image):
|
||||||
|
img = image
|
||||||
|
imwidth, imheight = img.size
|
||||||
|
if(imwidth == self.width and imheight == self.height):
|
||||||
|
img = img.convert('1')
|
||||||
|
elif(imwidth == self.height and imheight == self.width):
|
||||||
|
# image has correct dimensions, but needs to be rotated
|
||||||
|
img = img.rotate(90, expand=True).convert('1')
|
||||||
|
else:
|
||||||
|
logger.warning("Wrong image dimensions: must be " + str(self.width) + "x" + str(self.height))
|
||||||
|
# return a blank buffer
|
||||||
|
return [0x00] * (int(self.width/8) * self.height)
|
||||||
|
|
||||||
|
buf = bytearray(img.tobytes('raw'))
|
||||||
|
return buf
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Sends the image buffer in RAM to e-Paper and displays
|
||||||
|
parameter:
|
||||||
|
image : Image data
|
||||||
|
'''
|
||||||
|
def display(self, image):
|
||||||
|
if self.width%8 == 0:
|
||||||
|
linewidth = int(self.width/8)
|
||||||
|
else:
|
||||||
|
linewidth = int(self.width/8) + 1
|
||||||
|
|
||||||
|
self.send_command(0x24)
|
||||||
|
for j in range(0, self.height):
|
||||||
|
for i in range(0, linewidth):
|
||||||
|
self.send_data(image[i + j * linewidth])
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Sends the image buffer in RAM to e-Paper and partial refresh
|
||||||
|
parameter:
|
||||||
|
image : Image data
|
||||||
|
'''
|
||||||
|
def displayPartial(self, image):
|
||||||
|
epdconfig.digital_write(self.reset_pin, 0)
|
||||||
|
epdconfig.delay_ms(1)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
|
||||||
|
self.SetLut(self.lut_partial_update)
|
||||||
|
self.send_command(0x37)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x40)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x00)
|
||||||
|
|
||||||
|
self.send_command(0x3C) #BorderWavefrom
|
||||||
|
self.send_data(0x80)
|
||||||
|
|
||||||
|
self.send_command(0x22)
|
||||||
|
self.send_data(0xC0)
|
||||||
|
self.send_command(0x20)
|
||||||
|
self.ReadBusy()
|
||||||
|
|
||||||
|
self.SetWindow(0, 0, self.width - 1, self.height - 1)
|
||||||
|
self.SetCursor(0, 0)
|
||||||
|
|
||||||
|
self.send_command(0x24) # WRITE_RAM
|
||||||
|
# for j in range(0, self.height):
|
||||||
|
# for i in range(0, linewidth):
|
||||||
|
# self.send_data(image[i + j * linewidth])
|
||||||
|
self.send_data2(image)
|
||||||
|
self.TurnOnDisplayPart()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Refresh a base image
|
||||||
|
parameter:
|
||||||
|
image : Image data
|
||||||
|
'''
|
||||||
|
def displayPartBaseImage(self, image):
|
||||||
|
self.send_command(0x24)
|
||||||
|
self.send_data2(image)
|
||||||
|
|
||||||
|
self.send_command(0x26)
|
||||||
|
self.send_data2(image)
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Clear screen
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def Clear(self, color=0xFF):
|
||||||
|
if self.width%8 == 0:
|
||||||
|
linewidth = int(self.width/8)
|
||||||
|
else:
|
||||||
|
linewidth = int(self.width/8) + 1
|
||||||
|
# logger.debug(linewidth)
|
||||||
|
|
||||||
|
self.send_command(0x24)
|
||||||
|
self.send_data2([color] * int(self.height * linewidth))
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
'''
|
||||||
|
function : Enter sleep mode
|
||||||
|
parameter:
|
||||||
|
'''
|
||||||
|
def sleep(self):
|
||||||
|
self.send_command(0x10) #enter deep sleep
|
||||||
|
self.send_data(0x01)
|
||||||
|
|
||||||
|
epdconfig.delay_ms(2000)
|
||||||
|
epdconfig.module_exit()
|
||||||
|
|
||||||
|
### END OF FILE ###
|
||||||
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""GPIO/SPI-Unterbau fuer die vendored Waveshare-Panel-Treiber (epd2in13_V2.py,
|
||||||
|
epd2in13_V3.py), geschrieben gegen gpiozero + spidev statt gegen Waveshares eigenes
|
||||||
|
epdconfig.py, das RPi.GPIO voraussetzt - ein Modul, das auf Debian 13 (trixie) mit
|
||||||
|
Python 3.13 nicht mehr baut/importiert. gpiozero, lgpio (gpiozero's Pin-Factory auf
|
||||||
|
trixie) und spidev sind auf dem Ziel-Pi vorhanden.
|
||||||
|
|
||||||
|
Stellt genau die Schnittstelle bereit, die die beiden Treiber erwarten: die Pin-Konstanten
|
||||||
|
RST_PIN/DC_PIN/CS_PIN/BUSY_PIN sowie digital_write, digital_read, delay_ms, spi_writebyte,
|
||||||
|
spi_writebyte2, module_init und module_exit als Modulfunktionen (kein Klassen-Wrapper -
|
||||||
|
die Treiber greifen direkt auf `epdconfig.<name>` zu).
|
||||||
|
|
||||||
|
CS_PIN wird NICHT als GPIO geschaltet: spidev.SpiDev(0, 0) steuert die Hardware-CS0-Leitung
|
||||||
|
selbst bei jeder Übertragung, ein zusaetzliches Software-Toggle wuerde nur mit der Hardware
|
||||||
|
um die Leitung konkurrieren. digital_write()/digital_read() sind fuer CS_PIN deshalb
|
||||||
|
No-ops - genau das Muster, das Waveshares eigene, neuere epdconfig.py-Fassung fuer
|
||||||
|
Raspberry Pi inzwischen ebenfalls verwendet (dort ist die GPIO-Instanziierung fuer CS_PIN
|
||||||
|
auskommentiert).
|
||||||
|
|
||||||
|
Ein fehlender Import (gpiozero/spidev nicht installiert) oder ein fehlendes
|
||||||
|
/dev/spidev0.0 wirft DisplayUnavailable - display.py faengt das ab, protokolliert eine
|
||||||
|
Zeile und beendet sich sauber (sys.exit(0)), statt dass ein fehlendes Panel eine Migration
|
||||||
|
verhindern koennte (siehe Modul-Docstring von display.py)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import gpiozero
|
||||||
|
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden, hier nur defensiv
|
||||||
|
gpiozero = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import spidev
|
||||||
|
except ImportError: # pragma: no cover - dito
|
||||||
|
spidev = None
|
||||||
|
|
||||||
|
# lgpio ist die Pin-Factory, die gpiozero auf beiden Pis benutzt (Debian 13 trixie wie
|
||||||
|
# Bookworm). Sie meldet einen belegten Pin nicht als gpiozero-Ausnahme, sondern als
|
||||||
|
# lgpio.error - siehe _hardware_error_types(). Auf der Entwicklungsmaschine fehlt das
|
||||||
|
# Modul; dann bleibt es None und die Fehlerliste kommt ohne es aus.
|
||||||
|
try:
|
||||||
|
import lgpio
|
||||||
|
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden
|
||||||
|
lgpio = None
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayUnavailable(Exception):
|
||||||
|
"""Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der
|
||||||
|
Hardwarezugriff selbst ist fehlgeschlagen (z.B. Berechtigung, Pin belegt)."""
|
||||||
|
|
||||||
|
|
||||||
|
# Dieselben Pin-Nummern wie in Waveshares eigenem epdconfig.py fuer den Raspberry Pi.
|
||||||
|
RST_PIN = 17
|
||||||
|
DC_PIN = 25
|
||||||
|
CS_PIN = 8
|
||||||
|
BUSY_PIN = 24
|
||||||
|
|
||||||
|
_SPI_BUS = 0
|
||||||
|
_SPI_DEVICE = 0
|
||||||
|
_SPI_HZ = 4_000_000
|
||||||
|
|
||||||
|
_rst_pin = None
|
||||||
|
_dc_pin = None
|
||||||
|
_busy_pin = None
|
||||||
|
_spi = None
|
||||||
|
|
||||||
|
|
||||||
|
def digital_write(pin: int, value: int) -> None:
|
||||||
|
if pin == RST_PIN:
|
||||||
|
_rst_pin.value = bool(value)
|
||||||
|
elif pin == DC_PIN:
|
||||||
|
_dc_pin.value = bool(value)
|
||||||
|
elif pin == CS_PIN:
|
||||||
|
pass # Hardware-CS ueber spidev, siehe Modul-Docstring.
|
||||||
|
# Andere Pins fragen die Treiber nicht ab - kein else-Zweig noetig.
|
||||||
|
|
||||||
|
|
||||||
|
def digital_read(pin: int) -> int:
|
||||||
|
if pin == BUSY_PIN:
|
||||||
|
return int(bool(_busy_pin.value))
|
||||||
|
if pin == RST_PIN:
|
||||||
|
return int(bool(_rst_pin.value))
|
||||||
|
if pin == DC_PIN:
|
||||||
|
return int(bool(_dc_pin.value))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def delay_ms(ms: int) -> None:
|
||||||
|
time.sleep(ms / 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def spi_writebyte(data) -> None:
|
||||||
|
_spi.writebytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def spi_writebyte2(data) -> None:
|
||||||
|
_spi.writebytes2(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _busy_hint(error: BaseException) -> str:
|
||||||
|
"""Zusatz fuer die Fehlermeldung, wenn die Leitungen belegt sind. "GPIO busy" heisst
|
||||||
|
auf dieser Station praktisch immer "der Anzeigedienst haelt die Pins" - und trifft
|
||||||
|
ausgerechnet den Befehl, den der Hilfetext als ersten Handgriff der Abnahme nennt.
|
||||||
|
Die Meldung soll den Bediener also nicht zum Suchen schicken, sondern sagen, was los
|
||||||
|
ist. Leer, wenn es nicht danach aussieht - ein erfundener Hinweis waere schlimmer als
|
||||||
|
keiner."""
|
||||||
|
if "busy" in str(error).lower():
|
||||||
|
return (" - haelt der Anzeigedienst die Leitungen? "
|
||||||
|
"Pruefen mit 'systemctl status ninlight-display'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _hardware_error_types() -> tuple:
|
||||||
|
"""Die Ausnahmen, die als "keine/blockierte Hardware" gelten und damit zu
|
||||||
|
DisplayUnavailable werden. Bewusst eine ENUMERATION, kein `except Exception`: ein
|
||||||
|
echter Programmierfehler an anderer Stelle (TypeError, AttributeError, NameError)
|
||||||
|
soll weiterhin als das erkennbar bleiben, was er ist, statt hier als "Panel fehlt"
|
||||||
|
getarnt zu werden (Fixrunde 1, C2, zweiter Teil).
|
||||||
|
|
||||||
|
gpiozero.GPIOZeroError (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-
|
||||||
|
Familie (PinInvalidPin, PinUnsupported, ... - z.B. bei falscher/fehlender
|
||||||
|
Pin-Factory-Zuordnung) haengt direkt unter GPIOZeroError, nicht unter
|
||||||
|
GPIODeviceError - per `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory
|
||||||
|
erbt zusaetzlich von ImportError und war dadurch schon abgedeckt.
|
||||||
|
|
||||||
|
lgpio.error (Hardware-Abnahme): laeuft der Anzeigedienst, haelt er die GPIO-Leitungen,
|
||||||
|
und ein gleichzeitiges "ninlight-station display test" bekommt beim Anlegen des ersten
|
||||||
|
Pins ein `lgpio.error: 'GPIO busy'`. Das ist KEINE gpiozero.GPIOZeroError - gpiozero
|
||||||
|
reicht die Ausnahme seiner Pin-Factory unveraendert durch - und fiel deshalb durch
|
||||||
|
diese Liste hindurch: statt der vorgesehenen einen Protokollzeile gab es einen rohen
|
||||||
|
Stapelabzug und rc=1. lgpio.error erbt direkt von Exception, die drei
|
||||||
|
Programmierfehler oben bleiben davon also unberuehrt.
|
||||||
|
|
||||||
|
Wird bei JEDEM Aufruf neu gebaut statt einmal beim Import: nur so wirkt ein
|
||||||
|
monkeypatch.setattr(epdconfig, "lgpio", ...) im Test ueberhaupt (dasselbe Muster wie
|
||||||
|
bei gpiozero/spidev in tools/station/tests/test_render.py)."""
|
||||||
|
types: tuple = (OSError, ImportError)
|
||||||
|
if gpiozero is not None:
|
||||||
|
types = types + (gpiozero.GPIOZeroError,)
|
||||||
|
lgpio_error = getattr(lgpio, "error", None) if lgpio is not None else None
|
||||||
|
if isinstance(lgpio_error, type) and issubclass(lgpio_error, BaseException):
|
||||||
|
types = types + (lgpio_error,)
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def module_init() -> int:
|
||||||
|
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
|
||||||
|
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss.
|
||||||
|
|
||||||
|
Idempotent: ein zweiter Aufruf, waehrend die Pins/das SPI-Geraet noch offen sind (die
|
||||||
|
Panel-Treiber rufen dies bei JEDEM epd.init() erneut auf - Vollaufbau bei Moduswechsel
|
||||||
|
oder alle 30 Minuten loest also ohne diese Absicherung einen zweiten, unnoetigen
|
||||||
|
Oeffnungsversuch aus), kehrt sofort zurueck statt gpiozero.DigitalOutputDevice(17)
|
||||||
|
ein zweites Mal anzulegen. Das wuerde mit GPIOPinInUse scheitern - vom breiten
|
||||||
|
except unten (Fixrunde 1, C2) frueher faelschlich als "Panel fehlt" verschluckt,
|
||||||
|
obwohl es ein Programmierfehler in der Aufrufreihenfolge ist. Nach module_exit()
|
||||||
|
(z.B. durch epd.sleep()) ist _spi wieder None, ein echter Neuaufbau laeuft dann
|
||||||
|
normal."""
|
||||||
|
global _rst_pin, _dc_pin, _busy_pin, _spi
|
||||||
|
|
||||||
|
if _spi is not None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if gpiozero is None or spidev is None:
|
||||||
|
raise DisplayUnavailable("gpiozero/spidev nicht installiert")
|
||||||
|
|
||||||
|
spi_path = f"/dev/spidev{_SPI_BUS}.{_SPI_DEVICE}"
|
||||||
|
if not os.path.exists(spi_path):
|
||||||
|
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
|
||||||
|
|
||||||
|
hardware_errors = _hardware_error_types()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
|
||||||
|
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
|
||||||
|
_busy_pin = gpiozero.DigitalInputDevice(BUSY_PIN, pull_up=False)
|
||||||
|
|
||||||
|
_spi = spidev.SpiDev()
|
||||||
|
_spi.open(_SPI_BUS, _SPI_DEVICE)
|
||||||
|
_spi.max_speed_hz = _SPI_HZ
|
||||||
|
_spi.mode = 0b00
|
||||||
|
except hardware_errors as e:
|
||||||
|
module_exit()
|
||||||
|
raise DisplayUnavailable(
|
||||||
|
f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}{_busy_hint(e)}") from e
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def module_exit() -> None:
|
||||||
|
"""Gibt GPIO-Pins und SPI-Geraet frei. Verzeiht, wenn module_init() nie (vollstaendig)
|
||||||
|
gelaufen ist - display.py ruft das u.a. im Fehlerpfad von module_init() selbst auf."""
|
||||||
|
global _rst_pin, _dc_pin, _busy_pin, _spi
|
||||||
|
|
||||||
|
if _spi is not None:
|
||||||
|
try:
|
||||||
|
_spi.close()
|
||||||
|
except Exception: # noqa: BLE001 - Aufraeumen darf nicht seinerseits scheitern
|
||||||
|
pass
|
||||||
|
_spi = None
|
||||||
|
|
||||||
|
for attr_name in ("_rst_pin", "_dc_pin", "_busy_pin"):
|
||||||
|
device = globals()[attr_name]
|
||||||
|
if device is not None:
|
||||||
|
try:
|
||||||
|
device.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
globals()[attr_name] = None
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
|
||||||
|
|
||||||
|
blocks()/lines() sind bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe
|
||||||
|
tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb der
|
||||||
|
Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf den Pi
|
||||||
|
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
|
||||||
|
|
||||||
|
Die Schrift kommt aus FONT_SEARCH_PATHS (DejaVu auf beiden Pis) statt aus
|
||||||
|
ImageFont.load_default(): default ist rund 10 px hoch und auf 250x122 aus zwei Metern
|
||||||
|
nicht lesbar (Befund der Hardware-Abnahme) - siehe _load_font(), warum der
|
||||||
|
naheliegende Weg load_default(size=...) auf Pi 2 (Pillow 9.4.0) ausscheidet.
|
||||||
|
|
||||||
|
Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. blocks() liefert
|
||||||
|
UNGEKUERZTE Zeilen - die Breitenkuerzung (mit "..." statt Umbruch, Spec: eine zu lange
|
||||||
|
Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width()
|
||||||
|
gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist
|
||||||
|
seit Pillow 10.1 eine proportionale TrueType-Schrift, keine feste Bitmapschrift - eine
|
||||||
|
Kuerzung nach Zeichenzahl (fruehere Fassung: MAX_COLS) ist dafuer keine gueltige
|
||||||
|
Breitengrenze: "W"*40 misst bei dieser Schrift fast doppelt so breit wie "i"*40, und
|
||||||
|
ausgerechnet grossbuchstabenlastige Werte - Produktnamen aus der Lampenerkennung wie
|
||||||
|
"KLYQA GU10 RGB CW/WW" - waeren trotz weniger Zeichen als die alte Grenze ueber die
|
||||||
|
Panelbreite hinausgelaufen (Fixrunde 2, MAX_COLS-Kleinigkeit war in Fixrunde 1 nicht
|
||||||
|
wirklich behoben). _truncate_to_width() selbst importiert kein Pillow (sie nimmt eine
|
||||||
|
Messfunktion entgegen, die der Aufrufer liefert) und bleibt damit wie lines() ohne Pillow
|
||||||
|
testbar; nur compose() verdrahtet sie mit einer echten PIL-Textmessung.
|
||||||
|
|
||||||
|
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
|
||||||
|
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
|
||||||
|
/etc/ninlight-station.conf. display.py liest beides und reicht ein gemeinsames dict
|
||||||
|
herein. Jedes Feld wird ueber .get() mit Vorgabewert gelesen: status.json,
|
||||||
|
control.json und die Konfigurationsdatei koennen jederzeit fehlen, halb geschrieben oder
|
||||||
|
aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
|
||||||
|
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
PANEL_WIDTH_PX = 250
|
||||||
|
PANEL_HEIGHT_PX = 122
|
||||||
|
TEXT_MARGIN_PX = 2
|
||||||
|
|
||||||
|
# --- Rollen und ihre Groessen ------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Bis zur Hardware-Abnahme zeichnete compose() alle Zeilen in derselben
|
||||||
|
# ImageFont.load_default() (rund 10 px hoch) - auf 250x122 war das Kleingedrucktes und
|
||||||
|
# aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt
|
||||||
|
# deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders:
|
||||||
|
#
|
||||||
|
# header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle
|
||||||
|
# Panelbreite. Invertiert kostet auf e-Paper nichts (bistabil, kein
|
||||||
|
# Stromverbrauch pro Pixel) und trennt die Kopfzeile auf einen Blick
|
||||||
|
# vom Rest.
|
||||||
|
# header_phase - die Phase (AUTO/LISTEN/FREIGEGEBEN), NUR im Migrationsmodus und
|
||||||
|
# nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie
|
||||||
|
# header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme:
|
||||||
|
# die Phase ging als eigene kleine Zeile unter dem Lampennamen unter).
|
||||||
|
# Rettung/aus kennen keine Phase - dort bleibt die Kopfzeile
|
||||||
|
# unveraendert (nur header, mittig, wie zuvor). NUR das kurze Label -
|
||||||
|
# der Zielname der Freigabe steht als eigene Beiwerkzeile, siehe
|
||||||
|
# ARMED_DETAIL_PREFIX.
|
||||||
|
# hero - die eine wichtigste Information des Zustands (Migration: der
|
||||||
|
# erkannte Produktname; Rettung: die SSID), fett.
|
||||||
|
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung).
|
||||||
|
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"):
|
||||||
|
# gross und mittig, damit man quer durch den Raum sieht, dass nichts
|
||||||
|
# laeuft.
|
||||||
|
# footer - eine Zeile am UNTEREN Panelrand, ueber einen Trennstrich abgesetzt,
|
||||||
|
# unabhaengig vom Zeilenfluss darueber. Genau ein Block benutzt sie: der
|
||||||
|
# Bildsatz-Umschalter (mit oder ohne einkompilierte WLAN-Zugangsdaten).
|
||||||
|
# Er gehoert dorthin, weil er ein DAUERZUSTAND ist und nicht zum
|
||||||
|
# wechselnden Inhalt: er gilt in jedem Modus, auch bei ausgeschalteter
|
||||||
|
# Station, und man soll ihn vor dem Einschalten sehen - nicht erst,
|
||||||
|
# wenn zwanzig Lampen mit den eigenen Zugangsdaten geflasht sind.
|
||||||
|
# Waere er eine gewoehnliche Beiwerkzeile, fiele er im vollsten Zustand
|
||||||
|
# als letzte unten heraus, also ausgerechnet waehrend einer laufenden
|
||||||
|
# Migration.
|
||||||
|
ROLE_HEADER = "header"
|
||||||
|
ROLE_HEADER_PHASE = "header_phase"
|
||||||
|
ROLE_HERO = "hero"
|
||||||
|
ROLE_DETAIL = "detail"
|
||||||
|
ROLE_BIG = "big"
|
||||||
|
ROLE_FOOTER = "footer"
|
||||||
|
|
||||||
|
HEADER_FONT_PX = 20
|
||||||
|
HEADER_PHASE_FONT_PX = 13
|
||||||
|
HERO_FONT_PX = 16
|
||||||
|
DETAIL_FONT_PX = 11
|
||||||
|
BIG_FONT_PX = 26
|
||||||
|
FOOTER_FONT_PX = 11
|
||||||
|
|
||||||
|
# Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe:
|
||||||
|
# Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem
|
||||||
|
# Abstand nach unten uebrig (naechster Kommentar, BOTTOM_MARGIN_PX):
|
||||||
|
# 22 + 2 + 20 + 5*13 = 109. Die Phase (frueher eine sechste Beiwerkzeile: "AUTO"/
|
||||||
|
# "FREIGEGEBEN: ...") steht seit der zweiten Hardware-Abnahme in der Kopfzeile
|
||||||
|
# (ROLE_HEADER_PHASE) statt als eigene Zeile - das ist der Grund, warum nur noch fuenf
|
||||||
|
# statt sechs Beiwerkzeilen uebrig bleiben, nicht eine nachtraeglich erfundene Kuerzung.
|
||||||
|
HEADER_BAR_HEIGHT_PX = 22
|
||||||
|
HERO_LINE_HEIGHT_PX = 20
|
||||||
|
DETAIL_LINE_HEIGHT_PX = 13
|
||||||
|
|
||||||
|
# Hardware-Abnahme, zweiter Befund: auf dem echten Panel frisst ein Rahmen den unteren
|
||||||
|
# Rand, der volleste Zustand (s.o., endet bei 109 px) wirkte dort gedraengt. compose()
|
||||||
|
# bricht Beiwerkzeilen deshalb schon ab, bevor sie in die letzten BOTTOM_MARGIN_PX
|
||||||
|
# Zeilen hineinragen wuerden (siehe die Abbruchbedingung dort) - mit den obigen 109 px
|
||||||
|
# bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten.
|
||||||
|
BOTTOM_MARGIN_PX = 10
|
||||||
|
|
||||||
|
# Das Band am unteren Rand fuer ROLE_FOOTER (Trennstrich + eine 11-px-Zeile). Es liegt
|
||||||
|
# UEBER BOTTOM_MARGIN_PX, der Rand bleibt also weiterhin tintenfrei; der Zeilenfluss
|
||||||
|
# darueber bricht entsprechend frueher ab. Das kostet im vollsten Zustand (Migration mit
|
||||||
|
# erkannter Lampe) eine Beiwerkzeile - die unterste, also die unwichtigste nach der
|
||||||
|
# Sortierung in blocks(). Der Preis ist gewollt: welcher Bildsatz gilt, ist die einzige
|
||||||
|
# Auskunft auf diesem Panel, die man BRAUCHT, bevor etwas passiert.
|
||||||
|
FOOTER_BAND_HEIGHT_PX = 14
|
||||||
|
# Abstand des Trennstrichs zur Fusszeile.
|
||||||
|
FOOTER_RULE_GAP_PX = 2
|
||||||
|
|
||||||
|
# Mindestabstand zwischen dem Ende des Modus-Texts und dem Anfang der Phase in der
|
||||||
|
# Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten
|
||||||
|
# beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: <langer Zielname>")
|
||||||
|
# ineinanderlaufen.
|
||||||
|
HEADER_PHASE_GAP_PX = 6
|
||||||
|
# Bleibt nach Modus-Text und Puffer weniger als das fuer die Phase uebrig, wird sie ganz
|
||||||
|
# weggelassen statt ein einzelnes "…" hinzuzeichnen - das waere ohnehin keine Auskunft
|
||||||
|
# mehr wert (dritte Option aus der Abnahme: kuerzen, verkleinern oder weglassen; die
|
||||||
|
# ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer).
|
||||||
|
#
|
||||||
|
# Dritte Hardware-Abnahme, Befund M2: der Reviewer hat zu Recht angemerkt, dass dieser
|
||||||
|
# Zweig mit den heutigen Werten nicht feuern KANN - der Modus-Text im Migrationsmodus ist
|
||||||
|
# die Konstante "MIGRATION" (MODE_LABELS), available_px ist damit fest (112 px DejaVu,
|
||||||
|
# 127 px Arial) und nie < 24. Der Zweig bleibt trotzdem, aber er ist jetzt getestet
|
||||||
|
# (test_header_phase_is_dropped_when_too_little_room_is_left): was ihn aufweckt, ist
|
||||||
|
# nicht ein langer Zielname (den gibt es seit Befund I2 im Kopf gar nicht mehr), sondern
|
||||||
|
# ein schmalerer Rest neben dem Modus - eine groessere HEADER_FONT_PX, eine deutlich
|
||||||
|
# breitere Schrift, ein kuenftiger dritter Modus mit laengerem Namen oder ein groesserer
|
||||||
|
# HEADER_PHASE_GAP_PX. Ohne den Zweig waere die Folge kein fehlendes Wort, sondern ein
|
||||||
|
# "…", das sich mit dem Modus ueberlagert; das ist der Grund, warum hier ein
|
||||||
|
# totgeglaubtes Sicherheitsnetz billiger ist als sein Fehlen.
|
||||||
|
HEADER_PHASE_MIN_WIDTH_PX = 24
|
||||||
|
|
||||||
|
# Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert
|
||||||
|
# (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber
|
||||||
|
# erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und
|
||||||
|
# dort wirft load_default(size=...) TypeError ("unexpected keyword argument 'size'"), am
|
||||||
|
# Geraet gegengeprueft. Deshalb fuehrt der Hauptweg ueber ImageFont.truetype() auf eine
|
||||||
|
# Datei im Dateisystem: /usr/share/fonts/truetype/dejavu/ liegt auf BEIDEN Pis
|
||||||
|
# (Debian 13 trixie wie Bookworm) und ist damit der einzige Pfad, auf den es im Betrieb
|
||||||
|
# ankommt. Alles darunter existiert nur, damit compose() auch auf Rechnern ohne den
|
||||||
|
# Debian-Pfad eine skalierbare Schrift findet - insbesondere auf der macOS-
|
||||||
|
# Entwicklungsmaschine, wo tools/station/tests/test_render.py laufen muss.
|
||||||
|
#
|
||||||
|
# Letzter Rueckfall (in _load_font(), nicht in dieser Liste) ist load_default() OHNE
|
||||||
|
# Argument: dann ist die Schrift wieder klein, aber nichts stuerzt ab - die Zusage
|
||||||
|
# "eine fehlende Schrift darf die Anzeige (und damit nie eine Migration) aufhalten"
|
||||||
|
# gilt unveraendert.
|
||||||
|
FONT_SEARCH_PATHS = (
|
||||||
|
# Debian/Raspberry Pi OS - der Betriebsfall auf beiden Pis.
|
||||||
|
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||||
|
# Andere Linux-Distributionen legen dieselbe Schrift woanders ab.
|
||||||
|
("/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
|
||||||
|
("/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"),
|
||||||
|
# DejaVu per Homebrew (macOS), falls vorhanden.
|
||||||
|
("/opt/homebrew/share/fonts/DejaVuSans.ttf",
|
||||||
|
"/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf"),
|
||||||
|
# macOS-Bordmittel - nur fuer die Entwicklungsmaschine, nie fuer den Pi.
|
||||||
|
("/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||||||
|
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
|
||||||
|
)
|
||||||
|
|
||||||
|
MODE_LABELS = {"recovery": "RECOVERY", "migration": "MIGRATION"}
|
||||||
|
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
|
||||||
|
# Zielnamen nach sich), deshalb hier nicht gelistet.
|
||||||
|
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
||||||
|
|
||||||
|
# Dritte Hardware-Abnahme, Befund I2: in der Kopfzeile stand frueher
|
||||||
|
# "FREIGEGEBEN: <zielname>" - der Platz neben dem Modus reicht dafuer nicht (mit DejaVu
|
||||||
|
# auf dem Pi bleiben 112 px, der Text misst 182 px), gezeichnet wurde also "FREIGEGEBE…":
|
||||||
|
# ein Wortfragment, das bei JEDEM Ziel gleich aussieht. Ausgerechnet in "armed" muss der
|
||||||
|
# Bediener aber sehen, WELCHES Ziel freigegeben ist, sonst gibt er im Zweifel die falsche
|
||||||
|
# Lampe frei. Deshalb im Kopf nur noch das kurze Label (das auch bei einer deutlich
|
||||||
|
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
|
||||||
|
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
|
||||||
|
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
|
||||||
|
ARMED_LABEL = "ARMED"
|
||||||
|
ARMED_DETAIL_PREFIX = "Armed: "
|
||||||
|
|
||||||
|
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
|
||||||
|
# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts,
|
||||||
|
# "Lampe: sofort ins WLAN" sagt ihm genau das, was er wissen muss, bevor er einschaltet.
|
||||||
|
# Der gefaehrliche Zustand steht in Grossbuchstaben - er ist der, den man uebersehen soll
|
||||||
|
# und darf.
|
||||||
|
# Die Zeile muss UNGEKUERZT passen, und zwar auch mit einer breiteren Schrift als der
|
||||||
|
# hier gerade installierten: am Geraet (DejaVu) kam die alte, laengere Fassung als
|
||||||
|
# "Lampe -> Onboarding-AP (ohne WLA..." an - der Zusatz in der Klammer, also gerade die
|
||||||
|
# Aussage, fiel weg. Auf dem Mac des Entwicklers (Arial, schmaler) passte dieselbe Zeile
|
||||||
|
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe
|
||||||
|
# test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
|
||||||
|
IMAGESET_LABELS = {
|
||||||
|
"ohne-wlan": "no Wi-Fi data (onboarding AP)",
|
||||||
|
"mit-wlan": "WITH Wi-Fi data (own network)",
|
||||||
|
}
|
||||||
|
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie
|
||||||
|
# zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in
|
||||||
|
# BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige
|
||||||
|
# Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
|
||||||
|
IMAGESET_MISSING_LABELS = {
|
||||||
|
"ohne-wlan": "SET MISSING: no Wi-Fi data",
|
||||||
|
"mit-wlan": "SET MISSING: WITH Wi-Fi data",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
|
||||||
|
# oder neu gestartet wird. E-Paper ist bistabil - das zuletzt gezeichnete Bild bleibt fuer
|
||||||
|
# immer stehen, auch stromlos. Ohne diesen Schritt behauptet das Panel nach dem
|
||||||
|
# Ausschalten weiter den Zustand von vorher (bei der Abnahme stand dort die RUECKFRAGE,
|
||||||
|
# und der Pi sah aus, als warte er noch auf eine Eingabe).
|
||||||
|
#
|
||||||
|
# Die beiden Texte unterscheiden sich, und das ist der Punkt: beim Ausschalten DARF man
|
||||||
|
# den Stecker ziehen, beim Neustart NICHT - ein Zero W braucht gut anderthalb Minuten, bis
|
||||||
|
# der Anzeigedienst wieder zeichnet, und an genau diesem Pi hat ein Stromzug in dieser
|
||||||
|
# Wartezeit schon eine SD-Karte gekostet.
|
||||||
|
FINAL_LABELS = {
|
||||||
|
# "Strom kann getrennt werden" war in der Hero-Schrift 262,1 px breit und kam am
|
||||||
|
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
|
||||||
|
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
|
||||||
|
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
|
||||||
|
"poweroff": ("POWERED OFF", "Safe to unplug", []),
|
||||||
|
"reboot": ("REBOOTING", "Do NOT unplug!",
|
||||||
|
["The Pi is back in about 2 min.",
|
||||||
|
"This screen stays until then."]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
|
||||||
|
# "-dev" kann in der Produkt-ID stehen, in der Version, oder in beiden. Die Quelle steht
|
||||||
|
# mit auf dem Panel, weil genau hier am 2026-09-15 ein falsch signiertes Image
|
||||||
|
# ausgeliefert wurde: wer vor der Freigabe sieht, dass "dev" nur aus der Version kommt
|
||||||
|
# (oder dass "prod" bedeutet: nirgends ein "-dev"), kann eine Fehlklassifikation abfangen,
|
||||||
|
# bevor die Lampe das Image ablehnt. Kurzformen, weil die Beiwerkzeile schmal ist.
|
||||||
|
VARIANT_SOURCE_LABELS = {"both": "ID+Ver", "product_id": "ID", "version": "Ver"}
|
||||||
|
|
||||||
|
|
||||||
|
def _variant_label(last_seen: dict) -> str:
|
||||||
|
""""dev/Ver", "dev/ID", "dev/ID+Ver", "prod" - die Quelle nur, wo sie etwas aussagt.
|
||||||
|
Bei "prod" gibt es keine ("none"), und eine aeltere Serverfassung ohne das Feld
|
||||||
|
liefert wie bisher nur die Variante."""
|
||||||
|
variant = str(last_seen.get("variant") or "?")
|
||||||
|
label = VARIANT_SOURCE_LABELS.get(last_seen.get("variant_source"))
|
||||||
|
if not label:
|
||||||
|
return variant
|
||||||
|
return variant + "/" + label
|
||||||
|
|
||||||
|
|
||||||
|
def _hhmm(ts: object) -> str:
|
||||||
|
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
|
||||||
|
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
|
||||||
|
if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T":
|
||||||
|
return ts[11:16]
|
||||||
|
return "--:--"
|
||||||
|
|
||||||
|
|
||||||
|
def blocks(state: dict) -> list[tuple[str, str]]:
|
||||||
|
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE
|
||||||
|
(siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst
|
||||||
|
nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt
|
||||||
|
eine Ausnahme zu werfen.
|
||||||
|
|
||||||
|
Bleibt wie lines() ohne Pillow importierbar und testbar: hier entsteht nur Text, kein
|
||||||
|
Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt
|
||||||
|
ausschliesslich compose()."""
|
||||||
|
state = state if isinstance(state, dict) else {}
|
||||||
|
|
||||||
|
# Laeuft gerade eine Bedienung am Taster, ERSETZT sie das Statusbild komplett - eine
|
||||||
|
# Bedienung, die neben dem Statusbild um Platz kaempft, waere auf 250x122 unlesbar,
|
||||||
|
# und in diesem Moment ist der Bedienzustand die einzige Auskunft, auf die der
|
||||||
|
# Bediener wartet. display.read_ui() liefert {} (und damit kein "ui"), sobald nichts
|
||||||
|
# laeuft - dann geht es unveraendert weiter wie zuvor.
|
||||||
|
ui = state.get("ui")
|
||||||
|
if isinstance(ui, dict) and ui.get("view") not in (None, "", "idle"):
|
||||||
|
return _ui_blocks(ui)
|
||||||
|
|
||||||
|
mode = state.get("mode")
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
if mode == "final":
|
||||||
|
# Abschlussbildschirm - KEINE Fusszeile und keine weiteren Zeilen: die Station ist
|
||||||
|
# im Begriff zu verschwinden, und die einzige Auskunft, auf die es jetzt ankommt,
|
||||||
|
# ist "darf ich den Stecker ziehen?".
|
||||||
|
label = FINAL_LABELS.get(state.get("final"))
|
||||||
|
if label is None:
|
||||||
|
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
|
||||||
|
# zu sagen, wenn gleich wieder gebootet wird.
|
||||||
|
return [("Shutting down", ROLE_BIG)]
|
||||||
|
title, hero, details = label
|
||||||
|
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
|
||||||
|
+ [(d, ROLE_DETAIL) for d in details])
|
||||||
|
|
||||||
|
# Die Fusszeile haengt an JEDEM Statusbild - auch an "Station aus" und an "Status
|
||||||
|
# unbekannt", die beide sofort zurueckkehren. Sie wird deshalb hier gebaut und unten
|
||||||
|
# bei jedem Rueckgabepunkt angehaengt.
|
||||||
|
footer = _imageset_blocks(state)
|
||||||
|
|
||||||
|
if mode == "off":
|
||||||
|
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
|
||||||
|
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
|
||||||
|
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
|
||||||
|
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
|
||||||
|
out.append(("Station off", ROLE_BIG))
|
||||||
|
# Die Adresse, unter der der Pi gerade erreichbar ist - die einzige Auskunft, die
|
||||||
|
# man bei ausgeschalteter Station am Geraet wirklich braucht (per SSH draufgehen,
|
||||||
|
# ohne den Router zu befragen). display.py legt sie nur in DIESEN Zustand; fehlt
|
||||||
|
# das Feld (aeltere display.py-Fassung, von Hand gebauter Zustand im Test), bleibt
|
||||||
|
# die Zeile weg statt eine Adresse zu erfinden. Ist das Feld da, aber leer, sagt
|
||||||
|
# die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz"
|
||||||
|
# sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert.
|
||||||
|
#
|
||||||
|
# Beschriftet mit dem GERAET ("wlan0: 192.168.2.96"), nicht mit "LAN": derselbe
|
||||||
|
# Bediener las "LAN" als Falschauskunft, weil dieser Pi per WLAN im Netz haengt
|
||||||
|
# und gar kein Kabel hat (Rueckmeldung 2026-09-23). Der Geraetename grenzt
|
||||||
|
# ausserdem genauso eindeutig vom Zugangspunkt-Netz ab, das auf jedem ANDEREN
|
||||||
|
# Statusbild als SSID steht - und er sagt zusaetzlich, WORUEBER der Pi haengt.
|
||||||
|
# Ohne Netz "no home network" statt eines nackten "no network": letzteres wurde
|
||||||
|
# auf den Migrations-/Rettungs-Zugangspunkt bezogen (der im Aus-Zustand ja
|
||||||
|
# tatsaechlich aus ist). Gemeint ist das GEGENTEIL - das gewoehnliche Heimnetz.
|
||||||
|
ip = state.get("ip")
|
||||||
|
if isinstance(ip, str):
|
||||||
|
geraet = state.get("iface")
|
||||||
|
if not ip:
|
||||||
|
out.append(("no home network", ROLE_DETAIL))
|
||||||
|
elif isinstance(geraet, str) and geraet:
|
||||||
|
out.append((f"{geraet}: {ip}", ROLE_DETAIL))
|
||||||
|
else:
|
||||||
|
out.append((f"IP: {ip}", ROLE_DETAIL))
|
||||||
|
return out + footer
|
||||||
|
|
||||||
|
if mode not in ("recovery", "migration"):
|
||||||
|
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
|
||||||
|
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
|
||||||
|
# unterstellen.
|
||||||
|
out.append((f"Status unknown ({mode})" if mode else "Status unknown", ROLE_BIG))
|
||||||
|
return out + footer
|
||||||
|
|
||||||
|
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
||||||
|
|
||||||
|
ssid = state.get("ssid")
|
||||||
|
|
||||||
|
if mode == "recovery":
|
||||||
|
# Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
|
||||||
|
# Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
|
||||||
|
# wie vor der zweiten Hardware-Abnahme, siehe dort).
|
||||||
|
#
|
||||||
|
# Die SSID ist das, was der Bediener am Geraet braucht (er muss sich mit genau
|
||||||
|
# diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix, das den
|
||||||
|
# Platz nur wegnaehme.
|
||||||
|
if ssid:
|
||||||
|
out.append((str(ssid), ROLE_HERO))
|
||||||
|
else:
|
||||||
|
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
|
||||||
|
# nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
|
||||||
|
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
|
||||||
|
# Phase, deshalb dieser Block nur hier drin.
|
||||||
|
#
|
||||||
|
# Seit der zweiten Hardware-Abnahme steht die Phase NICHT mehr als eigene
|
||||||
|
# Beiwerkzeile unter dem Lampennamen (dort ging sie unter), sondern direkt
|
||||||
|
# NEBEN dem Modus in der Kopfzeile (ROLE_HEADER_PHASE, rechtsbuendig) - deshalb
|
||||||
|
# wird sie hier direkt nach dem ROLE_HEADER-Block angehaengt, nicht erst nach
|
||||||
|
# der grossen Zeile.
|
||||||
|
phase = state.get("phase")
|
||||||
|
armed_target = None
|
||||||
|
if phase == "armed":
|
||||||
|
# Nur das Label in den Kopf, der Zielname kommt weiter unten als eigene
|
||||||
|
# Beiwerkzeile (siehe ARMED_DETAIL_PREFIX) - in den Kopf passt er nicht,
|
||||||
|
# und dort war er bis zur dritten Abnahme faktisch unsichtbar.
|
||||||
|
out.append((ARMED_LABEL, ROLE_HEADER_PHASE))
|
||||||
|
armed_target = str(state.get("armed_target") or "?")
|
||||||
|
elif phase in PHASE_LABELS:
|
||||||
|
out.append((PHASE_LABELS[phase], ROLE_HEADER_PHASE))
|
||||||
|
|
||||||
|
last_seen = state.get("last_seen")
|
||||||
|
if not isinstance(last_seen, dict):
|
||||||
|
last_seen = {}
|
||||||
|
product_name = last_seen.get("product_name")
|
||||||
|
|
||||||
|
# Reihenfolge bewusst anders als frueher (da stand die SSID als zweite Zeile):
|
||||||
|
# gross kommt das, was sich gerade aendert und worauf man wartet - die erkannte
|
||||||
|
# Lampe. Solange keine erkannt ist, ruecke die SSID nach oben, damit die grosse
|
||||||
|
# Zeile nie leer bleibt.
|
||||||
|
if product_name:
|
||||||
|
out.append((str(product_name), ROLE_HERO))
|
||||||
|
elif ssid:
|
||||||
|
out.append((str(ssid), ROLE_HERO))
|
||||||
|
|
||||||
|
if armed_target is not None:
|
||||||
|
# ERSTE Beiwerkzeile, also die, die als letzte der unteren Randgrenze zum
|
||||||
|
# Opfer fiele (compose() bricht von unten ab, blocks() ist nach Wichtigkeit
|
||||||
|
# sortiert): in "armed" ist das freigegebene Ziel die Auskunft, wegen der
|
||||||
|
# der Bediener ueberhaupt aufs Panel sieht.
|
||||||
|
out.append((f"{ARMED_DETAIL_PREFIX}{armed_target}", ROLE_DETAIL))
|
||||||
|
|
||||||
|
if product_name:
|
||||||
|
target = last_seen.get("target")
|
||||||
|
variant = last_seen.get("variant")
|
||||||
|
if target or variant:
|
||||||
|
out.append(("{} ({})".format(target or "?", _variant_label(last_seen)),
|
||||||
|
ROLE_DETAIL))
|
||||||
|
lamp_version = last_seen.get("lamp_version")
|
||||||
|
if lamp_version:
|
||||||
|
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
|
||||||
|
if ssid and armed_target is None:
|
||||||
|
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
|
||||||
|
# SSID zweimal auf dem Panel.
|
||||||
|
#
|
||||||
|
# Und nur ohne Freigabezeile: aufs Panel passen neben der grossen Zeile
|
||||||
|
# fuenf Beiwerkzeilen (siehe BOTTOM_MARGIN_PX), die Freigabezeile ist die
|
||||||
|
# sechste. Von den beiden gibt die SSID nach - sie ist die Auskunft, mit
|
||||||
|
# der man das Netz FINDET, und in "armed" hat die Lampe es laengst
|
||||||
|
# gefunden (sie wurde ja erkannt, sonst gaebe es kein product_name);
|
||||||
|
# ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da.
|
||||||
|
out.append((f"SSID: {ssid}", ROLE_DETAIL))
|
||||||
|
|
||||||
|
# Der optionale Zeitschalter, in BEIDEN Modi und vor den reinen Zaehlerzeilen: ein
|
||||||
|
# Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner - man glaubt,
|
||||||
|
# die Station laeuft, und sie ist laengst aus. Fehlt er (der Regelfall, denn er
|
||||||
|
# entsteht nur mit "--for"), fehlt die Zeile ersatzlos.
|
||||||
|
#
|
||||||
|
# Er steht damit VOR "Ausgeliefert"/"zuletzt ausgeliefert": im vollsten Zustand
|
||||||
|
# passen fuenf Beiwerkzeilen aufs Panel (siehe BOTTOM_MARGIN_PX), die sechste faellt
|
||||||
|
# unten heraus. Von diesen beiden Auskuenften ist "wann geht die Station aus"
|
||||||
|
# diejenige, nach der man ueberhaupt aufs Panel sieht - der Zaehler der letzten
|
||||||
|
# Auslieferung steht auch noch in "ninlight-station status".
|
||||||
|
autooff_minutes = state.get("autooff_minutes")
|
||||||
|
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
|
||||||
|
if autooff_minutes > 0:
|
||||||
|
out.append(("Auto-off in {} min".format(autooff_minutes), ROLE_DETAIL))
|
||||||
|
else:
|
||||||
|
out.append(("Auto-off pending", ROLE_DETAIL))
|
||||||
|
|
||||||
|
served = state.get("served")
|
||||||
|
served_line = f"Served: {served if isinstance(served, int) else 0}"
|
||||||
|
# Lebenszeichen: ein kleiner Punkt, sobald IRGENDEINE Anfrage vor kurzem hereinkam
|
||||||
|
# (display.py:_recent_request() - auch eine erfolglose zaehlt). Angehaengt an eine
|
||||||
|
# bereits bestehende, immer vorhandene Zeile statt an einer eigenen Stelle im Bild
|
||||||
|
# gezeichnet: er braucht dadurch keine eigene Zeichenlogik in compose() und keinen
|
||||||
|
# eigenen Platz im knappen Zeilenbudget, und er faehrt ueber denselben
|
||||||
|
# Textvergleich (RefreshPlanner.decide() in display.py) mit, der ueber ein
|
||||||
|
# Neuzeichnen entscheidet - kein zusaetzlicher Takt, kein zusaetzliches Wachhalten
|
||||||
|
# des E-Papers, siehe display.py:ACTIVITY_WINDOW_S fuer die volle Begruendung.
|
||||||
|
if state.get("activity"):
|
||||||
|
served_line += " •"
|
||||||
|
out.append((served_line, ROLE_DETAIL))
|
||||||
|
|
||||||
|
recent = state.get("recent")
|
||||||
|
if isinstance(recent, list) and recent:
|
||||||
|
last = recent[-1]
|
||||||
|
if isinstance(last, dict):
|
||||||
|
out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
|
||||||
|
f"{last.get('status') or '?'}", ROLE_DETAIL))
|
||||||
|
|
||||||
|
return out + footer
|
||||||
|
|
||||||
|
|
||||||
|
def _imageset_blocks(state: dict) -> list[tuple[str, str]]:
|
||||||
|
"""Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt
|
||||||
|
(aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test).
|
||||||
|
Fehlt das Verzeichnis des GEWAEHLTEN Satzes, sagt die Zeile das - dann liefert
|
||||||
|
server.py naemlich nichts aus, und ein Panel, das trotzdem nur den Namen zeigt,
|
||||||
|
behauptet einen Betrieb, den es nicht gibt."""
|
||||||
|
name = state.get("imageset")
|
||||||
|
label = IMAGESET_LABELS.get(name)
|
||||||
|
if not label:
|
||||||
|
return []
|
||||||
|
if state.get("imageset_ok") is False:
|
||||||
|
label = IMAGESET_MISSING_LABELS[name]
|
||||||
|
return [(label, ROLE_FOOTER)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
|
||||||
|
"""Der Bedienzustand des Tasterdienstes in Zeilen mit Rolle. Bewusst DUMM: welcher
|
||||||
|
Text bei welcher Geste erscheint, entscheidet allein der Tasterdienst (dort ist es
|
||||||
|
ohne Hardware pruefbar) - hier wird er nur noch den vorhandenen Rollen zugeordnet,
|
||||||
|
damit Kopfbalken, Schriftgroessen, Breitenkuerzung und unterer Rand dieselben bleiben
|
||||||
|
wie im Statusbild.
|
||||||
|
|
||||||
|
Jedes Feld wird auf seinen Typ geprueft: die Datei kommt aus einem anderen Prozess
|
||||||
|
(der aelter, neuer oder halb geschrieben sein kann) - genau wie status.json darf sie
|
||||||
|
die Anzeige nie zum Absturz bringen."""
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
title = ui.get("title")
|
||||||
|
if isinstance(title, str) and title:
|
||||||
|
out.append((title, ROLE_HEADER))
|
||||||
|
hero = ui.get("hero")
|
||||||
|
if isinstance(hero, str) and hero:
|
||||||
|
out.append((hero, ROLE_HERO))
|
||||||
|
details = ui.get("details")
|
||||||
|
if isinstance(details, list):
|
||||||
|
for detail in details:
|
||||||
|
if isinstance(detail, str) and detail:
|
||||||
|
out.append((detail, ROLE_DETAIL))
|
||||||
|
if not out:
|
||||||
|
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
|
||||||
|
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
|
||||||
|
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
|
||||||
|
out.append(("Button menu", ROLE_BIG))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lines(state: dict) -> list[str]:
|
||||||
|
"""Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer
|
||||||
|
display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle
|
||||||
|
ist dafuer unerheblich, sie haengt nur am Modus). Wie blocks() ohne Pillow benutzbar."""
|
||||||
|
return [text for text, _role in blocks(state)]
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str:
|
||||||
|
"""Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
|
||||||
|
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt.
|
||||||
|
|
||||||
|
Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion
|
||||||
|
(und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit
|
||||||
|
`measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine
|
||||||
|
echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht
|
||||||
|
wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende
|
||||||
|
abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener
|
||||||
|
waere eins."""
|
||||||
|
if measure(text) <= max_width_px:
|
||||||
|
return text
|
||||||
|
ellipsis = "…"
|
||||||
|
trimmed = text
|
||||||
|
while trimmed and measure(trimmed + ellipsis) > max_width_px:
|
||||||
|
trimmed = trimmed[:-1]
|
||||||
|
return (trimmed + ellipsis) if trimmed else ellipsis
|
||||||
|
|
||||||
|
|
||||||
|
# Prozess-Cache fuer _load_font(). Dritte Hardware-Abnahme, Befund M4: compose() baute je
|
||||||
|
# Aufruf FUENF Schriften neu (auf der Entwicklungsmaschine gemessen: 25,0 ms je compose(),
|
||||||
|
# davon 21,2 ms Schriftaufrufe - auf einem ARMv6-Kern ohne NEON grob 0,5-1 s, und das auf
|
||||||
|
# demselben Kern, auf dem server.py die Lampen bedient, bei JEDER ausgelieferten Lampe,
|
||||||
|
# weil sich der Zaehler aendert).
|
||||||
|
#
|
||||||
|
# Der Schluessel enthaelt das hereingereichte ImageFont-MODUL und FONT_SEARCH_PATHS, nicht
|
||||||
|
# nur Groesse/Fettung: beide werden in den Tests ersetzt (nachgebautes Modul,
|
||||||
|
# monkeypatch auf eine leere oder andere Suchkette), und ein Cache, der das ignoriert,
|
||||||
|
# wuerde einem Test die Schrift eines anderen unterschieben - genau der Grund, aus dem
|
||||||
|
# Task 7b noch bewusst auf einen Cache verzichtet hatte.
|
||||||
|
_FONT_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_font(image_font, size_px: int, bold: bool = False):
|
||||||
|
"""Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der
|
||||||
|
erste Pfad, der sich oeffnen laesst, gewinnt. Das Ergebnis wird prozessweit
|
||||||
|
zwischengespeichert (siehe _FONT_CACHE): dieselbe Schrift in derselben Groesse wird
|
||||||
|
je Prozess genau einmal von der Platte gelesen und geparst.
|
||||||
|
|
||||||
|
`image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so
|
||||||
|
bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe
|
||||||
|
Muster wie die Messfunktion in _truncate_to_width() - und die Kette laesst sich mit
|
||||||
|
einem nachgebauten Modul pruefen, ohne Schriftdateien anzulegen oder Pillow
|
||||||
|
herunterzustufen.
|
||||||
|
|
||||||
|
Gefangen wird OSError (Datei fehlt/unlesbar/kein gueltiges Schriftformat) UND
|
||||||
|
ImportError: ein ohne FreeType gebautes Pillow laesst truetype() mit genau dieser
|
||||||
|
Ausnahme scheitern - dann darf die Anzeige nicht ausfallen, sondern faellt bis auf
|
||||||
|
load_default() durch.
|
||||||
|
|
||||||
|
load_default() wird BEWUSST OHNE size-Argument gerufen: das gibt es erst ab
|
||||||
|
Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort
|
||||||
|
ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte
|
||||||
|
Rettungsanker ist."""
|
||||||
|
# Das Modul selbst (nicht seine id()) im Schluessel: Module und die nachgebauten
|
||||||
|
# Testmodule sind ueber ihre Identitaet hashbar, und solange der Schluessel das
|
||||||
|
# Objekt festhaelt, kann keine recycelte id() zwei verschiedene Module
|
||||||
|
# zusammenwerfen.
|
||||||
|
key = (image_font, size_px, bold, FONT_SEARCH_PATHS)
|
||||||
|
if key in _FONT_CACHE:
|
||||||
|
return _FONT_CACHE[key]
|
||||||
|
|
||||||
|
for regular_path, bold_path in FONT_SEARCH_PATHS:
|
||||||
|
path = bold_path if bold else regular_path
|
||||||
|
try:
|
||||||
|
font = image_font.truetype(path, size_px)
|
||||||
|
break
|
||||||
|
except (OSError, ImportError):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
font = image_font.load_default()
|
||||||
|
|
||||||
|
_FONT_CACHE[key] = font
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_text(draw, text: str, font, top: int, height: int, fill: int,
|
||||||
|
align: str = "center") -> int:
|
||||||
|
"""Zeichnet `text` senkrecht mittig in das Band [top, top+height) und waagerecht
|
||||||
|
nach `align`: "center" (Panelmitte - bisheriges Verhalten, fuer ROLE_BIG und die
|
||||||
|
Kopfzeile ohne Phase), "left" (linker Rand + TEXT_MARGIN_PX - der Modus, sobald eine
|
||||||
|
Phase daneben steht) oder "right" (rechter Rand - TEXT_MARGIN_PX - die Phase).
|
||||||
|
|
||||||
|
Misst mit textbbox() statt mit der nominellen Schriftgroesse: die tatsaechliche
|
||||||
|
Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich kleiner als die
|
||||||
|
Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf einem 122 px hohen
|
||||||
|
Panel sichtbar zu tief aus.
|
||||||
|
|
||||||
|
Gibt die gezeichnete Tintenbreite zurueck - compose() braucht sie fuer den Modus in
|
||||||
|
der Kopfzeile, um daraus auszurechnen, wie viel Platz rechts noch fuer die Phase
|
||||||
|
uebrig ist."""
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
ink_width = bbox[2] - bbox[0]
|
||||||
|
if align == "left":
|
||||||
|
visual_x = TEXT_MARGIN_PX
|
||||||
|
elif align == "right":
|
||||||
|
visual_x = PANEL_WIDTH_PX - TEXT_MARGIN_PX - ink_width
|
||||||
|
else:
|
||||||
|
visual_x = (PANEL_WIDTH_PX - ink_width) // 2
|
||||||
|
y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1]
|
||||||
|
draw.text((visual_x - bbox[0], y), text, font=font, fill=fill)
|
||||||
|
return ink_width
|
||||||
|
|
||||||
|
|
||||||
|
def compose(state: dict):
|
||||||
|
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus blocks().
|
||||||
|
Importiert Pillow erst hier - siehe Modul-Docstring."""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
# Je Rolle FAUL geladen, nicht alle fuenf im Voraus (Befund M4): "Station aus" und
|
||||||
|
# "Status unbekannt" zeichnen genau eine Rolle (ROLE_BIG) - vier der fuenf Schriften
|
||||||
|
# waeren dort reine Ladezeit. Zusammen mit _FONT_CACHE kostet jede Rolle im Betrieb
|
||||||
|
# hoechstens einmal je Prozess etwas, danach nur noch einen Woerterbuchzugriff.
|
||||||
|
role_fonts = {
|
||||||
|
ROLE_HEADER: (HEADER_FONT_PX, True),
|
||||||
|
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
|
||||||
|
ROLE_HERO: (HERO_FONT_PX, True),
|
||||||
|
ROLE_DETAIL: (DETAIL_FONT_PX, False),
|
||||||
|
ROLE_BIG: (BIG_FONT_PX, True),
|
||||||
|
ROLE_FOOTER: (FOOTER_FONT_PX, True),
|
||||||
|
}
|
||||||
|
loaded: dict = {}
|
||||||
|
|
||||||
|
def font_for(role: str):
|
||||||
|
if role not in loaded:
|
||||||
|
size_px, bold = role_fonts[role]
|
||||||
|
loaded[role] = _load_font(ImageFont, size_px, bold=bold)
|
||||||
|
return loaded[role]
|
||||||
|
|
||||||
|
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
|
||||||
|
|
||||||
|
def fit(raw_text: str, font, width_px: float = max_width_px) -> str:
|
||||||
|
# draw.textlength() wirft ValueError("can't measure length of multiline text"),
|
||||||
|
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
|
||||||
|
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
|
||||||
|
# F5). blocks() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
|
||||||
|
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
|
||||||
|
# Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
|
||||||
|
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
|
||||||
|
text = raw_text.replace("\n", " ")
|
||||||
|
return _truncate_to_width(
|
||||||
|
text, measure=lambda t: draw.textlength(t, font=font),
|
||||||
|
max_width_px=width_px)
|
||||||
|
|
||||||
|
block_list = blocks(state)
|
||||||
|
# Nur wenn eine Phase folgt, ruckt der Modus in der Kopfzeile nach links (siehe
|
||||||
|
# Rollen-Kommentar oben) - Rettung und "aus" kennen keinen ROLE_HEADER_PHASE-Block
|
||||||
|
# und behalten die zentrierte Kopfzeile von vor der zweiten Hardware-Abnahme.
|
||||||
|
has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list)
|
||||||
|
header_mode_right_edge_px = 0.0
|
||||||
|
|
||||||
|
# Die Fusszeile sitzt an einem FESTEN Platz am unteren Rand, nicht im Zeilenfluss -
|
||||||
|
# der Zeilenfluss darueber muss deshalb frueher abbrechen. Ohne Fusszeile bleibt die
|
||||||
|
# Grenze exakt die alte (BOTTOM_MARGIN_PX), damit sich an allen bisherigen Bildern
|
||||||
|
# nichts verschiebt.
|
||||||
|
footer_texts = [text for text, role in block_list if role == ROLE_FOOTER]
|
||||||
|
block_list = [(text, role) for text, role in block_list if role != ROLE_FOOTER]
|
||||||
|
has_footer = bool(footer_texts)
|
||||||
|
footer_top_px = PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX - FOOTER_BAND_HEIGHT_PX
|
||||||
|
flow_limit_px = footer_top_px if has_footer else PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX
|
||||||
|
# ROLE_BIG bekommt das ganze Panel (bisheriges Verhalten) bzw. alles oberhalb der
|
||||||
|
# Fusszeile - der untere Rand ist dort kein Abbruchkriterium, sondern nur eine
|
||||||
|
# Reserve fuer den Zeilenfluss.
|
||||||
|
big_height_px = footer_top_px if has_footer else PANEL_HEIGHT_PX
|
||||||
|
|
||||||
|
# Die Fusszeile wird VOR dem Zeilenfluss gezeichnet und ist aus block_list bereits
|
||||||
|
# herausgenommen - sonst haette sie der "break" der Abbruchbedingung weiter unten
|
||||||
|
# verschluckt, sobald der Zeilenfluss vorher voll ist (also ausgerechnet im vollsten
|
||||||
|
# Zustand, waehrend einer laufenden Migration). Sie sitzt an festem Platz; ihre
|
||||||
|
# Reihenfolge innerhalb von blocks() spielt fuer das Bild keine Rolle.
|
||||||
|
if has_footer:
|
||||||
|
footer_font = font_for(ROLE_FOOTER)
|
||||||
|
# Trennstrich ueber die volle Breite, darunter die Zeile - so ist auf einen
|
||||||
|
# Blick klar, dass das keine weitere Beiwerkzeile ist, sondern ein Zustand,
|
||||||
|
# der unabhaengig vom Rest gilt.
|
||||||
|
draw.line((0, footer_top_px, PANEL_WIDTH_PX - 1, footer_top_px), fill=0)
|
||||||
|
_draw_text(draw, fit(footer_texts[0], footer_font), footer_font,
|
||||||
|
top=footer_top_px + FOOTER_RULE_GAP_PX,
|
||||||
|
height=FOOTER_BAND_HEIGHT_PX - FOOTER_RULE_GAP_PX,
|
||||||
|
fill=0, align="center")
|
||||||
|
|
||||||
|
y = TEXT_MARGIN_PX
|
||||||
|
for raw_text, role in block_list:
|
||||||
|
font = font_for(role)
|
||||||
|
|
||||||
|
if role == ROLE_BIG:
|
||||||
|
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
|
||||||
|
# Panel.
|
||||||
|
text = fit(raw_text, font)
|
||||||
|
# Mittig im Bereich UEBER der Fusszeile (ohne Fusszeile: im ganzen Panel) -
|
||||||
|
# sonst sitzt "Station aus" sichtbar zu tief und beruehrt den Trennstrich.
|
||||||
|
_draw_text(draw, text, font, top=0, height=big_height_px, fill=0,
|
||||||
|
align="center")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == ROLE_HEADER:
|
||||||
|
text = fit(raw_text, font)
|
||||||
|
draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0)
|
||||||
|
align = "left" if has_header_phase else "center"
|
||||||
|
ink_width = _draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
|
||||||
|
fill=255, align=align)
|
||||||
|
if align == "left":
|
||||||
|
header_mode_right_edge_px = TEXT_MARGIN_PX + ink_width
|
||||||
|
y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == ROLE_HEADER_PHASE:
|
||||||
|
# Rechtsbuendig NEBEN dem Modus (der links steht, s.o.) im selben Balken -
|
||||||
|
# gemessen ab dem tatsaechlichen rechten Rand des Modus-Texts, nicht ab
|
||||||
|
# einer angenommenen festen Breite: "MIGRATION" und "RETTUNG" sind
|
||||||
|
# unterschiedlich breit, und nur "MIGRATION" hat je eine Phase daneben.
|
||||||
|
available_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - header_mode_right_edge_px
|
||||||
|
- HEADER_PHASE_GAP_PX)
|
||||||
|
if available_px >= HEADER_PHASE_MIN_WIDTH_PX:
|
||||||
|
text = fit(raw_text, font, width_px=available_px)
|
||||||
|
_draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
|
||||||
|
fill=255, align="right")
|
||||||
|
# sonst: Phase weglassen (dritte Option aus der Abnahme) - bei so wenig
|
||||||
|
# Platz waere selbst ein gekuerztes "…" keine Auskunft mehr wert, und ein
|
||||||
|
# Ueberlappen mit dem Modus ist nicht erlaubt.
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = fit(raw_text, font)
|
||||||
|
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
|
||||||
|
if y + line_height > flow_limit_px:
|
||||||
|
# Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX
|
||||||
|
# reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die
|
||||||
|
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
|
||||||
|
break
|
||||||
|
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
|
||||||
|
y += line_height
|
||||||
|
|
||||||
|
return image
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ninlight Taster (Bedienung der Station ueber GPIO23)
|
||||||
|
# Wie beim Anzeigedienst nur After=, NICHT Requires=/BindsTo=/Wants=: ein fehlender oder
|
||||||
|
# abgestuerzter Taster darf server.py niemals mitreissen, und server.py muss starten
|
||||||
|
# koennen, auch wenn dieser Dienst fehlt oder deaktiviert ist. KEIN
|
||||||
|
# Wants=ninlight-station.service - das wuerde die absichtlich deaktivierte Station bei
|
||||||
|
# jedem Boot mit hochziehen (Fund der Hardware-Abnahme, ausfuehrlich in
|
||||||
|
# ninlight-display.service begruendet).
|
||||||
|
After=ninlight-station.service
|
||||||
|
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort werden sie mit
|
||||||
|
# "Unknown key name" ignoriert, und die Deckelung unten greift dann nie).
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
|
||||||
|
Environment=NINLIGHT_UI_FILE=/run/ninlight-button/ui.json
|
||||||
|
# "-"-Praefix VOR dem Dateipfad (nicht vor dem Schluesselnamen - so herum ist es ein
|
||||||
|
# ungueltiger Schluessel und systemd verwirft die Zeile ganz): ohne die
|
||||||
|
# Konfigurationsdatei startet der Taster trotzdem.
|
||||||
|
EnvironmentFile=-/etc/ninlight-station.conf
|
||||||
|
# Eigenes RuntimeDirectory, aus zwei Gruenden:
|
||||||
|
#
|
||||||
|
# 1. Hier liegt ui.json. Es darf NICHT in /run/ninlight-station liegen (dem
|
||||||
|
# RuntimeDirectory der Hauptunit): systemd raeumt das beim regulaeren Stop von
|
||||||
|
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue
|
||||||
|
# ("Rettung starten"/"Migration starten") am dringendsten gebraucht wird. So
|
||||||
|
# verschwindet ui.json genau dann, wenn dieser Dienst endet, und ein
|
||||||
|
# stehengebliebenes Menue kann ihn nicht ueberdauern.
|
||||||
|
# 2. lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start "$HOME/.lgd-nfy0" an.
|
||||||
|
# Unter systemd ist HOME per Vorgabe LEER und das Arbeitsverzeichnis "/" - der Pfad
|
||||||
|
# wird "//.lgd-nfy0" und scheitert. Am Geraet fuer den Anzeigedienst nachgemessen,
|
||||||
|
# siehe dort; derselbe Stolperstein trifft jeden gpiozero-Dienst.
|
||||||
|
RuntimeDirectory=ninlight-button
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
|
WorkingDirectory=/run/ninlight-button
|
||||||
|
Environment=HOME=/run/ninlight-button
|
||||||
|
ExecStart=/usr/bin/python3 /opt/ninlight-station/button/button.py
|
||||||
|
|
||||||
|
# button.py faengt einen fehlenden Taster (keine Bibliothek, kein Pin, Pin belegt) als
|
||||||
|
# ButtonUnavailable ab, protokolliert eine Zeile und beendet sich mit sys.exit(0) - das
|
||||||
|
# ist nie ein "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. on-failure
|
||||||
|
# (nicht always) deckelt damit genau das Richtige: echte Abstuerze werden neu versucht,
|
||||||
|
# ein dauerhaft fehlender Taster nicht.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# BEWUSST KEIN NoNewPrivileges=/ProtectSystem=strict/ProtectHome= hier - anders als beim
|
||||||
|
# Anzeigedienst, und das ist kein Versehen:
|
||||||
|
#
|
||||||
|
# Dieser Dienst ruft /usr/local/bin/ninlight-station auf, und dessen einziger Zweck ist
|
||||||
|
# es, NetworkManager-Profile umzuschalten, Units zu starten/stoppen und nach
|
||||||
|
# /run/ninlight-station zu schreiben - alles ueber "sudo". Kindprozesse erben den
|
||||||
|
# Namensraum und das no_new_privs-Flag dieser Unit: mit ProtectSystem=strict waere
|
||||||
|
# /run/ninlight-station fuer das CLI schreibgeschuetzt (mode/control.json liessen sich
|
||||||
|
# nicht mehr schreiben), und no_new_privs laesst kein setuid-Binary mehr zu (sudo). Eine
|
||||||
|
# Absicherung, die genau die Aktionen unmoeglich macht, wegen derer es den Dienst gibt,
|
||||||
|
# ist keine. Die Angriffsflaeche ist stattdessen eng gehalten: EIN Eingangspin, kein
|
||||||
|
# Netzwerk, keine Eingabe ausser der Taste selbst, und ausgefuehrt wird ausschliesslich
|
||||||
|
# die feste Liste von CLI-Unterbefehlen aus button.py.
|
||||||
|
MemoryMax=64M
|
||||||
|
|
||||||
|
# Laeuft als root (wie Haupt- und Anzeigedienst) fuer GPIO23 und fuer das CLI - kein
|
||||||
|
# SupplementaryGroups=gpio: auf reinem Debian gibt es die Gruppe oft gar nicht, und eine
|
||||||
|
# fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP), ausgerechnet in
|
||||||
|
# einer Unit, die nie stoeren darf.
|
||||||
|
|
||||||
|
# KEIN Zugriff auf SPI und keine Zeichenleitung: der Tasterdienst fasst den Panel-Bus
|
||||||
|
# nicht an (zwei Prozesse darauf ergeben "lgpio.error: 'GPIO busy'"). Er schreibt seinen
|
||||||
|
# Bedienzustand nach /run/ninlight-button/ui.json, der Anzeigedienst liest ihn und
|
||||||
|
# zeichnet.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation)
|
||||||
|
# Nur eine Anzeige fuer den Hauptdienst - After=, NICHT Requires=/BindsTo=/Wants=: ein
|
||||||
|
# fehlendes oder abgestuerztes Panel darf server.py niemals mitreissen, und server.py
|
||||||
|
# darf umgekehrt starten, auch wenn dieser Dienst fehlt oder deaktiviert ist. Siehe
|
||||||
|
# display.py's Modul-Docstring: die Anzeige ist nirgends Voraussetzung fuer die
|
||||||
|
# Migration.
|
||||||
|
#
|
||||||
|
# KEIN Wants=ninlight-station.service (stand hier zuerst, Fund der Hardware-Abnahme auf
|
||||||
|
# Pi 2): Wants= ist keine reine Reihenfolgeangabe, sondern AKTIVIERT die genannte Unit
|
||||||
|
# beim Start dieser Unit, unabhaengig davon, ob sie enabled ist - "ninlight-station" ist
|
||||||
|
# absichtlich disabled (siehe deren Kommentar: die Station ist per Vorgabe aus), wurde
|
||||||
|
# aber trotzdem bei jedem Boot mitgestartet, weil dieser (enabled) Anzeigedienst sie ueber
|
||||||
|
# Wants= mitzog. Ohne Zugangspunkt kann server.py 192.168.8.1 nicht binden, stirbt, wird
|
||||||
|
# von Restart=always dort mehrfach neu versucht und bleibt auf "failed" haengen - das
|
||||||
|
# flutet das Journal bei jedem Boot und laesst "ninlight-station status" faelschlich
|
||||||
|
# einen Fehler ("failed" statt "inactive") vermuten, wo absichtlich nichts laeuft.
|
||||||
|
# After= allein ordnet nur die Startreihenfolge, FALLS beide Units ohnehin gestartet
|
||||||
|
# werden (z.B. wenn jemand die Station von Hand einschaltet) - und genau das war hier
|
||||||
|
# gemeint, nicht "zieh die Station mit hoch". Nicht aus falscher Vorsicht wieder
|
||||||
|
# einbauen: der Nebeneffekt ist genau der oben beschriebene, kein Sicherheitsnetz.
|
||||||
|
After=ninlight-station.service
|
||||||
|
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort standen sie zuerst -
|
||||||
|
# Fixrunde 1, I1: systemd ignoriert sie dort mit "Unknown key name", die Vorgabe (5
|
||||||
|
# Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten
|
||||||
|
# eigentlich verhindern). ninlight-station.service (dieselbe Sektion) macht es richtig.
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Reihenfolge wichtig: systemd wendet Environment=/EnvironmentFile= in der Reihenfolge
|
||||||
|
# an, in der sie hier stehen, und eine spaetere Zuweisung ueberschreibt eine gleichnamige
|
||||||
|
# fruehere. EPD_REVISION=V3 hier ist nur der Vorgabewert, falls die Konfigurationsdatei
|
||||||
|
# (unten, per EnvironmentFile=) den Schluessel (noch) nicht enthaelt - z.B. auf einem Pi,
|
||||||
|
# der vor Task 6 aufgesetzt wurde. Steht er dort, gewinnt er.
|
||||||
|
Environment=EPD_REVISION=V3
|
||||||
|
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
|
||||||
|
Environment=NINLIGHT_STATION_CONF=/etc/ninlight-station.conf
|
||||||
|
# "-"-Praefix VOR dem Dateipfad, nicht vor dem Direktivennamen (Fixrunde 3, F1 - das war
|
||||||
|
# in Fixrunde 2 falsch herum: "-EnvironmentFile=..." ist kein gueltiger Schluesselname,
|
||||||
|
# systemd verwirft die Zeile komplett mit "Unknown key name", und die Konfigurationsdatei
|
||||||
|
# wurde dadurch ueberhaupt nicht mehr gelesen - Environment=EPD_REVISION=V3 oben haette
|
||||||
|
# IMMER gewonnen, unabhaengig davon, was in /etc/ninlight-station.conf steht). Mit dem
|
||||||
|
# Praefix am richtigen Platz gilt: ohne die Konfigurationsdatei startet die Anzeige
|
||||||
|
# trotzdem (mit den Environment=-Vorgaben oben) statt mit einem harten Fehler - "die
|
||||||
|
# Anzeige darf nirgends Voraussetzung sein" gilt auch fuer ihre eigene Konfiguration.
|
||||||
|
EnvironmentFile=-/etc/ninlight-station.conf
|
||||||
|
# RuntimeDirectory=/WorkingDirectory=/Environment=HOME= zusammen beheben einen Fund aus
|
||||||
|
# der Hardware-Abnahme: lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start eine
|
||||||
|
# Benachrichtigungs-Pipe unter "$HOME/.lgd-nfy0" an. Unter systemd ist HOME per Vorgabe
|
||||||
|
# LEER und das Arbeitsverzeichnis "/" - der Pfad wird "//.lgd-nfy0", und ProtectSystem=
|
||||||
|
# strict (unten) macht "/" schreibgeschuetzt. lgpio scheitert dann mit "Can't set
|
||||||
|
# permissions", gpiozero faellt ueber rpigpio/pigpio bis zur experimentellen NativeFactory
|
||||||
|
# durch, und DIE scheitert ihrerseits mit [Errno 22] Invalid argument - display.py faengt
|
||||||
|
# das korrekt als DisplayUnavailable ab und beendet sich mit 0 (siehe Kommentar zu
|
||||||
|
# Restart=on-failure unten), aber das Panel bleibt schwarz und das Journal sieht aus wie
|
||||||
|
# "kein Panel angeschlossen", obwohl die Hardware in Ordnung ist. Am Geraet nachgemessen
|
||||||
|
# (systemd-run mit denselben zwei Property=, gegen ein Shell-echo von $HOME und pwd).
|
||||||
|
#
|
||||||
|
# RuntimeDirectory=ninlight-display ist ABSICHTLICH ein eigenes Verzeichnis, nicht das
|
||||||
|
# RuntimeDirectory=ninlight-station der Hauptunit: dieser Dienst liest von dort nur
|
||||||
|
# (status.json/control.json ueber NINLIGHT_RUN_DIR, s.o.), er soll dort nichts anlegen
|
||||||
|
# koennen. Schriebe die Anzeige in fremden Laufzeitzustand, waere das eine unnoetige
|
||||||
|
# Kopplung an dessen Lebenszyklus (siehe die Begruendung zu ReadOnlyPaths= weiter unten:
|
||||||
|
# server.py's RuntimeDirectory wird beim Stop/Neustart geleert). systemd legt
|
||||||
|
# /run/ninlight-display beim Start dieser Unit selbst an (Vorgabe 0755, vom User dieses
|
||||||
|
# Dienstes - hier root, s.u. - beschreibbar) und raeumt es beim Stop wieder auf.
|
||||||
|
RuntimeDirectory=ninlight-display
|
||||||
|
WorkingDirectory=/run/ninlight-display
|
||||||
|
Environment=HOME=/run/ninlight-display
|
||||||
|
ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
|
||||||
|
|
||||||
|
# display.py faengt ein fehlendes/abgestuerztes Panel als DisplayUnavailable ab,
|
||||||
|
# protokolliert eine Zeile und beendet sich mit sys.exit(0) - das ist also nie ein
|
||||||
|
# "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. Restart=on-failure (nicht
|
||||||
|
# always) greift nur fuer echte Abstuerze (z.B. ein Python-Traceback aus einem
|
||||||
|
# Programmierfehler); ohne den Deckel wuerde ein dauerhaft fehlendes Panel sonst NICHT
|
||||||
|
# staendig neu versucht (gut so), waehrend ein echter Bug endlos neu starten wuerde und
|
||||||
|
# so das Journal fluten koennte.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
# KEIN ReadOnlyPaths=/run/ninlight-station hier (stand hier zuerst, Fixrunde 1, C3):
|
||||||
|
# server.py's Unit traegt RuntimeDirectory=ninlight-station, und "ninlight-station off"
|
||||||
|
# stoppt den Hauptdienst regulaer - dabei raeumt systemd dessen RuntimeDirectory auf,
|
||||||
|
# sofern nicht gerade ein Neustart lief (RuntimeDirectoryPreserve=restart deckt nur den
|
||||||
|
# Neustart-Fall ab, nicht den regulaeren Stop). Ein nicht mit "-" prefigierter Pfad in
|
||||||
|
# ReadOnlyPaths=/ReadWritePaths=/BindPaths=, der beim Start fehlt, laesst systemd die
|
||||||
|
# Namensraum-Einrichtung mit 226/NAMESPACE abbrechen - ausgerechnet dann, wenn die
|
||||||
|
# Anzeige "Station aus" zeigen soll, waere sie selbst nicht mehr startbar gewesen. Die
|
||||||
|
# Zeile war obendrein wirkungslos: ProtectSystem=strict macht "/" (und damit "/run",
|
||||||
|
# soweit nicht per ReadWritePaths= explizit freigegeben) bereits schreibgeschuetzt.
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
||||||
|
MemoryMax=64M
|
||||||
|
|
||||||
|
# Laeuft als root (wie der Hauptdienst) fuer /dev/spidev0.0 und die GPIO-Zeichenleitung -
|
||||||
|
# kein SupplementaryGroups=spi gpio hier (stand hier zuerst, Fixrunde 1, I2): auf reinem
|
||||||
|
# Debian (im Unterschied zu Raspberry Pi OS) gibt es diese Gruppen oft gar nicht, und
|
||||||
|
# eine fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP) - ausgerechnet
|
||||||
|
# in der Unit, die nie stoeren soll, wenn etwas an der Anzeige fehlt. Als root sind die
|
||||||
|
# Gruppen fuer den Geraetezugriff ohnehin nicht noetig.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ninlight-Station: "Ausgeschaltet"-Bild aufs E-Paper
|
||||||
|
Documentation=file:/opt/ninlight-station/README.md
|
||||||
|
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
|
||||||
|
# stromlos. Faehrt der Pi herunter, wird der Anzeigedienst mit abgeraeumt - ohne diese
|
||||||
|
# Unit behauptet das Panel danach dauerhaft den Zustand von vorher. Bei der Abnahme stand
|
||||||
|
# dort die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als warte er
|
||||||
|
# noch auf eine Eingabe.
|
||||||
|
#
|
||||||
|
# Sie haengt am SYSTEM, nicht am Tasterdienst: so greift sie bei jedem Herunterfahren -
|
||||||
|
# ueber das Tastermenue (systemctl poweroff), ueber "poweroff" per SSH, ueber jeden
|
||||||
|
# anderen Weg nach poweroff.target. Am Taster allein haetten wir die Anzeige nur auf
|
||||||
|
# einem von drei Wegen.
|
||||||
|
#
|
||||||
|
# Die Gegenstueck-Unit ninlight-reboot.service macht dasselbe fuer den Neustart, mit
|
||||||
|
# ANDEREM Text: beim Neustart darf man den Stecker gerade nicht ziehen.
|
||||||
|
DefaultDependencies=no
|
||||||
|
# Erst zeichnen, wenn der Anzeigedienst die GPIO-/SPI-Leitungen losgelassen hat: zwei
|
||||||
|
# Prozesse darauf enden in "lgpio.error: 'GPIO busy'" (in diesem Projekt schon zweimal
|
||||||
|
# passiert). "After=" auf eine Unit, die gerade gestoppt wird, ordnet deren Stop VOR den
|
||||||
|
# Start dieser hier - genau die gesuchte Reihenfolge. Das CLI wartet zusaetzlich, bis
|
||||||
|
# "systemctl is-active ninlight-display" nicht mehr anschlaegt (display_final()).
|
||||||
|
After=ninlight-display.service ninlight-button.service
|
||||||
|
# ... und noch BEVOR das System die Leitungen verliert: nach umount.target gibt es weder
|
||||||
|
# /opt/ninlight-station noch die Schriftdateien, aus denen das Bild entsteht.
|
||||||
|
Before=umount.target shutdown.target final.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Das Bild entsteht ueber das vorhandene CLI, nicht durch nachgebaute Logik: nur dort
|
||||||
|
# stehen EPD_REVISION aus /etc/ninlight-station.conf, die Uebergabe der GPIO-Leitungen
|
||||||
|
# und die Zusage "ein Fehler an der Anzeige haelt das Herunterfahren nie auf" (das CLI
|
||||||
|
# beendet sich hier immer mit 0).
|
||||||
|
ExecStart=/usr/local/bin/ninlight-station display final poweroff
|
||||||
|
# Ein Bildaufbau auf diesem Panel dauert Sekunden, nicht Minuten; dazu die Wartezeit auf
|
||||||
|
# die Freigabe der Leitungen. Der Deckel ist da, damit ein haengendes Panel das
|
||||||
|
# Herunterfahren nicht dauerhaft aufhaelt - systemds Vorgabe von 90 s waere hier eine
|
||||||
|
# sehr lange Zeit vor einem dunklen Geraet.
|
||||||
|
TimeoutStartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=poweroff.target
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ninlight-Station: "Neustart"-Bild aufs E-Paper
|
||||||
|
Documentation=file:/opt/ninlight-station/README.md
|
||||||
|
# Das Gegenstueck zu ninlight-poweroff.service, mit einem ANDEREN Text - und das ist hier
|
||||||
|
# kein Schoenheitsfehler, sondern der Zweck der zweiten Unit:
|
||||||
|
#
|
||||||
|
# Herunterfahren -> "AUSGESCHALTET / Strom kann getrennt werden"
|
||||||
|
# Neustart -> "NEUSTART / Strom NICHT trennen!"
|
||||||
|
#
|
||||||
|
# Ein Raspberry Pi Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder
|
||||||
|
# zeichnet. Stuende dort waehrenddessen "Strom kann getrennt werden", waere das eine
|
||||||
|
# Aufforderung zu genau dem Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
|
||||||
|
#
|
||||||
|
# Zwei Units statt einer mit Fallunterscheidung: systemd sagt einem ExecStop/ExecStart
|
||||||
|
# nicht, wohin die Reise geht - man muesste "systemctl list-jobs" nach "reboot.target"
|
||||||
|
# durchsuchen. Ueber WantedBy= ist es dagegen der Abschalt-Zieleinheit selbst entnommen
|
||||||
|
# und damit nicht zu verwechseln.
|
||||||
|
DefaultDependencies=no
|
||||||
|
# Reihenfolge und Begruendung wie in ninlight-poweroff.service (GPIO-Uebergabe, und noch
|
||||||
|
# vor umount.target zeichnen).
|
||||||
|
After=ninlight-display.service ninlight-button.service
|
||||||
|
Before=umount.target shutdown.target final.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/ninlight-station display final reboot
|
||||||
|
TimeoutStartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=reboot.target
|
||||||
Executable
+1265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ninlight recovery station (HTTP image server)
|
||||||
|
# Ohne den Zugangspunkt hat der Server beim Start keine Adresse zum Binden.
|
||||||
|
# Bewusst kein BindsTo=NetworkManager.service: ein dadurch ausgeloester Stop
|
||||||
|
# gilt fuer systemd als sauber und wuerde von KEINER Restart=-Politik erneut
|
||||||
|
# gestartet - ein NetworkManager-Neustart wuerde den Server dann still und
|
||||||
|
# dauerhaft mitreissen. Stattdessen nur Start-Reihenfolge vorgeben und den
|
||||||
|
# Prozess bei jedem Absturz (z.B. Bind-Fehler nach einem NM-Neustart) per
|
||||||
|
# Restart=always in kurzen Abstaenden neu versuchen lassen.
|
||||||
|
After=network.target NetworkManager.service
|
||||||
|
# Steht der Zugangspunkt nicht, kann server.py 192.168.8.1 nicht binden und
|
||||||
|
# stirbt sofort; Restart=always versucht es alle RestartSec=2 erneut. Damit
|
||||||
|
# das nicht endlos das Journal flutet, sondern nach kurzer Zeit ehrlich
|
||||||
|
# aufgibt (und ninlight-station dann mit einer klaren Fehlermeldung reagieren
|
||||||
|
# kann statt in einer stillen Neustartschleife zu haengen), hier explizit
|
||||||
|
# gedeckelt statt sich auf systemds Vorgabewerte zu verlassen.
|
||||||
|
StartLimitIntervalSec=30
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=/etc/ninlight-station.conf
|
||||||
|
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode --imageset-file /var/lib/ninlight-station/imageset
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
# Laeuft als root: Port 80 und - fuer 4c - die DHCP-Leases in
|
||||||
|
# /var/lib/NetworkManager (Verzeichnis 0700 root:root) sind sonst nicht erreichbar.
|
||||||
|
# Gegengewicht:
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
RuntimeDirectory=ninlight-station
|
||||||
|
# Ohne dies leert systemd /run/ninlight-station bei JEDEM Stop und Neustart, nicht
|
||||||
|
# nur beim allerersten Start - und ninlight-station (das CLI) schreibt mode/
|
||||||
|
# control.json immer VOR dem "systemctl restart", mit dem migrate/recovery on den
|
||||||
|
# Dienst (neu) starten. Ohne RuntimeDirectoryPreserve=restart wischt genau dieser
|
||||||
|
# Neustart die beiden gerade geschriebenen Dateien wieder weg, bevor der neue
|
||||||
|
# Serverprozess sie lesen kann - der Dienst faellt dann still auf den
|
||||||
|
# Recovery-Default zurueck (server.py resolve_mode()/read_control()), und ein
|
||||||
|
# ausdrueckliches "migrate on --listen" wird zu "Rettungsmodus" bzw. "auto"
|
||||||
|
# (Fixrunde 1, C1). "restart" (nicht "yes") reicht: das Verzeichnis muss nur einen
|
||||||
|
# Neustart ueberleben, nicht dauerhaft bestehen bleiben, wenn der Dienst regulaer
|
||||||
|
# gestoppt wird.
|
||||||
|
RuntimeDirectoryPreserve=restart
|
||||||
|
ReadWritePaths=/run/ninlight-station
|
||||||
|
RestrictAddressFamilies=AF_INET AF_UNIX
|
||||||
|
MemoryMax=128M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Produkt-IDs der alten Klyqa-Firmware und ihre Zuordnung zu ninlight-Zielen.
|
||||||
|
|
||||||
|
Vollstaendig uebernommen aus qcl_product_info.c der alten Firmware
|
||||||
|
(~/workspace/qcx-w600-firmware).
|
||||||
|
|
||||||
|
Die Variante (dev/prod) entscheidet ueber den Signierschluessel: Lampen mit
|
||||||
|
Entwicklungs-Bootloader akzeptieren nur den Entwicklungs-, Produktionslampen nur den
|
||||||
|
Produktionsschluessel. Sie ist aber KEINE Eigenschaft der Produkt-ID, sondern eine
|
||||||
|
Eigenschaft der ANFRAGE - das ist der Befund vom 2026-09-15 an echter Hardware:
|
||||||
|
|
||||||
|
/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img
|
||||||
|
|
||||||
|
Dieselbe Entwicklungs-G95, aber das "-dev" steht nur in der VERSION, nicht in der
|
||||||
|
Produkt-ID. Die Station hielt sie damit fuer eine Produktionslampe, lieferte das
|
||||||
|
produktionssignierte Image aus, und die Lampe lehnte es ab. Die Machbarkeitsprobe im
|
||||||
|
September hatte eine andere Entwicklungs-G95 mit "-dev" an BEIDEN Stellen gesehen, eine
|
||||||
|
Produktionslampe an keiner - alle drei Formen kommen also vor.
|
||||||
|
|
||||||
|
Regel seither: "-dev" an einer der beiden Stellen (Produkt-ID oder Version) bedeutet
|
||||||
|
Entwicklungsgeraet. PRODUCTS bildet nur noch auf Geraetetyp und Anzeigename ab, beide
|
||||||
|
Schreibweisen der Produkt-ID fuehren auf denselben Eintrag; die Variante ermittelt
|
||||||
|
parse_request() aus beiden Teilen und meldet in .variant_source mit, WORAUS sie stammt
|
||||||
|
("both", "product_id", "version", "none"), damit eine Fehlklassifikation vor der Freigabe
|
||||||
|
am Panel und in status.json sichtbar ist statt erst an der Lampe.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
Product = namedtuple("Product", "product_id target name")
|
||||||
|
ParsedRequest = namedtuple(
|
||||||
|
"ParsedRequest", "product_id lamp_version product variant variant_source")
|
||||||
|
|
||||||
|
# (Produkt-ID ohne "@", Ziel, Anzeigename) - die "-dev"-Schreibweise wird darunter
|
||||||
|
# als zweiter Schluessel auf denselben Eintrag erzeugt.
|
||||||
|
_BASE = [
|
||||||
|
("klyqa.lighting.rgb-cw-ww.e27", "e27_rgb", "Klyqa E27 RGB CW/WW"),
|
||||||
|
("klyqa.lighting.rgb-cw-ww.e14", "e14_rgb", "Klyqa E14 RGB CW/WW"),
|
||||||
|
("klyqa.lighting.rgb-cw-ww.gu10", "gu10_rgb", "Klyqa GU10 RGB CW/WW"),
|
||||||
|
("klyqa.lighting.cw-ww.e27", "e27_cw", "Klyqa E27 CW/WW"),
|
||||||
|
("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"),
|
||||||
|
("klyqa.lighting.cw-ww.gu10", "gu10_cw", "Klyqa GU10 CW/WW"),
|
||||||
|
("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage CW/WW"),
|
||||||
|
("qcx.montana.black.ledshield.rgb", "ledshield_rgb", "Montana Black RGB LED Shield"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]")
|
||||||
|
# Die Produkt-ID darf kein "_" enthalten - daran trennen wir Version und ID.
|
||||||
|
_PATH = re.compile(r"^/qcx_firmware_([^/_]+)_([^/_]+)_ota_light\.img$")
|
||||||
|
# "-dev" in der Version, aber nicht als Anfang eines laengeren Wortes ("-devel", "-dev2"):
|
||||||
|
# erlaubt sind danach nur Ende oder ein Trennzeichen wie "." (z.B. "V1.1-dev.3").
|
||||||
|
_DEV_IN_VERSION = re.compile(r"-dev(?![0-9A-Za-z])")
|
||||||
|
|
||||||
|
|
||||||
|
def websafe(product_id):
|
||||||
|
"""Ersetzt jedes Zeichen ausserhalb [0-9A-Za-z+-_.] durch '-', wie BuildOTAURL()
|
||||||
|
in qc_ota.c der alten Firmware. Praktisch betrifft das nur das fuehrende '@'."""
|
||||||
|
return _UNSAFE.sub("-", product_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _build():
|
||||||
|
t = {}
|
||||||
|
for pid, target, name in _BASE:
|
||||||
|
base_key = websafe("@" + pid)
|
||||||
|
product = Product(base_key, target, name)
|
||||||
|
# Beide Schreibweisen zeigen auf DENSELBEN Eintrag - "-dev" an der Produkt-ID
|
||||||
|
# sagt seit dem Hardwarebefund nichts mehr ueber das Ziel, nur noch (mit) etwas
|
||||||
|
# ueber die Variante, und die ermittelt parse_request().
|
||||||
|
t[base_key] = product
|
||||||
|
t[websafe("@" + pid + "-dev")] = product
|
||||||
|
# Das EVK hat nur eine Produktions-ID. Im Dev-Build meldet es sich laut
|
||||||
|
# qcl_product_info.c als "@klyqa.lighting.rgb-cw-ww.e27-dev" - die EVK-Zeile ist dort
|
||||||
|
# auskommentiert und durch die E27 ersetzt. Ein dev-signiertes EVK ist damit von einer
|
||||||
|
# dev-signierten E27 RGB NICHT unterscheidbar und bekommt deren Image. Das EVK wird
|
||||||
|
# ohnehin seriell geflasht; die Zeile steht nur der Vollstaendigkeit halber hier.
|
||||||
|
# Meldet es eine "-dev"-Version, gilt auch hier die Regel: dann wird evk_dev.img
|
||||||
|
# gesucht (und mangels Datei sauber mit 404 abgelehnt), nicht das falsch signierte.
|
||||||
|
key = websafe("@klyqa.lighting.w600.evk1.2")
|
||||||
|
t[key] = Product(key, "evk", "W600 EVK v1.2")
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCTS = _build()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_variant(product_id, lamp_version):
|
||||||
|
"""(variant, variant_source) aus Produkt-ID UND Version.
|
||||||
|
|
||||||
|
"-dev" an einer der beiden Stellen bedeutet Entwicklungsgeraet; variant_source sagt,
|
||||||
|
an welcher es stand: "both", "product_id", "version" oder - bei prod - "none"."""
|
||||||
|
in_id = product_id.endswith("-dev")
|
||||||
|
in_version = bool(_DEV_IN_VERSION.search(lamp_version))
|
||||||
|
if in_id and in_version:
|
||||||
|
return "dev", "both"
|
||||||
|
if in_id:
|
||||||
|
return "dev", "product_id"
|
||||||
|
if in_version:
|
||||||
|
return "dev", "version"
|
||||||
|
return "prod", "none"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_request(path):
|
||||||
|
"""Zerlegt einen angefragten Pfad. Gibt None zurueck, wenn er nicht dem Muster der
|
||||||
|
alten Firmware entspricht, sonst ParsedRequest - dessen .product ist None, wenn die
|
||||||
|
Produkt-ID unbekannt ist. Unbekannt heisst 404, nicht raten. .variant/.variant_source
|
||||||
|
stehen auch bei unbekannter Produkt-ID, sie haengen nicht an der Tabelle."""
|
||||||
|
m = _PATH.match(path)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
pid, version = m.group(1), m.group(2)
|
||||||
|
if pid != websafe(pid) or "/" in pid or ".." in pid.split("."):
|
||||||
|
return None
|
||||||
|
variant, source = classify_variant(pid, version)
|
||||||
|
return ParsedRequest(pid, version, PRODUCTS.get(pid), variant, source)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[project]
|
||||||
|
name = "ninlight-station"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "recovery station HTTP server for ninlight lamps"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
Executable
+767
@@ -0,0 +1,767 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ninlight recovery/migration station.
|
||||||
|
|
||||||
|
Serves firmware images to lamps that look for a rescue station while booting (mode
|
||||||
|
"recovery": ninlight_<target>_ota.img) or to old Klyqa lamps looking for their original
|
||||||
|
update server (mode "migration": qcx_firmware_<product-id>_<version>_ota_light.img).
|
||||||
|
Deliberately minimal: only files matching one of these two exact patterns ever leave the
|
||||||
|
machine, and only the pattern for the active mode is served.
|
||||||
|
|
||||||
|
Standard library only - the Pi gets no extra packages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# products.py lives next to this file. sys.path[0] already covers that when server.py is
|
||||||
|
# run the normal way (as a script or via `python3 server.py ...`), but a test that loads
|
||||||
|
# this module through importlib.util.spec_from_file_location (see test_server.py's
|
||||||
|
# test_log_error_is_not_silenced_by_log_message) does not get that for free.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import products
|
||||||
|
|
||||||
|
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw",
|
||||||
|
"ledshield_rgb")
|
||||||
|
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
||||||
|
TRAILER_SIZE = 512
|
||||||
|
TRAILER_MAGIC = b"NINL"
|
||||||
|
VERSION_OFF = 21
|
||||||
|
VERSION_LEN = 16
|
||||||
|
RECENT_MAX = 8
|
||||||
|
CHUNK = 32 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def trailer_version(path: Path) -> str:
|
||||||
|
"""Firmware version from the image trailer, or '?' if the file carries none."""
|
||||||
|
try:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
if path.stat().st_size < TRAILER_SIZE:
|
||||||
|
return "?"
|
||||||
|
f.seek(-TRAILER_SIZE, os.SEEK_END)
|
||||||
|
tail = f.read(TRAILER_SIZE)
|
||||||
|
except OSError:
|
||||||
|
return "?"
|
||||||
|
if tail[0:4] != TRAILER_MAGIC:
|
||||||
|
return "?"
|
||||||
|
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bildsatz: mit oder ohne einkompilierte WLAN-Zugangsdaten -----------------------
|
||||||
|
#
|
||||||
|
# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware (config.local.cmake). Es gibt
|
||||||
|
# deshalb zwei Abzuege je Ziel, und beide liegen auf dem Pi:
|
||||||
|
#
|
||||||
|
# "ohne-wlan" -> images/migrate-nocreds/ aus tools/build_nocreds.sh: die migrierte
|
||||||
|
# Lampe startet im eigenen Onboarding-Zugangspunkt (ninlight-<mac4>)
|
||||||
|
# und wartet, bis ihr jemand ein WLAN nennt.
|
||||||
|
# "mit-wlan" -> images/migrate-wlan/ aus build/: die Lampe haengt sofort im WLAN
|
||||||
|
# des Bedieners.
|
||||||
|
#
|
||||||
|
# Welcher ausgeliefert wird, entscheidet ein UMSCHALTER (ninlight-station bildsatz bzw.
|
||||||
|
# der Menueeintrag am Taster), nicht eine Abfrage je Migration: sein Zustand bleibt, bis
|
||||||
|
# ihn jemand aendert, und er steht dauerhaft auf dem Panel.
|
||||||
|
#
|
||||||
|
# Vorgabe ist "ohne-wlan", und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar,
|
||||||
|
# unbekannter Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in
|
||||||
|
# Funkreichweite migriert, auch fremde. Ein Abzug OHNE Zugangsdaten ist dann ein
|
||||||
|
# aergerlicher, aber harmloser Fehlgriff - die Lampe steht im Onboarding-AP und wartet.
|
||||||
|
# Ein Abzug MIT Zugangsdaten traegt das WLAN-Passwort des Bedieners unwiderruflich in
|
||||||
|
# ein fremdes Geraet. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||||
|
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||||
|
DEFAULT_IMAGE_SET = "ohne-wlan"
|
||||||
|
|
||||||
|
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (nicht nur die Migration): der
|
||||||
|
# Eigentuemer hat das ausdruecklich verlangt, denn bisher bekam eine Rettung IMMER ein
|
||||||
|
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ stammten aus build/). Die
|
||||||
|
# Rettungsabzuege OHNE Zugangsdaten kommen aus tools/build_nocreds.sh, genau wie ihr
|
||||||
|
# Migrations-Gegenstueck - deshalb dasselbe Namensschema, nur mit "rescue-" statt
|
||||||
|
# "migrate-" (deploy.sh legt sie entsprechend ab, siehe dort).
|
||||||
|
#
|
||||||
|
# Zwei getrennte Verzeichnispaare (migrate-*/rescue-*) statt einem gemeinsamen: die
|
||||||
|
# Migrationsabzuege sind signiert und tragen ein eigenes manifest.json, die
|
||||||
|
# Rettungsabzuege sind die gewoehnlichen _ota.img mit ninlight-Trailer. Sie
|
||||||
|
# unterscheiden sich in Form und Herstellung; ein gemeinsames Verzeichnis wuerde beide
|
||||||
|
# Herstellungswege ununterscheidbar ineinander mischen (siehe auch: "migrate-wlan/ und
|
||||||
|
# migrate-nocreds/ werden bei der Rettung nie angefasst" im Auftrag).
|
||||||
|
RESCUE_SET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_set(path: Path | None) -> str:
|
||||||
|
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
|
||||||
|
ist (inklusive ungueltigem UTF-8 - Fixrunde 1, Kleinigkeit: ein reines "except
|
||||||
|
OSError" liess eine Datei mit kaputten Bytes durchschlagen statt auf die Vorgabe zu
|
||||||
|
fallen) oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei
|
||||||
|
darf den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
|
||||||
|
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
|
||||||
|
if path is None:
|
||||||
|
return DEFAULT_IMAGE_SET
|
||||||
|
try:
|
||||||
|
value = path.read_text().strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return DEFAULT_IMAGE_SET
|
||||||
|
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
|
||||||
|
|
||||||
|
|
||||||
|
class Station:
|
||||||
|
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None,
|
||||||
|
mode: str = "recovery", control_path: Path | None = None,
|
||||||
|
imageset_path: Path | None = None) -> None:
|
||||||
|
self.image_dir = image_dir
|
||||||
|
# Bei JEDER Anfrage neu gelesen, nicht einmal beim Start: der Umschalter soll
|
||||||
|
# wirken, ohne den Dienst neu zu starten - ein Neustart mitten in einer
|
||||||
|
# laufenden Auslieferung waere genau das, was die Station nicht tun darf.
|
||||||
|
self.imageset_path = imageset_path
|
||||||
|
self.state_path = state_path
|
||||||
|
self.stall_after = stall_after
|
||||||
|
self.mode = mode
|
||||||
|
self.control_path = control_path or Path("/run/ninlight-station/control.json")
|
||||||
|
self.since = now_iso()
|
||||||
|
self.served = 0
|
||||||
|
self.recent: list[dict] = []
|
||||||
|
# Zeitpunkt der letzten Anfrage JEDER Art (erfolgreich oder nicht) - das
|
||||||
|
# Lebenszeichen fuer die Anzeige (display.py:_recent_request()). Anders als
|
||||||
|
# "served" zaehlt hier auch eine erfolglose Anfrage: der Eigentuemer wollte
|
||||||
|
# gerade nicht erst am Servierzaehler raten muessen, ob ueberhaupt etwas
|
||||||
|
# anklopft (Bedienerwunsch 2026-09-23). None, solange seit dem Start der
|
||||||
|
# Station noch keine einzige Anfrage einging.
|
||||||
|
self.last_activity: str | None = None
|
||||||
|
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
|
||||||
|
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
||||||
|
self.last_seen: dict | None = None
|
||||||
|
self._manifest_mtime: float | None = None
|
||||||
|
# Zu welchem Bildsatz der Cache gehoert - siehe _load_manifest().
|
||||||
|
self._manifest_path: Path | None = None
|
||||||
|
# Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch
|
||||||
|
# geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt
|
||||||
|
# jeder Eintrag seine eigene Version, image_version() muss also den ganzen
|
||||||
|
# Eintragssatz vorhalten, nicht nur einen String.
|
||||||
|
self._manifest_cache: dict = {}
|
||||||
|
# Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder
|
||||||
|
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
|
||||||
|
# dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf
|
||||||
|
# Platte geschrieben werden konnte (Fixrunde 1, I3). control_error landet in
|
||||||
|
# status.json, damit ein Bedienfehler nicht nur auf stdout verschwindet.
|
||||||
|
self._control_write_broken = False
|
||||||
|
self.control_error: str | None = None
|
||||||
|
# Cumulative bytes already delivered to each client while --stall-after is active,
|
||||||
|
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
|
||||||
|
# resumes with Range right where it left off, so a per-request counter would hand
|
||||||
|
# every reconnect a brand-new --stall-after allowance and the stall would never
|
||||||
|
# actually stick (see reserve_stall_bytes below).
|
||||||
|
self.stall_sent: dict[str, int] = {}
|
||||||
|
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
||||||
|
# can be downloading at once. Every mutation of served/recent/stall_sent and the
|
||||||
|
# snapshot that gets serialized to disk must happen under this same lock, or
|
||||||
|
# concurrent requests lose increments and stomp on each other's status.json writes.
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
|
def 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:
|
||||||
|
return self.rescue_dir() / f"ninlight_{target}_ota.img"
|
||||||
|
|
||||||
|
def image_set(self) -> str:
|
||||||
|
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
|
||||||
|
return read_image_set(self.imageset_path)
|
||||||
|
|
||||||
|
def migrate_dir(self) -> Path:
|
||||||
|
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes. Es kann fehlen (der Satz wurde
|
||||||
|
nie ausgeliefert) - dann wird NICHTS ausgeliefert, siehe Handler: auf den anderen
|
||||||
|
Satz auszuweichen waere genau der Fehlgriff, gegen den der Umschalter da ist."""
|
||||||
|
return self.image_dir / IMAGE_SET_DIRS[self.image_set()]
|
||||||
|
|
||||||
|
def migrate_image_path(self, target: str, variant: str) -> Path:
|
||||||
|
return self.migrate_dir() / f"{target}_{variant}.img"
|
||||||
|
|
||||||
|
def migrate_set_present(self) -> bool:
|
||||||
|
"""Liegt der eingestellte Bildsatz ueberhaupt auf dem Pi? "Vorhanden" heisst: das
|
||||||
|
Verzeichnis gibt es UND es enthaelt mindestens ein Image. Ein leeres Verzeichnis
|
||||||
|
zaehlt bewusst als nicht vorhanden - es entsteht genauso leicht (angelegt, aber nie
|
||||||
|
bestueckt) und unterscheidet sich fuer den Bediener in nichts; das Panel und das
|
||||||
|
CLI beurteilen es nach derselben Regel, sonst behaupten drei Stellen dreierlei."""
|
||||||
|
try:
|
||||||
|
return any(p.name.endswith(".img") for p in self.migrate_dir().iterdir())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_manifest(self) -> dict:
|
||||||
|
"""Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen
|
||||||
|
bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie
|
||||||
|
unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis
|
||||||
|
{} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit
|
||||||
|
eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem
|
||||||
|
AttributeError den Server abschiesst."""
|
||||||
|
path = self.migrate_dir() / "manifest.json"
|
||||||
|
try:
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
self._manifest_mtime = None
|
||||||
|
self._manifest_path = path
|
||||||
|
self._manifest_cache = {}
|
||||||
|
return self._manifest_cache
|
||||||
|
# Der Pfad gehoert mit in den Vergleich: nach einem Umschalten zeigt er auf eine
|
||||||
|
# ANDERE Datei, die zufaellig dieselbe mtime tragen kann (beide entstehen im
|
||||||
|
# selben Deploy, oft in derselben Sekunde). Ohne ihn behielte der Cache die
|
||||||
|
# Versionen des vorigen Satzes - und die stuenden dann in status.json und am
|
||||||
|
# Panel neben einem Image, das gar nicht von dort kommt.
|
||||||
|
if mtime != self._manifest_mtime or path != getattr(self, "_manifest_path", None):
|
||||||
|
self._manifest_path = path
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
self._manifest_cache = data
|
||||||
|
self._manifest_mtime = mtime
|
||||||
|
return self._manifest_cache
|
||||||
|
|
||||||
|
def image_version(self, target: str, variant: str) -> str:
|
||||||
|
"""Firmware-Version fuer genau das Migrationsimage, das gerade ausgeliefert
|
||||||
|
wird - die Migrationsimages selbst tragen keinen ninlight-Trailer, aus dem der
|
||||||
|
Server sie lesen koennte (Spec Abschnitt 4). Bevorzugt die Version aus dem
|
||||||
|
passenden Eintrag in manifest.json; nur wenn der Eintrag keine eigene traegt
|
||||||
|
(Bestandskompatibilitaet mit einem manifestweiten Feld), faellt es auf das
|
||||||
|
Feld auf oberster Ebene zurueck. Ein einzelnes manifestweites Feld allein
|
||||||
|
waere seit dem Zusammenfuehren mehrerer Deploys (deploy.sh, Fixrunde 1 I1)
|
||||||
|
schlicht nicht mehr wahr: uebernommene Alt-Eintraege koennen eine andere
|
||||||
|
Version tragen als die gerade neu signierten (Fixrunde 2, N2)."""
|
||||||
|
data = self._load_manifest()
|
||||||
|
images = data.get("images")
|
||||||
|
if isinstance(images, list):
|
||||||
|
for entry in images:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("target") == target and entry.get("variant") == variant:
|
||||||
|
version = entry.get("ninlight_version")
|
||||||
|
if isinstance(version, str) and version:
|
||||||
|
return version
|
||||||
|
break
|
||||||
|
top = data.get("ninlight_version")
|
||||||
|
return top if isinstance(top, str) and top else "unbekannt"
|
||||||
|
|
||||||
|
def read_control(self):
|
||||||
|
"""Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" -
|
||||||
|
eine fehlende Steuerdatei darf eine Migration nie blockieren, sondern hoechstens
|
||||||
|
bequemer machen als beabsichtigt. Bei jeder Anfrage frisch gelesen, damit
|
||||||
|
"ninlight-station arm" ohne Neustart des Dienstes wirkt."""
|
||||||
|
try:
|
||||||
|
d = json.loads(self.control_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return "auto", None
|
||||||
|
phase = d.get("phase")
|
||||||
|
if phase not in ("auto", "listen", "armed"):
|
||||||
|
return "auto", None
|
||||||
|
return phase, d.get("armed_target")
|
||||||
|
|
||||||
|
def write_control(self, phase: str, armed_target: str | None) -> None:
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self.control_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=self.control_path.parent, prefix=".control-")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump({"phase": phase, "armed_target": armed_target}, f)
|
||||||
|
# Dasselbe Muster wie write_state() unten, aus demselben Grund: der
|
||||||
|
# Dienst laeuft als root, mkstemp() legt 0600 an, und ohne das
|
||||||
|
# explizite chmod() koennte das CLI die Steuerdatei nicht lesen.
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, self.control_path)
|
||||||
|
except BaseException:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
self._control_write_broken = True
|
||||||
|
self.control_error = f"cannot write {self.control_path}: {e}"
|
||||||
|
print(f"warning: {self.control_error}", flush=True)
|
||||||
|
else:
|
||||||
|
self._control_write_broken = False
|
||||||
|
self.control_error = None
|
||||||
|
|
||||||
|
def claim_delivery(self, target: str) -> tuple[str, str | None, bool]:
|
||||||
|
"""Entscheidet unter dem Lock, ob eine Anfrage fuer `target` gerade ausgeliefert
|
||||||
|
werden darf, und verbraucht - falls die Freigabe dafuer verantwortlich ist - diese
|
||||||
|
sofort per Compare-and-Set (lesen und auf "listen" setzen, ohne das Lock
|
||||||
|
dazwischen loszulassen). Ohne das koennten zwei gleichzeitige Anfragen (zwei
|
||||||
|
Lampen, oder Erst- und Range-Resume-Verbindung derselben Lampe) beide phase ==
|
||||||
|
"armed" sehen und beide ausgeliefert bekommen (Fixrunde 1, C1) - genau das Szenario,
|
||||||
|
fuer das die Freigabephase laut Spec 3.3 da ist.
|
||||||
|
|
||||||
|
Liefert (phase, armed_target) wie sie VOR dem Verbrauch galten, plus ob die
|
||||||
|
aktuelle Anfrage ausliefern darf. Bricht die Auslieferung anschliessend ab
|
||||||
|
(status == "aborted") oder findet der Aufrufer kein Image, muss er bei
|
||||||
|
phase == "armed" restore_armed(target) aufrufen, damit die Freigabe fuer den
|
||||||
|
naechsten Bootversuch stehen bleibt.
|
||||||
|
|
||||||
|
Schlaegt das Schreiben von "listen" fehl (I3), bekommt nur die eine Anfrage, die
|
||||||
|
den Fehler zuerst ausloest, noch ihre Auslieferung - jede weitere schlaegt hier
|
||||||
|
erneut fehl (_control_write_broken bleibt True), bis ein write_control()-Versuch
|
||||||
|
wieder klappt. Damit bleibt "armed" nicht dauerhaft scharf, nur weil /run gerade
|
||||||
|
nicht beschreibbar ist, heilt sich aber von selbst, sobald es das wieder ist."""
|
||||||
|
with self.lock:
|
||||||
|
phase, armed_target = self.read_control()
|
||||||
|
if phase == "listen" or (phase == "armed" and armed_target != target):
|
||||||
|
return phase, armed_target, False
|
||||||
|
if phase == "armed":
|
||||||
|
was_broken = self._control_write_broken
|
||||||
|
self.write_control("listen", None)
|
||||||
|
if self._control_write_broken:
|
||||||
|
return phase, armed_target, not was_broken
|
||||||
|
return phase, armed_target, True
|
||||||
|
|
||||||
|
def restore_armed(self, target: str) -> None:
|
||||||
|
"""Setzt eine per claim_delivery() verbrauchte Freigabe zurueck, wenn die
|
||||||
|
Auslieferung danach doch nicht (vollstaendig) stattgefunden hat - ein Abbruch darf
|
||||||
|
die Freigabe nicht verbrauchen, sonst muesste am Geraet erneut von Hand gearmt
|
||||||
|
werden, obwohl die Lampe es beim naechsten Bootversuch selbst nochmal versuchen
|
||||||
|
wuerde (Spec 3.3, Brief-Schritt 3.6)."""
|
||||||
|
self.write_control("armed", target)
|
||||||
|
|
||||||
|
def reserve_stall_bytes(self, ip: str, n: int) -> int:
|
||||||
|
"""How many of the next `n` bytes this client may still receive under --stall-after.
|
||||||
|
|
||||||
|
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
|
||||||
|
that the lamp abandons and resumes via Range does not get a fresh budget just
|
||||||
|
because it is a new TCP connection. Only called when stall_after is set.
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
sent_so_far = self.stall_sent.get(ip, 0)
|
||||||
|
allowed = max(0, min(n, self.stall_after - sent_so_far))
|
||||||
|
self.stall_sent[ip] = sent_so_far + allowed
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||||
|
with self.lock:
|
||||||
|
if status == "ok":
|
||||||
|
self.served += 1
|
||||||
|
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})
|
||||||
|
del self.recent[:-RECENT_MAX]
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
|
def record_migration(self, ip: str, target: str | None, variant: str | None,
|
||||||
|
product_id: str, lamp_version: str, product_name: str | None,
|
||||||
|
version: str, nbytes: int, status: str,
|
||||||
|
variant_source: str | None = None) -> None:
|
||||||
|
"""Protokolliert eine Migrationsanfrage. Anders als record() oben wird hier auch
|
||||||
|
dann ein Eintrag angelegt (Status "listen"/"unknown"/"notfound"), wenn nichts
|
||||||
|
ausgeliefert wurde - das ist der ganze Zweck der Horchphase. last_seen wird nur bei
|
||||||
|
einer erkannten Produkt-ID gesetzt (target is not None); eine unbekannte Produkt-ID
|
||||||
|
hat kein Modell, das die Anzeige zeigen koennte."""
|
||||||
|
with self.lock:
|
||||||
|
if status == "ok":
|
||||||
|
self.served += 1
|
||||||
|
entry = {"ts": now_iso(), "ip": ip, "target": target, "variant": variant,
|
||||||
|
"variant_source": variant_source,
|
||||||
|
"product_id": product_id, "lamp_version": lamp_version,
|
||||||
|
"version": version, "bytes": nbytes, "status": status}
|
||||||
|
# Jede Anfrage zaehlt als Lebenszeichen, auch "listen"/"unknown"/"notfound"/
|
||||||
|
# "noimageset" - siehe self.last_activity oben und record()'s Gegenstueck.
|
||||||
|
self.last_activity = entry["ts"]
|
||||||
|
self.recent.append(entry)
|
||||||
|
del self.recent[:-RECENT_MAX]
|
||||||
|
if target is not None:
|
||||||
|
self.last_seen = {"product_id": product_id, "target": target,
|
||||||
|
"variant": variant, "variant_source": variant_source,
|
||||||
|
"product_name": product_name,
|
||||||
|
"lamp_version": lamp_version, "ip": ip, "ts": entry["ts"]}
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
|
def write_state(self) -> None:
|
||||||
|
with self.lock:
|
||||||
|
payload = {"since": self.since, "mode": self.mode, "served": self.served,
|
||||||
|
"recent": list(self.recent),
|
||||||
|
# In BEIDEN Modi (nicht nur migration, siehe unten) - das
|
||||||
|
# Lebenszeichen auf dem Panel gilt fuer die Rettung genauso wie
|
||||||
|
# fuer die Migration (display.py:_recent_request()).
|
||||||
|
"last_request_ts": self.last_activity}
|
||||||
|
if self.mode == "migration":
|
||||||
|
phase, armed_target = self.read_control()
|
||||||
|
payload["phase"] = phase
|
||||||
|
payload["armed_target"] = armed_target
|
||||||
|
payload["last_seen"] = self.last_seen
|
||||||
|
payload["control_error"] = self.control_error
|
||||||
|
try:
|
||||||
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
# The service runs as root and mkstemp() creates the file 0600, so
|
||||||
|
# without this an operator reading status.json as a normal user (see
|
||||||
|
# tools/station/ninlight-station) gets "permission denied" - and since
|
||||||
|
# os.replace() preserves the mode it arrives with, that wrong mode
|
||||||
|
# would otherwise survive onto the live file on every single write,
|
||||||
|
# not just the first. os.chmod() ignores the process umask (unlike the
|
||||||
|
# mode argument to mkstemp/open), so a restrictive umask on the Pi
|
||||||
|
# can't quietly bring the 0600 back. Set it before the rename so
|
||||||
|
# there's never a window where the path holds a 0600 file.
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, self.state_path)
|
||||||
|
except BaseException:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
server_version = "ninlight-station"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def station(self) -> Station:
|
||||||
|
return self.server.station # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||||
|
pass
|
||||||
|
|
||||||
|
def log_error(self, fmt: str, *args) -> None:
|
||||||
|
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
|
||||||
|
# silence above in favour of our own log() calls - but that also silences it for
|
||||||
|
# requests that never reach do_GET/do_POST (a malformed request line, a client
|
||||||
|
# timeout mid-request). The station is the only diagnostic tool available during a
|
||||||
|
# rescue, so those cases must still produce a line instead of nothing.
|
||||||
|
try:
|
||||||
|
client = self.client_address[0]
|
||||||
|
except (AttributeError, IndexError):
|
||||||
|
client = "?"
|
||||||
|
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
|
||||||
|
|
||||||
|
def log(self, status: int, nbytes: int, started: float) -> None:
|
||||||
|
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||||
|
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
self.reject(405, "method not allowed")
|
||||||
|
self.log(405, 0, started)
|
||||||
|
|
||||||
|
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||||
|
|
||||||
|
def reject(self, status: int, text: str) -> None:
|
||||||
|
body = (text + "\n").encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
if self.station.mode == "migration":
|
||||||
|
self._do_get_migration(started)
|
||||||
|
else:
|
||||||
|
self._do_get_recovery(started)
|
||||||
|
|
||||||
|
def _do_get_recovery(self, started: float) -> None:
|
||||||
|
m = PATH_RE.match(self.path)
|
||||||
|
if not m:
|
||||||
|
self.reject(404, "not found")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
target = m.group(1)
|
||||||
|
path = self.station.image_path(target)
|
||||||
|
if not path.is_file():
|
||||||
|
# Record before responding: a client that sees the 404 complete may check
|
||||||
|
# status.json right away, so the entry must already be on disk by then.
|
||||||
|
self.station.record(self.client_address[0], target, "?", 0, "notfound")
|
||||||
|
self.reject(404, "no image for this device")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
version = trailer_version(path)
|
||||||
|
sent, status = self._serve_file(path, started)
|
||||||
|
# Record only now that the transfer has actually finished (or failed): "ok" must
|
||||||
|
# never be written before the last byte is confirmed sent, or status.json can
|
||||||
|
# claim success for a device that never received the end of the image.
|
||||||
|
self.station.record(self.client_address[0], target, version, sent, status)
|
||||||
|
|
||||||
|
def _do_get_migration(self, started: float) -> None:
|
||||||
|
parsed = products.parse_request(self.path)
|
||||||
|
if parsed is None:
|
||||||
|
# Ein fremder Pfad, kein qcx_firmware_*-Muster - kein Protokolleintrag in
|
||||||
|
# status.json, das waere nur Rauschen (Brief Schritt 3.4).
|
||||||
|
self.reject(404, "not found")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
pid, lamp_version, product, variant, variant_source = parsed
|
||||||
|
ip = self.client_address[0]
|
||||||
|
if product is None:
|
||||||
|
self.station.record_migration(ip, None, None, pid, lamp_version, None, "?", 0,
|
||||||
|
"unknown")
|
||||||
|
self.reject(404, "unknown product")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
# variant/variant_source kommen aus parse_request(), nicht mehr aus der
|
||||||
|
# Produkttabelle: das "-dev" kann in der Produkt-ID stehen, in der Version, oder in
|
||||||
|
# beiden (Hardwarebefund 2026-09-15, siehe products.py). Das entscheidet, welches
|
||||||
|
# der beiden unterschiedlich signierten Images ausgeliefert wird.
|
||||||
|
target, name = product.target, product.name
|
||||||
|
version = self.station.image_version(target, variant)
|
||||||
|
# claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter
|
||||||
|
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
|
||||||
|
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
|
||||||
|
phase, armed_target, allowed = self.station.claim_delivery(target)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
# last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase:
|
||||||
|
# die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht.
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||||
|
version, 0, "listen", variant_source)
|
||||||
|
self.reject(404, "listening only")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = self.station.migrate_image_path(target, variant)
|
||||||
|
if not self.station.migrate_set_present():
|
||||||
|
# Der EINGESTELLTE Bildsatz liegt gar nicht auf dem Pi. Hier wird bewusst
|
||||||
|
# nichts ausgeliefert und auch nicht auf den anderen Satz ausgewichen: der
|
||||||
|
# Umschalter existiert genau dafuer, dass niemand versehentlich Lampen mit
|
||||||
|
# den eigenen WLAN-Zugangsdaten flasht. Ein eigener Status, damit der
|
||||||
|
# Bediener am Panel den Unterschied zu "dieses Modell fehlt" sieht.
|
||||||
|
if phase == "armed":
|
||||||
|
self.station.restore_armed(target)
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||||
|
version, 0, "noimageset", variant_source)
|
||||||
|
self.reject(404, "selected image set not available")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
if not path.is_file():
|
||||||
|
if phase == "armed":
|
||||||
|
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten,
|
||||||
|
# dass es gar kein Image gibt - das darf nicht auf Kosten der Freigabe gehen.
|
||||||
|
self.station.restore_armed(target)
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||||
|
version, 0, "notfound", variant_source)
|
||||||
|
self.reject(404, "no image for this device")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
sent, status = self._serve_file(path, started)
|
||||||
|
if phase == "armed" and status == "aborted":
|
||||||
|
# Bricht der Transfer ab, bleibt die Freigabe stehen, damit die Lampe es beim
|
||||||
|
# naechsten Boot noch einmal versuchen kann (Spec 3.3). claim_delivery() hat sie
|
||||||
|
# bereits auf "listen" gesetzt, bevor _serve_file() ueberhaupt anfing zu senden
|
||||||
|
# (das ist der Punkt von C1) - bei einem Abbruch wird das hier zurueckgenommen.
|
||||||
|
self.station.restore_armed(target)
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name, version,
|
||||||
|
sent, status, variant_source)
|
||||||
|
|
||||||
|
def _serve_file(self, path: Path, started: float) -> tuple[int, str]:
|
||||||
|
"""Sendet `path` mit Range-Unterstuetzung; liefert (gesendete Bytes, Status).
|
||||||
|
Gemeinsam fuer Rettungs- und Migrationsroute - Uebertragungsmechanik (Range,
|
||||||
|
--stall-after, Abbruchbehandlung) ist identisch, nur was mit dem Ergebnis
|
||||||
|
geschieht, unterscheidet sich."""
|
||||||
|
size = path.stat().st_size
|
||||||
|
start = 0
|
||||||
|
rng = self.headers.get("Range", "")
|
||||||
|
if rng.startswith("bytes="):
|
||||||
|
first = rng.split("=", 1)[1].split("-", 1)[0]
|
||||||
|
if first.isdigit() and int(first) < size:
|
||||||
|
start = int(first)
|
||||||
|
|
||||||
|
self.send_response(206 if start else 200)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(size - start))
|
||||||
|
if start:
|
||||||
|
self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
|
||||||
|
self.send_header("Accept-Ranges", "bytes")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
status = "ok"
|
||||||
|
remaining = size - start
|
||||||
|
client_ip = self.client_address[0]
|
||||||
|
try:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
f.seek(start)
|
||||||
|
while sent < remaining:
|
||||||
|
read_size = min(CHUNK, remaining - sent)
|
||||||
|
if self.station.stall_after is not None:
|
||||||
|
# Reserve against this client's cumulative budget, not just this
|
||||||
|
# request's byte count - see Station.reserve_stall_bytes.
|
||||||
|
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
|
||||||
|
if read_size == 0:
|
||||||
|
# test aid: hold the connection open and send nothing more, so
|
||||||
|
# the firmware's stall handling can be exercised on real
|
||||||
|
# hardware. We still notice if the peer gives up and closes its
|
||||||
|
# side, so a test (or an impatient client) that hangs up is
|
||||||
|
# recorded as "aborted" instead of leaking the thread forever.
|
||||||
|
while True:
|
||||||
|
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
try:
|
||||||
|
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||||
|
except OSError:
|
||||||
|
peek = b""
|
||||||
|
if not peek:
|
||||||
|
raise ConnectionResetError("peer closed during stall")
|
||||||
|
chunk = f.read(read_size)
|
||||||
|
if not chunk:
|
||||||
|
# Die Datei ist kuerzer als das eingangs per stat() ermittelte
|
||||||
|
# Content-Length (z.B. von deploy.sh waehrend einer laufenden
|
||||||
|
# Auslieferung durch eine kleinere ersetzt) - sent < remaining an
|
||||||
|
# dieser Stelle immer, denn die Schleifenbedingung oben haette sonst
|
||||||
|
# schon abgebrochen. Das ist ein abgebrochener, kein erfolgreicher
|
||||||
|
# Transfer (Fixrunde 1, I2): ohne dies wuerde eine im Migrationsmodus
|
||||||
|
# laufende "armed"-Freigabe faelschlich als verbraucht gelten, obwohl
|
||||||
|
# die Lampe nie das vollstaendige Image bekommen hat.
|
||||||
|
status = "aborted"
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
sent += len(chunk)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||||
|
status = "aborted"
|
||||||
|
self.log(206 if start else 200, sent, started)
|
||||||
|
return sent, status
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mode(explicit_mode: str | None, mode_file: Path | None) -> str:
|
||||||
|
"""Bestimmt den Betriebsmodus. Vorrang: ein ausdrueckliches --mode schlaegt
|
||||||
|
--mode-file, --mode-file schlaegt die Vorgabe "recovery". Eine fehlende oder
|
||||||
|
unlesbare/unerwartete Modusdatei bedeutet ebenfalls "recovery" - Task 4 baut die
|
||||||
|
systemd-Unit darauf auf, dass der Modus ohne Neustart-Argumentaenderung aus einer
|
||||||
|
Datei kommt (/run/ninlight-station/mode), und ein fehlerhafter Zustand dieser Datei
|
||||||
|
darf den Dienst nicht zum Absturz bringen oder unbeabsichtigt scharf schalten."""
|
||||||
|
if explicit_mode is not None:
|
||||||
|
return explicit_mode
|
||||||
|
if mode_file is not None:
|
||||||
|
try:
|
||||||
|
text = mode_file.read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
return "recovery"
|
||||||
|
if text in ("recovery", "migration"):
|
||||||
|
return text
|
||||||
|
return "recovery"
|
||||||
|
return "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="ninlight recovery/migration station HTTP server")
|
||||||
|
p.add_argument("--image-dir", required=True, type=Path)
|
||||||
|
p.add_argument("--bind", default="192.168.8.1")
|
||||||
|
p.add_argument("--port", type=int, default=80)
|
||||||
|
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
|
||||||
|
p.add_argument("--mode", choices=("recovery", "migration"), default=None,
|
||||||
|
help="ausdruecklicher Modus - schlaegt --mode-file (Vorgabe: siehe dort)")
|
||||||
|
p.add_argument("--mode-file", type=Path, default=None,
|
||||||
|
help="Datei mit 'recovery' oder 'migration'; wird ohne --mode verwendet, "
|
||||||
|
"Vorgabe bei fehlender/kaputter Datei ist 'recovery'")
|
||||||
|
p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"),
|
||||||
|
help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus")
|
||||||
|
p.add_argument("--imageset-file", type=Path, default=None,
|
||||||
|
help="Datei mit 'ohne-wlan' oder 'mit-wlan' (Umschalter des "
|
||||||
|
"Migrations-Bildsatzes); Vorgabe bei fehlender/kaputter Datei "
|
||||||
|
"ist 'ohne-wlan'")
|
||||||
|
p.add_argument("--stall-after", type=int, default=None,
|
||||||
|
help="test aid: stop sending after N bytes but keep the connection open")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
|
||||||
|
if not a.image_dir.is_dir():
|
||||||
|
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
mode = resolve_mode(a.mode, a.mode_file)
|
||||||
|
station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control,
|
||||||
|
imageset_path=a.imageset_file)
|
||||||
|
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
||||||
|
httpd.daemon_threads = True
|
||||||
|
httpd.station = station # type: ignore[attr-defined]
|
||||||
|
# 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): "
|
||||||
|
f"{', '.join(images) or 'none'}", flush=True)
|
||||||
|
if mode == "recovery":
|
||||||
|
# Bildsatz auch fuer die Rettung im Journal - dieselbe Begruendung wie unten fuer
|
||||||
|
# die Migration: "warum bekam die Lampe ein Abbild ohne/mit Zugangsdaten?" ist
|
||||||
|
# sonst nirgends nachschlagbar. rescue_set_present() (bislang toter Code) ist das
|
||||||
|
# hier tatsaechlich Verwendete.
|
||||||
|
selected = station.image_set()
|
||||||
|
present = "vorhanden" if station.rescue_set_present() else "FEHLT"
|
||||||
|
print(f"{now_iso()} image set: {selected} ({station.rescue_dir()}, {present})",
|
||||||
|
flush=True)
|
||||||
|
elif mode == "migration":
|
||||||
|
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
|
||||||
|
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
|
||||||
|
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
|
||||||
|
selected = station.image_set()
|
||||||
|
present = "vorhanden" if station.migrate_set_present() else "FEHLT"
|
||||||
|
print(f"{now_iso()} image set: {selected} ({station.migrate_dir()}, {present})",
|
||||||
|
flush=True)
|
||||||
|
if a.stall_after is not None:
|
||||||
|
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+583
@@ -0,0 +1,583 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches
|
||||||
|
# Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem
|
||||||
|
# schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration"
|
||||||
|
# unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet).
|
||||||
|
#
|
||||||
|
# Aufruf: tools/station/setup_pi.sh <pi-host>
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
|
||||||
|
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und in deploy.sh), damit ein Test gegen
|
||||||
|
# Stubs statt einen echten Pi laufen kann.
|
||||||
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||||
|
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
|
||||||
|
# Wo die per scp uebertragenen Dateien auf dem Zielsystem zwischenlanden, bevor
|
||||||
|
# sie an ihren endgueltigen Platz installiert werden. Ueberschreibbar wie die
|
||||||
|
# anderen NINLIGHT_*-Pfade: auf dem Pi bleibt es bei /tmp, ein Test kann hier in
|
||||||
|
# eine Sandbox statt in das echte /tmp des Entwicklungsrechners umleiten
|
||||||
|
# (Fixrunde 2, N4) - sowohl fuer die lokale scp-Zielangabe unten als auch fuer
|
||||||
|
# die REMOTE-Bloecke, die dieselbe Variable (mit demselben Default) referenzieren.
|
||||||
|
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||||
|
|
||||||
|
echo "== Voraussetzungen auf $HOST pruefen =="
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
|
||||||
|
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
|
||||||
|
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
|
||||||
|
echo " ok"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== Konfiguration =="
|
||||||
|
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example"
|
||||||
|
# CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es
|
||||||
|
# beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte
|
||||||
|
# Systempfade laufen.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
|
||||||
|
TEMPLATE=${NINLIGHT_STAGE_DIR:-/tmp}/ninlight-station.conf.example
|
||||||
|
if [ -f "$CONF" ]; then
|
||||||
|
# Eine bestehende Konfiguration bleibt bei ihren Werten - nur Schluessel, die
|
||||||
|
# eine neuere Version des CLI zusaetzlich verlangt (z.B. die Migrations- und
|
||||||
|
# Anzeige-Schluessel aus Task 4), werden aus der Vorlage ergaenzt. Ohne das
|
||||||
|
# wuerde das Ausrollen eines neuen ninlight-station auf einen bereits
|
||||||
|
# eingerichteten Pi jeden Unterbefehl - "off" eingeschlossen - mit "Schluessel
|
||||||
|
# ... fehlt" blockieren, und ein bereits laufender Zugangspunkt bliebe an, bis
|
||||||
|
# jemand die 0600-Datei von Hand ergaenzt (Fixrunde 1, I4).
|
||||||
|
ADDED=""
|
||||||
|
for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
|
||||||
|
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
|
||||||
|
# Nicht nur "^KEY=": eine vorhandene Zuweisung mit fuehrendem Leerraum oder
|
||||||
|
# einem "export" davor (beides gueltiges ". datei"-Shell) wuerde sonst als
|
||||||
|
# fehlend gelten, die Vorlagenzeile wird angehaengt, und da das CLI die
|
||||||
|
# Datei per ". $CONF" sourct, gewinnt die LETZTE Zuweisung - ein eigenes
|
||||||
|
# Passwort wuerde so still durch den oeffentlichen Vorlagenwert ersetzt
|
||||||
|
# (Fixrunde 2, N3: genau das "vorhandene Werte nicht ueberschreiben" aus I4,
|
||||||
|
# nur eine Formatierungsvariante weiter).
|
||||||
|
if ! sudo grep -Eq "^[[:space:]]*(export[[:space:]]+)?${KEY}=" "$CONF"; then
|
||||||
|
LINE=$(grep -E "^${KEY}=" "$TEMPLATE" || true)
|
||||||
|
if [ -n "$LINE" ]; then
|
||||||
|
printf '%s\n' "$LINE" | sudo tee -a "$CONF" >/dev/null
|
||||||
|
ADDED="$ADDED $KEY"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$ADDED" ]; then
|
||||||
|
echo " $CONF: fehlende Schluessel ergaenzt:$ADDED (vorhandene Werte unveraendert)"
|
||||||
|
else
|
||||||
|
echo " $CONF existiert bereits, alle Schluessel vorhanden"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
sudo install -m 0600 "$TEMPLATE" "$CONF"
|
||||||
|
echo " $CONF aus der Vorlage angelegt"
|
||||||
|
fi
|
||||||
|
rm -f "$TEMPLATE"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== Server, CLI und Units installieren =="
|
||||||
|
# Reststand der Ablage wegraeumen, BEVOR das scp laeuft (Task 6b F1, I1). "scp -r quelle
|
||||||
|
# ziel" kopiert das Quellverzeichnis IN ein bereits vorhandenes Ziel hinein - aus
|
||||||
|
# einem liegengebliebenen "…/ninlight-display/" wird dann "…/ninlight-display/display/",
|
||||||
|
# die Installationsschleife unten ueberspringt Verzeichnisse und findet gar nichts mehr.
|
||||||
|
# Ergebnis waere ein Lauf, der Erfolg meldet, den Anzeigedienst aktiviert und ein leeres
|
||||||
|
# $ROOT_DIR/display hinterlaesst: 203/EXEC im 5-Sekunden-Takt, Panel schwarz. Genau so
|
||||||
|
# ein Reststand entsteht, wenn ein frueherer Lauf nach dem scp abbricht (sudo-Timeout,
|
||||||
|
# Platte voll, Strg-C) - die Aufraeumzeile unten laeuft dann nicht mehr. Idempotent
|
||||||
|
# heisst, dass auch dieser Fall glattgeht (siehe Kopf dieser Datei).
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||||
|
rm -rf "$STAGE_DIR/ninlight-display" "$STAGE_DIR/ninlight-button"
|
||||||
|
REMOTE
|
||||||
|
"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
|
||||||
|
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
||||||
|
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
||||||
|
"$HOST:$STAGE_DIR/"
|
||||||
|
# Das Anzeigeverzeichnis als Ganzes: render.py, display.py, epdconfig.py, die beiden
|
||||||
|
# uebernommenen Panel-Treiber und HERKUNFT.md (der Herkunftsnachweis gehoert mit auf den
|
||||||
|
# Pi). "scp -r" statt rsync, weil setup_pi.sh - anders als deploy.sh - ausdruecklich nur
|
||||||
|
# ssh/scp voraussetzt; __pycache__ wird auf der Gegenseite weggeraeumt (unten).
|
||||||
|
"$SCP_BIN" -q -r "$HERE/display" "$HOST:$STAGE_DIR/ninlight-display"
|
||||||
|
# Das Tasterverzeichnis genauso: als Ganzes, mit demselben "vorher aufraeumen"-Schutz
|
||||||
|
# gegen ein "scp -r" auf ein bereits vorhandenes Ziel (siehe Kommentar oben zu
|
||||||
|
# ninlight-display).
|
||||||
|
"$SCP_BIN" -q -r "$HERE/button" "$HOST:$STAGE_DIR/ninlight-button"
|
||||||
|
# Pfade als Variablen mit Default (wie in deploy.sh): so kann ein Test die
|
||||||
|
# Installation gegen ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen
|
||||||
|
# lassen.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||||
|
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||||
|
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
||||||
|
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||||
|
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
||||||
|
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
||||||
|
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
||||||
|
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
|
||||||
|
|
||||||
|
# Die Ablage wird in JEDEM Fall abgeraeumt, auch wenn dieser Block vorzeitig aussteigt
|
||||||
|
# (Task 6b F2, N1). Vorher stand das "rm -rf" nur am Blockende und wurde beim Abbruch
|
||||||
|
# uebersprungen - die uebertragenen Dateien blieben unter /tmp liegen und waren beim
|
||||||
|
# naechsten Lauf genau der Reststand, gegen den der Block eigentlich absichert.
|
||||||
|
cleanup_stage() {
|
||||||
|
rm -rf "$STAGE_DIR/server.py" "$STAGE_DIR/products.py" "$STAGE_DIR/ninlight-station" \
|
||||||
|
"$STAGE_DIR/ninlight-station.service" "$STAGE_DIR/ninlight-display.service" \
|
||||||
|
"$STAGE_DIR/ninlight-display" "$STAGE_DIR/ninlight-button.service" \
|
||||||
|
"$STAGE_DIR/ninlight-button" "$STAGE_DIR/ninlight-poweroff.service" \
|
||||||
|
"$STAGE_DIR/ninlight-reboot.service"
|
||||||
|
}
|
||||||
|
trap cleanup_stage EXIT
|
||||||
|
|
||||||
|
rm -rf "$STAGE_DIR/ninlight-display/__pycache__" "$STAGE_DIR/ninlight-button/__pycache__"
|
||||||
|
# Harte Vorbedingung, und zwar VOR dem ersten "sudo install" (Task 6b F2, N1): ohne
|
||||||
|
# display.py in der Ablage hat das scp nicht geliefert, was es sollte - abgebrochen,
|
||||||
|
# falsch verschachtelt oder gar nicht gelaufen. Die Installationsschleife weiter unten
|
||||||
|
# wuerde das stillschweigend als "nichts zu tun" verbuchen, der Lauf meldete Erfolg und
|
||||||
|
# der aktivierte Anzeigedienst liefe gegen eine nicht existierende Datei.
|
||||||
|
#
|
||||||
|
# Die Pruefung stand zuerst NACH den vier "sudo install" darunter - die Meldung "Nichts
|
||||||
|
# installiert" war damit schlicht unwahr: server.py, products.py, das CLI und die
|
||||||
|
# Stations-Unit lagen dann bereits auf dem Pi. Ausgerechnet an dem Werkbank-Moment, fuer
|
||||||
|
# den diese Pruefung gedacht ist, log das Skript den Bediener an. Jetzt stimmt der Satz.
|
||||||
|
if [ ! -f "$STAGE_DIR/ninlight-display/display.py" ]; then
|
||||||
|
echo "Fehler: $STAGE_DIR/ninlight-display/display.py fehlt - die Uebertragung des Anzeigeverzeichnisses ist fehlgeschlagen." >&2
|
||||||
|
echo " Nichts installiert: $ROOT_DIR, $BIN_PATH und alle drei Units sind unveraendert. Ablage aufgeraeumt, Lauf einfach wiederholen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Dieselbe harte Vorbedingung wie bei display.py, aus demselben Grund: ohne button.py in
|
||||||
|
# der Ablage hat das scp nicht geliefert, was es sollte, und die Installationsschleife
|
||||||
|
# unten wuerde das stillschweigend als "nichts zu tun" verbuchen.
|
||||||
|
if [ ! -f "$STAGE_DIR/ninlight-button/button.py" ]; then
|
||||||
|
echo "Fehler: $STAGE_DIR/ninlight-button/button.py fehlt - die Uebertragung des Tasterverzeichnisses ist fehlgeschlagen." >&2
|
||||||
|
echo " Nichts installiert: $ROOT_DIR, $BIN_PATH und alle drei Units sind unveraendert. Ablage aufgeraeumt, Lauf einfach wiederholen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" "$ROOT_DIR/display" "$ROOT_DIR/button"
|
||||||
|
# Dauerhafter Zustand, der einen Neustart ueberleben muss - bisher nur der Umschalter des
|
||||||
|
# Migrations-Bildsatzes (/var/lib/ninlight-station/imageset). BEWUSST NICHT in /run: der
|
||||||
|
# Umschalter ist ein Zustand, den der Bediener setzt und der bis zur naechsten Aenderung
|
||||||
|
# gilt; ein tmpfs wuerde ihn bei jedem Neustart stillschweigend zuruecksetzen.
|
||||||
|
#
|
||||||
|
# 0755, weil der Anzeigedienst die Datei lesen muss (er zeigt den Zustand dauerhaft auf
|
||||||
|
# dem Panel). Die Datei SELBST wird hier nicht angelegt: fehlt sie, gilt in CLI, Server
|
||||||
|
# und Anzeige derselbe sichere Vorgabewert "ohne-wlan" - eine hier vorgeschriebene Datei
|
||||||
|
# waere eine zweite Quelle fuer denselben Wert.
|
||||||
|
#
|
||||||
|
# Die beiden Bildsatzverzeichnisse werden hier ebenfalls NICHT angelegt: ein leeres
|
||||||
|
# Verzeichnis ist fuer den Bediener nicht von einem bestueckten zu unterscheiden, und
|
||||||
|
# genau das soll das Panel melden koennen ("SATZ FEHLT"). Sie entstehen in deploy.sh,
|
||||||
|
# sobald wirklich Images hineinkommen.
|
||||||
|
STATE_DIR=${NINLIGHT_STATION_STATE_DIR:-/var/lib/ninlight-station}
|
||||||
|
sudo install -d -m 0755 "$STATE_DIR"
|
||||||
|
sudo install -m 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py"
|
||||||
|
sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py"
|
||||||
|
sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH"
|
||||||
|
sudo install -m 0644 "$STAGE_DIR/ninlight-station.service" "$UNIT_PATH"
|
||||||
|
# Anzeige: display.py ist ausfuehrbar (die Unit ruft es zwar ueber /usr/bin/python3 auf,
|
||||||
|
# "ninlight-station display test" aber auch von Hand), der Rest ist importierter Code.
|
||||||
|
for f in "$STAGE_DIR"/ninlight-display/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
case "$(basename "$f")" in
|
||||||
|
display.py) sudo install -m 0755 "$f" "$ROOT_DIR/display/$(basename "$f")" ;;
|
||||||
|
*) sudo install -m 0644 "$f" "$ROOT_DIR/display/$(basename "$f")" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# Was im Repo geloescht wurde, muss auch hier verschwinden - dasselbe Argument wie in
|
||||||
|
# deploy.sh (dort per "rsync --delete"): display/ ist reiner Code aus dem Repo, es gibt
|
||||||
|
# keinen Bestand, der einen Lauf ueberleben muesste, und ein zurueckgebliebenes altes
|
||||||
|
# Modul wuerde weiter mit importiert. rsync steht hier nicht zur Verfuegung (setup_pi.sh
|
||||||
|
# setzt ausdruecklich nur ssh/scp voraus), also von Hand.
|
||||||
|
for f in "$ROOT_DIR"/display/*; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
if [ ! -e "$STAGE_DIR/ninlight-display/$(basename "$f")" ]; then
|
||||||
|
sudo rm -rf "$f"
|
||||||
|
echo " veraltete Datei entfernt: display/$(basename "$f")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sudo install -m 0644 "$STAGE_DIR/ninlight-display.service" "$DISPLAY_UNIT_PATH"
|
||||||
|
# Taster: dieselbe Behandlung wie die Anzeige - button.py ist ausfuehrbar (die Unit ruft
|
||||||
|
# es zwar ueber /usr/bin/python3 auf, von Hand pruefen laesst es sich trotzdem), der Rest
|
||||||
|
# (aktuell nichts weiter) waere importierter Code.
|
||||||
|
for f in "$STAGE_DIR"/ninlight-button/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
case "$(basename "$f")" in
|
||||||
|
button.py) sudo install -m 0755 "$f" "$ROOT_DIR/button/$(basename "$f")" ;;
|
||||||
|
*) sudo install -m 0644 "$f" "$ROOT_DIR/button/$(basename "$f")" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# Dasselbe Argument wie bei display/: button/ ist reiner Code aus dem Repo.
|
||||||
|
for f in "$ROOT_DIR"/button/*; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
if [ ! -e "$STAGE_DIR/ninlight-button/$(basename "$f")" ]; then
|
||||||
|
sudo rm -rf "$f"
|
||||||
|
echo " veraltete Datei entfernt: button/$(basename "$f")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sudo install -m 0644 "$STAGE_DIR/ninlight-button.service" "$BUTTON_UNIT_PATH"
|
||||||
|
# Abschlussbild beim Herunterfahren und beim Neustart (zwei Units, zwei Texte).
|
||||||
|
sudo install -m 0644 "$STAGE_DIR/ninlight-poweroff.service" "$POWEROFF_UNIT_PATH"
|
||||||
|
sudo install -m 0644 "$STAGE_DIR/ninlight-reboot.service" "$REBOOT_UNIT_PATH"
|
||||||
|
# Das Aufraeumen der Ablage macht der EXIT-Trap oben - auf beiden Wegen aus diesem Block.
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
echo " installiert"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== Pakete =="
|
||||||
|
# Pillow kommt auf dem Pi bewusst aus dem Distributionspaket (python3-pil), nicht per
|
||||||
|
# pip: tools/station/ laeuft dort unter dem System-Python 3.13, und Debian 13 laesst ein
|
||||||
|
# "pip install" ins System-Python gar nicht mehr zu (PEP 668). Ohne Pillow zeichnet die
|
||||||
|
# Anzeige nicht - display.py faengt den ImportError ab und beendet sich still mit 0
|
||||||
|
# (Absicht, damit ein fehlendes Panel nie eine Migration verhindert). Genau deshalb muss
|
||||||
|
# das Paket ueberhaupt hier kommen: sonst faellt es nirgends auf.
|
||||||
|
#
|
||||||
|
# Aber NACH der Installation und nur als Warnung (Task 6b F1, I4): eine Migrationsstation
|
||||||
|
# steht typischerweise nicht am Netz, und mit veralteten Paketlisten oder ohne Internet
|
||||||
|
# scheitert apt. Unter "set -e" vor der Installation haette das den Lauf abgebrochen,
|
||||||
|
# bevor server.py, das CLI, beide Units und beide NetworkManager-Profile installiert sind
|
||||||
|
# - man haette dann GAR KEINE Station statt einer Station ohne Panel. Das waere die
|
||||||
|
# einzige harte Stelle in einer sonst durchgehend weichen Kette gewesen (display.py
|
||||||
|
# beendet sich mit 0, deploy.sh warnt nur, enable/restart unten warnen nur).
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
if python3 -c 'import PIL' 2>/dev/null; then
|
||||||
|
echo " python3-pil ist bereits vorhanden"
|
||||||
|
else
|
||||||
|
# "update" zuerst: auf einem laenger nicht gepflegten Pi zeigen die Paketlisten auf
|
||||||
|
# Versionen, die es im Archiv nicht mehr gibt, und "install" scheitert mit 404.
|
||||||
|
#
|
||||||
|
# Aber "|| true", und "install" laeuft UNABHAENGIG davon (Task 6b F2, N2): "apt-get
|
||||||
|
# update" liefert schon dann einen Fehler, wenn eine einzige Quelle nicht erreichbar
|
||||||
|
# ist - eine veraltete Drittanbieter-Liste, teilweises Netz. Mit "update && install"
|
||||||
|
# bekaeme ein Pi, auf dem "install" aus dem Paketcache oder den erreichbaren Quellen
|
||||||
|
# muehelos durchgelaufen waere, still kein Pillow. Das Auffrischen ist eine
|
||||||
|
# Verbesserung der Chancen, keine Voraussetzung.
|
||||||
|
sudo apt-get update >/dev/null 2>&1 || true
|
||||||
|
if sudo apt-get install -y python3-pil; then
|
||||||
|
echo " python3-pil installiert"
|
||||||
|
else
|
||||||
|
echo " Warnung: python3-pil liess sich nicht installieren (kein Netz? veraltete Paketlisten?)." >&2
|
||||||
|
echo " Die Station ist vollstaendig eingerichtet, das E-Paper bleibt aber leer," >&2
|
||||||
|
echo " bis 'sudo apt install python3-pil' durchlaeuft und der Anzeigedienst neu startet." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== SPI aktivieren =="
|
||||||
|
# Auf einem frischen Raspberry Pi OS ist SPI ausgeschaltet: dtparam=spi=on steht
|
||||||
|
# auskommentiert in der Boot-Konfiguration, /dev/spidev0.0 fehlt, und der Anzeigedienst
|
||||||
|
# meldet "E-Paper-Anzeige nicht verfuegbar" - das sieht nach fehlender Hardware aus,
|
||||||
|
# ist aber nur ein abgeschalteter Bus. Ohne diesen Schritt haette jede frische Station
|
||||||
|
# ein schwarzes Panel, bis jemand von Hand nachhilft (wie hier gestern geschehen).
|
||||||
|
#
|
||||||
|
# Die Aenderung wirkt erst nach einem Neustart der Boot-Konfiguration - das Skript
|
||||||
|
# startet den Pi NICHT selbst neu (ein unerwarteter Reboot mitten in einer Fernsitzung
|
||||||
|
# waere ueberraschender als ein Hinweis am Ende), meldet ueber den Exit-Code des
|
||||||
|
# Remote-Blocks aber, ob einer noetig war (10 = geaendert, 0 = schon an, 20 = keine
|
||||||
|
# Boot-Konfiguration gefunden). $SPI_REBOOT_NEEDED steuert unten, ob der Hinweis in den
|
||||||
|
# naechsten Schritten erscheint - nur dann, wenn wirklich etwas geaendert wurde.
|
||||||
|
SPI_REBOOT_NEEDED=0
|
||||||
|
set +e
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
# Ueberschreibbar wie die anderen NINLIGHT_*-Pfade oben: auf dem Pi bleibt es bei den
|
||||||
|
# echten Pfaden, ein Test kann hier gegen Sandbox-Dateien statt gegen echte
|
||||||
|
# Systempfade laufen.
|
||||||
|
SPIDEV=${NINLIGHT_SPIDEV_PATH:-/dev/spidev0.0}
|
||||||
|
CONFIG_NEW=${NINLIGHT_BOOT_CONFIG_NEW:-/boot/firmware/config.txt}
|
||||||
|
CONFIG_OLD=${NINLIGHT_BOOT_CONFIG_OLD:-/boot/config.txt}
|
||||||
|
|
||||||
|
# Existiert das Geraeteknoten schon, ist SPI aktiv - unabhaengig davon, was in der
|
||||||
|
# Boot-Konfiguration steht (es kann auch per Overlay oder einem anderen Mechanismus
|
||||||
|
# kommen). Dann kein Eingriff und kein Neustart-Hinweis.
|
||||||
|
if [ -e "$SPIDEV" ]; then
|
||||||
|
echo " SPI ist bereits aktiv ($SPIDEV vorhanden)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Aktuelle Raspberry-Pi-OS-Images nutzen /boot/firmware/config.txt, aeltere
|
||||||
|
# /boot/config.txt - beide werden geprueft, die neuere zuerst. Gibt es keinen von
|
||||||
|
# beiden, ist das kein Grund abzubrechen: ein anderes Boot-Verfahren (oder gar kein
|
||||||
|
# Raspberry Pi OS) ist moeglich, und die Anzeige ist nirgends Voraussetzung fuer eine
|
||||||
|
# funktionierende Station (dasselbe Prinzip wie beim apt-Schritt und beim
|
||||||
|
# Anzeigedienst oben).
|
||||||
|
CONFIG=""
|
||||||
|
if sudo test -f "$CONFIG_NEW"; then
|
||||||
|
CONFIG="$CONFIG_NEW"
|
||||||
|
elif sudo test -f "$CONFIG_OLD"; then
|
||||||
|
CONFIG="$CONFIG_OLD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONFIG" ]; then
|
||||||
|
echo "Warnung: weder $CONFIG_NEW noch $CONFIG_OLD gefunden - vermutlich kein" >&2
|
||||||
|
echo " Raspberry Pi OS oder ein anderes Boot-Verfahren. SPI konnte nicht" >&2
|
||||||
|
echo " geprueft werden - bleibt das E-Paper schwarz, ist das der" >&2
|
||||||
|
echo " wahrscheinlichste Grund." >&2
|
||||||
|
exit 20
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Eine aktive (nicht auskommentierte) "dtparam=spi=on"-Zeile heisst: schon an - dann
|
||||||
|
# nichts tun und keinen Neustart verlangen, den es nicht braucht.
|
||||||
|
if sudo grep -Eq '^[[:space:]]*dtparam=spi=on[[:space:]]*$' "$CONFIG"; then
|
||||||
|
echo " SPI ist in $CONFIG bereits eingeschaltet"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP="${CONFIG}.bak-ninlight"
|
||||||
|
if sudo test -f "$BACKUP"; then
|
||||||
|
# Nicht ueberschreiben: sonst waere beim zweiten Lauf (z.B. weil zwischendurch von
|
||||||
|
# Hand wieder auf "off" gestellt wurde) der urspruengliche Originalzustand weg.
|
||||||
|
echo " Sicherung $BACKUP existiert bereits - wird nicht ueberschrieben"
|
||||||
|
else
|
||||||
|
sudo cp -p "$CONFIG" "$BACKUP"
|
||||||
|
echo " Sicherung angelegt: $BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Drei Faelle bleiben: die Zeile fehlt ganz, sie steht auskommentiert da, oder sie
|
||||||
|
# steht aktiv auf "off" (z.B. von Hand deaktiviert). In allen dreien wird auf
|
||||||
|
# "dtparam=spi=on" normalisiert - eine vorhandene Zeile wird ERSETZT statt eine
|
||||||
|
# zweite anzuhaengen, sonst stuenden am Ende zwei widerspruechliche
|
||||||
|
# dtparam=spi-Zeilen in der Datei und es waere Zufall, welche NetworkManager beim
|
||||||
|
# naechsten Boot zuletzt liest.
|
||||||
|
#
|
||||||
|
# Kein "sed -i": dieser Block laeuft zwar auf dem Pi (GNU) unter "REMOTE", aber der
|
||||||
|
# Stub-Test fuehrt ihn woertlich auf dem Entwicklungsrechner (macOS/BSD) aus - "sed
|
||||||
|
# -i" braucht dort ein Suffix-Argument, GNU-sed keins, und die falsche Reihenfolge
|
||||||
|
# war in diesem Projekt schon zweimal ein Fehler. Deshalb sed nur als Filter
|
||||||
|
# (Eingabe->Ausgabe), das Ergebnis geht ueber eine Temp-Datei und "sudo install" an
|
||||||
|
# den Zielpfad - das funktioniert auf beiden Plattformen identisch.
|
||||||
|
if sudo grep -Eq '^[[:space:]]*#?[[:space:]]*dtparam=spi=(on|off)[[:space:]]*$' "$CONFIG"; then
|
||||||
|
TMP_CONFIG=$(mktemp "${TMPDIR:-/tmp}/ninlight-spi-config.XXXXXX")
|
||||||
|
sudo sed -E 's/^[[:space:]]*#?[[:space:]]*dtparam=spi=(on|off)[[:space:]]*$/dtparam=spi=on/' "$CONFIG" >"$TMP_CONFIG"
|
||||||
|
sudo install -m 0644 -o root -g root "$TMP_CONFIG" "$CONFIG"
|
||||||
|
rm -f "$TMP_CONFIG"
|
||||||
|
echo " $CONFIG: dtparam=spi=on gesetzt (vorhandene Zeile aktiviert)"
|
||||||
|
else
|
||||||
|
printf '%s\n' 'dtparam=spi=on' | sudo tee -a "$CONFIG" >/dev/null
|
||||||
|
echo " $CONFIG: dtparam=spi=on ergaenzt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Signalisiert dem Aufrufer, dass ein Neustart noetig ist (siehe oben).
|
||||||
|
exit 10
|
||||||
|
REMOTE
|
||||||
|
SPI_RC=$?
|
||||||
|
set -e
|
||||||
|
case "$SPI_RC" in
|
||||||
|
0) ;;
|
||||||
|
10) SPI_REBOOT_NEEDED=1 ;;
|
||||||
|
20) ;;
|
||||||
|
*) echo "Warnung: SPI-Aktivierung ergab einen unerwarteten Fehler (rc=$SPI_RC) - /boot/firmware/config.txt bzw. /boot/config.txt von Hand pruefen." >&2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "== Anzeigedienst aktivieren =="
|
||||||
|
# Die Anzeige wird aktiviert (startet also beim Booten mit) UND jetzt gestartet - anders
|
||||||
|
# als die Station selbst, die absichtlich aus bleibt: die Anzeige sendet nichts, sie
|
||||||
|
# zeigt nur an, und "Station aus" ist genau das, was nach dem Einrichten auf dem Panel
|
||||||
|
# stehen soll. Erst hier, nach dem Paketschritt: so startet der Dienst bereits mit
|
||||||
|
# vorhandenem Pillow, statt sich beim ersten Zeichnen still mit 0 zu beenden und bis zum
|
||||||
|
# naechsten manuellen Neustart nichts anzuzeigen.
|
||||||
|
#
|
||||||
|
# Fehlt Panel oder Pillow, beendet sich der Dienst still mit 0 (siehe display.py) -
|
||||||
|
# deshalb nur Warnungen: ein fehlendes Panel darf setup_pi.sh nicht abbrechen.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
sudo systemctl enable ninlight-display.service >/dev/null 2>&1 || \
|
||||||
|
echo " Warnung: 'systemctl enable ninlight-display' fehlgeschlagen - die Anzeige startet nach einem Neustart des Pi nicht von allein mit" >&2
|
||||||
|
sudo systemctl restart ninlight-display.service >/dev/null 2>&1 || \
|
||||||
|
echo " Warnung: 'systemctl restart ninlight-display' fehlgeschlagen - journalctl -u ninlight-display" >&2
|
||||||
|
echo " Anzeigedienst aktiviert"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== Tasterdienst aktivieren =="
|
||||||
|
# Dieselbe weiche Behandlung wie die Anzeige, aus demselben Grund: der Taster ist
|
||||||
|
# nirgends Voraussetzung. Fehlt gpiozero, fehlt der Taster oder ist GPIO23 belegt,
|
||||||
|
# beendet sich button.py still mit 0 (siehe dessen Modul-Docstring) - ein
|
||||||
|
# fehlgeschlagenes enable/start darf setup_pi.sh trotzdem nicht abbrechen, denn eine
|
||||||
|
# Station ohne Taster muss vollstaendig benutzbar bleiben.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
sudo systemctl enable ninlight-button.service >/dev/null 2>&1 || \
|
||||||
|
echo " Warnung: 'systemctl enable ninlight-button' fehlgeschlagen - der Taster startet nach einem Neustart des Pi nicht von allein mit" >&2
|
||||||
|
sudo systemctl restart ninlight-button.service >/dev/null 2>&1 || \
|
||||||
|
echo " Warnung: 'systemctl restart ninlight-button' fehlgeschlagen - journalctl -u ninlight-button" >&2
|
||||||
|
echo " Tasterdienst aktiviert"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== Abschlussbild aktivieren =="
|
||||||
|
# Zwei Units, zwei Texte: beim Herunterfahren "Strom kann getrennt werden", beim Neustart
|
||||||
|
# "Strom NICHT trennen!". Ohne sie bliebe auf dem E-Paper fuer immer das letzte Bild
|
||||||
|
# stehen - bei der Abnahme war das die Rueckfrage des Tastermenues.
|
||||||
|
#
|
||||||
|
# Dieselbe weiche Behandlung wie Anzeige und Taster: ein fehlgeschlagenes "enable" ist
|
||||||
|
# eine Warnung, kein Abbruch. Eine Station, deren Panel nach dem Ausschalten das falsche
|
||||||
|
# Bild zeigt, ist aergerlich; eine Station, die sich gar nicht einrichten laesst, ist
|
||||||
|
# unbrauchbar. "enable" allein, kein "start": diese Units tun beim Systemstart NICHTS,
|
||||||
|
# sie gehoeren zur Abschalt-Transaktion.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
for unit in ninlight-poweroff.service ninlight-reboot.service; do
|
||||||
|
sudo systemctl enable "$unit" >/dev/null 2>&1 || \
|
||||||
|
echo " Warnung: 'systemctl enable $unit' fehlgeschlagen - das E-Paper behaelt nach dem Herunterfahren sein letztes Bild" >&2
|
||||||
|
done
|
||||||
|
echo " Abschlussbild aktiviert (Herunterfahren und Neustart)"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "== NetworkManager-Profile =="
|
||||||
|
# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
|
||||||
|
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
|
||||||
|
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
|
||||||
|
# ". /etc/ninlight-station.conf" wuerde also mit "Permission denied"
|
||||||
|
# abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der
|
||||||
|
# Datei aufzuweichen.
|
||||||
|
# - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen-
|
||||||
|
# argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per
|
||||||
|
# "ps"/"/proc/<pid>/cmdline" sichtbar. Deshalb wird das Verbindungsprofil
|
||||||
|
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
|
||||||
|
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
|
||||||
|
# Prozessliste.
|
||||||
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
|
||||||
|
NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections}
|
||||||
|
eval "$(sudo cat "$CONF")"
|
||||||
|
|
||||||
|
# Die Keyfiles werden erst in einer Temp-Datei aufgebaut und dann per "install"
|
||||||
|
# an ihren Platz gelegt - dazwischen liegt das WLAN-Passwort in einer Datei, die
|
||||||
|
# noch nicht 0600 root:root ist. Ein Abbruch (set -e) genau dort darf sie nicht
|
||||||
|
# liegen lassen. Ein EXIT-Trap in write_profile() selbst waere dafuer untauglich:
|
||||||
|
# die Funktion laeuft zweimal, der zweite trap ueberschriebe den ersten, und
|
||||||
|
# "$tmp" ist "local" - beim Auswerten des Traps am Skriptende gaebe es die
|
||||||
|
# Variable gar nicht mehr. Deshalb ein gemeinsames Verzeichnis mit genau einem
|
||||||
|
# Trap darum herum (0700, mktemp -d legt es so an).
|
||||||
|
#
|
||||||
|
# Zur Reichweite des Traps (Task 6b F2, N3 - die Begruendung hier war vorher falsch):
|
||||||
|
# der EXIT-Trap allein WUERDE bei einem Strg-C schon reichen, bash fuehrt ihn auch beim
|
||||||
|
# Abbruch durch ein nicht abgefangenes SIGINT aus (Exit 130). Der Grund fuer die
|
||||||
|
# Signalhandler ist ein anderer: sie sollen den Exit-Code ehrlich halten. Ein Handler
|
||||||
|
# fuer INT/TERM, der nicht selbst beendet, laesst bash naemlich NACH dem Handler
|
||||||
|
# weiterlaufen - ein Strg-C mitten im Schreiben der Profile koennte dann in "fertig,
|
||||||
|
# Exit 0" muenden, obwohl der Lauf abgebrochen wurde. Deshalb raeumt jeder Handler auf
|
||||||
|
# und beendet danach ausdruecklich mit dem ueblichen Signal-Code (128+n).
|
||||||
|
#
|
||||||
|
# Mit ausdruecklicher Vorlage unter ${TMPDIR:-/tmp}, nicht als blankes "mktemp -d":
|
||||||
|
# BSD-mktemp (macOS, wo diese Suite auch laeuft) beachtet TMPDIR ohne Vorlage gar nicht
|
||||||
|
# und legt unabhaengig davon unter /var/folders/... an, GNU-mktemp (der Pi) dagegen
|
||||||
|
# schon. Ohne Vorlage landet das Verzeichnis also je nach Plattform woanders - ein Test,
|
||||||
|
# der nachsieht, ob wirklich nichts liegengeblieben ist, sucht dann auf macOS an der
|
||||||
|
# falschen Stelle und ist gruen, ohne etwas geprueft zu haben.
|
||||||
|
PROFILE_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/ninlight-profile.XXXXXX")
|
||||||
|
trap 'rm -rf "$PROFILE_TMPDIR"' EXIT
|
||||||
|
trap 'rm -rf "$PROFILE_TMPDIR"; exit 130' INT
|
||||||
|
trap 'rm -rf "$PROFILE_TMPDIR"; exit 143' TERM
|
||||||
|
trap 'rm -rf "$PROFILE_TMPDIR"; exit 129' HUP
|
||||||
|
|
||||||
|
# $1 = Profilname, $2 = SSID, $3 = PSK, $4 = "update" (bestehendes Profil neu schreiben,
|
||||||
|
# UUID behalten) oder "keep" (bestehendes Profil unangetastet lassen).
|
||||||
|
#
|
||||||
|
# Beide Profile bekommen dieselbe Adresse 192.168.8.1/24 auf wlan0 - genau deshalb
|
||||||
|
# schliessen sie sich gegenseitig aus, und genau deshalb prueft ninlight-station's
|
||||||
|
# ap_switch() nach jedem Umschalten nach, dass wirklich nur eines aktiv ist.
|
||||||
|
write_profile() {
|
||||||
|
local id=$1 ssid=$2 psk=$3 mode=$4
|
||||||
|
# Ein leerer Profilname wuerde eine Datei ".nmconnection" anlegen, eine leere SSID
|
||||||
|
# oder ein leeres PSK ein Profil, das NetworkManager beim Hochfahren stumm ablehnt -
|
||||||
|
# in beiden Faellen faellt es erst auf, wenn jemand mit einer toten Lampe vor der
|
||||||
|
# Station steht. Der Konfigurationsschritt oben ergaenzt fehlende Schluessel aus der
|
||||||
|
# Vorlage; leer sein koennen sie trotzdem (von Hand geleert).
|
||||||
|
if [ -z "$id" ] || [ -z "$ssid" ] || [ -z "$psk" ]; then
|
||||||
|
echo "Fehler: Profilname, SSID oder PSK ist leer - $CONF pruefen (Profil '$id')" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local conn_file="$NM_DIR/${id}.nmconnection"
|
||||||
|
local uuid action tmp
|
||||||
|
if sudo test -f "$conn_file"; then
|
||||||
|
if [ "$mode" = "keep" ]; then
|
||||||
|
# Der Migrations-AP ist auf manchen Pis von Hand angelegt worden (SSID/PSK sind
|
||||||
|
# von der alten Klyqa-Firmware fest vorgegeben, es gibt also nichts zu variieren).
|
||||||
|
# Ein bestehendes Profil wird deshalb nicht ueberschrieben - es koennte
|
||||||
|
# handjustierte Felder (Kanal, Band, Regulierungsdomaene) tragen, die dieses
|
||||||
|
# Skript gar nicht kennt.
|
||||||
|
echo " Profil $id existiert bereits - unveraendert gelassen"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
uuid=$(sudo sed -n 's/^uuid=//p' "$conn_file" | head -n1)
|
||||||
|
action="aktualisiert"
|
||||||
|
else
|
||||||
|
# /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback
|
||||||
|
# auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt
|
||||||
|
# ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst.
|
||||||
|
uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
action="angelegt"
|
||||||
|
fi
|
||||||
|
tmp=$(mktemp "$PROFILE_TMPDIR/profile.XXXXXX")
|
||||||
|
{
|
||||||
|
printf '%s\n' '[connection]'
|
||||||
|
printf 'id=%s\n' "$id"
|
||||||
|
printf 'uuid=%s\n' "$uuid"
|
||||||
|
printf '%s\n' 'type=wifi'
|
||||||
|
printf '%s\n' 'interface-name=wlan0'
|
||||||
|
printf '%s\n' 'autoconnect=false'
|
||||||
|
printf '\n%s\n' '[wifi]'
|
||||||
|
printf '%s\n' 'mode=ap'
|
||||||
|
printf 'ssid=%s\n' "$ssid"
|
||||||
|
printf '%s\n' 'band=bg'
|
||||||
|
printf '%s\n' 'channel=6'
|
||||||
|
printf '\n%s\n' '[wifi-security]'
|
||||||
|
printf '%s\n' 'key-mgmt=wpa-psk'
|
||||||
|
printf '%s\n' 'proto=rsn'
|
||||||
|
printf '%s\n' 'pairwise=ccmp;'
|
||||||
|
printf '%s\n' 'group=ccmp;'
|
||||||
|
printf 'psk=%s\n' "$psk"
|
||||||
|
printf '\n%s\n' '[ipv4]'
|
||||||
|
printf '%s\n' 'method=shared'
|
||||||
|
printf '%s\n' 'address1=192.168.8.1/24'
|
||||||
|
printf '\n%s\n' '[ipv6]'
|
||||||
|
printf '%s\n' 'method=disabled'
|
||||||
|
} >"$tmp"
|
||||||
|
sudo install -o root -g root -m 0600 "$tmp" "$conn_file"
|
||||||
|
rm -f "$tmp"
|
||||||
|
sudo nmcli connection load "$conn_file" >/dev/null
|
||||||
|
echo " Profil $id $action"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_profile "$AP_CONNECTION" "$SSID" "$PSK" update
|
||||||
|
# Ohne dieses zweite Profil scheitert "ninlight-station migrate on" auf einem frisch
|
||||||
|
# eingerichteten Pi an need_profile "$MIGRATE_AP_CONNECTION".
|
||||||
|
write_profile "$MIGRATE_AP_CONNECTION" "$MIGRATE_SSID" "$MIGRATE_PSK" keep
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
# Der Neustart-Hinweis steht an eigener, auffaelliger Stelle VOR den "Naechsten
|
||||||
|
# Schritten" - nicht dazwischengemischt - und nur, wenn oben wirklich etwas an der
|
||||||
|
# Boot-Konfiguration geaendert wurde (SPI_REBOOT_NEEDED). Ein Hinweis bei jedem Lauf,
|
||||||
|
# auch wenn SPI laengst an ist, wuerde nach ein paar Durchlaeufen ignoriert.
|
||||||
|
SPI_NOTE=""
|
||||||
|
if [ "$SPI_REBOOT_NEEDED" -eq 1 ]; then
|
||||||
|
SPI_NOTE='
|
||||||
|
WICHTIG: SPI wurde soeben erst eingeschaltet (Aenderung an der Boot-Konfiguration) -
|
||||||
|
das wirkt erst nach einem Neustart. Bis dahin bleibt das E-Paper schwarz,
|
||||||
|
auch wenn sonst alles richtig eingerichtet ist.
|
||||||
|
ssh <pi-host> sudo reboot
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Fertig. Die Station ist absichtlich noch AUS.
|
||||||
|
$SPI_NOTE
|
||||||
|
Naechste Schritte:
|
||||||
|
ssh <pi-host> ninlight-station display test
|
||||||
|
Testbild aufs E-Paper - bleibt es leer oder
|
||||||
|
verzerrt, steht EPD_REVISION in
|
||||||
|
/etc/ninlight-station.conf auf der falschen
|
||||||
|
Panel-Revision (V2 statt V3 oder umgekehrt)
|
||||||
|
tools/station/deploy.sh <pi-host> Images ausliefern
|
||||||
|
ssh <pi-host> ninlight-station on Station einschalten
|
||||||
|
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
|
||||||
|
|
||||||
|
Der Taster auf GPIO23 ist mit eingerichtet: kurzer Druck oeffnet das Menue am Geraet,
|
||||||
|
3 s gehalten brechen eine laufende Migration/Rettung ab.
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# /etc/ninlight-station.conf - von tools/station/setup_pi.sh angelegt.
|
||||||
|
# Muss zu den Build-Parametern der Firmware passen (NINLIGHT_RECOVERY_SSID,
|
||||||
|
# NINLIGHT_RECOVERY_PASSWORD in config.local.cmake). Weicht etwas ab, findet
|
||||||
|
# die Lampe die Station nicht.
|
||||||
|
#
|
||||||
|
# Werte immer in einfachen Anführungszeichen: ninlight-station liest diese
|
||||||
|
# Datei per POSIX ". datei" ein, und dabei wertet die Shell auch in doppelten
|
||||||
|
# Anführungszeichen $ und ` aus - ein Passwort mit "$1" wuerde so lautlos
|
||||||
|
# durch ein Shell-Argument ersetzt. Einfache Anführungszeichen unterbinden
|
||||||
|
# das; systemds EnvironmentFile= entfernt sie ebenso wie doppelte und fuehrt
|
||||||
|
# ohnehin nie eine Shell aus.
|
||||||
|
|
||||||
|
SSID='ninlight-recovery'
|
||||||
|
|
||||||
|
# Oeffentlicher Default. Fuer den echten Einsatz hier UND in config.local.cmake
|
||||||
|
# einen eigenen Wert setzen - wer das Passwort kennt und in Funkreichweite ist,
|
||||||
|
# kann jeder Lampe beim naechsten Boot beliebige Firmware unterschieben.
|
||||||
|
PSK='ninlight-recovery'
|
||||||
|
|
||||||
|
IMAGE_DIR='/opt/ninlight-station/images'
|
||||||
|
|
||||||
|
# NetworkManager-Profil, in das "ninlight-station off" zurueckschaltet.
|
||||||
|
HOME_CONNECTION='preconfigured'
|
||||||
|
|
||||||
|
# NetworkManager-Profil der Station selbst.
|
||||||
|
AP_CONNECTION='ninlight-recovery'
|
||||||
|
|
||||||
|
# Von der alten Klyqa-Firmware fest vorgegeben (qcx-w600-firmware) - nicht
|
||||||
|
# aenderbar, ohne dass alte Lampen die Migrationsstation nicht mehr finden.
|
||||||
|
MIGRATE_SSID='qconnex-firmware'
|
||||||
|
MIGRATE_PSK='qconnex-firmware'
|
||||||
|
|
||||||
|
# NetworkManager-Profil der Migrations-Station.
|
||||||
|
MIGRATE_AP_CONNECTION='qconnex-firmware'
|
||||||
|
|
||||||
|
# Panel-Revision der E-Paper-Anzeige (V2 oder V3) - am Geraet nicht auslesbar,
|
||||||
|
# siehe README.md.
|
||||||
|
EPD_REVISION='V3'
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
"""Tests fuer den Tasterdienst (tools/station/button/button.py).
|
||||||
|
|
||||||
|
Die Bedienlogik ist reine Zustandsfuehrung und wird hier OHNE Hardware geprueft - aber
|
||||||
|
bewusst NICHT, indem der Ablauf im Test nachgebaut wird: getrieben wird die ECHTE
|
||||||
|
Hauptschleife button.run_daemon() mit einer Fake-Uhr, einem nach Zeitplan gedrueckten
|
||||||
|
Taster-Doppel, dem ECHTEN CliActions (nur dessen subprocess.run ist ersetzt) und einem
|
||||||
|
protokollierenden Schreiber.
|
||||||
|
|
||||||
|
Der Grund steht in display.py/test_render.py: bei der Zeitsteuerung der Anzeige trieben
|
||||||
|
die Tests eine KOPIE der Verdrahtung zwischen Zustandsmaschine und Ausfuehrung, und man
|
||||||
|
konnte die eigentliche Aktion durch eine falsche ersetzen, ohne dass ein Test rot wurde.
|
||||||
|
Deshalb laeuft hier alles ueber dieselbe Schleife, die auch auf dem Pi laeuft."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "button"))
|
||||||
|
|
||||||
|
import button # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zustaende, von denen die Menueeintraege abhaengen -------------------------------
|
||||||
|
|
||||||
|
STATE_OFF = {"running": False, "mode": "recovery", "target": None,
|
||||||
|
"imageset": "ohne-wlan"}
|
||||||
|
STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None,
|
||||||
|
"imageset": "ohne-wlan"}
|
||||||
|
STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb",
|
||||||
|
"imageset": "ohne-wlan"}
|
||||||
|
STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None,
|
||||||
|
"imageset": "ohne-wlan"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fake-Uhr, Taster-Doppel, Protokoll ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _StopDaemon(Exception):
|
||||||
|
"""Bricht run_daemon()s Endlosschleife ab, sobald die simulierte Zeit abgelaufen ist."""
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClock:
|
||||||
|
def __init__(self, until_s: float) -> None:
|
||||||
|
self.t = 0.0
|
||||||
|
self.until_s = until_s
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.t
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.t += seconds
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
self.t += seconds
|
||||||
|
if self.t > self.until_s:
|
||||||
|
raise _StopDaemon
|
||||||
|
|
||||||
|
|
||||||
|
class _Recorder:
|
||||||
|
"""Gemeinsame Zeitleiste von Schreiber und Ausfuehrung - nur so faellt auf, wenn die
|
||||||
|
Reihenfolge in run_daemon() kippt (z.B. das "Bitte warten" erst NACH dem
|
||||||
|
sekundenlangen CLI-Aufruf geschrieben wuerde)."""
|
||||||
|
|
||||||
|
def __init__(self, clock: _FakeClock) -> None:
|
||||||
|
self.clock = clock
|
||||||
|
self.events: list[tuple[float, str, object]] = []
|
||||||
|
|
||||||
|
def note(self, kind: str, payload: object) -> None:
|
||||||
|
self.events.append((self.clock.monotonic(), kind, payload))
|
||||||
|
|
||||||
|
def views(self) -> list[tuple[float, dict]]:
|
||||||
|
return [(t, p) for t, k, p in self.events if k == "view"]
|
||||||
|
|
||||||
|
def runs(self) -> list[tuple[float, list]]:
|
||||||
|
return [(t, p) for t, k, p in self.events if k == "run"]
|
||||||
|
|
||||||
|
def argvs(self) -> list[list]:
|
||||||
|
return [p for _t, p in self.runs()]
|
||||||
|
|
||||||
|
def kinds(self) -> list[str]:
|
||||||
|
return [v.get("view") for _t, v in self.views()]
|
||||||
|
|
||||||
|
def first_view(self, name: str):
|
||||||
|
for t, v in self.views():
|
||||||
|
if v.get("view") == name:
|
||||||
|
return t, v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _ScriptedButton:
|
||||||
|
"""Taster-Doppel: gedrueckt genau waehrend der angegebenen Zeitfenster. `is_pressed`
|
||||||
|
ist wie bei gpiozero ein PEGEL (die Entprellung macht dort bounce_time), keine
|
||||||
|
Flanke - genau das, was run_daemon() abfragt."""
|
||||||
|
|
||||||
|
def __init__(self, clock: _FakeClock, presses) -> None:
|
||||||
|
self.clock = clock
|
||||||
|
self.presses = list(presses)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pressed(self) -> bool:
|
||||||
|
t = self.clock.monotonic()
|
||||||
|
return any(start <= t < end for start, end in self.presses)
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingWriter:
|
||||||
|
"""Schreiber-Doppel: protokolliert jeden tatsaechlich geschriebenen Bedienzustand.
|
||||||
|
Dedupliziert wie der echte UiWriter, damit die Zeitleiste die ANZEIGEWECHSEL zeigt
|
||||||
|
und nicht 50 identische Zeilen je Sekunde."""
|
||||||
|
|
||||||
|
def __init__(self, rec: _Recorder) -> None:
|
||||||
|
self.rec = rec
|
||||||
|
self.path = Path("/dev/null")
|
||||||
|
self._last = None
|
||||||
|
|
||||||
|
def write(self, view: dict) -> bool:
|
||||||
|
if view == self._last:
|
||||||
|
return False
|
||||||
|
self._last = dict(view)
|
||||||
|
self.rec.note("view", dict(view))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_runner(clock: _FakeClock, rec: _Recorder, duration_s: float = 0.0, rc: int = 0):
|
||||||
|
"""Ersetzt subprocess.run in CliActions - der Rest von CliActions (und damit die
|
||||||
|
ZUSAMMENSETZUNG der Kommandozeile, auf die es sicherheitsrelevant ankommt) ist echt."""
|
||||||
|
|
||||||
|
def run(argv, check=False, timeout=None):
|
||||||
|
rec.note("run", list(argv))
|
||||||
|
# Ein CLI-Aufruf dauert Sekunden (Profilumschaltung) - die simulierte Uhr muss
|
||||||
|
# das mitmachen, sonst pruefen die Tests eine Schleife, die es so nicht gibt.
|
||||||
|
clock.advance(duration_s)
|
||||||
|
return types.SimpleNamespace(returncode=rc)
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _drive(presses, duration_s: float, state=STATE_OFF, action_duration_s: float = 0.0,
|
||||||
|
rc: int = 0) -> _Recorder:
|
||||||
|
"""Laesst das echte button.run_daemon() `duration_s` simulierte Sekunden laufen."""
|
||||||
|
clock = _FakeClock(duration_s)
|
||||||
|
rec = _Recorder(clock)
|
||||||
|
actions = button.CliActions(cli="/usr/local/bin/ninlight-station",
|
||||||
|
runner=_fake_runner(clock, rec, action_duration_s, rc))
|
||||||
|
try:
|
||||||
|
button.run_daemon(button=_ScriptedButton(clock, presses),
|
||||||
|
writer=_RecordingWriter(rec), actions=actions, clock=clock,
|
||||||
|
read_state=lambda: dict(state))
|
||||||
|
except _StopDaemon:
|
||||||
|
pass
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def _short(at: float):
|
||||||
|
"""Ein kurzer Druck (deutlich unter SHORT_PRESS_MAX_S, deutlich ueber DEBOUNCE_S)."""
|
||||||
|
return (at, at + 0.2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Menueaufbau --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _labels(state):
|
||||||
|
return [a.label for a in button.menu_actions(state)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_off_offers_starting_and_shutdown():
|
||||||
|
assert _labels(STATE_OFF) == ["Start recovery", "Migration: listen",
|
||||||
|
"Migration: AUTOMATIC", "Switch to: with Wi-Fi",
|
||||||
|
"Shut down"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_listening_migration_comes_before_the_automatic_one():
|
||||||
|
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
|
||||||
|
irgendwo loslaesst, soll nicht im Automatikmodus landen."""
|
||||||
|
labels = _labels(STATE_OFF)
|
||||||
|
assert labels.index("Migration: listen") < labels.index("Migration: AUTOMATIC")
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
|
||||||
|
assert _labels(STATE_RECOVERY) == ["Station off", "Switch to: with Wi-Fi",
|
||||||
|
"Refresh display", "Shut down"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_in_migration_offers_the_detected_target():
|
||||||
|
assert _labels(STATE_MIGRATION) == ["Arm: gu10_rgb", "Station off",
|
||||||
|
"Switch to: with Wi-Fi", "Refresh display",
|
||||||
|
"Shut down"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_in_migration_without_a_detected_target_offers_no_release():
|
||||||
|
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
|
||||||
|
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
|
||||||
|
assert "Armed: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
|
||||||
|
assert _labels(STATE_MIGRATION_BLIND) == ["Station off", "Switch to: with Wi-Fi",
|
||||||
|
"Refresh display", "Shut down"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_refresh_entry_skips_the_confirmation():
|
||||||
|
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Refresh display"
|
||||||
|
veraendert nichts und braucht keine."""
|
||||||
|
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
|
||||||
|
for action in button.menu_actions(state):
|
||||||
|
assert action.confirm is (action.label != "Refresh display"), action.label
|
||||||
|
|
||||||
|
|
||||||
|
def test_actions_run_through_the_station_cli():
|
||||||
|
"""Die Aktionen gehen ueber das vorhandene CLI, nicht durch nachgebaute Logik - und
|
||||||
|
das Herunterfahren als einzige an systemd selbst."""
|
||||||
|
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
|
||||||
|
assert actions.argv_for(button.menu_actions(STATE_OFF)[0]) == [
|
||||||
|
"/usr/local/bin/ninlight-station", "recovery", "on"]
|
||||||
|
assert actions.argv_for(button.action_shutdown()) == ["systemctl", "poweroff"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bedienung, getrieben durch die echte Hauptschleife ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_press_opens_the_menu():
|
||||||
|
rec = _drive([_short(1.0)], 4.0)
|
||||||
|
assert rec.argvs() == []
|
||||||
|
t, view = rec.first_view("menu")
|
||||||
|
assert view["hero"] == "> Start recovery"
|
||||||
|
assert view["active"] is True
|
||||||
|
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
|
||||||
|
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
|
||||||
|
# display.UI_POLL_INTERVAL_S).
|
||||||
|
assert 1.2 <= t <= 1.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_press_steps_to_the_next_entry():
|
||||||
|
rec = _drive([_short(1.0), _short(2.0)], 4.0)
|
||||||
|
assert rec.argvs() == []
|
||||||
|
assert rec.views()[-1][1]["hero"] == "> Migration: listen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_press_wraps_around_the_last_entry():
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
|
||||||
|
_short(6.0)], 8.0)
|
||||||
|
assert rec.views()[-1][1]["hero"] == "> Start recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_one_and_a_half_seconds_selects_the_entry():
|
||||||
|
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
|
||||||
|
t, view = rec.first_view("confirm")
|
||||||
|
assert view["hero"] == "Start recovery"
|
||||||
|
assert 3.5 <= t <= 3.6
|
||||||
|
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
|
||||||
|
assert rec.argvs() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_in_the_confirmation_executes_the_entry():
|
||||||
|
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "recovery", "on"]]
|
||||||
|
assert 6.5 <= rec.runs()[0][0] <= 6.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_press_in_the_confirmation_cancels_and_returns_to_the_menu():
|
||||||
|
rec = _drive([_short(1.0), (2.0, 4.0), _short(5.0)], 8.0)
|
||||||
|
assert rec.argvs() == []
|
||||||
|
assert rec.views()[-1][1]["view"] == "menu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_refresh_entry_runs_without_a_confirmation():
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0,
|
||||||
|
state=STATE_RECOVERY)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "display", "test"]]
|
||||||
|
assert "confirm" not in rec.kinds()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_listening_entry_calls_the_cli_with_listen():
|
||||||
|
"""Der zweite Eintrag ("Migration: listen") ruft das CLI MIT --listen: der
|
||||||
|
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
|
||||||
|
freigibt."""
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on",
|
||||||
|
"--listen"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_automatic_entry_calls_the_cli_without_listen():
|
||||||
|
"""Der dritte Eintrag ("Migration: AUTOMATIC") ruft dasselbe CLI OHNE --listen - das
|
||||||
|
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
|
||||||
|
fremde Lampen ungefragt migriert werden."""
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on"]]
|
||||||
|
assert "--listen" not in rec.argvs()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_automatic_entry_asks_a_louder_question_than_the_others():
|
||||||
|
"""Zusage: die Rueckfrage des Automatikmodus benennt die Folge, statt nur "Wirklich?"
|
||||||
|
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege."""
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
|
||||||
|
_t, view = rec.first_view("confirm")
|
||||||
|
assert view["title"] == "WARNING"
|
||||||
|
assert view["hero"] == "Migration: AUTOMATIC"
|
||||||
|
text = " ".join(view["details"]).lower()
|
||||||
|
assert "every" in text and "other people" in text
|
||||||
|
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
|
||||||
|
assert view["details"][-2:] == ["hold (1.5 s): yes", "tap: no"]
|
||||||
|
|
||||||
|
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
|
||||||
|
_t, plain = rec.first_view("confirm")
|
||||||
|
assert plain["title"] == "CONFIRM"
|
||||||
|
assert plain["details"] == ["hold (1.5 s): yes", "tap: no"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
|
||||||
|
"""Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen - der Bediener soll die
|
||||||
|
Wirkung am Panel sehen, waehrend er noch haelt."""
|
||||||
|
rec = _drive([(1.0, 8.0)], 10.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"]]
|
||||||
|
t = rec.runs()[0][0]
|
||||||
|
assert 4.0 <= t <= 4.1
|
||||||
|
# ... und danach laeuft am Panel der Countdown der zweiten Stufe.
|
||||||
|
later = [v for vt, v in rec.views() if vt > t]
|
||||||
|
assert any("Shutting down in" in v.get("hero", "") for v in later)
|
||||||
|
|
||||||
|
|
||||||
|
def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
|
||||||
|
rec = _drive([(1.0, 7.0)], 12.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"]]
|
||||||
|
assert ["systemctl", "poweroff"] not in rec.argvs()
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_ten_seconds_shuts_down():
|
||||||
|
rec = _drive([(1.0, 12.0)], 14.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "off"],
|
||||||
|
["systemctl", "poweroff"]]
|
||||||
|
assert 11.0 <= rec.runs()[1][0] <= 11.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shutdown_countdown_is_visible_before_it_happens():
|
||||||
|
"""Das Herunterfahren ist nur ueber eine SICHTBARE Zustandsaenderung hinweg
|
||||||
|
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
|
||||||
|
rec = _drive([(1.0, 12.0)], 14.0)
|
||||||
|
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
|
||||||
|
assert "Shutting down in 6 s" in heroes
|
||||||
|
assert "Shutting down in 1 s" in heroes
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blocking_action_does_not_break_the_hold_gesture():
|
||||||
|
"""Der Abbruch-Aufruf blockiert die Schleife sekundenlang (Profilumschaltung). Danach
|
||||||
|
wird derselbe PEGEL gegen dieselbe Uhr gemessen - die zweite Stufe muss trotzdem
|
||||||
|
erreichbar bleiben."""
|
||||||
|
rec = _drive([(1.0, 12.0)], 14.0, action_duration_s=2.0)
|
||||||
|
assert rec.argvs()[-1] == ["systemctl", "poweroff"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_waiting_screen_is_written_before_the_slow_call():
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 9.0,
|
||||||
|
state=STATE_RECOVERY, action_duration_s=4.0)
|
||||||
|
kinds = [k for _t, k, _p in rec.events]
|
||||||
|
run_at = kinds.index("run")
|
||||||
|
assert rec.events[run_at - 1][2].get("view") == "busy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_menu_closes_after_twenty_seconds_without_input():
|
||||||
|
rec = _drive([_short(1.0)], 26.0)
|
||||||
|
assert rec.kinds()[-1] == "idle"
|
||||||
|
closed_at = [t for t, v in rec.views() if v.get("view") == "idle" and t > 2.0][0]
|
||||||
|
assert 21.2 <= closed_at <= 21.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_confirmation_expires_after_ten_seconds_as_a_cancel():
|
||||||
|
rec = _drive([_short(1.0), (2.0, 4.0)], 18.0)
|
||||||
|
assert rec.argvs() == []
|
||||||
|
assert rec.kinds()[-1] == "idle"
|
||||||
|
expired_at = [t for t, v in rec.views() if v.get("view") == "idle" and t > 4.0][0]
|
||||||
|
assert 13.5 <= expired_at <= 13.7
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_expired_confirmation_leaves_nothing_pending():
|
||||||
|
"""Der Abbruch muss die vorgemerkte Aktion auch wirklich vergessen - eine Aktion, die
|
||||||
|
nach dem Verfall noch in der Maschine liegt, ist genau das, was eine Rueckfrage
|
||||||
|
verhindern soll."""
|
||||||
|
ui = button.ButtonUI(0.0, read_state=lambda: dict(STATE_OFF))
|
||||||
|
ui.update(1.0, True)
|
||||||
|
ui.update(1.2, False) # kurzer Druck: Menue auf
|
||||||
|
ui.update(2.0, True)
|
||||||
|
ui.update(3.6, True) # 1,6 s gehalten: Eintrag gewaehlt -> Rueckfrage
|
||||||
|
ui.update(3.8, False)
|
||||||
|
assert ui.screen == "confirm" and ui.pending is not None
|
||||||
|
|
||||||
|
ui.update(3.6 + button.CONFIRM_TIMEOUT_S + 0.1, False)
|
||||||
|
|
||||||
|
assert ui.screen == "idle"
|
||||||
|
assert ui.pending is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_action_says_so_on_the_panel():
|
||||||
|
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
|
||||||
|
assert any(v.get("view") == "result" and v.get("title") == "ERROR"
|
||||||
|
for _t, v in rec.views())
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_happens_without_any_press():
|
||||||
|
rec = _drive([], 30.0)
|
||||||
|
assert rec.argvs() == []
|
||||||
|
assert rec.kinds() == ["idle"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bedienzustandsdatei ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
|
||||||
|
"""Dasselbe Muster und dieselben Rechte wie status.json/control.json - ohne 0644
|
||||||
|
kann der Anzeigedienst die Datei nicht lesen, ohne das Umbenennen sieht er sie halb
|
||||||
|
geschrieben."""
|
||||||
|
path = tmp_path / "run" / "ui.json"
|
||||||
|
writer = button.UiWriter(path)
|
||||||
|
view = {"view": "menu", "title": "MENU", "hero": "> Station off", "details": [],
|
||||||
|
"active": True}
|
||||||
|
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
|
||||||
|
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
|
||||||
|
# "der Anzeigedienst kann die Datei lesen" an einer Umgebungsvariablen des Systems.
|
||||||
|
old_umask = os.umask(0o077)
|
||||||
|
try:
|
||||||
|
assert writer.write(view) is True
|
||||||
|
finally:
|
||||||
|
os.umask(old_umask)
|
||||||
|
assert json.loads(path.read_text()) == view
|
||||||
|
assert oct(os.stat(path).st_mode & 0o777) == "0o644"
|
||||||
|
# Keine Temp-Datei zurueckgelassen (sie wuerde sonst mit jedem Neustart ein weiteres
|
||||||
|
# Bruchstueck im RuntimeDirectory hinterlassen).
|
||||||
|
assert [p.name for p in path.parent.iterdir()] == ["ui.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ui_file_is_only_written_on_change(tmp_path):
|
||||||
|
writer = button.UiWriter(tmp_path / "ui.json")
|
||||||
|
view = {"view": "idle", "active": False}
|
||||||
|
assert writer.write(view) is True
|
||||||
|
assert writer.write(dict(view)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unwritable_ui_file_is_survived_and_logged_once(tmp_path, caplog):
|
||||||
|
"""Ein Fehler am Taster darf niemals eine laufende Migration stoeren - und eine Zeile
|
||||||
|
je Schleifentakt wuerde das Journal fluten."""
|
||||||
|
blocker = tmp_path / "blocker"
|
||||||
|
blocker.write_text("kein Verzeichnis")
|
||||||
|
writer = button.UiWriter(blocker / "ui.json")
|
||||||
|
with caplog.at_level("WARNING", logger="ninlight-button"):
|
||||||
|
assert writer.write({"view": "menu", "active": True}) is False
|
||||||
|
assert writer.write({"view": "confirm", "active": True}) is False
|
||||||
|
assert len(caplog.records) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der Taster ist nirgends Voraussetzung ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_gpiozero_logs_exactly_one_line_and_exits_zero(monkeypatch, caplog):
|
||||||
|
"""Dieselbe Zusage wie bei der Anzeige (dort seit fuenf Fixrunden): kein Taster ->
|
||||||
|
GENAU EINE Protokollzeile und sys.exit(0), kein Fehler - sonst startet systemd
|
||||||
|
(Restart=on-failure) den Dienst endlos neu und flutet das Journal."""
|
||||||
|
monkeypatch.setitem(sys.modules, "gpiozero", None)
|
||||||
|
with caplog.at_level("INFO", logger="ninlight-button"):
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
button.main()
|
||||||
|
assert excinfo.value.code == 0
|
||||||
|
assert len(caplog.records) == 1
|
||||||
|
assert "gpiozero" in caplog.records[0].getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_busy_pin_is_treated_like_a_missing_button(monkeypatch, caplog):
|
||||||
|
""""GPIO busy" kommt je nach Pin-Factory als gpiozero-Fehler, als roher lgpio.error
|
||||||
|
oder als OSError - alles davon heisst "kein Taster", nicht "abstuerzen"."""
|
||||||
|
class _Busy(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = types.SimpleNamespace(
|
||||||
|
Button=lambda *a, **k: (_ for _ in ()).throw(_Busy("GPIO busy")))
|
||||||
|
monkeypatch.setitem(sys.modules, "gpiozero", fake)
|
||||||
|
with caplog.at_level("INFO", logger="ninlight-button"):
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
button.main()
|
||||||
|
assert excinfo.value.code == 0
|
||||||
|
assert len(caplog.records) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failing_cli_call_never_escapes(monkeypatch):
|
||||||
|
"""subprocess-Fehler (Binary fehlt, Timeout) duerfen den Dienst nicht beenden."""
|
||||||
|
def boom(argv, check=False, timeout=None):
|
||||||
|
raise OSError("kein CLI da")
|
||||||
|
|
||||||
|
actions = button.CliActions(runner=boom)
|
||||||
|
assert actions.run(button.action_cancel()) is False
|
||||||
|
|
||||||
|
def slow(argv, check=False, timeout=None):
|
||||||
|
raise subprocess.TimeoutExpired(argv, timeout)
|
||||||
|
|
||||||
|
assert button.CliActions(runner=slow).run(button.action_cancel()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
|
||||||
|
"""Menueeintraege haengen am Stationszustand. Ist der nicht zu ermitteln, bleibt das
|
||||||
|
Menue zu, statt einen falschen Zustand zu behaupten - und der Dienst laeuft weiter."""
|
||||||
|
ui = button.ButtonUI(0.0, read_state=lambda: (_ for _ in ()).throw(RuntimeError("weg")))
|
||||||
|
with caplog.at_level("WARNING", logger="ninlight-button"):
|
||||||
|
ui.update(0.0, True)
|
||||||
|
assert ui.update(0.2, False) is None
|
||||||
|
assert ui.view(0.3) == {"view": "idle", "active": False}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bildsatz-Umschalter im Menue ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_image_set_entry_names_where_it_switches_to():
|
||||||
|
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
|
||||||
|
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
|
||||||
|
assert "Switch to: with Wi-Fi" in _labels(STATE_OFF)
|
||||||
|
ohne = dict(STATE_OFF, imageset="mit-wlan")
|
||||||
|
assert "Switch to: no Wi-Fi" in _labels(ohne)
|
||||||
|
assert "Switch to: with Wi-Fi" not in _labels(ohne)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_image_set_entry_switches_through_the_cli():
|
||||||
|
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
|
||||||
|
assert actions.argv_for(button.action_imageset("ohne-wlan")) == [
|
||||||
|
"/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]
|
||||||
|
assert actions.argv_for(button.action_imageset("mit-wlan")) == [
|
||||||
|
"/usr/local/bin/ninlight-station", "bildsatz", "ohne-wlan"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
|
||||||
|
loud = button.action_imageset("ohne-wlan")
|
||||||
|
back = button.action_imageset("mit-wlan")
|
||||||
|
assert loud.confirm and back.confirm
|
||||||
|
assert loud.confirm_title == "WARNING"
|
||||||
|
assert back.confirm_title == "CONFIRM"
|
||||||
|
assert "Wi-Fi password" in " ".join(loud.confirm_details)
|
||||||
|
assert back.confirm_details is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_image_set_entry_really_runs_the_switch(tmp_path):
|
||||||
|
"""Durch die echte Hauptschleife, nicht nur ueber menu_actions(): vierter Eintrag im
|
||||||
|
ausgeschalteten Zustand, Auswahl halten, Rueckfrage halten."""
|
||||||
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0),
|
||||||
|
(5.0, 7.0), (8.0, 10.0)], 12.0)
|
||||||
|
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_state_reads_the_same_switch_file_as_the_cli(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(button, "_service_active", lambda service_name: False)
|
||||||
|
switch = tmp_path / "imageset"
|
||||||
|
state = lambda: button.station_state(run_dir=tmp_path, service_name="x",
|
||||||
|
imageset_path=switch)
|
||||||
|
assert state()["imageset"] == "ohne-wlan" # Datei fehlt -> sichere Vorgabe
|
||||||
|
switch.write_text("mit-wlan\n")
|
||||||
|
assert state()["imageset"] == "mit-wlan"
|
||||||
|
switch.write_text("quatsch")
|
||||||
|
assert state()["imageset"] == "ohne-wlan"
|
||||||
Executable
+1501
File diff suppressed because it is too large
Load Diff
Executable
+1770
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import products # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_is_complete():
|
||||||
|
"""Acht Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine
|
||||||
|
Produktions-ID hat."""
|
||||||
|
assert len(products.PRODUCTS) == 17
|
||||||
|
targets = {p.target for p in products.PRODUCTS.values()}
|
||||||
|
assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb",
|
||||||
|
"e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_carries_no_variant():
|
||||||
|
"""Die Variante ist eine Eigenschaft der Anfrage, nicht der Produkt-ID - sie darf gar
|
||||||
|
nicht mehr aus der Tabelle zu holen sein (Hardwarebefund 2026-09-15)."""
|
||||||
|
assert not hasattr(products.PRODUCTS["-klyqa.lighting.cw-ww.g95"], "variant")
|
||||||
|
assert "variant" not in products.Product._fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_spellings_map_to_the_same_device():
|
||||||
|
with_dev = products.PRODUCTS["-klyqa.lighting.cw-ww.g95-dev"]
|
||||||
|
without = products.PRODUCTS["-klyqa.lighting.cw-ww.g95"]
|
||||||
|
assert with_dev.target == without.target == "g95_cw"
|
||||||
|
assert with_dev.name == without.name == "Klyqa G95 Vintage CW/WW"
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_id_maps_to_device_and_name():
|
||||||
|
p = products.PRODUCTS["-klyqa.lighting.rgb-cw-ww.gu10"]
|
||||||
|
assert p.target == "gu10_rgb"
|
||||||
|
assert p.name == "Klyqa GU10 RGB CW/WW"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_real_request_from_g95_dev_in_both_places():
|
||||||
|
"""Am 2026-09-13 an einer echten Entwicklungs-G95 protokolliert: "-dev" in Produkt-ID
|
||||||
|
UND Version."""
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
|
||||||
|
assert r is not None
|
||||||
|
assert r.product_id == "-klyqa.lighting.cw-ww.g95-dev"
|
||||||
|
assert r.lamp_version == "V1.1.1.1800-dev"
|
||||||
|
assert r.product.target == "g95_cw"
|
||||||
|
assert (r.variant, r.variant_source) == ("dev", "both")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_real_request_from_g95_dev_only_in_version():
|
||||||
|
"""Am 2026-09-15 an einer echten Entwicklungs-G95 protokolliert - die Anfrage, an der
|
||||||
|
die Station bisher scheiterte: Produkt-ID OHNE "-dev", Version MIT. Sie muss trotzdem
|
||||||
|
als Entwicklungsgeraet gelten, sonst geht das produktionssignierte Image raus."""
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
|
||||||
|
assert r is not None
|
||||||
|
assert r.product_id == "-klyqa.lighting.cw-ww.g95"
|
||||||
|
assert r.lamp_version == "V1.1.1.2357-dev"
|
||||||
|
assert r.product.target == "g95_cw"
|
||||||
|
assert (r.variant, r.variant_source) == ("dev", "version")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_real_request_from_gu10_without_any_dev():
|
||||||
|
"""Die erfolgreich migrierte Produktionslampe: nirgends ein "-dev"."""
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
|
||||||
|
assert r.product.target == "gu10_rgb"
|
||||||
|
assert (r.variant, r.variant_source) == ("prod", "none")
|
||||||
|
assert r.lamp_version == "V1.1.0.1138"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_only_in_product_id_is_dev():
|
||||||
|
"""Die vierte denkbare Kombination - so noch nicht am Geraet gesehen, aber die
|
||||||
|
Machbarkeitsprobe zeigt, dass die Produkt-ID das "-dev" tragen kann, und nichts
|
||||||
|
garantiert, dass die Version es dann auch tut."""
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800_ota_light.img")
|
||||||
|
assert r.product.target == "g95_cw"
|
||||||
|
assert (r.variant, r.variant_source) == ("dev", "product_id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_must_be_a_whole_word_in_the_version():
|
||||||
|
""""-devel"/"-dev2" sind kein Entwicklungskennzeichen - sonst wuerde eine
|
||||||
|
Produktionslampe mit so einer Version das dev-signierte Image bekommen."""
|
||||||
|
for version in ("V1.1.1.2357-devel", "V1.1.1.2357-dev2"):
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_" + version + "_ota_light.img")
|
||||||
|
assert (r.variant, r.variant_source) == ("prod", "none"), version
|
||||||
|
# ... ein "-dev" vor einem Trennzeichen dagegen schon.
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1-dev.3_ota_light.img")
|
||||||
|
assert (r.variant, r.variant_source) == ("dev", "version")
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_is_classified_even_for_unknown_product_ids():
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-acme.lightbulb.xxl_V9.9-dev_ota_light.img")
|
||||||
|
assert r.product is None
|
||||||
|
assert (r.variant, r.variant_source) == ("dev", "version")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_product_id_parses_but_has_no_product():
|
||||||
|
r = products.parse_request(
|
||||||
|
"/qcx_firmware_-acme.lightbulb.xxl_V9.9_ota_light.img")
|
||||||
|
assert r is not None
|
||||||
|
assert r.product is None
|
||||||
|
assert r.product_id == "-acme.lightbulb.xxl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_matching_paths():
|
||||||
|
for p in ("/", "/ninlight_evk_ota.img", "/qcx_firmware_.img",
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95_ota_light.img",
|
||||||
|
"/x/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img"):
|
||||||
|
assert products.parse_request(p) is None, p
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_traversal_is_not_parseable():
|
||||||
|
assert products.parse_request(
|
||||||
|
"/qcx_firmware_../../etc/passwd_V1_ota_light.img") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_websafe_transform():
|
||||||
|
assert products.websafe("@klyqa.lighting.cw-ww.g95") == "-klyqa.lighting.cw-ww.g95"
|
||||||
|
assert products.websafe("a b/c") == "a-b-c"
|
||||||
|
assert products.websafe("keep+all-_.09Az") == "keep+all-_.09Az"
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+1142
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user