Compare commits
106
Commits
v0.3.7
...
7fd279721f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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`.
|
||||||
|
|
||||||
@@ -65,12 +65,48 @@ Verfügbare Befehle in der Konsole: `help`.
|
|||||||
gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits
|
gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits
|
||||||
Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich.
|
Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich.
|
||||||
Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback).
|
Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback).
|
||||||
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
|
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. den Typ `sacn`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
|
||||||
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
|
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
|
||||||
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
|
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
|
||||||
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
|
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
|
||||||
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
|
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
|
||||||
|
|
||||||
|
### sACN / E1.31
|
||||||
|
|
||||||
|
Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf
|
||||||
|
`239.255.<universe_hi>.<universe_lo>` und per Unicast direkt an ihre IP. Nach einem
|
||||||
|
Update ist der Empfang aktiv: Universe 1, Startslot 1, Belegung `rgb`.
|
||||||
|
|
||||||
|
Es gibt zwei Slot-Belegungen, umschaltbar über `layout`:
|
||||||
|
|
||||||
|
| `layout` | Slots ab `start_slot` | wofür |
|
||||||
|
|---|---|---|
|
||||||
|
| `dimmer` | Dimmer, R, G, B, CW, WW — ohne RGB: Dimmer, CW, WW | DMX-Pulte, die einen Master-Dimmer erwarten |
|
||||||
|
| `rgb` (Vorgabe) | R, G, B (3 Slots auf jedem Board) | pixelorientierte Werkzeuge wie LedFx, die je Pixel drei Kanäle schreiben |
|
||||||
|
|
||||||
|
Im `rgb`-Layout ist jede Lampe genau ein Pixel: Lampe 1 auf Offset 0, Lampe 2 auf Offset 3,
|
||||||
|
und so weiter. Ein Board ohne Farbkanäle nimmt den größten der drei Werte auf beide
|
||||||
|
Weißkanäle — es folgt damit der Helligkeit des Effekts.
|
||||||
|
Wieviele es sind, steht als `slots` in `GET system/settings`.
|
||||||
|
|
||||||
|
Während Pakete fließen, hat sACN Vorrang; die Werte gehen ohne Fade direkt an die
|
||||||
|
Kanäle, und der per API gesetzte Zustand bleibt unangetastet. 2,5 s nach dem letzten
|
||||||
|
Paket endet die Übernahme — je nach `on_timeout` fällt die Lampe auf den per API
|
||||||
|
gesetzten Zustand zurück (`release`, Vorgabe) oder behält die letzten Werte (`hold`).
|
||||||
|
Ein Befehl an `device/state` holt die Kontrolle in beiden Fällen sofort zurück.
|
||||||
|
|
||||||
|
Umstellen:
|
||||||
|
|
||||||
|
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
|
||||||
|
-H 'Authorization: <token>' \
|
||||||
|
-d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}'
|
||||||
|
|
||||||
|
Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im
|
||||||
|
WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts
|
||||||
|
speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`.
|
||||||
|
|
||||||
|
Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`.
|
||||||
|
|
||||||
## Firmware-Update
|
## Firmware-Update
|
||||||
|
|
||||||
Der Build erzeugt pro Gerät zwei Images:
|
Der Build erzeugt pro Gerät zwei Images:
|
||||||
@@ -95,11 +131,19 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
|
|||||||
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
|
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
|
||||||
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
|
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
|
||||||
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
|
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
|
||||||
sauber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt), aber
|
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
|
||||||
darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang
|
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
|
||||||
am EVK gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout — das `RECV_TIMEOUT` von 5 s des
|
|
||||||
HTTP-Clients greift dort nicht — und bricht danach ohne Wiederaufnahme ab. Die Lampe bleibt in dieser Zeit
|
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
|
||||||
über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab.
|
`RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
|
||||||
|
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
|
||||||
|
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
|
||||||
|
diesen Fall darf man sich also verlassen.
|
||||||
|
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
|
||||||
|
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
|
||||||
|
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
|
||||||
|
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
|
||||||
|
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
|
||||||
|
|
||||||
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
@@ -59,11 +59,11 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
|
|||||||
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
||||||
|
|
||||||
## Später (Lampen)
|
## Später (Lampen)
|
||||||
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
|
- [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
|
||||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
|
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
|
||||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: unteres Bereichsende deutlich warm, oberes deutlich kühler (damals noch 2500–5000 K, inzwischen 2000–5000 K), Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
||||||
- [ ] Farbtemperaturbereiche aller Varianten
|
- [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.*
|
||||||
|
|
||||||
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||||
|
|
||||||
@@ -73,3 +73,63 @@ unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läuf
|
|||||||
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
|
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
|
||||||
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
|
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
|
||||||
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
|
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
|
||||||
|
|
||||||
|
## Recovery gegen die installierte Pi-Station (2026-09-13)
|
||||||
|
|
||||||
|
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
|
||||||
|
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
|
||||||
|
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
|
||||||
|
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
|
||||||
|
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
|
||||||
|
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
|
||||||
|
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
|
||||||
|
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
|
||||||
|
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
|
||||||
|
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
|
||||||
|
|
||||||
|
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
|
||||||
|
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
|
||||||
|
sieht pro Boot einen vollen Transfer.
|
||||||
|
|
||||||
|
## Migration echter Lampen (Plan 4b, 2026-09-13)
|
||||||
|
|
||||||
|
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
|
||||||
|
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
|
||||||
|
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
|
||||||
|
|
||||||
|
## Migrationsstation (Plan 4c) — Abnahme 2026-09-14
|
||||||
|
|
||||||
|
Station: Raspberry Pi Zero W Rev 1.1, Raspberry Pi OS Bookworm 12 (Python 3.11.2, Pillow 9.4.0,
|
||||||
|
armv6), Waveshare 2,13" E-Paper **Revision V3** als HAT, ein Taster auf GPIO23 (noch ohne Funktion).
|
||||||
|
|
||||||
|
- [x] `setup_pi.sh` richtet einen **nackten** Pi vollständig ein — Pakete, SPI, beide
|
||||||
|
NetworkManager-Profile, Server, CLI, Anzeigedienst. Zweimal von Grund auf durchgelaufen.
|
||||||
|
- [x] SPI wird automatisch eingeschaltet, der Neustart-Hinweis erscheint nur bei echter Änderung.
|
||||||
|
- [x] `deploy.sh` liefert 8 OTA-Images und 16 signierte Migrationsimages aus.
|
||||||
|
- [x] Panel-Revision am Testbild bestimmt: **V3**.
|
||||||
|
- [x] Horchphase: GU10 RGB meldet sich, wird erkannt, bekommt **404** — es floss kein Byte.
|
||||||
|
Protokoll: `/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.1.103_ota_light.img 404 0B`
|
||||||
|
Daraus abgeleitet: Ziel `gu10_rgb`, Variante **prod** (kein `-dev`), Lampenversion V1.1.1.103.
|
||||||
|
- [x] Erkennung am Panel sichtbar, vom Bediener bestätigt.
|
||||||
|
- [x] Zeitschalter holt die Station selbsttätig ins Heim-WLAN zurück (3 und 15 Minuten geprüft).
|
||||||
|
- [x] `arm gu10_rgb` freigegeben, Lampe migriert, startet neu, läuft mit `0.3.8-24-geaaef`,
|
||||||
|
per mDNS auffindbar und über REST steuerbar. `last_ota_error: null`.
|
||||||
|
- [x] Migrierte Lampe meldet `color: true`, `color_temp: [2700, 6500]`, `transition_default: 300`,
|
||||||
|
`wake_on_change: false`.
|
||||||
|
- [x] Ausschaltfade ohne Farbrest an der frisch migrierten Lampe — 2026-09-14 bestätigt.
|
||||||
|
- [ ] **Nicht geprüft und weiterhin offen: E27 RGB, E14 (RGB und CW), GU10 CW.** Von diesen
|
||||||
|
Varianten lag kein Exemplar vor. Ihre Pinbelegung stammt aus der alten Firmware und war
|
||||||
|
nie an echter Hardware verifiziert.
|
||||||
|
|
||||||
|
**Befund während der Abnahme, behoben in `0fe9ca6`:** `ninlight-station off` stoppte den
|
||||||
|
Anzeigedienst, aber kein `on`-Pfad startete ihn wieder — nach dem ersten `off` (das auch der
|
||||||
|
Zeitschalter auslöst) blieb das Panel bis zum Neustart des Pi dunkel. `off` fasst den
|
||||||
|
Anzeigedienst seitdem gar nicht mehr an: er läuft durchgehend und erkennt den ausgeschalteten
|
||||||
|
Zustand selbst (`systemctl is-active ninlight-station`), zeichnet also von sich aus
|
||||||
|
**Station aus**. Der ausdrückliche Diagnosebefehl `ninlight-station display off` bleibt erhalten.
|
||||||
|
Abgesichert durch die Fälle D4 und D4b in `tools/station/tests/test_cli.sh`.
|
||||||
|
|
||||||
|
**Hardware-Lehre:** Vier Kernel-Paniken an einem Tag, alle mit derselben Fehleradresse und
|
||||||
|
unabhängig von Karte und Konfiguration, kamen von einem Mehrfach-Netzteil, das sein Budget
|
||||||
|
dynamisch neu verhandelt. Mit einem eigenen Netzteil: `throttled=0x0` auch unter Schreiblast.
|
||||||
|
Die zerschossene erste SD-Karte war die Folge, nicht die Ursache.
|
||||||
|
|||||||
@@ -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()
|
||||||
+33
-8
@@ -13,10 +13,12 @@
|
|||||||
#include "drivers/nl_led.h"
|
#include "drivers/nl_led.h"
|
||||||
#include "light/nl_boot_state.h"
|
#include "light/nl_boot_state.h"
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_http_server.h"
|
#include "net/nl_http_server.h"
|
||||||
#include "net/nl_mdns.h"
|
#include "net/nl_mdns.h"
|
||||||
#include "net/nl_recovery.h"
|
#include "net/nl_recovery.h"
|
||||||
|
#include "net/nl_sacn.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
#include "sys/nl_app_api.h"
|
#include "sys/nl_app_api.h"
|
||||||
@@ -147,6 +149,7 @@ static void light_task(void *arg)
|
|||||||
now = nl_app_now_ms();
|
now = nl_app_now_ms();
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
nl_engine_tick(now - last);
|
nl_engine_tick(now - last);
|
||||||
|
nl_dmx_tick(now);
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
last = now;
|
last = now;
|
||||||
s_light_beat++;
|
s_light_beat++;
|
||||||
@@ -160,9 +163,10 @@ static void app_task(void *arg)
|
|||||||
nl_engine_cfg_t cfg;
|
nl_engine_cfg_t cfg;
|
||||||
nl_light_state_t last, initial, pending;
|
nl_light_state_t last, initial, pending;
|
||||||
bool have_last, after_ota;
|
bool have_last, after_ota;
|
||||||
bool light_alive = false, net_alive = false, http_alive = false;
|
bool light_alive = false, net_alive = false, http_alive = false, sacn_alive = false;
|
||||||
|
bool sacn_required;
|
||||||
uint8_t boots;
|
uint8_t boots;
|
||||||
uint32_t boot_ms, light_seen, net_seen, http_seen;
|
uint32_t boot_ms, light_seen, net_seen, http_seen, sacn_seen;
|
||||||
|
|
||||||
(void)arg;
|
(void)arg;
|
||||||
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
|
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
|
||||||
@@ -202,8 +206,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);
|
||||||
@@ -230,9 +236,23 @@ static void app_task(void *arg)
|
|||||||
|
|
||||||
nl_http_server_start(nl_app_api_ctx());
|
nl_http_server_start(nl_app_api_ctx());
|
||||||
|
|
||||||
|
{
|
||||||
|
static nl_settings_t sacn_boot; /* copy: the network task may change s_settings later */
|
||||||
|
nl_app_lock();
|
||||||
|
sacn_boot = s_settings;
|
||||||
|
nl_app_unlock();
|
||||||
|
nl_sacn_apply_settings(&sacn_boot);
|
||||||
|
}
|
||||||
|
nl_sacn_start();
|
||||||
|
/* Nur ein tatsaechlich gestarteter Task darf den Wachhund mitbestimmen: ohne Task
|
||||||
|
* bewegt sich nl_sacn_beat nie, die Bedingung waere nie erfuellt und die Lampe liefe
|
||||||
|
* in eine endlose Reset-Schleife. Ein Zusatzdienst faellt dann eben aus. */
|
||||||
|
sacn_required = nl_sacn_running();
|
||||||
|
|
||||||
light_seen = s_light_beat;
|
light_seen = s_light_beat;
|
||||||
net_seen = nl_net_beat;
|
net_seen = nl_net_beat;
|
||||||
http_seen = nl_http_beat;
|
http_seen = nl_http_beat;
|
||||||
|
sacn_seen = nl_sacn_beat;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint32_t now;
|
uint32_t now;
|
||||||
bool due;
|
bool due;
|
||||||
@@ -261,9 +281,10 @@ static void app_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feed the watchdog only once the light, network and HTTP tasks have each made
|
/* Feed the watchdog only once the light, network, HTTP and sACN tasks have each
|
||||||
* progress since the last feed - a single stuck task (e.g. nl_app_lock() held
|
* made progress since the last feed - a single stuck task (e.g. nl_app_lock() held
|
||||||
* forever) must still starve the watchdog, not just the light task. */
|
* forever) must still starve the watchdog, not just the light task. Ausnahme: der
|
||||||
|
* sACN-Task, wenn er gar nicht erst angelegt werden konnte (sacn_required). */
|
||||||
if (s_light_beat != light_seen) {
|
if (s_light_beat != light_seen) {
|
||||||
light_seen = s_light_beat;
|
light_seen = s_light_beat;
|
||||||
light_alive = true;
|
light_alive = true;
|
||||||
@@ -276,8 +297,12 @@ static void app_task(void *arg)
|
|||||||
http_seen = nl_http_beat;
|
http_seen = nl_http_beat;
|
||||||
http_alive = true;
|
http_alive = true;
|
||||||
}
|
}
|
||||||
if (light_alive && net_alive && http_alive) {
|
if (nl_sacn_beat != sacn_seen) {
|
||||||
light_alive = net_alive = http_alive = false;
|
sacn_seen = nl_sacn_beat;
|
||||||
|
sacn_alive = true;
|
||||||
|
}
|
||||||
|
if (light_alive && net_alive && http_alive && (sacn_alive || !sacn_required)) {
|
||||||
|
light_alive = net_alive = http_alive = sacn_alive = false;
|
||||||
tls_watchdog_clr();
|
tls_watchdog_clr();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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,39 @@ typedef struct {
|
|||||||
|
|
||||||
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
|
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
|
||||||
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
|
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
|
||||||
* while sizeof(nl_settings_t) is a multiple of 4 (204 today). Adding a single uint16_t would move
|
* while sizeof(nl_settings_t) is a multiple of 4 (216 today, 208 before sACN). Adding a single
|
||||||
* offsetof(rec_t, crc) two bytes further than the reader looks and every stored record would
|
* uint16_t would move offsetof(rec_t, crc) two bytes further than the reader looks and every
|
||||||
* silently fail its CRC on the next boot - Wi-Fi, token and name gone on every lamp. */
|
* stored record would silently fail its CRC on the next boot - Wi-Fi, token and name gone on
|
||||||
|
* every lamp. Aus demselben Grund gilt fuer neue Felder: sie beginnen bei oder hinter dem
|
||||||
|
* vorherigen sizeof (also nicht unterhalb von 208), denn ein alter Datensatz ist genau
|
||||||
|
* sizeof lang und traegt seine Fuellbytes mit - ein Feld darunter wuerde beim Lesen von
|
||||||
|
* diesen Fuellbytes (Null) ueberschrieben statt seine Vorgabe zu behalten. */
|
||||||
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
|
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
|
||||||
|
|
||||||
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload
|
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload
|
||||||
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
|
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
|
||||||
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
|
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
|
||||||
|
|
||||||
|
/* Die Satzlaenge muss ein Vielfaches von 4 bleiben - sonst zeigt NL_SETTINGS_CRC_OFF() woanders
|
||||||
|
* hin als offsetof(rec_t, crc), und jeder gespeicherte Datensatz faellt beim naechsten Boot
|
||||||
|
* durch die CRC-Pruefung. Wer hier anstoesst, fuegt ein Fuellbyte hinzu, statt die Pruefung
|
||||||
|
* zu entfernen. */
|
||||||
|
typedef char nl_settings_size_check[(sizeof(nl_settings_t) % 4u == 0u) ? 1 : -1];
|
||||||
|
|
||||||
|
/* Das erste mit sACN angehaengte Feld muss genau dort beginnen, wo der alte Satz aufhoerte:
|
||||||
|
* bei 208, dem sizeof(nl_settings_t) der Firmware vor sACN. Saetze, die eine solche Firmware
|
||||||
|
* geschrieben hat, sind 208 Byte lang und enthalten hinter wake_on_change (Offset 206) noch
|
||||||
|
* ein Fuellbyte. read_rec() kopiert hdr.length Bytes ueber die Vorgaben - ein Feld unterhalb
|
||||||
|
* von 208 wuerde also von diesem Fuellbyte (Null) ueberschrieben und seine Vorgabe verlieren.
|
||||||
|
* Bei sacn_enabled hiesse das: sACN nach dem Update auf jeder Lampe aus. */
|
||||||
|
typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 208 ? 1 : -1];
|
||||||
|
|
||||||
|
/* Dasselbe fuer das naechste angehaengte Feld: sacn_layout muss bei 216 beginnen, dem
|
||||||
|
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
|
||||||
|
* eingefuegt - dann kaeme es aus einem aelteren Satz als Fuellbyte zurueck, nicht als
|
||||||
|
* Vorgabe. */
|
||||||
|
typedef char nl_settings_layout_offset_check[offsetof(nl_settings_t, sacn_layout) == 216 ? 1 : -1];
|
||||||
|
|
||||||
static uint32_t s_addr[2];
|
static uint32_t s_addr[2];
|
||||||
|
|
||||||
void nl_settings_defaults(nl_settings_t *s)
|
void nl_settings_defaults(nl_settings_t *s)
|
||||||
@@ -47,6 +71,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,28 @@ 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 */
|
||||||
} 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
|
||||||
|
|||||||
@@ -115,6 +115,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 +198,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)) {
|
||||||
@@ -249,6 +265,17 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
|
|||||||
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
|
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
|
||||||
else
|
else
|
||||||
cJSON_AddNullToObject(o, "last_ota_error");
|
cJSON_AddNullToObject(o, "last_ota_error");
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
|
||||||
|
ctx->get_settings(&s);
|
||||||
|
cJSON_AddBoolToObject(sa, "active", in.sacn_active);
|
||||||
|
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
|
||||||
|
/* Sagt, ob der Beitritt zur Multicast-Gruppe gelungen ist. Ohne dieses Feld war bei
|
||||||
|
* der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die
|
||||||
|
* Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */
|
||||||
|
cJSON_AddBoolToObject(sa, "multicast", in.sacn_joined);
|
||||||
|
}
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +289,25 @@ 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));
|
||||||
|
}
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +355,62 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
|
|||||||
err = "invalid all";
|
err = "invalid all";
|
||||||
else
|
else
|
||||||
s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
|
s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
|
||||||
|
} else if (strcmp(type, "sacn") == 0) {
|
||||||
|
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
|
||||||
|
const char *ot = NULL, *lo = NULL;
|
||||||
|
long un = 0, st = 0;
|
||||||
|
int unp, stp, otp, lop;
|
||||||
|
bool rgb;
|
||||||
|
uint8_t slots;
|
||||||
|
uint16_t want_start;
|
||||||
|
|
||||||
|
/* Das Layout wird ZUERST ausgewertet: es bestimmt die Slotzahl und damit die
|
||||||
|
* Obergrenze fuer start_slot. Wer in derselben Anfrage beides setzt, soll gegen das
|
||||||
|
* neue Layout geprueft werden, nicht gegen das alte. */
|
||||||
|
lop = get_string(j, "layout", 1, 16, &lo);
|
||||||
|
if (lop < 0 || (lop == 1 && strcmp(lo, "dimmer") != 0 && strcmp(lo, "rgb") != 0)) {
|
||||||
|
err = "invalid layout";
|
||||||
|
lop = 0; /* die Zweige unten duerfen den Wert dann nicht mehr benutzen */
|
||||||
|
rgb = s.sacn_layout != 0;
|
||||||
|
} else {
|
||||||
|
rgb = (lop == 1) ? (strcmp(lo, "rgb") == 0) : (s.sacn_layout != 0);
|
||||||
|
}
|
||||||
|
slots = nl_board_dmx_slots(ctx->board, rgb);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
/* invalid layout steht schon fest */
|
||||||
|
} else if (en && !cJSON_IsBool(en)) {
|
||||||
|
err = "invalid enabled";
|
||||||
|
} else if ((unp = get_int(j, "universe", 1, 63999, &un)) < 0) {
|
||||||
|
err = "invalid universe";
|
||||||
|
/* Upper bound folds the board's slot count in: the highest start_slot that still
|
||||||
|
* leaves room for all of them is 512 - slots + 1. */
|
||||||
|
} else if ((stp = get_int(j, "start_slot", 1, 512 - slots + 1, &st)) < 0) {
|
||||||
|
err = "invalid start_slot";
|
||||||
|
} else if ((otp = get_string(j, "on_timeout", 1, 16, &ot)) < 0 ||
|
||||||
|
(otp == 1 && strcmp(ot, "release") != 0 && strcmp(ot, "hold") != 0)) {
|
||||||
|
err = "invalid on_timeout";
|
||||||
|
} else {
|
||||||
|
/* Ein Wechsel auf ein breiteres Layout kann einen bisher zulaessigen start_slot
|
||||||
|
* unzulaessig machen. Das wird abgelehnt statt still verschoben: eine Lampe, die
|
||||||
|
* nach einer Einstellung woanders im Universe liegt als angegeben, findet niemand
|
||||||
|
* wieder. */
|
||||||
|
want_start = (stp == 1) ? (uint16_t)st : s.sacn_start_slot;
|
||||||
|
if ((uint32_t)want_start + slots - 1u > 512u) {
|
||||||
|
err = "invalid start_slot";
|
||||||
|
} else {
|
||||||
|
if (en)
|
||||||
|
s.sacn_enabled = cJSON_IsTrue(en) ? 1 : 0;
|
||||||
|
if (unp == 1)
|
||||||
|
s.sacn_universe = (uint16_t)un;
|
||||||
|
if (stp == 1)
|
||||||
|
s.sacn_start_slot = (uint16_t)st;
|
||||||
|
if (otp == 1)
|
||||||
|
s.sacn_hold = strcmp(ot, "hold") == 0 ? 1 : 0;
|
||||||
|
if (lop == 1)
|
||||||
|
s.sacn_layout = rgb ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
err = "invalid type";
|
err = "invalid type";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ typedef struct {
|
|||||||
uint32_t uptime_s;
|
uint32_t uptime_s;
|
||||||
uint32_t free_heap;
|
uint32_t free_heap;
|
||||||
const char *last_ota_error; /* NULL until OTA exists */
|
const char *last_ota_error; /* NULL until OTA exists */
|
||||||
|
bool sacn_active; /* true, solange die Lampe ferngesteuert wird */
|
||||||
|
bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */
|
||||||
} nl_sys_info_t;
|
} nl_sys_info_t;
|
||||||
|
|
||||||
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+19
-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,12 +65,16 @@ 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); }
|
||||||
@@ -79,7 +90,13 @@ static void get_info(nl_sys_info_t *out)
|
|||||||
nl_names_mac_str(mac, out->mac);
|
nl_names_mac_str(mac, out->mac);
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
memcpy(out->name, nl_app_settings()->name, sizeof out->name);
|
memcpy(out->name, nl_app_settings()->name, sizeof out->name);
|
||||||
|
/* Gleicher Sperrabschnitt wie der Name: nl_dmx ist nicht threadsicher, und
|
||||||
|
* nl_app_lock() ist nicht rekursiv - also hier mit erledigen statt ein zweites Mal
|
||||||
|
* zu sperren. */
|
||||||
|
out->sacn_active = nl_dmx_active();
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
|
/* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */
|
||||||
|
out->sacn_joined = nl_sacn_joined();
|
||||||
nl_wifi_status(&w);
|
nl_wifi_status(&w);
|
||||||
out->wifi_mode = w.mode;
|
out->wifi_mode = w.mode;
|
||||||
memcpy(out->ssid, w.ssid, sizeof out->ssid);
|
memcpy(out->ssid, w.ssid, sizeof out->ssid);
|
||||||
|
|||||||
@@ -5,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)
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+252
@@ -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,134 @@ static void test_settings_name_token_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- system/settings: sacn --- */
|
||||||
|
static const cJSON *sacn_field(const char *name)
|
||||||
|
{
|
||||||
|
const cJSON *s = cJSON_GetObjectItemCaseSensitive(doc, "sacn");
|
||||||
|
return cJSON_GetObjectItemCaseSensitive(s, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_settings_exposes_sacn_block(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("release", cJSON_GetStringValue(sacn_field("on_timeout")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_settings_sacn_block_reflects_stored_values(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_enabled = 0;
|
||||||
|
fake_settings.sacn_universe = 12;
|
||||||
|
fake_settings.sacn_start_slot = 7;
|
||||||
|
fake_settings.sacn_hold = 1;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(12, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(7, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("hold", cJSON_GetStringValue(sacn_field("on_timeout")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_all_fields(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"enabled\":false,\"universe\":300,"
|
||||||
|
"\"start_slot\":100,\"on_timeout\":\"hold\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, fake_settings.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(100, fake_settings.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_single_field_leaves_rest(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_universe = 9;
|
||||||
|
fake_settings.sacn_hold = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"start_slot\":50}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(50, fake_settings.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(9, fake_settings.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_rejects_bad_values(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"enabled\":\"ja\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":64000}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"on_timeout\":\"halten\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid on_timeout", error_text());
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls); /* nichts davon darf gespeichert werden */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* universe/start_slot muessen ueber get_int() laufen wie jedes andere Zahlenfeld in dieser
|
||||||
|
* Datei - ein Bruchwert darf nicht klammheimlich auf einen Integer abgeschnitten werden. */
|
||||||
|
static void test_put_sacn_rejects_fractional_numbers(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":12.5}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":2.5}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Board mit RGB braucht 6 Slots: ab 508 passt der Satz nicht mehr ins Universe. */
|
||||||
|
static void test_put_sacn_start_slot_must_leave_room_for_all_slots(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_layout = 0; /* die Vorgabe ist rgb mit 3 Slots - hier geht es um 6 */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":507}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":508}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_start_slot_limit_follows_the_board(void)
|
||||||
|
{
|
||||||
|
fake_api_reset("g95_cw");
|
||||||
|
fake_settings.sacn_layout = 0; /* im dimmer-Layout haengt die Slotzahl am Board */
|
||||||
|
/* Ein Board ohne Farbe hat auch mit Dimmer nur drei Slots: 510 passt noch. */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":511}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_info_exposes_sacn_state(void)
|
||||||
|
{
|
||||||
|
fake_info.sacn_active = true;
|
||||||
|
fake_info.sacn_joined = true;
|
||||||
|
fake_settings.sacn_universe = 5;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(5, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("multicast")));
|
||||||
|
}
|
||||||
|
|
||||||
/* --- system/command --- */
|
/* --- system/command --- */
|
||||||
static void test_commands(void)
|
static void test_commands(void)
|
||||||
{
|
{
|
||||||
@@ -339,9 +512,76 @@ static void test_commands(void)
|
|||||||
TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
|
TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- sacn: Belegung --- */
|
||||||
|
|
||||||
|
static void test_settings_reports_the_layout_and_matching_slots(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
/* Vorgabe: rgb - und darin belegt auch ein Board mit Farbe nur drei Slots. */
|
||||||
|
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
|
||||||
|
fake_settings.sacn_layout = 0;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("dimmer", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_layout(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"rgb\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_rejects_an_unknown_layout(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"pixel\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid layout", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Obergrenze fuer start_slot haengt am Layout. Im rgb-Layout (3 Slots) ist 510 erlaubt,
|
||||||
|
* mit Dimmer (6 Slots) nicht mehr - ein Wechsel darf einen unzulaessigen Zustand also nicht
|
||||||
|
* still herstellen. */
|
||||||
|
static void test_put_sacn_start_slot_limit_follows_the_layout(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_layout = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||||
|
|
||||||
|
/* Zurueck auf dimmer wuerde 510 + 6 - 1 = 515 bedeuten: abgelehnt. */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout); /* nichts uebernommen */
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_layout_and_start_slot_together(void)
|
||||||
|
{
|
||||||
|
/* Beides in einer Anfrage: geprueft wird gegen das NEUE Layout. */
|
||||||
|
fake_settings.sacn_layout = 0;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"layout\":\"rgb\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_settings_reports_the_layout_and_matching_slots);
|
||||||
|
RUN_TEST(test_put_sacn_layout);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_an_unknown_layout);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_layout);
|
||||||
|
RUN_TEST(test_put_sacn_layout_and_start_slot_together);
|
||||||
RUN_TEST(test_unknown_path_and_method);
|
RUN_TEST(test_unknown_path_and_method);
|
||||||
RUN_TEST(test_get_state);
|
RUN_TEST(test_get_state);
|
||||||
RUN_TEST(test_white_board_capabilities);
|
RUN_TEST(test_white_board_capabilities);
|
||||||
@@ -350,14 +590,26 @@ 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_commands);
|
RUN_TEST(test_commands);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-7
@@ -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);
|
||||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
if (b->ct_min == b->ct_max) {
|
||||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
/* 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_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,109 @@ static void test_erase(void)
|
|||||||
TEST_ASSERT_FALSE(nl_settings_load(&out));
|
TEST_ASSERT_FALSE(nl_settings_load(&out));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_sacn_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sacn_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("dmx"), out;
|
||||||
|
in.sacn_enabled = 0;
|
||||||
|
in.sacn_universe = 4711;
|
||||||
|
in.sacn_start_slot = 300;
|
||||||
|
in.sacn_hold = 1;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(4711, out.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, out.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Datensatz aus einer Firmware VOR sACN: die vier Felder muessen als echte Vorgaben
|
||||||
|
* zurueckkommen, nicht als Null - sonst waere sACN auf jeder aktualisierten Lampe aus.
|
||||||
|
* Die Laenge ist bewusst die Literalzahl 208: das war sizeof(nl_settings_t) vor diesem
|
||||||
|
* Zweig und damit die Laenge, die auf jeder ausgelieferten Lampe im Flash steht. Ein
|
||||||
|
* offsetof() auf ein heutiges Feld wuerde mit der Struktur mitwandern und genau den Fall
|
||||||
|
* verfehlen, um den es hier geht. Das letzte Byte (207) war im 208-Byte-Satz reines
|
||||||
|
* Fuellbyte und daher 0 - es wird hier nachgestellt, denn genau dieses Byte legt sich
|
||||||
|
* beim Lesen ueber das erste neue Feld. */
|
||||||
|
static void test_record_without_sacn_gets_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("alt"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 208u; /* sizeof(nl_settings_t) vor sACN */
|
||||||
|
fake_flash_mem[NL_FL_SETTINGS_A + 12 + 207] = 0; /* Fuellbyte des alten Satzes */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ninHome", out.wifi_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Satzlaenge muss durch 4 teilbar bleiben, sonst laufen Schreiber und Leser beim CRC
|
||||||
|
* auseinander (siehe NL_SETTINGS_CRC_OFF in nl_settings.c). */
|
||||||
|
static void test_sacn_layout_defaults_to_rgb(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sacn_layout_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("layout"), out;
|
||||||
|
in.sacn_layout = 0; /* weg von der Vorgabe, sonst prueft der Test nur diese */
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Derselbe Fallstrick wie bei sacn_enabled, eine Feldgeneration weiter: ein Satz, der von
|
||||||
|
* einer Firmware MIT sACN, aber OHNE layout geschrieben wurde, ist 216 Byte lang. Laege
|
||||||
|
* sacn_layout darunter, kaeme es als Fuellbyte dieses Satzes zurueck statt als Vorgabe. */
|
||||||
|
static void test_record_without_layout_gets_the_default(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("vor-layout"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
in.sacn_layout = 0; /* der abgeschnittene Satz darf diese 0 nicht zurueckliefern */
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 216u; /* sizeof(nl_settings_t) vor dem Layout-Feld */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("vor-layout", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_record_size_stays_aligned(void)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
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 +294,12 @@ int main(void)
|
|||||||
RUN_TEST(test_ota_pending_default_and_roundtrip);
|
RUN_TEST(test_ota_pending_default_and_roundtrip);
|
||||||
RUN_TEST(test_crc_offset_matches_reader);
|
RUN_TEST(test_crc_offset_matches_reader);
|
||||||
RUN_TEST(test_erase);
|
RUN_TEST(test_erase);
|
||||||
|
RUN_TEST(test_sacn_defaults);
|
||||||
|
RUN_TEST(test_sacn_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_sacn_gets_defaults);
|
||||||
|
RUN_TEST(test_sacn_layout_defaults_to_rgb);
|
||||||
|
RUN_TEST(test_sacn_layout_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_layout_gets_the_default);
|
||||||
|
RUN_TEST(test_record_size_stays_aligned);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+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 "$@"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
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()
|
||||||
@@ -11,14 +11,147 @@ Lötkolben käme.
|
|||||||
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
||||||
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
||||||
|
|
||||||
|
## Voraussetzung
|
||||||
|
|
||||||
|
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
|
||||||
|
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
|
||||||
|
würde dort nur mit einer Fehlermeldung enden.
|
||||||
|
|
||||||
## Bedienung
|
## Bedienung
|
||||||
|
|
||||||
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
||||||
tools/station/deploy.sh <pi-host> [target] Images ausliefern
|
tools/station/deploy.sh <pi-host> [target] Images ausliefern
|
||||||
ssh <pi-host> ninlight-station on einschalten
|
ssh <pi-host> ninlight-station on einschalten
|
||||||
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
|
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
|
||||||
|
ssh <pi-host> ninlight-station bildsatz welcher Migrations-Bildsatz gilt
|
||||||
ssh <pi-host> ninlight-station off ausschalten
|
ssh <pi-host> ninlight-station off ausschalten
|
||||||
|
|
||||||
|
## Bildsatz: mit oder ohne WLAN-Zugangsdaten
|
||||||
|
|
||||||
|
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es
|
||||||
|
gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor — **für beide
|
||||||
|
Betriebsarten**, Migration **und Rettung**:
|
||||||
|
|
||||||
|
| Umschalterstellung | Migration | Rettung | gebaut mit | die Lampe … |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `images/rescue-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
|
||||||
|
| `mit-wlan` | `images/migrate-wlan/` | `images/rescue-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` |
|
||||||
|
|
||||||
|
Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
|
||||||
|
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration/
|
||||||
|
Rettung**: der Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts
|
||||||
|
und bleibt, bis ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der
|
||||||
|
Fußzeile des Panels** — in jedem Modus, auch bei ausgeschalteter Station: man soll vor
|
||||||
|
jedem Einschalten sehen, was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu
|
||||||
|
flashen, weil die Einstellung von vorletzter Woche noch stand.
|
||||||
|
|
||||||
|
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
|
||||||
|
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
|
||||||
|
Klyqa-Lampe in Funkreichweite migriert, auch fremde. Mit dem Abzug ohne Zugangsdaten ist
|
||||||
|
ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und wartet. Mit
|
||||||
|
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden
|
||||||
|
Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||||
|
|
||||||
|
**Der Umschalter galt bisher nur für die Migration — jetzt auch für die Rettung, und das
|
||||||
|
hat eine spürbare Folge.** Bis zu dieser Erweiterung bekam eine Rettung *immer* ein
|
||||||
|
Abbild **mit** Zugangsdaten (der flache Bestand aus `build/`). Seitdem gilt für die
|
||||||
|
Rettung dieselbe Vorgabe `ohne-wlan` wie für die Migration: **eine gerettete Lampe steht
|
||||||
|
danach im eigenen Onboarding-Zugangspunkt statt im eigenen WLAN**, bis jemand ihr eines
|
||||||
|
nennt. Das ist gewollt (dieselbe Abwägung wie oben — der teure Fehler darf nie der sein,
|
||||||
|
der ohne Zutun passiert), soll aber niemanden überraschen: die Fußzeile des Panels zeigt
|
||||||
|
den Bildsatz deshalb ausdrücklich auch im Rettungsmodus, und stellt man bewusst auf
|
||||||
|
`mit-wlan` um, gilt das für beide Betriebsarten gemeinsam.
|
||||||
|
|
||||||
|
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie
|
||||||
|
gelaufen), wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz,
|
||||||
|
und auch nicht auf einen alten, flachen Bestand direkt in `images/` (siehe unten). Der
|
||||||
|
Server antwortet mit 404 (Migration zusätzlich mit `status: noimageset`), und auf dem
|
||||||
|
Panel steht „SATZ FEHLT" — in JEDEM Modus. „Vorhanden" heißt dabei überall dasselbe:
|
||||||
|
Verzeichnis da **und** mindestens ein Image darin.
|
||||||
|
|
||||||
|
`deploy.sh` liefert alle vier Sätze aus (migrate-wlan/migrate-nocreds für die Migration,
|
||||||
|
rescue-wlan/rescue-nocreds für die Rettung). Fehlt `build-nocreds/` oder ist es für ein
|
||||||
|
Ziel unvollständig, bleibt der jeweils betroffene Satz `ohne-wlan` auf dem Pi unangetastet
|
||||||
|
(statt geleert zu werden) und der Lauf meldet, womit er entsteht.
|
||||||
|
|
||||||
|
**Rückwärtskompatibilität bewusst nicht gegeben:** vor dieser Erweiterung lagen
|
||||||
|
Rettungsabbilder flach in `images/` (immer mit Zugangsdaten). Ein Pi mit diesem alten
|
||||||
|
Bestand muss **einmal neu per `deploy.sh` bestückt werden** — der Deploy räumt den
|
||||||
|
flachen Altbestand dabei automatisch weg. Bis dahin liefert die Rettung in der Vorgabe
|
||||||
|
`ohne-wlan` sichtbar nichts aus (statt den alten Bestand mit Zugangsdaten stillschweigend
|
||||||
|
weiter zu bedienen, was den ganzen Sinn dieser Erweiterung unterlaufen würde).
|
||||||
|
|
||||||
|
## Taster am Gerät
|
||||||
|
|
||||||
|
Sobald die Station läuft, wird `wlan0` zum Zugangspunkt und **jede SSH-Sitzung reißt ab**.
|
||||||
|
Für genau diesen Zustand gibt es einen Taster auf **GPIO23**; die Rückmeldung kommt aufs
|
||||||
|
E-Paper. Ein Taster, zwei Zusammenhänge:
|
||||||
|
|
||||||
|
| | kurz (unter 1 s) | gehalten |
|
||||||
|
|----------------------|------------------|-------------------------------------------------------|
|
||||||
|
| Ruhe (Statusbild) | Menü öffnen | 3 s → Abbruch (Station aus), weiter 10 s → Herunterfahren |
|
||||||
|
| Menü offen | nächster Eintrag | 1,5 s → Eintrag auswählen |
|
||||||
|
| Rückfrage | abbrechen | 1,5 s → bestätigen |
|
||||||
|
|
||||||
|
Der Abbruch feuert **bei 3 s**, nicht erst beim Loslassen — danach läuft auf dem Panel ein
|
||||||
|
Countdown bis zum Herunterfahren, den Loslassen jederzeit abbricht. Das Menü schließt nach
|
||||||
|
20 s ohne Eingabe, eine Rückfrage verfällt nach 10 s als Abbruch. Die Einträge hängen vom
|
||||||
|
Zustand ab (Station aus / Rettung läuft / Migration läuft); jede verändernde Auswahl
|
||||||
|
bekommt eine Rückfrage.
|
||||||
|
|
||||||
|
**Die beiden Migrationsarten sind getrennte Einträge.** „Migration: horchen"
|
||||||
|
(`migrate on --listen`) steht zuerst und ist der naheliegende: der Zugangspunkt steht,
|
||||||
|
aber es geht nichts raus, bis du eine erkannte Lampe freigibst. „Migration: AUTOMATIK"
|
||||||
|
(`migrate on`) folgt danach und bekommt eine **andere, deutlichere Rückfrage** — der
|
||||||
|
Migrations-Zugangspunkt hat ein öffentlich bekanntes Passwort (es steht in der alten
|
||||||
|
Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite
|
||||||
|
ungefragt migriert, auch fremde. Die Rückfrage benennt genau das, statt nur „Wirklich?"
|
||||||
|
zu fragen.
|
||||||
|
|
||||||
|
Der Taster ist nirgends Voraussetzung: fehlt `gpiozero` oder der Taster, protokolliert der
|
||||||
|
Dienst eine Zeile und beendet sich ohne Fehler. Er fasst den SPI-Bus **nicht** an (zwei
|
||||||
|
Prozesse auf denselben Leitungen ergeben `GPIO busy`), sondern schreibt seinen
|
||||||
|
Bedienzustand nach `/run/ninlight-button/ui.json`; der Anzeigedienst liest ihn und
|
||||||
|
zeichnet. Ausgeführt wird ausschließlich über das CLI.
|
||||||
|
|
||||||
|
Dienst und Unit liegen in `tools/station/button/` bzw.
|
||||||
|
`tools/station/ninlight-button.service`. `setup_pi.sh` installiert, aktiviert und startet
|
||||||
|
den Tasterdienst wie den Anzeigedienst; `deploy.sh` liefert `button/` bei jedem Deploy mit
|
||||||
|
aus. Ein fehlgeschlagenes Aktivieren/Starten ist dabei nur eine Warnung — eine Station
|
||||||
|
ohne Taster muss vollständig benutzbar bleiben.
|
||||||
|
|
||||||
|
## Abschlussbildschirm beim Herunterfahren und beim Neustart
|
||||||
|
|
||||||
|
E-Paper behält sein Bild **dauerhaft**, auch stromlos. Fährt der Pi herunter, wird der
|
||||||
|
Anzeigedienst mit abgeräumt, und das zuletzt gezeichnete Bild bleibt für immer stehen —
|
||||||
|
bei der Abnahme stand dort die **Rückfrage** des Tastermenüs, und der ausgeschaltete Pi
|
||||||
|
sah aus, als warte er noch auf eine Eingabe.
|
||||||
|
|
||||||
|
Dafür gibt es zwei systemd-Units, `ninlight-poweroff.service` (`WantedBy=poweroff.target`)
|
||||||
|
und `ninlight-reboot.service` (`WantedBy=reboot.target`). Sie hängen am **System**, nicht
|
||||||
|
am Tasterdienst: so greifen sie bei jedem Herunterfahren — über das Menü, über `poweroff`
|
||||||
|
per SSH, über `reboot` — und nicht nur auf einem von drei Wegen.
|
||||||
|
|
||||||
|
**Die Texte unterscheiden sich, und das ist der Punkt:**
|
||||||
|
|
||||||
|
| | Panel |
|
||||||
|
|---|---|
|
||||||
|
| Herunterfahren | „AUSGESCHALTET — Strom kann getrennt werden" |
|
||||||
|
| Neustart | „NEUSTART — Strom NICHT trennen!" plus der Hinweis, dass es rund 2 min dauert |
|
||||||
|
|
||||||
|
Ein Zero W braucht gut anderthalb Minuten, bis der Anzeigedienst wieder zeichnet. Stünde
|
||||||
|
dort währenddessen „Strom kann getrennt werden", wäre das eine Aufforderung zu genau dem
|
||||||
|
Griff, der an diesem Pi schon eine SD-Karte gekostet hat.
|
||||||
|
|
||||||
|
**Reihenfolge:** der Anzeigedienst hält die GPIO-/SPI-Leitungen; zwei Prozesse darauf
|
||||||
|
ergeben `lgpio.error: 'GPIO busy'`. Die Units ordnen sich deshalb per
|
||||||
|
`After=ninlight-display.service` hinter dessen Stop ein, und `ninlight-station display
|
||||||
|
final <poweroff|reboot>` wartet zusätzlich, bis `systemctl is-active ninlight-display`
|
||||||
|
nicht mehr anschlägt — dasselbe Muster wie `display_handover()` bei `display test`, nur
|
||||||
|
ohne Wiederanfahren. Gibt der Dienst die Leitungen nicht frei, wird **nicht** gezeichnet,
|
||||||
|
sondern eine Zeile ins Journal geschrieben: ein Fehler an der Anzeige darf das
|
||||||
|
Herunterfahren nie aufhalten.
|
||||||
|
|
||||||
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
|
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
|
||||||
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
|
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
|
||||||
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
|
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
|
||||||
|
|||||||
@@ -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()
|
||||||
+630
-9
@@ -13,8 +13,15 @@ MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
|||||||
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
||||||
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
||||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||||
|
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
|
||||||
|
# dieselbe Variable wie dort und in tools/release.sh). Hier schon gebraucht, nicht erst
|
||||||
|
# beim Signieren der Migrationsimages weiter unten: seit der Bildsatz-Umschalter auch die
|
||||||
|
# RETTUNG betrifft, braucht dieser Baum auch fuer die Rettungsabzuege (_ota.img statt
|
||||||
|
# _gz.img) einen eigenen Fundlauf, parallel zu dem fuer build/ unten.
|
||||||
|
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||||
|
|
||||||
declare -a FILES=()
|
declare -a FILES=()
|
||||||
|
declare -a RESCUE_NOCREDS_FILES=()
|
||||||
if [ $# -gt 0 ]; then
|
if [ $# -gt 0 ]; then
|
||||||
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
||||||
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
||||||
@@ -24,17 +31,60 @@ if [ $# -gt 0 ]; then
|
|||||||
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
||||||
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
||||||
FILES+=("$f")
|
FILES+=("$f")
|
||||||
|
# build-nocreds/ ist wie beim Signieren unten OPTIONAL/nachsichtig: ein Deploy darf
|
||||||
|
# nicht daran scheitern, dass tools/build_nocreds.sh fuer dieses Ziel noch nicht
|
||||||
|
# gelaufen ist. Fehlt die Datei, bleibt der Rettungs-Bildsatz "ohne-wlan" fuer GENAU
|
||||||
|
# dieses Ziel auf dem Pi unangetastet (sync_image_set unten arbeitet ohne --delete,
|
||||||
|
# wenn diese Liste nicht jedes Ziel aus FILES abdeckt).
|
||||||
|
nf="$ROOT/$NOCREDS_BUILD_REL/$t/ninlight_${t}_ota.img"
|
||||||
|
if [ -f "$nf" ]; then
|
||||||
|
RESCUE_NOCREDS_FILES+=("$nf")
|
||||||
|
else
|
||||||
|
echo "Hinweis: $nf fehlt - $t bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert" >&2
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
FULL_SYNC=1
|
FULL_SYNC=1
|
||||||
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
||||||
|
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
||||||
|
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
||||||
|
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
||||||
|
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
||||||
|
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
||||||
|
# kein Zufall.
|
||||||
|
declare -A SEEN_AT=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
||||||
|
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEEN_AT[$base]=$f
|
||||||
|
FILES+=("$f")
|
||||||
|
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||||
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
||||||
|
# Derselbe Fundlauf fuer build-nocreds/ - genauso nachsichtig wie beim gezielten Deploy
|
||||||
|
# oben: fehlt der Baum komplett (tools/build_nocreds.sh nie gelaufen), bleibt
|
||||||
|
# RESCUE_NOCREDS_FILES einfach leer, kein Abbruch.
|
||||||
|
if [ -d "$ROOT/$NOCREDS_BUILD_REL" ]; then
|
||||||
|
declare -A SEEN_AT_NOCREDS=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
if [ -n "${SEEN_AT_NOCREDS[$base]:-}" ]; then
|
||||||
|
echo "Fehler: $base mehrfach unter $NOCREDS_BUILD_REL gefunden - ${SEEN_AT_NOCREDS[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEEN_AT_NOCREDS[$base]=$f
|
||||||
|
RESCUE_NOCREDS_FILES+=("$f")
|
||||||
|
done < <(find "$ROOT/$NOCREDS_BUILD_REL" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== Images pruefen =="
|
echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) =="
|
||||||
STAGE=$(mktemp -d)
|
STAGE=$(mktemp -d)
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
||||||
|
declare -A NINL_VERSION=()
|
||||||
for f in "${FILES[@]}"; do
|
for f in "${FILES[@]}"; do
|
||||||
base=$(basename "$f")
|
base=$(basename "$f")
|
||||||
target=${base#ninlight_}; target=${target%_ota.img}
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
@@ -45,9 +95,355 @@ for f in "${FILES[@]}"; do
|
|||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
set -- $out
|
set -- $out
|
||||||
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||||
cp "$f" "$STAGE/$base"
|
NINL_VERSION[$target]="$2"
|
||||||
|
# Landet jetzt in einem eigenen Unterverzeichnis (rescue-wlan/) statt flach in STAGE -
|
||||||
|
# server.py:image_path() folgt seit diesem Umschalter demselben Bildsatz-Umschalter
|
||||||
|
# wie migrate_dir(), und "mit-wlan" ist genau dieser Satz (siehe RESCUE_SET_DIRS dort).
|
||||||
|
mkdir -p "$STAGE/rescue-wlan"
|
||||||
|
cp "$f" "$STAGE/rescue-wlan/$base"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten
|
||||||
|
# Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den
|
||||||
|
# gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine
|
||||||
|
# Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie,
|
||||||
|
# ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer
|
||||||
|
# jedes Ziel gelaufen" (siehe oben).
|
||||||
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then
|
||||||
|
echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) =="
|
||||||
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
||||||
|
echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $out
|
||||||
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||||
|
mkdir -p "$STAGE/rescue-nocreds"
|
||||||
|
cp "$f" "$STAGE/rescue-nocreds/$base"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
||||||
|
fi
|
||||||
|
# "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz
|
||||||
|
# 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf
|
||||||
|
# dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die
|
||||||
|
# Migrationsimages weiter unten, nur ohne Signierschritt dazwischen).
|
||||||
|
RESCUE_NOCREDS_COMPLETE=1
|
||||||
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then
|
||||||
|
RESCUE_NOCREDS_COMPLETE=0
|
||||||
|
else
|
||||||
|
declare -A RESCUE_NOCREDS_TARGETS=()
|
||||||
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
||||||
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
||||||
|
RESCUE_NOCREDS_TARGETS[$t]=1
|
||||||
|
done
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
||||||
|
[ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Migrationsimages signieren =="
|
||||||
|
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
||||||
|
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
||||||
|
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
|
||||||
|
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
|
||||||
|
# ausgeliefert (Exit-Code bleibt 0).
|
||||||
|
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
|
||||||
|
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
|
||||||
|
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
|
||||||
|
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
|
||||||
|
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
|
||||||
|
KEY_DEV="$SIGN_DIR/fw_sign.pem"
|
||||||
|
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
|
||||||
|
# MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
|
||||||
|
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
|
||||||
|
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
|
||||||
|
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
|
||||||
|
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
|
||||||
|
MIGRATE_COMPLETE=0
|
||||||
|
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
|
||||||
|
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
||||||
|
# einer, darf --delete auf dem Pi nicht laufen.
|
||||||
|
NOCREDS_COMPLETE=0
|
||||||
|
# NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
|
||||||
|
|
||||||
|
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
||||||
|
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
||||||
|
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
|
||||||
|
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
|
||||||
|
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
|
||||||
|
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
|
||||||
|
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
|
||||||
|
sign_set() {
|
||||||
|
local src_root=$1 stage_sub=$2 strictness=$3
|
||||||
|
local complete=1 produced=0
|
||||||
|
local f base target gz
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
|
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
|
||||||
|
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
|
||||||
|
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
|
||||||
|
# annimmt.
|
||||||
|
gz="$src_root/$target/ninlight_${target}_gz.img"
|
||||||
|
if [ ! -f "$gz" ]; then
|
||||||
|
if [ "$strictness" = "strict" ]; then
|
||||||
|
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
|
||||||
|
complete=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
mkdir -p "$STAGE/$stage_sub"
|
||||||
|
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||||
|
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|
||||||
|
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
||||||
|
produced=1
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||||
|
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|
||||||
|
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
||||||
|
produced=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$produced" -eq 1 ] || return 1
|
||||||
|
local VERSION_ARGS=()
|
||||||
|
local t
|
||||||
|
for t in "${!NINL_VERSION[@]}"; do
|
||||||
|
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
|
||||||
|
done
|
||||||
|
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
|
||||||
|
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
|
||||||
|
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
|
||||||
|
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
|
||||||
|
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
|
||||||
|
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
|
||||||
|
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
|
||||||
|
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
|
||||||
|
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
|
||||||
|
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
|
||||||
|
python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
migrate_dir = sys.argv[1]
|
||||||
|
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
|
||||||
|
entries = []
|
||||||
|
for name in sorted(os.listdir(migrate_dir)):
|
||||||
|
if name.endswith("_prod.img"):
|
||||||
|
target, variant = name[: -len("_prod.img")], "prod"
|
||||||
|
elif name.endswith("_dev.img"):
|
||||||
|
target, variant = name[: -len("_dev.img")], "dev"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
with open(os.path.join(migrate_dir, name), "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
entries.append({
|
||||||
|
"target": target,
|
||||||
|
"variant": variant,
|
||||||
|
"file": name,
|
||||||
|
"bytes": len(data),
|
||||||
|
"sha256": hashlib.sha256(data).hexdigest(),
|
||||||
|
"ninlight_version": version_map.get(target, ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"images": entries,
|
||||||
|
}
|
||||||
|
versions = {e["ninlight_version"] for e in entries}
|
||||||
|
if len(versions) == 1:
|
||||||
|
(version,) = versions
|
||||||
|
if version:
|
||||||
|
manifest["ninlight_version"] = version
|
||||||
|
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
||||||
|
json.dump(manifest, fh, indent=2, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
PY
|
||||||
|
[ "$complete" -eq 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
HAVE_PROD=0
|
||||||
|
HAVE_DEV=0
|
||||||
|
if [ -d "$SIGN_DIR" ]; then
|
||||||
|
if [ -f "$KEY_PROD" ]; then
|
||||||
|
HAVE_PROD=1
|
||||||
|
else
|
||||||
|
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2
|
||||||
|
fi
|
||||||
|
if [ -f "$KEY_DEV" ]; then
|
||||||
|
HAVE_DEV=1
|
||||||
|
else
|
||||||
|
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
||||||
|
BOTH_KEYS=0
|
||||||
|
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
|
||||||
|
|
||||||
|
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
|
||||||
|
# und die Station beide vorhalten soll (der Bediener schaltet per
|
||||||
|
# "ninlight-station bildsatz" bzw. am Taster um):
|
||||||
|
#
|
||||||
|
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
|
||||||
|
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
|
||||||
|
#
|
||||||
|
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
|
||||||
|
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
|
||||||
|
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
|
||||||
|
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
|
||||||
|
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
|
||||||
|
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
|
||||||
|
echo " Bildsatz migrate-wlan (aus build/)"
|
||||||
|
if sign_set "$ROOT/build" migrate-wlan strict; then
|
||||||
|
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
|
||||||
|
fi
|
||||||
|
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
|
||||||
|
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
|
||||||
|
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
|
||||||
|
else
|
||||||
|
if [ ! -d "$STAGE/migrate-nocreds" ]; then
|
||||||
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
|
||||||
|
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
|
||||||
|
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
|
||||||
|
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
|
||||||
|
# dagegen ungeprueft, was in build-nocreds/ lag.
|
||||||
|
#
|
||||||
|
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
|
||||||
|
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
|
||||||
|
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
|
||||||
|
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
||||||
|
# Fehler. Lieber ein abgebrochener Deploy.
|
||||||
|
#
|
||||||
|
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen.
|
||||||
|
# Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der
|
||||||
|
# Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden:
|
||||||
|
# er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an
|
||||||
|
# migrate-nocreds/, der Rettungssatz lief ungeprueft durch.
|
||||||
|
# migrate-wlan/ und rescue-wlan/ bleiben ungeprueft: die SOLLEN die Zugangsdaten
|
||||||
|
# enthalten, das ist ihr Zweck.
|
||||||
|
#
|
||||||
|
# Gesucht wird im ENTPACKTEN Rumpf, nicht in der Datei, wie sie auf der Platte liegt:
|
||||||
|
# jedes Abbild (_ota.img wie die signierten _prod.img/_dev.img) ist 56 Byte Kopf, dann
|
||||||
|
# ein gzip-Strom, dann 512 Byte Trailer bzw. Signatur. Im komprimierten Strom steht der
|
||||||
|
# Klartext des Passworts nicht - eine Suche im Rohbyteinhalt meldete deshalb IMMER
|
||||||
|
# "sauber", auch fuer den Satz aus build/, der die Zugangsdaten nachweislich traegt. Die
|
||||||
|
# Pruefung hat bis hierhin also nie irgendetwas geprueft.
|
||||||
|
#
|
||||||
|
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
|
||||||
|
# "dann eben ungeprueft durchlassen". Genau diese Nachsicht waere der Weg, auf dem das
|
||||||
|
# Passwort am Ende doch hinausgeht.
|
||||||
|
#
|
||||||
|
# Der Pruefkoerper in tools/release.sh ist verwandt, aber nicht mehr identisch: dort
|
||||||
|
# wird ein fertiges Archiv aus lauter verschiedenen Dateiarten geprueft, hier ein
|
||||||
|
# Bildsatz aus Firmware-Abbildern.
|
||||||
|
# Als if-Zweige und nicht als "[ -d … ] && CHECK_SETS+=(…)": unter "set -e" waere eine
|
||||||
|
# solche Zeile, wenn das Verzeichnis fehlt, ein fehlgeschlagenes letztes Kommando und
|
||||||
|
# risse den Deploy ab.
|
||||||
|
declare -a CHECK_SETS=()
|
||||||
|
if [ -d "$STAGE/migrate-nocreds" ]; then CHECK_SETS+=(migrate-nocreds); fi
|
||||||
|
if [ -d "$STAGE/rescue-nocreds" ]; then CHECK_SETS+=(rescue-nocreds); fi
|
||||||
|
if [ ${#CHECK_SETS[@]} -gt 0 ]; then
|
||||||
|
echo "== Bildsaetze 'ohne-wlan' auf Zugangsdaten pruefen =="
|
||||||
|
LOCAL="$ROOT/config.local.cmake"
|
||||||
|
if [ -f "$LOCAL" ]; then
|
||||||
|
python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:]
|
||||||
|
text = open(local, encoding="utf-8", errors="replace").read()
|
||||||
|
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
||||||
|
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
||||||
|
# haette nur falsche Alarme erzeugt.
|
||||||
|
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||||
|
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||||
|
# Aufbau eines Abbilds: 56 Byte Kopf (vom wm_tool erzeugt), dann der gzip-Strom mit der
|
||||||
|
# eigentlichen Firmware, dann 512 Byte ninlight-Trailer bzw. RSA-Signatur.
|
||||||
|
HEADER_LEN = 56
|
||||||
|
TRAILER_LEN = 512
|
||||||
|
needles = []
|
||||||
|
for name in SECRETS:
|
||||||
|
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||||
|
if m and m.group(1) not in PUBLIC:
|
||||||
|
needles.append((name, m.group(1).encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(raw):
|
||||||
|
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
|
||||||
|
if len(raw) <= HEADER_LEN + TRAILER_LEN:
|
||||||
|
raise ValueError("Datei ist kuerzer als Kopf + Trailer")
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
body = dec.decompress(raw[HEADER_LEN:-TRAILER_LEN])
|
||||||
|
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
|
||||||
|
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
|
||||||
|
# ungeprueft - und genau dort koennte das Passwort stehen.
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom ist unvollstaendig")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
broken = []
|
||||||
|
images = 0
|
||||||
|
for image_set in sets:
|
||||||
|
root_dir = os.path.join(stage, image_set)
|
||||||
|
for root, _dirs, files in os.walk(root_dir):
|
||||||
|
for fn in sorted(files):
|
||||||
|
path = os.path.join(root, fn)
|
||||||
|
rel = os.path.relpath(path, stage)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
# Durchsucht werden beide Sichten: der entpackte Rumpf (dort steht die
|
||||||
|
# Firmware) UND die Rohbytes (Kopf und Trailer werden nicht komprimiert,
|
||||||
|
# und Beidateien wie manifest.json gibt es nur roh).
|
||||||
|
views = [raw]
|
||||||
|
if fn.endswith(".img"):
|
||||||
|
images += 1
|
||||||
|
try:
|
||||||
|
views.append(unpack(raw))
|
||||||
|
except Exception as exc:
|
||||||
|
broken.append("%s (%s)" % (rel, exc))
|
||||||
|
continue
|
||||||
|
for name, needle in needles:
|
||||||
|
if any(needle in view for view in views):
|
||||||
|
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
|
||||||
|
hits.append("%s in %s" % (name, rel))
|
||||||
|
if broken:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Abbild im Bildsatz 'ohne-wlan' laesst sich nicht entpacken, also auch "
|
||||||
|
"nicht auf Zugangsdaten pruefen - nichts uebertragen:\n "
|
||||||
|
+ "\n ".join(broken) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
if hits:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
|
||||||
|
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
print(" sauber (%d Abbilder in %d Saetzen geprueft: %s)"
|
||||||
|
% (images, len(sets), ", ".join(sets)))
|
||||||
|
CHECKPY
|
||||||
|
else
|
||||||
|
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "== uebertragen =="
|
echo "== uebertragen =="
|
||||||
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
||||||
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
||||||
@@ -60,31 +456,231 @@ echo "== uebertragen =="
|
|||||||
# ausgelieferte Verzeichnis.
|
# ausgelieferte Verzeichnis.
|
||||||
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
|
# -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 -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
||||||
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
|
||||||
|
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
||||||
|
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
||||||
|
"$HOST:/tmp/"
|
||||||
|
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
|
||||||
|
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
|
||||||
|
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
|
||||||
|
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
|
||||||
|
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
|
||||||
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
|
||||||
|
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
|
||||||
|
# __pycache__ dieses Rechners.
|
||||||
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
||||||
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
||||||
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
||||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
|
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE'
|
||||||
set -e
|
set -e
|
||||||
FULL_SYNC=$1
|
FULL_SYNC=$1
|
||||||
|
MIGRATE_COMPLETE=$2
|
||||||
|
NOCREDS_COMPLETE=$3
|
||||||
|
RESCUE_NOCREDS_COMPLETE=$4
|
||||||
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||||
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
||||||
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||||
|
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
||||||
|
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
||||||
|
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
||||||
|
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
|
||||||
|
|
||||||
sudo install -d -m 0755 "$ROOT_DIR/images"
|
sudo install -d -m 0755 "$ROOT_DIR/images"
|
||||||
if [ "$FULL_SYNC" -eq 1 ]; then
|
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||||
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
||||||
echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt"
|
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
||||||
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
||||||
|
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
||||||
|
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
||||||
|
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
||||||
|
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
||||||
|
REMOVED=""
|
||||||
|
if [ -n "$OLD_LIST" ]; then
|
||||||
|
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
||||||
|
fi
|
||||||
|
if [ -n "$REMOVED" ]; then
|
||||||
|
echo " voller Abgleich - folgende Images werden entfernt:"
|
||||||
|
echo "$REMOVED" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " voller Abgleich - keine Images werden entfernt"
|
||||||
|
fi
|
||||||
|
# migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
|
||||||
|
# hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
|
||||||
|
# Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
|
||||||
|
# unter /tmp/ninlight-images deshalb gar nicht existiert.
|
||||||
|
#
|
||||||
|
# Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die
|
||||||
|
# Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die
|
||||||
|
# Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem
|
||||||
|
# Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle
|
||||||
|
# unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt
|
||||||
|
# noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt.
|
||||||
|
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
||||||
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
else
|
else
|
||||||
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||||
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
||||||
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
||||||
sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
||||||
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
|
fi
|
||||||
|
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
||||||
|
# beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
|
||||||
|
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
|
||||||
|
#
|
||||||
|
# Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
|
||||||
|
# seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
|
||||||
|
# fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
|
||||||
|
# tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
|
||||||
|
# geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
|
||||||
|
# frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
|
||||||
|
# diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten
|
||||||
|
# betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die
|
||||||
|
# Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren
|
||||||
|
# greift dort also einfach nicht.
|
||||||
|
sync_image_set() {
|
||||||
|
set=$1
|
||||||
|
complete=$2
|
||||||
|
if [ -d "/tmp/ninlight-images/$set" ]; then
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
|
||||||
|
if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then
|
||||||
|
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
|
||||||
|
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
|
||||||
|
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
|
||||||
|
sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
||||||
|
else
|
||||||
|
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
|
||||||
|
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
|
||||||
|
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
|
||||||
|
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
|
||||||
|
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
|
||||||
|
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
|
||||||
|
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
||||||
|
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
|
||||||
|
sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
||||||
|
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
|
||||||
|
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
||||||
|
with open(new_manifest_path) as fh:
|
||||||
|
new_manifest = json.load(fh)
|
||||||
|
if not isinstance(new_manifest, dict):
|
||||||
|
new_manifest = {}
|
||||||
|
try:
|
||||||
|
with open(manifest_path) as fh:
|
||||||
|
old_manifest = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
old_manifest = {}
|
||||||
|
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
|
||||||
|
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
|
||||||
|
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
|
||||||
|
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
|
||||||
|
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
|
||||||
|
# Stationscode zurueck.
|
||||||
|
if not isinstance(old_manifest, dict):
|
||||||
|
old_manifest = {}
|
||||||
|
old_images = old_manifest.get("images")
|
||||||
|
if not isinstance(old_images, list):
|
||||||
|
old_images = []
|
||||||
|
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
|
||||||
|
new_images = new_manifest.get("images")
|
||||||
|
if not isinstance(new_images, list):
|
||||||
|
new_images = []
|
||||||
|
for e in new_images:
|
||||||
|
if isinstance(e, dict):
|
||||||
|
by_key[(e.get("target"), e.get("variant"))] = e
|
||||||
|
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
|
||||||
|
merged = {
|
||||||
|
"built": new_manifest.get("built"),
|
||||||
|
"images": merged_images,
|
||||||
|
}
|
||||||
|
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
|
||||||
|
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
|
||||||
|
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
|
||||||
|
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
|
||||||
|
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
|
||||||
|
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
|
||||||
|
# Fall, dass wirklich alle Eintraege uebereinstimmen.
|
||||||
|
versions = {e.get("ninlight_version") for e in merged_images}
|
||||||
|
if len(versions) == 1:
|
||||||
|
(version,) = versions
|
||||||
|
if isinstance(version, str) and version:
|
||||||
|
merged["ninlight_version"] = version
|
||||||
|
with open(manifest_path, "w") as fh:
|
||||||
|
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
MERGE
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis
|
||||||
|
# vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
|
||||||
|
# ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
|
||||||
|
# ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
|
||||||
|
# (Fixrunde 2, Kleinigkeit).
|
||||||
|
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem
|
||||||
|
# Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der
|
||||||
|
# andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der
|
||||||
|
# fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus.
|
||||||
|
sync_image_set migrate-wlan "$MIGRATE_COMPLETE"
|
||||||
|
sync_image_set migrate-nocreds "$NOCREDS_COMPLETE"
|
||||||
|
# rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht
|
||||||
|
# 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie
|
||||||
|
# das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein
|
||||||
|
# Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen
|
||||||
|
# Schluesseln.
|
||||||
|
sync_image_set rescue-wlan 1
|
||||||
|
sync_image_set rescue-nocreds "$RESCUE_NOCREDS_COMPLETE"
|
||||||
|
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
||||||
|
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
||||||
|
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
||||||
|
if [ -d "$ROOT_DIR/images/migrate" ]; then
|
||||||
|
sudo rm -rf "$ROOT_DIR/images/migrate"
|
||||||
|
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
||||||
|
fi
|
||||||
|
# Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege
|
||||||
|
# FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest
|
||||||
|
# dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung,
|
||||||
|
# warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die
|
||||||
|
# normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja
|
||||||
|
# nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht
|
||||||
|
# uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser
|
||||||
|
# Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv
|
||||||
|
# (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen
|
||||||
|
# gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten.
|
||||||
|
OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null)
|
||||||
|
if [ -n "$OLD_FLAT_RESCUE" ]; then
|
||||||
|
echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f
|
||||||
|
echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)"
|
||||||
fi
|
fi
|
||||||
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
||||||
|
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
||||||
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
||||||
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
|
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
|
||||||
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
|
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
|
||||||
|
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
|
||||||
|
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/display"
|
||||||
|
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
|
||||||
|
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
|
||||||
|
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
|
||||||
|
sudo install -d -m 0755 "$ROOT_DIR/button"
|
||||||
|
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
|
||||||
|
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
|
||||||
|
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
|
||||||
|
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
|
||||||
|
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
|
||||||
|
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
|
||||||
|
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
|
||||||
|
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
if systemctl is-active --quiet ninlight-station; then
|
if systemctl is-active --quiet ninlight-station; then
|
||||||
if sudo systemctl restart ninlight-station; then
|
if sudo systemctl restart ninlight-station; then
|
||||||
@@ -96,6 +692,31 @@ if systemctl is-active --quiet ninlight-station; then
|
|||||||
else
|
else
|
||||||
echo " Dienst laeuft nicht (Station ist aus)"
|
echo " Dienst laeuft nicht (Station ist aus)"
|
||||||
fi
|
fi
|
||||||
|
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
|
||||||
|
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
|
||||||
|
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
|
||||||
|
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
|
||||||
|
# uebersehen.
|
||||||
|
if systemctl is-active --quiet ninlight-display; then
|
||||||
|
if sudo systemctl restart ninlight-display; then
|
||||||
|
echo " Anzeigedienst neu gestartet"
|
||||||
|
else
|
||||||
|
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Anzeigedienst laeuft nicht"
|
||||||
|
fi
|
||||||
|
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
|
||||||
|
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
|
||||||
|
if systemctl is-active --quiet ninlight-button; then
|
||||||
|
if sudo systemctl restart ninlight-button; then
|
||||||
|
echo " Tasterdienst neu gestartet"
|
||||||
|
else
|
||||||
|
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Tasterdienst laeuft nicht"
|
||||||
|
fi
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
echo "fertig."
|
echo "fertig."
|
||||||
|
|||||||
@@ -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
|
||||||
+1194
-64
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ StartLimitBurst=5
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/ninlight-station.conf
|
EnvironmentFile=/etc/ninlight-station.conf
|
||||||
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR}
|
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR} --mode-file /run/ninlight-station/mode --imageset-file /var/lib/ninlight-station/imageset
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|
||||||
@@ -32,6 +32,18 @@ ProtectSystem=strict
|
|||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
RuntimeDirectory=ninlight-station
|
RuntimeDirectory=ninlight-station
|
||||||
|
# Ohne dies leert systemd /run/ninlight-station bei JEDEM Stop und Neustart, nicht
|
||||||
|
# nur beim allerersten Start - und ninlight-station (das CLI) schreibt mode/
|
||||||
|
# control.json immer VOR dem "systemctl restart", mit dem migrate/recovery on den
|
||||||
|
# Dienst (neu) starten. Ohne RuntimeDirectoryPreserve=restart wischt genau dieser
|
||||||
|
# Neustart die beiden gerade geschriebenen Dateien wieder weg, bevor der neue
|
||||||
|
# Serverprozess sie lesen kann - der Dienst faellt dann still auf den
|
||||||
|
# Recovery-Default zurueck (server.py resolve_mode()/read_control()), und ein
|
||||||
|
# ausdrueckliches "migrate on --listen" wird zu "Rettungsmodus" bzw. "auto"
|
||||||
|
# (Fixrunde 1, C1). "restart" (nicht "yes") reicht: das Verzeichnis muss nur einen
|
||||||
|
# Neustart ueberleben, nicht dauerhaft bestehen bleiben, wenn der Dienst regulaer
|
||||||
|
# gestoppt wird.
|
||||||
|
RuntimeDirectoryPreserve=restart
|
||||||
ReadWritePaths=/run/ninlight-station
|
ReadWritePaths=/run/ninlight-station
|
||||||
RestrictAddressFamilies=AF_INET AF_UNIX
|
RestrictAddressFamilies=AF_INET AF_UNIX
|
||||||
MemoryMax=128M
|
MemoryMax=128M
|
||||||
|
|||||||
@@ -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)
|
||||||
+560
-41
@@ -1,9 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""ninlight recovery station.
|
"""ninlight recovery/migration station.
|
||||||
|
|
||||||
Serves firmware images to lamps that look for a rescue station while booting. Deliberately
|
Serves firmware images to lamps that look for a rescue station while booting (mode
|
||||||
minimal: it hands out nothing but files whose name matches ninlight_<target>_ota.img, so a
|
"recovery": ninlight_<target>_ota.img) or to old Klyqa lamps looking for their original
|
||||||
stray file in the image directory can never leave the machine.
|
update server (mode "migration": qcx_firmware_<product-id>_<version>_ota_light.img).
|
||||||
|
Deliberately minimal: only files matching one of these two exact patterns ever leave the
|
||||||
|
machine, and only the pattern for the active mode is served.
|
||||||
|
|
||||||
Standard library only - the Pi gets no extra packages.
|
Standard library only - the Pi gets no extra packages.
|
||||||
"""
|
"""
|
||||||
@@ -24,7 +26,15 @@ from datetime import datetime, timezone
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw")
|
# products.py lives next to this file. sys.path[0] already covers that when server.py is
|
||||||
|
# run the normal way (as a script or via `python3 server.py ...`), but a test that loads
|
||||||
|
# this module through importlib.util.spec_from_file_location (see test_server.py's
|
||||||
|
# test_log_error_is_not_silenced_by_log_message) does not get that for free.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import products
|
||||||
|
|
||||||
|
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw",
|
||||||
|
"ledshield_rgb")
|
||||||
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
||||||
TRAILER_SIZE = 512
|
TRAILER_SIZE = 512
|
||||||
TRAILER_MAGIC = b"NINL"
|
TRAILER_MAGIC = b"NINL"
|
||||||
@@ -53,36 +63,375 @@ def trailer_version(path: Path) -> str:
|
|||||||
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
|
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bildsatz: mit oder ohne einkompilierte WLAN-Zugangsdaten -----------------------
|
||||||
|
#
|
||||||
|
# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware (config.local.cmake). Es gibt
|
||||||
|
# deshalb zwei Abzuege je Ziel, und beide liegen auf dem Pi:
|
||||||
|
#
|
||||||
|
# "ohne-wlan" -> images/migrate-nocreds/ aus tools/build_nocreds.sh: die migrierte
|
||||||
|
# Lampe startet im eigenen Onboarding-Zugangspunkt (ninlight-<mac4>)
|
||||||
|
# und wartet, bis ihr jemand ein WLAN nennt.
|
||||||
|
# "mit-wlan" -> images/migrate-wlan/ aus build/: die Lampe haengt sofort im WLAN
|
||||||
|
# des Bedieners.
|
||||||
|
#
|
||||||
|
# Welcher ausgeliefert wird, entscheidet ein UMSCHALTER (ninlight-station bildsatz bzw.
|
||||||
|
# der Menueeintrag am Taster), nicht eine Abfrage je Migration: sein Zustand bleibt, bis
|
||||||
|
# ihn jemand aendert, und er steht dauerhaft auf dem Panel.
|
||||||
|
#
|
||||||
|
# Vorgabe ist "ohne-wlan", und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar,
|
||||||
|
# unbekannter Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in
|
||||||
|
# Funkreichweite migriert, auch fremde. Ein Abzug OHNE Zugangsdaten ist dann ein
|
||||||
|
# aergerlicher, aber harmloser Fehlgriff - die Lampe steht im Onboarding-AP und wartet.
|
||||||
|
# Ein Abzug MIT Zugangsdaten traegt das WLAN-Passwort des Bedieners unwiderruflich in
|
||||||
|
# ein fremdes Geraet. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||||
|
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||||
|
DEFAULT_IMAGE_SET = "ohne-wlan"
|
||||||
|
|
||||||
|
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (nicht nur die Migration): der
|
||||||
|
# Eigentuemer hat das ausdruecklich verlangt, denn bisher bekam eine Rettung IMMER ein
|
||||||
|
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ stammten aus build/). Die
|
||||||
|
# Rettungsabzuege OHNE Zugangsdaten kommen aus tools/build_nocreds.sh, genau wie ihr
|
||||||
|
# Migrations-Gegenstueck - deshalb dasselbe Namensschema, nur mit "rescue-" statt
|
||||||
|
# "migrate-" (deploy.sh legt sie entsprechend ab, siehe dort).
|
||||||
|
#
|
||||||
|
# Zwei getrennte Verzeichnispaare (migrate-*/rescue-*) statt einem gemeinsamen: die
|
||||||
|
# Migrationsabzuege sind signiert und tragen ein eigenes manifest.json, die
|
||||||
|
# Rettungsabzuege sind die gewoehnlichen _ota.img mit ninlight-Trailer. Sie
|
||||||
|
# unterscheiden sich in Form und Herstellung; ein gemeinsames Verzeichnis wuerde beide
|
||||||
|
# Herstellungswege ununterscheidbar ineinander mischen (siehe auch: "migrate-wlan/ und
|
||||||
|
# migrate-nocreds/ werden bei der Rettung nie angefasst" im Auftrag).
|
||||||
|
RESCUE_SET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_set(path: Path | None) -> str:
|
||||||
|
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
|
||||||
|
ist (inklusive ungueltigem UTF-8 - Fixrunde 1, Kleinigkeit: ein reines "except
|
||||||
|
OSError" liess eine Datei mit kaputten Bytes durchschlagen statt auf die Vorgabe zu
|
||||||
|
fallen) oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei
|
||||||
|
darf den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
|
||||||
|
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
|
||||||
|
if path is None:
|
||||||
|
return DEFAULT_IMAGE_SET
|
||||||
|
try:
|
||||||
|
value = path.read_text().strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return DEFAULT_IMAGE_SET
|
||||||
|
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
|
||||||
|
|
||||||
|
|
||||||
class Station:
|
class Station:
|
||||||
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None:
|
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None,
|
||||||
|
mode: str = "recovery", control_path: Path | None = None,
|
||||||
|
imageset_path: Path | None = None) -> None:
|
||||||
self.image_dir = image_dir
|
self.image_dir = image_dir
|
||||||
|
# Bei JEDER Anfrage neu gelesen, nicht einmal beim Start: der Umschalter soll
|
||||||
|
# wirken, ohne den Dienst neu zu starten - ein Neustart mitten in einer
|
||||||
|
# laufenden Auslieferung waere genau das, was die Station nicht tun darf.
|
||||||
|
self.imageset_path = imageset_path
|
||||||
self.state_path = state_path
|
self.state_path = state_path
|
||||||
self.stall_after = stall_after
|
self.stall_after = stall_after
|
||||||
|
self.mode = mode
|
||||||
|
self.control_path = control_path or Path("/run/ninlight-station/control.json")
|
||||||
self.since = now_iso()
|
self.since = now_iso()
|
||||||
self.served = 0
|
self.served = 0
|
||||||
self.recent: list[dict] = []
|
self.recent: list[dict] = []
|
||||||
|
# Zeitpunkt der letzten Anfrage JEDER Art (erfolgreich oder nicht) - das
|
||||||
|
# Lebenszeichen fuer die Anzeige (display.py:_recent_request()). Anders als
|
||||||
|
# "served" zaehlt hier auch eine erfolglose Anfrage: der Eigentuemer wollte
|
||||||
|
# gerade nicht erst am Servierzaehler raten muessen, ob ueberhaupt etwas
|
||||||
|
# anklopft (Bedienerwunsch 2026-09-23). None, solange seit dem Start der
|
||||||
|
# Station noch keine einzige Anfrage einging.
|
||||||
|
self.last_activity: str | None = None
|
||||||
|
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
|
||||||
|
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
||||||
|
self.last_seen: dict | None = None
|
||||||
|
self._manifest_mtime: float | None = None
|
||||||
|
# Zu welchem Bildsatz der Cache gehoert - siehe _load_manifest().
|
||||||
|
self._manifest_path: Path | None = None
|
||||||
|
# Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch
|
||||||
|
# geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt
|
||||||
|
# jeder Eintrag seine eigene Version, image_version() muss also den ganzen
|
||||||
|
# Eintragssatz vorhalten, nicht nur einen String.
|
||||||
|
self._manifest_cache: dict = {}
|
||||||
|
# Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder
|
||||||
|
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
|
||||||
|
# dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf
|
||||||
|
# Platte geschrieben werden konnte (Fixrunde 1, I3). control_error landet in
|
||||||
|
# status.json, damit ein Bedienfehler nicht nur auf stdout verschwindet.
|
||||||
|
self._control_write_broken = False
|
||||||
|
self.control_error: str | None = None
|
||||||
|
# Cumulative bytes already delivered to each client while --stall-after is active,
|
||||||
|
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
|
||||||
|
# resumes with Range right where it left off, so a per-request counter would hand
|
||||||
|
# every reconnect a brand-new --stall-after allowance and the stall would never
|
||||||
|
# actually stick (see reserve_stall_bytes below).
|
||||||
|
self.stall_sent: dict[str, int] = {}
|
||||||
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
||||||
# can be downloading at once. Every mutation of served/recent and the snapshot
|
# can be downloading at once. Every mutation of served/recent/stall_sent and the
|
||||||
# that gets serialized to disk must happen under this same lock, or concurrent
|
# snapshot that gets serialized to disk must happen under this same lock, or
|
||||||
# requests lose increments and stomp on each other's status.json writes.
|
# concurrent requests lose increments and stomp on each other's status.json writes.
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.write_state()
|
self.write_state()
|
||||||
|
|
||||||
|
def rescue_dir(self) -> Path:
|
||||||
|
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes fuer die RETTUNG. Dieselbe Regel
|
||||||
|
wie migrate_dir() (dort steht die ausfuehrliche Begruendung): der Umschalter
|
||||||
|
entscheidet, nicht eine Abfrage je Rettung, und es wird nie stillschweigend auf
|
||||||
|
den jeweils anderen Satz ausgewichen.
|
||||||
|
|
||||||
|
Bestandskompatibilitaet bewusst NICHT gegeben: vor diesem Umschalter lagen
|
||||||
|
Rettungsabzuege FLACH in image_dir (immer MIT Zugangsdaten, aus build/). Ein
|
||||||
|
Pi mit diesem alten Bestand wuerde, faende image_path() dort noch etwas, bei der
|
||||||
|
Vorgabe "ohne-wlan" weiterhin klaglos ein Abbild MIT Zugangsdaten ausliefern -
|
||||||
|
also genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend
|
||||||
|
fortsetzen, die dieser Umschalter gerade abstellen soll. Ein Pi mit altem
|
||||||
|
Bestand muss deshalb einmal per deploy.sh neu bestueckt werden (das raeumt den
|
||||||
|
flachen Altbestand auch auf); bis dahin liefert die Rettung in der Vorgabe
|
||||||
|
"ohne-wlan" sichtbar nichts aus (Status "notfound"), statt einen Fehlgriff zu
|
||||||
|
verstecken."""
|
||||||
|
return self.image_dir / RESCUE_SET_DIRS[self.image_set()]
|
||||||
|
|
||||||
|
def rescue_set_present(self) -> bool:
|
||||||
|
"""Liegt der eingestellte Rettungs-Bildsatz ueberhaupt auf dem Pi? Dieselbe Regel
|
||||||
|
wie migrate_set_present() (Verzeichnis vorhanden UND mindestens ein Image
|
||||||
|
darin), hier fuer die Startzeile im Journal (main()) verwendet. Panel
|
||||||
|
(display.py) und CLI (ninlight-station) sind eigene Prozesse und rufen diese
|
||||||
|
Methode nicht auf, urteilen aber nach DERSELBEN REGEL - eigene, gleich lautende
|
||||||
|
Pruefungen, kein gemeinsamer Code."""
|
||||||
|
try:
|
||||||
|
return any(p.name.endswith(".img") for p in self.rescue_dir().iterdir())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
def image_path(self, target: str) -> Path:
|
def image_path(self, target: str) -> Path:
|
||||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
return self.rescue_dir() / f"ninlight_{target}_ota.img"
|
||||||
|
|
||||||
|
def image_set(self) -> str:
|
||||||
|
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
|
||||||
|
return read_image_set(self.imageset_path)
|
||||||
|
|
||||||
|
def migrate_dir(self) -> Path:
|
||||||
|
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes. Es kann fehlen (der Satz wurde
|
||||||
|
nie ausgeliefert) - dann wird NICHTS ausgeliefert, siehe Handler: auf den anderen
|
||||||
|
Satz auszuweichen waere genau der Fehlgriff, gegen den der Umschalter da ist."""
|
||||||
|
return self.image_dir / IMAGE_SET_DIRS[self.image_set()]
|
||||||
|
|
||||||
|
def migrate_image_path(self, target: str, variant: str) -> Path:
|
||||||
|
return self.migrate_dir() / f"{target}_{variant}.img"
|
||||||
|
|
||||||
|
def migrate_set_present(self) -> bool:
|
||||||
|
"""Liegt der eingestellte Bildsatz ueberhaupt auf dem Pi? "Vorhanden" heisst: das
|
||||||
|
Verzeichnis gibt es UND es enthaelt mindestens ein Image. Ein leeres Verzeichnis
|
||||||
|
zaehlt bewusst als nicht vorhanden - es entsteht genauso leicht (angelegt, aber nie
|
||||||
|
bestueckt) und unterscheidet sich fuer den Bediener in nichts; das Panel und das
|
||||||
|
CLI beurteilen es nach derselben Regel, sonst behaupten drei Stellen dreierlei."""
|
||||||
|
try:
|
||||||
|
return any(p.name.endswith(".img") for p in self.migrate_dir().iterdir())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_manifest(self) -> dict:
|
||||||
|
"""Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen
|
||||||
|
bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie
|
||||||
|
unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis
|
||||||
|
{} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit
|
||||||
|
eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem
|
||||||
|
AttributeError den Server abschiesst."""
|
||||||
|
path = self.migrate_dir() / "manifest.json"
|
||||||
|
try:
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
self._manifest_mtime = None
|
||||||
|
self._manifest_path = path
|
||||||
|
self._manifest_cache = {}
|
||||||
|
return self._manifest_cache
|
||||||
|
# Der Pfad gehoert mit in den Vergleich: nach einem Umschalten zeigt er auf eine
|
||||||
|
# ANDERE Datei, die zufaellig dieselbe mtime tragen kann (beide entstehen im
|
||||||
|
# selben Deploy, oft in derselben Sekunde). Ohne ihn behielte der Cache die
|
||||||
|
# Versionen des vorigen Satzes - und die stuenden dann in status.json und am
|
||||||
|
# Panel neben einem Image, das gar nicht von dort kommt.
|
||||||
|
if mtime != self._manifest_mtime or path != getattr(self, "_manifest_path", None):
|
||||||
|
self._manifest_path = path
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
self._manifest_cache = data
|
||||||
|
self._manifest_mtime = mtime
|
||||||
|
return self._manifest_cache
|
||||||
|
|
||||||
|
def image_version(self, target: str, variant: str) -> str:
|
||||||
|
"""Firmware-Version fuer genau das Migrationsimage, das gerade ausgeliefert
|
||||||
|
wird - die Migrationsimages selbst tragen keinen ninlight-Trailer, aus dem der
|
||||||
|
Server sie lesen koennte (Spec Abschnitt 4). Bevorzugt die Version aus dem
|
||||||
|
passenden Eintrag in manifest.json; nur wenn der Eintrag keine eigene traegt
|
||||||
|
(Bestandskompatibilitaet mit einem manifestweiten Feld), faellt es auf das
|
||||||
|
Feld auf oberster Ebene zurueck. Ein einzelnes manifestweites Feld allein
|
||||||
|
waere seit dem Zusammenfuehren mehrerer Deploys (deploy.sh, Fixrunde 1 I1)
|
||||||
|
schlicht nicht mehr wahr: uebernommene Alt-Eintraege koennen eine andere
|
||||||
|
Version tragen als die gerade neu signierten (Fixrunde 2, N2)."""
|
||||||
|
data = self._load_manifest()
|
||||||
|
images = data.get("images")
|
||||||
|
if isinstance(images, list):
|
||||||
|
for entry in images:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("target") == target and entry.get("variant") == variant:
|
||||||
|
version = entry.get("ninlight_version")
|
||||||
|
if isinstance(version, str) and version:
|
||||||
|
return version
|
||||||
|
break
|
||||||
|
top = data.get("ninlight_version")
|
||||||
|
return top if isinstance(top, str) and top else "unbekannt"
|
||||||
|
|
||||||
|
def read_control(self):
|
||||||
|
"""Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" -
|
||||||
|
eine fehlende Steuerdatei darf eine Migration nie blockieren, sondern hoechstens
|
||||||
|
bequemer machen als beabsichtigt. Bei jeder Anfrage frisch gelesen, damit
|
||||||
|
"ninlight-station arm" ohne Neustart des Dienstes wirkt."""
|
||||||
|
try:
|
||||||
|
d = json.loads(self.control_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return "auto", None
|
||||||
|
phase = d.get("phase")
|
||||||
|
if phase not in ("auto", "listen", "armed"):
|
||||||
|
return "auto", None
|
||||||
|
return phase, d.get("armed_target")
|
||||||
|
|
||||||
|
def write_control(self, phase: str, armed_target: str | None) -> None:
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self.control_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=self.control_path.parent, prefix=".control-")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump({"phase": phase, "armed_target": armed_target}, f)
|
||||||
|
# Dasselbe Muster wie write_state() unten, aus demselben Grund: der
|
||||||
|
# Dienst laeuft als root, mkstemp() legt 0600 an, und ohne das
|
||||||
|
# explizite chmod() koennte das CLI die Steuerdatei nicht lesen.
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, self.control_path)
|
||||||
|
except BaseException:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
self._control_write_broken = True
|
||||||
|
self.control_error = f"cannot write {self.control_path}: {e}"
|
||||||
|
print(f"warning: {self.control_error}", flush=True)
|
||||||
|
else:
|
||||||
|
self._control_write_broken = False
|
||||||
|
self.control_error = None
|
||||||
|
|
||||||
|
def claim_delivery(self, target: str) -> tuple[str, str | None, bool]:
|
||||||
|
"""Entscheidet unter dem Lock, ob eine Anfrage fuer `target` gerade ausgeliefert
|
||||||
|
werden darf, und verbraucht - falls die Freigabe dafuer verantwortlich ist - diese
|
||||||
|
sofort per Compare-and-Set (lesen und auf "listen" setzen, ohne das Lock
|
||||||
|
dazwischen loszulassen). Ohne das koennten zwei gleichzeitige Anfragen (zwei
|
||||||
|
Lampen, oder Erst- und Range-Resume-Verbindung derselben Lampe) beide phase ==
|
||||||
|
"armed" sehen und beide ausgeliefert bekommen (Fixrunde 1, C1) - genau das Szenario,
|
||||||
|
fuer das die Freigabephase laut Spec 3.3 da ist.
|
||||||
|
|
||||||
|
Liefert (phase, armed_target) wie sie VOR dem Verbrauch galten, plus ob die
|
||||||
|
aktuelle Anfrage ausliefern darf. Bricht die Auslieferung anschliessend ab
|
||||||
|
(status == "aborted") oder findet der Aufrufer kein Image, muss er bei
|
||||||
|
phase == "armed" restore_armed(target) aufrufen, damit die Freigabe fuer den
|
||||||
|
naechsten Bootversuch stehen bleibt.
|
||||||
|
|
||||||
|
Schlaegt das Schreiben von "listen" fehl (I3), bekommt nur die eine Anfrage, die
|
||||||
|
den Fehler zuerst ausloest, noch ihre Auslieferung - jede weitere schlaegt hier
|
||||||
|
erneut fehl (_control_write_broken bleibt True), bis ein write_control()-Versuch
|
||||||
|
wieder klappt. Damit bleibt "armed" nicht dauerhaft scharf, nur weil /run gerade
|
||||||
|
nicht beschreibbar ist, heilt sich aber von selbst, sobald es das wieder ist."""
|
||||||
|
with self.lock:
|
||||||
|
phase, armed_target = self.read_control()
|
||||||
|
if phase == "listen" or (phase == "armed" and armed_target != target):
|
||||||
|
return phase, armed_target, False
|
||||||
|
if phase == "armed":
|
||||||
|
was_broken = self._control_write_broken
|
||||||
|
self.write_control("listen", None)
|
||||||
|
if self._control_write_broken:
|
||||||
|
return phase, armed_target, not was_broken
|
||||||
|
return phase, armed_target, True
|
||||||
|
|
||||||
|
def restore_armed(self, target: str) -> None:
|
||||||
|
"""Setzt eine per claim_delivery() verbrauchte Freigabe zurueck, wenn die
|
||||||
|
Auslieferung danach doch nicht (vollstaendig) stattgefunden hat - ein Abbruch darf
|
||||||
|
die Freigabe nicht verbrauchen, sonst muesste am Geraet erneut von Hand gearmt
|
||||||
|
werden, obwohl die Lampe es beim naechsten Bootversuch selbst nochmal versuchen
|
||||||
|
wuerde (Spec 3.3, Brief-Schritt 3.6)."""
|
||||||
|
self.write_control("armed", target)
|
||||||
|
|
||||||
|
def reserve_stall_bytes(self, ip: str, n: int) -> int:
|
||||||
|
"""How many of the next `n` bytes this client may still receive under --stall-after.
|
||||||
|
|
||||||
|
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
|
||||||
|
that the lamp abandons and resumes via Range does not get a fresh budget just
|
||||||
|
because it is a new TCP connection. Only called when stall_after is set.
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
sent_so_far = self.stall_sent.get(ip, 0)
|
||||||
|
allowed = max(0, min(n, self.stall_after - sent_so_far))
|
||||||
|
self.stall_sent[ip] = sent_so_far + allowed
|
||||||
|
return allowed
|
||||||
|
|
||||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if status == "ok":
|
if status == "ok":
|
||||||
self.served += 1
|
self.served += 1
|
||||||
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
ts = now_iso()
|
||||||
|
# Jede Anfrage zaehlt als Lebenszeichen, nicht nur eine erfolgreiche - siehe
|
||||||
|
# self.last_activity oben.
|
||||||
|
self.last_activity = ts
|
||||||
|
self.recent.append({"ts": ts, "ip": ip, "target": target,
|
||||||
"version": version, "bytes": nbytes, "status": status})
|
"version": version, "bytes": nbytes, "status": status})
|
||||||
del self.recent[:-RECENT_MAX]
|
del self.recent[:-RECENT_MAX]
|
||||||
self.write_state()
|
self.write_state()
|
||||||
|
|
||||||
|
def record_migration(self, ip: str, target: str | None, variant: str | None,
|
||||||
|
product_id: str, lamp_version: str, product_name: str | None,
|
||||||
|
version: str, nbytes: int, status: str,
|
||||||
|
variant_source: str | None = None) -> None:
|
||||||
|
"""Protokolliert eine Migrationsanfrage. Anders als record() oben wird hier auch
|
||||||
|
dann ein Eintrag angelegt (Status "listen"/"unknown"/"notfound"), wenn nichts
|
||||||
|
ausgeliefert wurde - das ist der ganze Zweck der Horchphase. last_seen wird nur bei
|
||||||
|
einer erkannten Produkt-ID gesetzt (target is not None); eine unbekannte Produkt-ID
|
||||||
|
hat kein Modell, das die Anzeige zeigen koennte."""
|
||||||
|
with self.lock:
|
||||||
|
if status == "ok":
|
||||||
|
self.served += 1
|
||||||
|
entry = {"ts": now_iso(), "ip": ip, "target": target, "variant": variant,
|
||||||
|
"variant_source": variant_source,
|
||||||
|
"product_id": product_id, "lamp_version": lamp_version,
|
||||||
|
"version": version, "bytes": nbytes, "status": status}
|
||||||
|
# Jede Anfrage zaehlt als Lebenszeichen, auch "listen"/"unknown"/"notfound"/
|
||||||
|
# "noimageset" - siehe self.last_activity oben und record()'s Gegenstueck.
|
||||||
|
self.last_activity = entry["ts"]
|
||||||
|
self.recent.append(entry)
|
||||||
|
del self.recent[:-RECENT_MAX]
|
||||||
|
if target is not None:
|
||||||
|
self.last_seen = {"product_id": product_id, "target": target,
|
||||||
|
"variant": variant, "variant_source": variant_source,
|
||||||
|
"product_name": product_name,
|
||||||
|
"lamp_version": lamp_version, "ip": ip, "ts": entry["ts"]}
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
def write_state(self) -> None:
|
def write_state(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
|
payload = {"since": self.since, "mode": self.mode, "served": self.served,
|
||||||
|
"recent": list(self.recent),
|
||||||
|
# In BEIDEN Modi (nicht nur migration, siehe unten) - das
|
||||||
|
# Lebenszeichen auf dem Panel gilt fuer die Rettung genauso wie
|
||||||
|
# fuer die Migration (display.py:_recent_request()).
|
||||||
|
"last_request_ts": self.last_activity}
|
||||||
|
if self.mode == "migration":
|
||||||
|
phase, armed_target = self.read_control()
|
||||||
|
payload["phase"] = phase
|
||||||
|
payload["armed_target"] = armed_target
|
||||||
|
payload["last_seen"] = self.last_seen
|
||||||
|
payload["control_error"] = self.control_error
|
||||||
try:
|
try:
|
||||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||||
@@ -119,12 +468,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def log_error(self, fmt: str, *args) -> None:
|
||||||
|
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
|
||||||
|
# silence above in favour of our own log() calls - but that also silences it for
|
||||||
|
# requests that never reach do_GET/do_POST (a malformed request line, a client
|
||||||
|
# timeout mid-request). The station is the only diagnostic tool available during a
|
||||||
|
# rescue, so those cases must still produce a line instead of nothing.
|
||||||
|
try:
|
||||||
|
client = self.client_address[0]
|
||||||
|
except (AttributeError, IndexError):
|
||||||
|
client = "?"
|
||||||
|
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
|
||||||
|
|
||||||
def log(self, status: int, nbytes: int, started: float) -> None:
|
def log(self, status: int, nbytes: int, started: float) -> None:
|
||||||
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||||
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
self.reject(405, "method not allowed")
|
self.reject(405, "method not allowed")
|
||||||
|
self.log(405, 0, started)
|
||||||
|
|
||||||
do_PUT = do_DELETE = do_HEAD = do_POST
|
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||||
|
|
||||||
@@ -139,6 +502,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
if self.station.mode == "migration":
|
||||||
|
self._do_get_migration(started)
|
||||||
|
else:
|
||||||
|
self._do_get_recovery(started)
|
||||||
|
|
||||||
|
def _do_get_recovery(self, started: float) -> None:
|
||||||
m = PATH_RE.match(self.path)
|
m = PATH_RE.match(self.path)
|
||||||
if not m:
|
if not m:
|
||||||
self.reject(404, "not found")
|
self.reject(404, "not found")
|
||||||
@@ -154,6 +523,90 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.log(404, 0, started)
|
self.log(404, 0, started)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
version = trailer_version(path)
|
||||||
|
sent, status = self._serve_file(path, started)
|
||||||
|
# Record only now that the transfer has actually finished (or failed): "ok" must
|
||||||
|
# never be written before the last byte is confirmed sent, or status.json can
|
||||||
|
# claim success for a device that never received the end of the image.
|
||||||
|
self.station.record(self.client_address[0], target, version, sent, status)
|
||||||
|
|
||||||
|
def _do_get_migration(self, started: float) -> None:
|
||||||
|
parsed = products.parse_request(self.path)
|
||||||
|
if parsed is None:
|
||||||
|
# Ein fremder Pfad, kein qcx_firmware_*-Muster - kein Protokolleintrag in
|
||||||
|
# status.json, das waere nur Rauschen (Brief Schritt 3.4).
|
||||||
|
self.reject(404, "not found")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
pid, lamp_version, product, variant, variant_source = parsed
|
||||||
|
ip = self.client_address[0]
|
||||||
|
if product is None:
|
||||||
|
self.station.record_migration(ip, None, None, pid, lamp_version, None, "?", 0,
|
||||||
|
"unknown")
|
||||||
|
self.reject(404, "unknown product")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
# variant/variant_source kommen aus parse_request(), nicht mehr aus der
|
||||||
|
# Produkttabelle: das "-dev" kann in der Produkt-ID stehen, in der Version, oder in
|
||||||
|
# beiden (Hardwarebefund 2026-09-15, siehe products.py). Das entscheidet, welches
|
||||||
|
# der beiden unterschiedlich signierten Images ausgeliefert wird.
|
||||||
|
target, name = product.target, product.name
|
||||||
|
version = self.station.image_version(target, variant)
|
||||||
|
# claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter
|
||||||
|
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
|
||||||
|
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
|
||||||
|
phase, armed_target, allowed = self.station.claim_delivery(target)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
# last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase:
|
||||||
|
# die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht.
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||||
|
version, 0, "listen", variant_source)
|
||||||
|
self.reject(404, "listening only")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = self.station.migrate_image_path(target, variant)
|
||||||
|
if not self.station.migrate_set_present():
|
||||||
|
# Der EINGESTELLTE Bildsatz liegt gar nicht auf dem Pi. Hier wird bewusst
|
||||||
|
# nichts ausgeliefert und auch nicht auf den anderen Satz ausgewichen: der
|
||||||
|
# Umschalter existiert genau dafuer, dass niemand versehentlich Lampen mit
|
||||||
|
# den eigenen WLAN-Zugangsdaten flasht. Ein eigener Status, damit der
|
||||||
|
# Bediener am Panel den Unterschied zu "dieses Modell fehlt" sieht.
|
||||||
|
if phase == "armed":
|
||||||
|
self.station.restore_armed(target)
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||||
|
version, 0, "noimageset", variant_source)
|
||||||
|
self.reject(404, "selected image set not available")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
if not path.is_file():
|
||||||
|
if phase == "armed":
|
||||||
|
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten,
|
||||||
|
# dass es gar kein Image gibt - das darf nicht auf Kosten der Freigabe gehen.
|
||||||
|
self.station.restore_armed(target)
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||||
|
version, 0, "notfound", variant_source)
|
||||||
|
self.reject(404, "no image for this device")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
sent, status = self._serve_file(path, started)
|
||||||
|
if phase == "armed" and status == "aborted":
|
||||||
|
# Bricht der Transfer ab, bleibt die Freigabe stehen, damit die Lampe es beim
|
||||||
|
# naechsten Boot noch einmal versuchen kann (Spec 3.3). claim_delivery() hat sie
|
||||||
|
# bereits auf "listen" gesetzt, bevor _serve_file() ueberhaupt anfing zu senden
|
||||||
|
# (das ist der Punkt von C1) - bei einem Abbruch wird das hier zurueckgenommen.
|
||||||
|
self.station.restore_armed(target)
|
||||||
|
self.station.record_migration(ip, target, variant, pid, lamp_version, name, version,
|
||||||
|
sent, status, variant_source)
|
||||||
|
|
||||||
|
def _serve_file(self, path: Path, started: float) -> tuple[int, str]:
|
||||||
|
"""Sendet `path` mit Range-Unterstuetzung; liefert (gesendete Bytes, Status).
|
||||||
|
Gemeinsam fuer Rettungs- und Migrationsroute - Uebertragungsmechanik (Range,
|
||||||
|
--stall-after, Abbruchbehandlung) ist identisch, nur was mit dem Ergebnis
|
||||||
|
geschieht, unterscheidet sich."""
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
start = 0
|
start = 0
|
||||||
rng = self.headers.get("Range", "")
|
rng = self.headers.get("Range", "")
|
||||||
@@ -162,7 +615,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if first.isdigit() and int(first) < size:
|
if first.isdigit() and int(first) < size:
|
||||||
start = int(first)
|
start = int(first)
|
||||||
|
|
||||||
version = trailer_version(path)
|
|
||||||
self.send_response(206 if start else 200)
|
self.send_response(206 if start else 200)
|
||||||
self.send_header("Content-Type", "application/octet-stream")
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
self.send_header("Content-Length", str(size - start))
|
self.send_header("Content-Length", str(size - start))
|
||||||
@@ -175,51 +627,88 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
sent = 0
|
sent = 0
|
||||||
status = "ok"
|
status = "ok"
|
||||||
remaining = size - start
|
remaining = size - start
|
||||||
|
client_ip = self.client_address[0]
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
f.seek(start)
|
f.seek(start)
|
||||||
while sent < remaining:
|
while sent < remaining:
|
||||||
if self.station.stall_after is not None and sent >= self.station.stall_after:
|
read_size = min(CHUNK, remaining - sent)
|
||||||
# test aid: hold the connection open and send nothing more, so the
|
|
||||||
# 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")
|
|
||||||
read_size = CHUNK
|
|
||||||
if self.station.stall_after is not None:
|
if self.station.stall_after is not None:
|
||||||
# Don't overshoot the stall boundary within a single read: without
|
# Reserve against this client's cumulative budget, not just this
|
||||||
# this a whole CHUNK could be sent before the check above ever
|
# request's byte count - see Station.reserve_stall_bytes.
|
||||||
# notices we've passed --stall-after.
|
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
|
||||||
read_size = min(read_size, max(1, self.station.stall_after - sent))
|
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)
|
chunk = f.read(read_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
|
# Die Datei ist kuerzer als das eingangs per stat() ermittelte
|
||||||
|
# Content-Length (z.B. von deploy.sh waehrend einer laufenden
|
||||||
|
# Auslieferung durch eine kleinere ersetzt) - sent < remaining an
|
||||||
|
# dieser Stelle immer, denn die Schleifenbedingung oben haette sonst
|
||||||
|
# schon abgebrochen. Das ist ein abgebrochener, kein erfolgreicher
|
||||||
|
# Transfer (Fixrunde 1, I2): ohne dies wuerde eine im Migrationsmodus
|
||||||
|
# laufende "armed"-Freigabe faelschlich als verbraucht gelten, obwohl
|
||||||
|
# die Lampe nie das vollstaendige Image bekommen hat.
|
||||||
|
status = "aborted"
|
||||||
break
|
break
|
||||||
self.wfile.write(chunk)
|
self.wfile.write(chunk)
|
||||||
sent += len(chunk)
|
sent += len(chunk)
|
||||||
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||||
status = "aborted"
|
status = "aborted"
|
||||||
# Record only now that the transfer has actually finished (or failed): "ok" must
|
|
||||||
# never be written before the last byte is confirmed sent, or status.json can
|
|
||||||
# claim success for a device that never received the end of the image.
|
|
||||||
self.log(206 if start else 200, sent, started)
|
self.log(206 if start else 200, sent, started)
|
||||||
self.station.record(self.client_address[0], target, version, sent, status)
|
return sent, status
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mode(explicit_mode: str | None, mode_file: Path | None) -> str:
|
||||||
|
"""Bestimmt den Betriebsmodus. Vorrang: ein ausdrueckliches --mode schlaegt
|
||||||
|
--mode-file, --mode-file schlaegt die Vorgabe "recovery". Eine fehlende oder
|
||||||
|
unlesbare/unerwartete Modusdatei bedeutet ebenfalls "recovery" - Task 4 baut die
|
||||||
|
systemd-Unit darauf auf, dass der Modus ohne Neustart-Argumentaenderung aus einer
|
||||||
|
Datei kommt (/run/ninlight-station/mode), und ein fehlerhafter Zustand dieser Datei
|
||||||
|
darf den Dienst nicht zum Absturz bringen oder unbeabsichtigt scharf schalten."""
|
||||||
|
if explicit_mode is not None:
|
||||||
|
return explicit_mode
|
||||||
|
if mode_file is not None:
|
||||||
|
try:
|
||||||
|
text = mode_file.read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
return "recovery"
|
||||||
|
if text in ("recovery", "migration"):
|
||||||
|
return text
|
||||||
|
return "recovery"
|
||||||
|
return "recovery"
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
p = argparse.ArgumentParser(description="ninlight recovery station HTTP server")
|
p = argparse.ArgumentParser(description="ninlight recovery/migration station HTTP server")
|
||||||
p.add_argument("--image-dir", required=True, type=Path)
|
p.add_argument("--image-dir", required=True, type=Path)
|
||||||
p.add_argument("--bind", default="192.168.8.1")
|
p.add_argument("--bind", default="192.168.8.1")
|
||||||
p.add_argument("--port", type=int, default=80)
|
p.add_argument("--port", type=int, default=80)
|
||||||
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
|
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
|
||||||
|
p.add_argument("--mode", choices=("recovery", "migration"), default=None,
|
||||||
|
help="ausdruecklicher Modus - schlaegt --mode-file (Vorgabe: siehe dort)")
|
||||||
|
p.add_argument("--mode-file", type=Path, default=None,
|
||||||
|
help="Datei mit 'recovery' oder 'migration'; wird ohne --mode verwendet, "
|
||||||
|
"Vorgabe bei fehlender/kaputter Datei ist 'recovery'")
|
||||||
|
p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"),
|
||||||
|
help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus")
|
||||||
|
p.add_argument("--imageset-file", type=Path, default=None,
|
||||||
|
help="Datei mit 'ohne-wlan' oder 'mit-wlan' (Umschalter des "
|
||||||
|
"Migrations-Bildsatzes); Vorgabe bei fehlender/kaputter Datei "
|
||||||
|
"ist 'ohne-wlan'")
|
||||||
p.add_argument("--stall-after", type=int, default=None,
|
p.add_argument("--stall-after", type=int, default=None,
|
||||||
help="test aid: stop sending after N bytes but keep the connection open")
|
help="test aid: stop sending after N bytes but keep the connection open")
|
||||||
a = p.parse_args(argv)
|
a = p.parse_args(argv)
|
||||||
@@ -228,13 +717,43 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
|
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
station = Station(a.image_dir, a.state, a.stall_after)
|
mode = resolve_mode(a.mode, a.mode_file)
|
||||||
|
station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control,
|
||||||
|
imageset_path=a.imageset_file)
|
||||||
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
||||||
httpd.daemon_threads = True
|
httpd.daemon_threads = True
|
||||||
httpd.station = station # type: ignore[attr-defined]
|
httpd.station = station # type: ignore[attr-defined]
|
||||||
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
|
# Fixrunde 1, Kleinigkeit: vor dem Bildsatz-Umschalter fuer die Rettung lagen deren
|
||||||
print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): "
|
# Abbilder flach in a.image_dir, das Glob dort war also die richtige Auskunft. Seit
|
||||||
|
# image_path() aus rescue_dir() liest (dem eingestellten Satz), muss diese Zeile
|
||||||
|
# demselben Verzeichnis folgen - sonst zeigt das Journal "0 image(s)" fuer eine
|
||||||
|
# Rettung, die tatsaechlich welche hat (oder umgekehrt).
|
||||||
|
if mode == "recovery":
|
||||||
|
try:
|
||||||
|
images = sorted(p.name for p in station.rescue_dir().glob("ninlight_*_ota.img"))
|
||||||
|
except OSError:
|
||||||
|
images = []
|
||||||
|
else:
|
||||||
|
images = []
|
||||||
|
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
|
||||||
f"{', '.join(images) or 'none'}", flush=True)
|
f"{', '.join(images) or 'none'}", flush=True)
|
||||||
|
if mode == "recovery":
|
||||||
|
# Bildsatz auch fuer die Rettung im Journal - dieselbe Begruendung wie unten fuer
|
||||||
|
# die Migration: "warum bekam die Lampe ein Abbild ohne/mit Zugangsdaten?" ist
|
||||||
|
# sonst nirgends nachschlagbar. rescue_set_present() (bislang toter Code) ist das
|
||||||
|
# hier tatsaechlich Verwendete.
|
||||||
|
selected = station.image_set()
|
||||||
|
present = "vorhanden" if station.rescue_set_present() else "FEHLT"
|
||||||
|
print(f"{now_iso()} image set: {selected} ({station.rescue_dir()}, {present})",
|
||||||
|
flush=True)
|
||||||
|
elif mode == "migration":
|
||||||
|
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
|
||||||
|
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
|
||||||
|
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
|
||||||
|
selected = station.image_set()
|
||||||
|
present = "vorhanden" if station.migrate_set_present() else "FEHLT"
|
||||||
|
print(f"{now_iso()} image set: {selected} ({station.migrate_dir()}, {present})",
|
||||||
|
flush=True)
|
||||||
if a.stall_after is not None:
|
if a.stall_after is not None:
|
||||||
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
|
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+543
-68
@@ -1,47 +1,432 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent:
|
# Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches
|
||||||
# mehrfaches Ausfuehren aendert nichts und schadet nicht.
|
# Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem
|
||||||
|
# schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration"
|
||||||
|
# unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet).
|
||||||
#
|
#
|
||||||
# Aufruf: tools/station/setup_pi.sh <pi-host>
|
# Aufruf: tools/station/setup_pi.sh <pi-host>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
|
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
|
||||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
|
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
|
||||||
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT in deploy.sh), damit ein Test gegen Stubs statt
|
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und in deploy.sh), damit ein Test gegen
|
||||||
# einen echten Pi laufen kann.
|
# Stubs statt einen echten Pi laufen kann.
|
||||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||||
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
|
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
|
||||||
|
# Wo die per scp uebertragenen Dateien auf dem Zielsystem zwischenlanden, bevor
|
||||||
|
# sie an ihren endgueltigen Platz installiert werden. Ueberschreibbar wie die
|
||||||
|
# anderen NINLIGHT_*-Pfade: auf dem Pi bleibt es bei /tmp, ein Test kann hier in
|
||||||
|
# eine Sandbox statt in das echte /tmp des Entwicklungsrechners umleiten
|
||||||
|
# (Fixrunde 2, N4) - sowohl fuer die lokale scp-Zielangabe unten als auch fuer
|
||||||
|
# die REMOTE-Bloecke, die dieselbe Variable (mit demselben Default) referenzieren.
|
||||||
|
STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp}
|
||||||
|
|
||||||
echo "== Voraussetzungen auf $HOST pruefen =="
|
echo "== Voraussetzungen auf $HOST pruefen =="
|
||||||
"$SSH_BIN" "$HOST" 'set -e
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
|
set -e
|
||||||
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
|
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&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; }
|
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
|
||||||
echo " ok"'
|
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 =="
|
echo "== Konfiguration =="
|
||||||
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
|
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example"
|
||||||
"$SSH_BIN" "$HOST" 'set -e
|
# CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es
|
||||||
if [ -f /etc/ninlight-station.conf ]; then
|
# beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte
|
||||||
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert"
|
# Systempfade laufen.
|
||||||
|
"$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
|
else
|
||||||
sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf
|
echo " $CONF existiert bereits, alle Schluessel vorhanden"
|
||||||
echo " /etc/ninlight-station.conf aus der Vorlage angelegt"
|
|
||||||
fi
|
fi
|
||||||
rm -f /tmp/ninlight-station.conf.example'
|
else
|
||||||
|
sudo install -m 0600 "$TEMPLATE" "$CONF"
|
||||||
|
echo " $CONF aus der Vorlage angelegt"
|
||||||
|
fi
|
||||||
|
rm -f "$TEMPLATE"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
echo "== Server, CLI und Unit installieren =="
|
echo "== Server, CLI und Units installieren =="
|
||||||
"$SCP_BIN" -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
# Reststand der Ablage wegraeumen, BEVOR das scp laeuft (Task 6b F1, I1). "scp -r quelle
|
||||||
"$SSH_BIN" "$HOST" 'set -e
|
# ziel" kopiert das Quellverzeichnis IN ein bereits vorhandenes Ziel hinein - aus
|
||||||
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images
|
# einem liegengebliebenen "…/ninlight-display/" wird dann "…/ninlight-display/display/",
|
||||||
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
|
# die Installationsschleife unten ueberspringt Verzeichnisse und findet gar nichts mehr.
|
||||||
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
|
# Ergebnis waere ein Lauf, der Erfolg meldet, den Anzeigedienst aktiviert und ein leeres
|
||||||
sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service
|
# $ROOT_DIR/display hinterlaesst: 203/EXEC im 5-Sekunden-Takt, Panel schwarz. Genau so
|
||||||
rm -f /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
|
# ein Reststand entsteht, wenn ein frueherer Lauf nach dem scp abbricht (sudo-Timeout,
|
||||||
sudo systemctl daemon-reload
|
# Platte voll, Strg-C) - die Aufraeumzeile unten laeuft dann nicht mehr. Idempotent
|
||||||
echo " installiert"'
|
# 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}
|
||||||
|
|
||||||
echo "== NetworkManager-Profil =="
|
# 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:
|
# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
|
||||||
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
|
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
|
||||||
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
|
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
|
||||||
@@ -56,53 +441,143 @@ echo "== NetworkManager-Profil =="
|
|||||||
# Prozessliste.
|
# Prozessliste.
|
||||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||||
set -e
|
set -e
|
||||||
eval "$(sudo cat /etc/ninlight-station.conf)"
|
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
|
||||||
CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection"
|
NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections}
|
||||||
if sudo test -f "$CONN_FILE"; then
|
eval "$(sudo cat "$CONF")"
|
||||||
UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1)
|
|
||||||
ACTION="aktualisiert"
|
# Die Keyfiles werden erst in einer Temp-Datei aufgebaut und dann per "install"
|
||||||
else
|
# an ihren Platz gelegt - dazwischen liegt das WLAN-Passwort in einer Datei, die
|
||||||
UUID=$(cat /proc/sys/kernel/random/uuid)
|
# noch nicht 0600 root:root ist. Ein Abbruch (set -e) genau dort darf sie nicht
|
||||||
ACTION="angelegt"
|
# liegen lassen. Ein EXIT-Trap in write_profile() selbst waere dafuer untauglich:
|
||||||
fi
|
# die Funktion laeuft zweimal, der zweite trap ueberschriebe den ersten, und
|
||||||
TMP=$(mktemp)
|
# "$tmp" ist "local" - beim Auswerten des Traps am Skriptende gaebe es die
|
||||||
trap 'rm -f "$TMP"' EXIT
|
# Variable gar nicht mehr. Deshalb ein gemeinsames Verzeichnis mit genau einem
|
||||||
{
|
# Trap darum herum (0700, mktemp -d legt es so an).
|
||||||
printf '%s\n' '[connection]'
|
#
|
||||||
printf 'id=%s\n' "$AP_CONNECTION"
|
# Zur Reichweite des Traps (Task 6b F2, N3 - die Begruendung hier war vorher falsch):
|
||||||
printf 'uuid=%s\n' "$UUID"
|
# der EXIT-Trap allein WUERDE bei einem Strg-C schon reichen, bash fuehrt ihn auch beim
|
||||||
printf '%s\n' 'type=wifi'
|
# Abbruch durch ein nicht abgefangenes SIGINT aus (Exit 130). Der Grund fuer die
|
||||||
printf '%s\n' 'interface-name=wlan0'
|
# Signalhandler ist ein anderer: sie sollen den Exit-Code ehrlich halten. Ein Handler
|
||||||
printf '%s\n' 'autoconnect=false'
|
# fuer INT/TERM, der nicht selbst beendet, laesst bash naemlich NACH dem Handler
|
||||||
printf '\n%s\n' '[wifi]'
|
# weiterlaufen - ein Strg-C mitten im Schreiben der Profile koennte dann in "fertig,
|
||||||
printf '%s\n' 'mode=ap'
|
# Exit 0" muenden, obwohl der Lauf abgebrochen wurde. Deshalb raeumt jeder Handler auf
|
||||||
printf 'ssid=%s\n' "$SSID"
|
# und beendet danach ausdruecklich mit dem ueblichen Signal-Code (128+n).
|
||||||
printf '%s\n' 'band=bg'
|
#
|
||||||
printf '%s\n' 'channel=6'
|
# Mit ausdruecklicher Vorlage unter ${TMPDIR:-/tmp}, nicht als blankes "mktemp -d":
|
||||||
printf '\n%s\n' '[wifi-security]'
|
# BSD-mktemp (macOS, wo diese Suite auch laeuft) beachtet TMPDIR ohne Vorlage gar nicht
|
||||||
printf '%s\n' 'key-mgmt=wpa-psk'
|
# und legt unabhaengig davon unter /var/folders/... an, GNU-mktemp (der Pi) dagegen
|
||||||
printf '%s\n' 'proto=rsn'
|
# schon. Ohne Vorlage landet das Verzeichnis also je nach Plattform woanders - ein Test,
|
||||||
printf '%s\n' 'pairwise=ccmp;'
|
# der nachsieht, ob wirklich nichts liegengeblieben ist, sucht dann auf macOS an der
|
||||||
printf '%s\n' 'group=ccmp;'
|
# falschen Stelle und ist gruen, ohne etwas geprueft zu haben.
|
||||||
printf 'psk=%s\n' "$PSK"
|
PROFILE_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/ninlight-profile.XXXXXX")
|
||||||
printf '\n%s\n' '[ipv4]'
|
trap 'rm -rf "$PROFILE_TMPDIR"' EXIT
|
||||||
printf '%s\n' 'method=shared'
|
trap 'rm -rf "$PROFILE_TMPDIR"; exit 130' INT
|
||||||
printf '%s\n' 'address1=192.168.8.1/24'
|
trap 'rm -rf "$PROFILE_TMPDIR"; exit 143' TERM
|
||||||
printf '\n%s\n' '[ipv6]'
|
trap 'rm -rf "$PROFILE_TMPDIR"; exit 129' HUP
|
||||||
printf '%s\n' 'method=disabled'
|
|
||||||
} >"$TMP"
|
# $1 = Profilname, $2 = SSID, $3 = PSK, $4 = "update" (bestehendes Profil neu schreiben,
|
||||||
sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE"
|
# UUID behalten) oder "keep" (bestehendes Profil unangetastet lassen).
|
||||||
rm -f "$TMP"
|
#
|
||||||
sudo nmcli connection load "$CONN_FILE" >/dev/null
|
# Beide Profile bekommen dieselbe Adresse 192.168.8.1/24 auf wlan0 - genau deshalb
|
||||||
echo " Profil $AP_CONNECTION $ACTION"
|
# 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
|
REMOTE
|
||||||
|
|
||||||
cat <<'EOF'
|
# Der Neustart-Hinweis steht an eigener, auffaelliger Stelle VOR den "Naechsten
|
||||||
|
# Schritten" - nicht dazwischengemischt - und nur, wenn oben wirklich etwas an der
|
||||||
|
# Boot-Konfiguration geaendert wurde (SPI_REBOOT_NEEDED). Ein Hinweis bei jedem Lauf,
|
||||||
|
# auch wenn SPI laengst an ist, wuerde nach ein paar Durchlaeufen ignoriert.
|
||||||
|
SPI_NOTE=""
|
||||||
|
if [ "$SPI_REBOOT_NEEDED" -eq 1 ]; then
|
||||||
|
SPI_NOTE='
|
||||||
|
WICHTIG: SPI wurde soeben erst eingeschaltet (Aenderung an der Boot-Konfiguration) -
|
||||||
|
das wirkt erst nach einem Neustart. Bis dahin bleibt das E-Paper schwarz,
|
||||||
|
auch wenn sonst alles richtig eingerichtet ist.
|
||||||
|
ssh <pi-host> sudo reboot
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
Fertig. Die Station ist absichtlich noch AUS.
|
Fertig. Die Station ist absichtlich noch AUS.
|
||||||
|
$SPI_NOTE
|
||||||
Naechste Schritte:
|
Naechste Schritte:
|
||||||
|
ssh <pi-host> ninlight-station display test
|
||||||
|
Testbild aufs E-Paper - bleibt es leer oder
|
||||||
|
verzerrt, steht EPD_REVISION in
|
||||||
|
/etc/ninlight-station.conf auf der falschen
|
||||||
|
Panel-Revision (V2 statt V3 oder umgekehrt)
|
||||||
tools/station/deploy.sh <pi-host> Images ausliefern
|
tools/station/deploy.sh <pi-host> Images ausliefern
|
||||||
ssh <pi-host> ninlight-station on Station einschalten
|
ssh <pi-host> ninlight-station on Station einschalten
|
||||||
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
|
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
|
||||||
|
|
||||||
|
Der Taster auf GPIO23 ist mit eingerichtet: kurzer Druck oeffnet das Menue am Geraet,
|
||||||
|
3 s gehalten brechen eine laufende Migration/Rettung ab.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -24,3 +24,15 @@ HOME_CONNECTION='preconfigured'
|
|||||||
|
|
||||||
# NetworkManager-Profil der Station selbst.
|
# NetworkManager-Profil der Station selbst.
|
||||||
AP_CONNECTION='ninlight-recovery'
|
AP_CONNECTION='ninlight-recovery'
|
||||||
|
|
||||||
|
# Von der alten Klyqa-Firmware fest vorgegeben (qcx-w600-firmware) - nicht
|
||||||
|
# aenderbar, ohne dass alte Lampen die Migrationsstation nicht mehr finden.
|
||||||
|
MIGRATE_SSID='qconnex-firmware'
|
||||||
|
MIGRATE_PSK='qconnex-firmware'
|
||||||
|
|
||||||
|
# NetworkManager-Profil der Migrations-Station.
|
||||||
|
MIGRATE_AP_CONNECTION='qconnex-firmware'
|
||||||
|
|
||||||
|
# Panel-Revision der E-Paper-Anzeige (V2 oder V3) - am Geraet nicht auslesbar,
|
||||||
|
# siehe README.md.
|
||||||
|
EPD_REVISION='V3'
|
||||||
|
|||||||
@@ -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
+1500
-14
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
@@ -2,6 +2,7 @@ import hashlib
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
import socket
|
import socket
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -42,6 +43,14 @@ class Station:
|
|||||||
def __init__(self, tmp_path, *extra):
|
def __init__(self, tmp_path, *extra):
|
||||||
self.image_dir = tmp_path / "images"
|
self.image_dir = tmp_path / "images"
|
||||||
self.image_dir.mkdir()
|
self.image_dir.mkdir()
|
||||||
|
# image_path() folgt jetzt dem Bildsatz-Umschalter, genauso wie migrate_dir() es
|
||||||
|
# tut - ohne --imageset-file gilt die Vorgabe "ohne-wlan" (server.py,
|
||||||
|
# DEFAULT_IMAGE_SET), also landen Rettungsabbilder in image_dir/rescue-nocreds.
|
||||||
|
# Die meisten Tests hier pruefen nur die Ausliefermechanik, nicht den
|
||||||
|
# Bildsatz-Umschalter selbst - die Bildsatz-spezifischen Tests weiter unten legen
|
||||||
|
# ihre Abbilder gezielt in andere Verzeichnisse.
|
||||||
|
self.rescue_dir = self.image_dir / "rescue-nocreds"
|
||||||
|
self.rescue_dir.mkdir()
|
||||||
self.state = tmp_path / "status.json"
|
self.state = tmp_path / "status.json"
|
||||||
self.port = free_port()
|
self.port = free_port()
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
@@ -92,6 +101,44 @@ class Station:
|
|||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
self.proc.wait(timeout=5)
|
self.proc.wait(timeout=5)
|
||||||
|
|
||||||
|
def wait_for_log_line(self, contains, timeout=3.0):
|
||||||
|
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
|
||||||
|
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
|
||||||
|
check that a request actually produced a log line (F3: every request must,
|
||||||
|
including rejected ones and errors the base handler catches before routing).
|
||||||
|
|
||||||
|
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
|
||||||
|
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
|
||||||
|
read() can slurp everything currently sitting in the pipe (banner *and* the log
|
||||||
|
line we're waiting for) into its own buffer on the very first call, while only
|
||||||
|
handing back the first line. select() then reports the fd as not-readable on the
|
||||||
|
next iteration - the data isn't gone, it is just buffered somewhere select() can't
|
||||||
|
see - and a fixed-size test timeout would flake on exactly the line it's checking
|
||||||
|
for.
|
||||||
|
"""
|
||||||
|
fd = self.proc.stdout.fileno()
|
||||||
|
buf = getattr(self, "_log_buf", "")
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
while "\n" in buf:
|
||||||
|
line, buf = buf.split("\n", 1)
|
||||||
|
self._log_buf = buf
|
||||||
|
if contains in line:
|
||||||
|
return line + "\n"
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
self._log_buf = buf
|
||||||
|
return None
|
||||||
|
r, _, _ = select.select([fd], [], [], remaining)
|
||||||
|
if not r:
|
||||||
|
self._log_buf = buf
|
||||||
|
return None
|
||||||
|
chunk = os.read(fd, 65536)
|
||||||
|
if not chunk:
|
||||||
|
self._log_buf = buf
|
||||||
|
return None
|
||||||
|
buf += chunk.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def station(tmp_path):
|
def station(tmp_path):
|
||||||
@@ -101,7 +148,7 @@ def station(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_serves_a_known_image(station):
|
def test_serves_a_known_image(station):
|
||||||
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
data = make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
r = c.getresponse()
|
r = c.getresponse()
|
||||||
@@ -118,7 +165,7 @@ def test_missing_image_is_404(station):
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_target_is_404(station):
|
def test_unknown_target_is_404(station):
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
||||||
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", path)
|
c.request("GET", path)
|
||||||
@@ -139,8 +186,45 @@ def test_other_methods_are_405(station):
|
|||||||
assert c.getresponse().status == 405
|
assert c.getresponse().status == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejected_method_produces_a_log_line(station):
|
||||||
|
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
|
||||||
|
the station is the only diagnostic tool during a rescue, and the spec requires a line
|
||||||
|
per request, rejections included."""
|
||||||
|
c = station.conn()
|
||||||
|
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
||||||
|
assert c.getresponse().status == 405
|
||||||
|
line = station.wait_for_log_line(" 405 ")
|
||||||
|
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
|
||||||
|
assert "/ninlight_evk_ota.img" in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_error_is_not_silenced_by_log_message(capsys):
|
||||||
|
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
|
||||||
|
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
|
||||||
|
server.py silences log_message() in favour of its own per-request log() calls - which
|
||||||
|
used to silence log_error() right along with it, since it wasn't overridden separately.
|
||||||
|
This exercises Handler.log_error() directly rather than over a raw socket: this
|
||||||
|
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
|
||||||
|
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
|
||||||
|
genuine malformed-request-line test unreliable here."""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
class _Stub:
|
||||||
|
client_address = ("203.0.113.5", 54321)
|
||||||
|
|
||||||
|
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "ERROR" in out
|
||||||
|
assert "203.0.113.5" in out
|
||||||
|
assert "code 400, message Bad request version" in out
|
||||||
|
|
||||||
|
|
||||||
def test_range_request_returns_206_and_the_right_slice(station):
|
def test_range_request_returns_206_and_the_right_slice(station):
|
||||||
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
data = make_image(station.rescue_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
||||||
r = c.getresponse()
|
r = c.getresponse()
|
||||||
@@ -151,7 +235,7 @@ def test_range_request_returns_206_and_the_right_slice(station):
|
|||||||
|
|
||||||
|
|
||||||
def test_state_file_records_a_delivery(station):
|
def test_state_file_records_a_delivery(station):
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
c.getresponse().read()
|
c.getresponse().read()
|
||||||
@@ -165,6 +249,21 @@ def test_state_file_records_a_delivery(station):
|
|||||||
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_file_carries_a_last_request_timestamp_for_the_display(station):
|
||||||
|
"""display.py zeigt einen kleinen Lebenszeichen-Punkt, sobald eine Anfrage jeder Art
|
||||||
|
hereinkommt (auch ein 404) - dafuer muss status.json den Zeitpunkt tragen, nicht nur
|
||||||
|
den Erfolgszaehler. Vor der ersten Anfrage ist das Feld da, aber None."""
|
||||||
|
initial = station.read_state()
|
||||||
|
assert "last_request_ts" in initial
|
||||||
|
assert initial["last_request_ts"] is None
|
||||||
|
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_g95_cw_ota.img") # existiert nicht - zaehlt trotzdem
|
||||||
|
c.getresponse().read()
|
||||||
|
state = station.read_state(lambda s: s["last_request_ts"] is not None)
|
||||||
|
assert isinstance(state["last_request_ts"], str) and state["last_request_ts"]
|
||||||
|
|
||||||
|
|
||||||
def test_state_file_records_a_missing_image(station):
|
def test_state_file_records_a_missing_image(station):
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_g95_cw_ota.img")
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||||
@@ -190,7 +289,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
|||||||
finally:
|
finally:
|
||||||
os.umask(old_umask)
|
os.umask(old_umask)
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
||||||
|
|
||||||
def world_readable() -> bool:
|
def world_readable() -> bool:
|
||||||
mode = stat.S_IMODE(s.state.stat().st_mode)
|
mode = stat.S_IMODE(s.state.stat().st_mode)
|
||||||
@@ -213,7 +312,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_recent_is_capped_at_eight(station):
|
def test_recent_is_capped_at_eight(station):
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
@@ -226,7 +325,7 @@ def test_recent_is_capped_at_eight(station):
|
|||||||
def test_aborted_transfer_is_recorded(tmp_path):
|
def test_aborted_transfer_is_recorded(tmp_path):
|
||||||
s = Station(tmp_path, "--stall-after", "256")
|
s = Station(tmp_path, "--stall-after", "256")
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
||||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
sock.recv(4096)
|
sock.recv(4096)
|
||||||
@@ -250,7 +349,7 @@ def test_aborted_transfer_is_recorded(tmp_path):
|
|||||||
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||||
s = Station(tmp_path, "--stall-after", "512")
|
s = Station(tmp_path, "--stall-after", "512")
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
got = b""
|
got = b""
|
||||||
@@ -278,6 +377,62 @@ def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
|||||||
s.stop()
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
|
||||||
|
"""--stall-after must stop a client for good, not just for one request.
|
||||||
|
|
||||||
|
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
|
||||||
|
on the connection, and opens a new one with a Range header picking up right where it
|
||||||
|
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
|
||||||
|
exercises for a single request. A per-request byte counter would hand that resumed
|
||||||
|
request a brand-new --stall-after allowance, so the "stall" would only ever delay
|
||||||
|
delivery instead of actually stopping it - which is what was observed on the EVK: the
|
||||||
|
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
|
||||||
|
away with the complete image. This test reproduces that reconnect and checks that the
|
||||||
|
second, resumed request gets no further bytes at all.
|
||||||
|
"""
|
||||||
|
s = Station(tmp_path, "--stall-after", "1000")
|
||||||
|
try:
|
||||||
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||||
|
|
||||||
|
# First request: burns through the whole --stall-after budget, then the server
|
||||||
|
# holds the connection open and sends nothing further.
|
||||||
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
|
got = b""
|
||||||
|
sock.settimeout(2)
|
||||||
|
try:
|
||||||
|
while len(got) < 200000:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
got += chunk
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
assert len(got) < 20000 # far less than the full image, same bound as above
|
||||||
|
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
|
||||||
|
|
||||||
|
# Second request, resuming with Range exactly like a real lamp would. This
|
||||||
|
# client's cumulative budget is already spent, so it must get zero more bytes of
|
||||||
|
# body - not a fresh --stall-after allowance just because it's a new connection.
|
||||||
|
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
|
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
|
||||||
|
sock2.settimeout(2)
|
||||||
|
header = b""
|
||||||
|
while b"\r\n\r\n" not in header:
|
||||||
|
header += sock2.recv(4096)
|
||||||
|
assert b" 206 " in header.split(b"\r\n", 1)[0]
|
||||||
|
body_after_header = header.split(b"\r\n\r\n", 1)[1]
|
||||||
|
assert body_after_header == b"", "resumed request must not receive any body bytes"
|
||||||
|
try:
|
||||||
|
more = sock2.recv(1)
|
||||||
|
except socket.timeout:
|
||||||
|
more = None
|
||||||
|
assert more in (None, b""), "server sent data after the client's stall budget was spent"
|
||||||
|
sock2.close()
|
||||||
|
finally:
|
||||||
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_downloads_are_all_recorded(station):
|
def test_concurrent_downloads_are_all_recorded(station):
|
||||||
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
||||||
|
|
||||||
@@ -287,7 +442,7 @@ def test_concurrent_downloads_are_all_recorded(station):
|
|||||||
concurrent requests can lose increments (two threads read the same old value) or have
|
concurrent requests can lose increments (two threads read the same old value) or have
|
||||||
one thread's status.json write clobber another's.
|
one thread's status.json write clobber another's.
|
||||||
"""
|
"""
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
||||||
expected_len = 5000 + TRAILER_SIZE
|
expected_len = 5000 + TRAILER_SIZE
|
||||||
n_workers = 20
|
n_workers = 20
|
||||||
errors = []
|
errors = []
|
||||||
@@ -321,3 +476,718 @@ def test_concurrent_downloads_are_all_recorded(station):
|
|||||||
assert entry["status"] == "ok"
|
assert entry["status"] == "ok"
|
||||||
assert entry["target"] == "evk"
|
assert entry["target"] == "evk"
|
||||||
assert entry["bytes"] == expected_len
|
assert entry["bytes"] == expected_len
|
||||||
|
|
||||||
|
|
||||||
|
# --- Migrationsroute (Task 3) -------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Eigener Testklient statt der Station-Klasse oben: die migration_server/server-Fixtures
|
||||||
|
# brauchen get()/status()/set_control()/read_control()/delete_control()/write_raw_control(),
|
||||||
|
# waehrend die Station-Klasse und die "station"-Fixture oben unveraendert fuer die
|
||||||
|
# bestehenden Rettungsmodus-Tests weiterlaufen sollen.
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, status, headers, body):
|
||||||
|
self.status = status
|
||||||
|
self.headers = headers
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationTestServer:
|
||||||
|
"""Startet server.py mit --mode/--control und bietet die vom Brief geforderten
|
||||||
|
Hilfsmethoden.
|
||||||
|
|
||||||
|
status()/read_control() folgen demselben Muster wie Station.read_state() oben
|
||||||
|
(Zeilen 68-90): sie pollen bis zu einigen Sekunden auf ein optionales Praedikat, statt
|
||||||
|
sich auf eine feste Pause nach get() zu verlassen. Grund ist derselbe wie dort -
|
||||||
|
server.py schreibt status.json/control.json fuer eine Anfrage erst, nachdem der
|
||||||
|
Antwortkoerper vollstaendig gesendet ist, aber http.client's read() kann zurueckkehren,
|
||||||
|
sobald Content-Length Bytes angekommen sind, was diesem Schreiben knapp zuvorkommen
|
||||||
|
kann. Jeder Aufrufer kennt sein eigenes Praedikat (das ohnehin schon als Assertion
|
||||||
|
dasteht), daher kein pauschales Warten hier."""
|
||||||
|
|
||||||
|
def __init__(self, tmp_path, mode, *extra_args):
|
||||||
|
self.image_dir = tmp_path / "images"
|
||||||
|
self.image_dir.mkdir()
|
||||||
|
self.state_path = tmp_path / "status.json"
|
||||||
|
# Eigenes Unterverzeichnis fuer die Steuerdatei, getrennt von status.json: ein Test
|
||||||
|
# fuer I3 (Fixrunde 1) macht genau dieses Verzeichnis schreibgeschuetzt, um einen
|
||||||
|
# fehlschlagenden write_control() zu erzwingen, und darf dabei nicht auch das
|
||||||
|
# Schreiben von status.json lahmlegen, ueber das derselbe Test den Fehler beobachtet.
|
||||||
|
control_dir = tmp_path / "control"
|
||||||
|
control_dir.mkdir()
|
||||||
|
self.control_path = control_dir / "control.json"
|
||||||
|
# Der Umschalter des Bildsatzes. Die Datei existiert zunaechst NICHT - das ist
|
||||||
|
# der Auslieferungszustand und muss "ohne-wlan" (migrate-nocreds/) bedeuten.
|
||||||
|
self.imageset_path = tmp_path / "imageset"
|
||||||
|
self.port = free_port()
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
|
||||||
|
"--bind", "127.0.0.1", "--port", str(self.port),
|
||||||
|
"--state", str(self.state_path), "--mode", mode,
|
||||||
|
"--control", str(self.control_path),
|
||||||
|
"--imageset-file", str(self.imageset_path), *extra_args],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
)
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
raise RuntimeError("server died: " + (self.proc.stdout.read() or ""))
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise RuntimeError("server did not come up")
|
||||||
|
|
||||||
|
def get(self, path, headers=None):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||||
|
try:
|
||||||
|
conn.request("GET", path, headers=headers or {})
|
||||||
|
r = conn.getresponse()
|
||||||
|
body = r.read()
|
||||||
|
return _Response(r.status, dict(r.getheaders()), body)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _poll_json(self, path, predicate=None, timeout=5.0):
|
||||||
|
"""Wie Station.read_state() oben: liest `path` wiederholt (bis zu `timeout`
|
||||||
|
Sekunden), bis er sich parsen laesst und - falls angegeben - `predicate` erfuellt.
|
||||||
|
Ohne predicate reicht ein einziges erfolgreiches Parsen, genau wie dort."""
|
||||||
|
last = None
|
||||||
|
last_err = None
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
last = json.loads(path.read_text())
|
||||||
|
if predicate is None or predicate(last):
|
||||||
|
return last
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
last_err = e
|
||||||
|
time.sleep(0.02)
|
||||||
|
if last is None:
|
||||||
|
raise AssertionError(f"could not read {path}: {last_err}")
|
||||||
|
raise AssertionError(f"{path} never satisfied predicate, last seen: {last!r}")
|
||||||
|
|
||||||
|
def status(self, predicate=None):
|
||||||
|
return self._poll_json(self.state_path, predicate)
|
||||||
|
|
||||||
|
def read_control(self, predicate=None):
|
||||||
|
return self._poll_json(self.control_path, predicate)
|
||||||
|
|
||||||
|
def set_control(self, phase, armed_target=None):
|
||||||
|
tmp = self.control_path.with_name(self.control_path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps({"phase": phase, "armed_target": armed_target}))
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, self.control_path)
|
||||||
|
|
||||||
|
def delete_control(self):
|
||||||
|
try:
|
||||||
|
self.control_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def write_raw_control(self, text):
|
||||||
|
self.control_path.write_text(text)
|
||||||
|
|
||||||
|
def set_image_set(self, value):
|
||||||
|
"""Stellt den Umschalter so um, wie es das CLI tut: eine Datei mit dem Namen des
|
||||||
|
Bildsatzes. `None` loescht sie wieder (= Auslieferungszustand)."""
|
||||||
|
if value is None:
|
||||||
|
try:
|
||||||
|
self.imageset_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
self.imageset_path.write_text(value)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.proc.terminate()
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(tmp_path):
|
||||||
|
srv = MigrationTestServer(tmp_path, "recovery")
|
||||||
|
yield srv, srv.image_dir
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def migration_server(tmp_path):
|
||||||
|
srv = MigrationTestServer(tmp_path, "migration")
|
||||||
|
yield srv, srv.image_dir
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_auto_serves_known_product(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000)
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.body == b"IMG" * 1000
|
||||||
|
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||||
|
assert st["last_seen"]["target"] == "g95_cw"
|
||||||
|
assert st["last_seen"]["variant"] == "dev"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_dev_only_in_version_serves_the_dev_image(migration_server):
|
||||||
|
"""Der Hardwarebefund vom 2026-09-15: Produkt-ID OHNE "-dev", Version MIT. Frueher
|
||||||
|
ging hier das produktionssignierte Image raus (200, und die Lampe lehnte es ab) -
|
||||||
|
jetzt muss das dev-signierte gehen, und status.json muss die Quelle mitfuehren."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"PROD" * 1000)
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.body == b"DEV" * 1000
|
||||||
|
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||||
|
assert st["last_seen"]["target"] == "g95_cw"
|
||||||
|
assert st["last_seen"]["variant"] == "dev"
|
||||||
|
assert st["last_seen"]["variant_source"] == "version"
|
||||||
|
assert st["recent"][-1]["variant"] == "dev"
|
||||||
|
assert st["recent"][-1]["variant_source"] == "version"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_production_lamp_still_gets_the_prod_image(migration_server):
|
||||||
|
"""Die erfolgreich migrierte GU10: nirgends ein "-dev", also prod - und die Quelle
|
||||||
|
"none", damit am Panel/in status.json steht, worauf die Einstufung beruht."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000)
|
||||||
|
(imgdir / "migrate-nocreds" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 1000)
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.body == b"PROD" * 1000
|
||||||
|
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||||
|
assert st["last_seen"]["variant"] == "prod"
|
||||||
|
assert st["last_seen"]["variant_source"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_listen_records_but_serves_nothing(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.set_control(phase="listen")
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||||
|
assert r.status == 404
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["last_seen"]["target"] == "g95_cw" # erkannt
|
||||||
|
assert st["served"] == 0 # aber nichts ausgeliefert
|
||||||
|
assert st["recent"][-1]["status"] == "listen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||||
|
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||||
|
assert srv.get(p).status == 200
|
||||||
|
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
||||||
|
assert srv.get(p).status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_armed_for_other_target_does_not_serve(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.set_control(phase="armed", armed_target="gu10_rgb")
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||||
|
assert r.status == 404
|
||||||
|
assert srv.read_control()["phase"] == "armed" # Freigabe bleibt stehen
|
||||||
|
|
||||||
|
|
||||||
|
# --- Eine Freigabe wird NUR durch eine erfolgreiche Auslieferung verbraucht ----------
|
||||||
|
#
|
||||||
|
# Die Zusage (Spec 3.3): claim_delivery() nimmt die Freigabe schon unter der Sperre
|
||||||
|
# weg, BEVOR ueberhaupt gesendet wird (das ist der Punkt von C1 aus Fixrunde 1). Geht
|
||||||
|
# danach nichts raus, muss restore_armed() sie zurueckgeben - sonst steht der Bediener
|
||||||
|
# vor einer Lampe, die beim naechsten Boot nichts mehr bekommt, und muss raten, warum.
|
||||||
|
#
|
||||||
|
# Es gibt drei solche Stellen in _do_get_migration(): fehlender Bildsatz, fehlendes
|
||||||
|
# Einzelimage und abgebrochener Transfer. Nur die erste (die juengste) war getestet -
|
||||||
|
# das Abschlussreview hat die beiden anderen mit je einem "if False:" ausgehebelt, ohne
|
||||||
|
# dass ein einziger der 225 Tests rot wurde. Die beiden folgenden Tests schliessen das.
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_image_does_not_burn_the_release(migration_server):
|
||||||
|
"""Der Bildsatz ist da, nur DIESES Modell fehlt darin (Status "notfound"). Die
|
||||||
|
Freigabe darf daran nicht verfallen: sie war fuer genau dieses Ziel gedacht, und ein
|
||||||
|
fehlendes Image ist ein Deploy-Versaeumnis, kein Auslieferungsvorgang."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
# Der Satz existiert (ein anderes Modell liegt darin), e14_cw fehlt darin.
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.set_control(phase="armed", armed_target="e14_cw")
|
||||||
|
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
|
||||||
|
|
||||||
|
assert r.status == 404
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["recent"][-1]["status"] == "notfound"
|
||||||
|
assert st["served"] == 0
|
||||||
|
control = srv.read_control()
|
||||||
|
assert control["phase"] == "armed", \
|
||||||
|
"die Freigabe wurde verbraucht, obwohl es gar kein Image auszuliefern gab"
|
||||||
|
assert control["armed_target"] == "e14_cw", "das freigegebene Ziel ging verloren"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aborted_transfer_does_not_burn_the_release(tmp_path):
|
||||||
|
"""Bricht die Uebertragung ab (Lampe verliert Strom oder Funk mitten im Download),
|
||||||
|
muss die Freigabe stehen bleiben, damit die Lampe es beim naechsten Boot erneut
|
||||||
|
versuchen kann - sonst muss jemand vor Ort erneut freigeben, und genau diese
|
||||||
|
Nachsicht ist der Grund, warum restore_armed() ueberhaupt existiert.
|
||||||
|
|
||||||
|
Nachgestellt wie test_aborted_transfer_is_recorded oben: --stall-after laesst den
|
||||||
|
Server nach n Byte verstummen, der Test schliesst die Verbindung danach, und
|
||||||
|
_serve_file() meldet "aborted"."""
|
||||||
|
srv = MigrationTestServer(tmp_path, "migration", "--stall-after", "256")
|
||||||
|
try:
|
||||||
|
imgdir = srv.image_dir
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"y" * 100000)
|
||||||
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||||
|
|
||||||
|
sock = socket.create_connection(("127.0.0.1", srv.port), timeout=5)
|
||||||
|
try:
|
||||||
|
sock.sendall(("GET " + G95_DEV + " HTTP/1.1\r\nHost: x\r\n\r\n").encode())
|
||||||
|
sock.recv(4096)
|
||||||
|
time.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
st = srv.status(lambda s: s["recent"] and s["recent"][-1]["status"] == "aborted")
|
||||||
|
assert st["recent"][-1]["bytes"] < 100000
|
||||||
|
assert st["served"] == 0
|
||||||
|
control = srv.read_control(lambda c: True)
|
||||||
|
assert control["phase"] == "armed", \
|
||||||
|
"die Freigabe wurde von einem ABGEBROCHENEN Transfer verbraucht"
|
||||||
|
assert control["armed_target"] == "g95_cw", "das freigegebene Ziel ging verloren"
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_product_id_is_404_and_logged(migration_server):
|
||||||
|
srv, _ = migration_server
|
||||||
|
r = srv.get("/qcx_firmware_-acme.bulb_V1_ota_light.img")
|
||||||
|
assert r.status == 404
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["recent"][-1]["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_migrate_image_is_404(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
# Der Bildsatz ist da (er enthaelt ein anderes Modell), nur dieses eine Modell fehlt
|
||||||
|
# darin - ein anderer Zustand als ein fehlender Bildsatz (siehe
|
||||||
|
# test_a_selected_but_missing_image_set_delivers_nothing).
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
|
||||||
|
assert r.status == 404
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["recent"][-1]["status"] == "notfound"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_path_is_404_in_migration_mode(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "ninlight_evk_ota.img").write_bytes(b"x" * 600)
|
||||||
|
assert srv.get("/ninlight_evk_ota.img").status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_path_is_404_in_recovery_mode(server):
|
||||||
|
srv, _ = server
|
||||||
|
assert srv.get(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_control_file_means_auto(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.delete_control()
|
||||||
|
assert srv.get(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_control_file_means_auto(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.write_raw_control("{das ist kein json")
|
||||||
|
assert srv.get(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_resume_on_migration_image(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40)
|
||||||
|
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||||
|
r = srv.get(p, headers={"Range": "bytes=1000-"})
|
||||||
|
assert r.status == 206
|
||||||
|
assert r.headers["Content-Range"] == "bytes 1000-10239/10240"
|
||||||
|
assert r.body == (bytes(range(256)) * 40)[1000:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_comes_from_manifest(migration_server):
|
||||||
|
"""Faellt auf das manifestweite Feld zurueck, wenn der Eintrag selbst keine eigene
|
||||||
|
Version traegt - Bestandskompatibilitaet mit einem Manifest aus der Zeit vor
|
||||||
|
Fixrunde 2 (N2)."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
(imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
|
||||||
|
"ninlight_version": "0.3.9",
|
||||||
|
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]}))
|
||||||
|
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["recent"][-1]["version"] == "0.3.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_prefers_the_delivered_entry_over_the_manifest_wide_field(migration_server):
|
||||||
|
"""Fixrunde 2, N2: nach einem Merge in deploy.sh (Voll-Deploy mit nur einem der
|
||||||
|
beiden Signierschluessel) koennen Eintraege im selben Manifest unterschiedliche
|
||||||
|
Firmware-Versionen tragen - z.B. ein neu signiertes g95_cw_dev.img auf 1.2.0, aber
|
||||||
|
ein unveraendert liegen gebliebenes aelteres g95_cw_prod.img auf 1.0.0. Der Server
|
||||||
|
darf dann nicht das manifestweite Feld fuer beide protokollieren, sondern muss die
|
||||||
|
Version DES AUSGELIEFERTEN Eintrags nehmen."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG-DEV")
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"IMG-PROD")
|
||||||
|
(imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
|
||||||
|
# Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen
|
||||||
|
# darauf im Test auffallen wuerde.
|
||||||
|
"ninlight_version": "1.2.0",
|
||||||
|
"images": [
|
||||||
|
{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
|
||||||
|
"ninlight_version": "1.2.0"},
|
||||||
|
{"target": "g95_cw", "variant": "prod", "file": "g95_cw_prod.img",
|
||||||
|
"ninlight_version": "1.0.0"},
|
||||||
|
]}))
|
||||||
|
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img")
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["recent"][-1]["version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
||||||
|
"""Dieselbe 0600-Falle wie bei status.json (siehe
|
||||||
|
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
|
||||||
|
control.json: das CLI aus Task 4 muss die Datei lesen UND schreiben koennen."""
|
||||||
|
old_umask = os.umask(0o077)
|
||||||
|
try:
|
||||||
|
srv = MigrationTestServer(tmp_path, "migration")
|
||||||
|
finally:
|
||||||
|
os.umask(old_umask)
|
||||||
|
try:
|
||||||
|
(srv.image_dir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(srv.image_dir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||||
|
|
||||||
|
def world_readable():
|
||||||
|
mode = stat.S_IMODE(srv.control_path.stat().st_mode)
|
||||||
|
return bool(mode & stat.S_IROTH)
|
||||||
|
|
||||||
|
# set_control() oben schreibt direkt aus dem Test, nicht ueber den Server - das
|
||||||
|
# will hier nicht geprueft werden. Erst die Anfrage unten bringt den Server selbst
|
||||||
|
# dazu, control.json zu schreiben (armed -> listen), also denselben Codepfad
|
||||||
|
# (Station.write_control()) wie write_state() fuer status.json.
|
||||||
|
assert srv.get(
|
||||||
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||||
|
srv.read_control(lambda _: world_readable())
|
||||||
|
assert world_readable(), "control.json ist nach dem Schreiben durch den Server " \
|
||||||
|
"nicht welt-lesbar"
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_armed_serves_exactly_once_under_concurrency(migration_server):
|
||||||
|
"""C1 (Fixrunde 1): read_control()/Phasenpruefung/Auslieferung/write_control("listen")
|
||||||
|
liefen ohne gemeinsame Sperre ab - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst-
|
||||||
|
und Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und
|
||||||
|
beide die volle Auslieferung bekommen. station.claim_delivery() muss das Lesen und das
|
||||||
|
Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen
|
||||||
|
Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000)
|
||||||
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||||
|
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||||
|
|
||||||
|
n_workers = 20
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
r = srv.get(p)
|
||||||
|
with results_lock:
|
||||||
|
results.append(r.status)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=10)
|
||||||
|
|
||||||
|
assert len(results) == n_workers, "nicht alle Worker sind fertig geworden"
|
||||||
|
assert results.count(200) == 1, f"erwartet genau eine 200, bekommen: {results}"
|
||||||
|
assert results.count(404) == n_workers - 1
|
||||||
|
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_write_failure_blocks_further_armed_deliveries(migration_server):
|
||||||
|
"""I3 (Fixrunde 1): schlaegt das Schreiben von control.json fehl (z.B. volles oder
|
||||||
|
schreibgeschuetztes /run), darf die Freigabe nicht dauerhaft auf "armed" stehen bleiben
|
||||||
|
- sonst wuerde jede weitere Lampe des Ziels ebenfalls bedient, genau die Luecke aus C1,
|
||||||
|
nur diesmal durch einen Schreibfehler statt durch Nebenlaeufigkeit ausgeloest. Die eine
|
||||||
|
Anfrage, die den Fehler ausloest, bekommt noch ihre Auslieferung (wie eine normale
|
||||||
|
Freigabe); jede weitere schlaegt fehl, bis das Schreiben wieder klappt. Der Fehler muss
|
||||||
|
ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht
|
||||||
|
mehr wirkt."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||||
|
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||||
|
|
||||||
|
control_dir = srv.control_path.parent
|
||||||
|
old_mode = control_dir.stat().st_mode
|
||||||
|
os.chmod(control_dir, 0o500) # r-x fuer den Besitzer, kein Schreiben mehr moeglich
|
||||||
|
try:
|
||||||
|
r1 = srv.get(p)
|
||||||
|
assert r1.status == 200, "die eine vorgesehene Auslieferung muss noch rausgehen"
|
||||||
|
st = srv.status(lambda s: s.get("control_error"))
|
||||||
|
assert st["control_error"], "Schreibfehler muss in status.json sichtbar sein"
|
||||||
|
|
||||||
|
r2 = srv.get(p)
|
||||||
|
assert r2.status == 404, \
|
||||||
|
"ein Schreibfehler darf die Freigabe nicht dauerhaft offen lassen"
|
||||||
|
finally:
|
||||||
|
os.chmod(control_dir, old_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_server_module():
|
||||||
|
"""Laedt server.py wie test_log_error_is_not_silenced_by_log_message oben - als
|
||||||
|
eigenstaendiges Modulobjekt, ohne es in sys.modules einzutragen, damit mehrfache
|
||||||
|
Aufrufe (einer je resolve_mode-Test) sich nicht in die Quere kommen."""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ninlight_station_server_rm", SERVER)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_mode_explicit_mode_wins_over_file(tmp_path):
|
||||||
|
"""I5 (Fixrunde 1): --mode schlaegt --mode-file."""
|
||||||
|
module = _load_server_module()
|
||||||
|
mode_file = tmp_path / "mode"
|
||||||
|
mode_file.write_text("migration")
|
||||||
|
assert module.resolve_mode("recovery", mode_file) == "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_mode_reads_the_mode_file(tmp_path):
|
||||||
|
module = _load_server_module()
|
||||||
|
mode_file = tmp_path / "mode"
|
||||||
|
mode_file.write_text("migration")
|
||||||
|
assert module.resolve_mode(None, mode_file) == "migration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_mode_missing_file_means_recovery(tmp_path):
|
||||||
|
module = _load_server_module()
|
||||||
|
mode_file = tmp_path / "mode" # bewusst nicht angelegt
|
||||||
|
assert module.resolve_mode(None, mode_file) == "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_mode_garbage_file_means_recovery(tmp_path):
|
||||||
|
module = _load_server_module()
|
||||||
|
mode_file = tmp_path / "mode"
|
||||||
|
mode_file.write_text("garbage\n")
|
||||||
|
assert module.resolve_mode(None, mode_file) == "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_mode_default_without_mode_file_is_recovery():
|
||||||
|
module = _load_server_module()
|
||||||
|
assert module.resolve_mode(None, None) == "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Umschalter: mit oder ohne einkompilierte WLAN-Zugangsdaten ---------------------
|
||||||
|
#
|
||||||
|
# Zwei Bildsaetze liegen auf dem Pi (images/migrate-nocreds und images/migrate-wlan);
|
||||||
|
# welcher ausgeliefert wird, entscheidet allein der Umschalter. Geprueft wird deshalb
|
||||||
|
# immer an der TATSAECHLICH AUSGELIEFERTEN DATEI, nicht an einem Pfad oder einem
|
||||||
|
# Statusfeld - der Fehlgriff, gegen den der Umschalter da ist, ist ein falscher Inhalt
|
||||||
|
# in einer Lampe.
|
||||||
|
|
||||||
|
def _both_sets(imgdir):
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100)
|
||||||
|
(imgdir / "migrate-wlan").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"MITW" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
G95_DEV = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_image_set_is_the_one_without_wifi_credentials(migration_server):
|
||||||
|
"""Ohne Umschalterdatei (Auslieferungszustand) geht der Abzug OHNE Zugangsdaten
|
||||||
|
raus. Die sichere Vorgabe ist die, bei der ein Fehlgriff niemandem das eigene
|
||||||
|
WLAN-Passwort in ein fremdes Geraet schreibt."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
_both_sets(imgdir)
|
||||||
|
r = srv.get(G95_DEV)
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.body == b"OHNE" * 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_switch_decides_which_file_is_actually_delivered(migration_server):
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
_both_sets(imgdir)
|
||||||
|
srv.set_image_set("mit-wlan")
|
||||||
|
assert srv.get(G95_DEV).body == b"MITW" * 100
|
||||||
|
# ... und wieder zurueck, ohne Dienstneustart: der Umschalter wirkt sofort.
|
||||||
|
srv.set_image_set("ohne-wlan")
|
||||||
|
assert srv.get(G95_DEV).body == b"OHNE" * 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_switch_file_falls_back_to_the_safe_set(migration_server):
|
||||||
|
"""Unbekannter Inhalt ist kein Grund, den teureren Satz zu nehmen."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
_both_sets(imgdir)
|
||||||
|
srv.set_image_set("voellig-anderes")
|
||||||
|
assert srv.get(G95_DEV).body == b"OHNE" * 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_selected_but_missing_image_set_delivers_nothing(migration_server):
|
||||||
|
"""Der gewaehlte Satz liegt nicht auf dem Pi: es darf NICHTS rausgehen - insbesondere
|
||||||
|
nicht die Datei des anderen Satzes, die daneben liegt und genauso heisst."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100)
|
||||||
|
srv.set_image_set("mit-wlan") # migrate-wlan/ existiert nicht
|
||||||
|
|
||||||
|
r = srv.get(G95_DEV)
|
||||||
|
assert r.status == 404
|
||||||
|
assert b"OHNE" not in r.body
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["served"] == 0
|
||||||
|
# Eigener Status, damit am Panel/in status.json "Satz fehlt" von "dieses Modell
|
||||||
|
# fehlt" unterscheidbar bleibt.
|
||||||
|
assert st["recent"][-1]["status"] == "noimageset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_image_set_does_not_burn_the_release(migration_server):
|
||||||
|
"""Wie beim fehlenden Einzelimage: eine Freigabe darf nicht daran verfallen, dass es
|
||||||
|
gar nichts auszuliefern gab."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
|
||||||
|
srv.set_image_set("mit-wlan")
|
||||||
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||||
|
assert srv.get(G95_DEV).status == 404
|
||||||
|
assert srv.read_control()["phase"] == "armed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_server):
|
||||||
|
"""Nach dem Umschalten darf nicht die Version des vorigen Satzes stehenbleiben - die
|
||||||
|
beiden manifest.json entstehen im selben Deploy und koennen dieselbe mtime tragen."""
|
||||||
|
srv, imgdir = migration_server
|
||||||
|
_both_sets(imgdir)
|
||||||
|
for name, version in (("migrate-nocreds", "0.4.0-onb"), ("migrate-wlan", "0.4.0-wlan")):
|
||||||
|
(imgdir / name / "manifest.json").write_text(json.dumps({
|
||||||
|
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
|
||||||
|
"ninlight_version": version}]}))
|
||||||
|
# Beiden Manifesten dieselbe mtime geben - genau der Fall, den ein reiner
|
||||||
|
# mtime-Vergleich nicht bemerkt haette.
|
||||||
|
stamp = 1600000000
|
||||||
|
for name in ("migrate-nocreds", "migrate-wlan"):
|
||||||
|
os.utime(imgdir / name / "manifest.json", (stamp, stamp))
|
||||||
|
|
||||||
|
assert srv.get(G95_DEV).status == 200
|
||||||
|
assert srv.status(lambda s: s["recent"])["recent"][-1]["version"] == "0.4.0-onb"
|
||||||
|
|
||||||
|
srv.set_image_set("mit-wlan")
|
||||||
|
assert srv.get(G95_DEV).status == 200
|
||||||
|
assert srv.status(
|
||||||
|
lambda s: len(s["recent"]) >= 2
|
||||||
|
)["recent"][-1]["version"] == "0.4.0-wlan"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Derselbe Umschalter gilt jetzt auch fuer die RETTUNG ---------------------------
|
||||||
|
#
|
||||||
|
# Der Eigentuemer hat das ausdruecklich verlangt: bisher bekam eine Rettung IMMER ein
|
||||||
|
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ kamen aus build/). image_path()
|
||||||
|
# folgt jetzt demselben Umschalter wie migrate_dir() - dieselben Tests wie oben fuer die
|
||||||
|
# Migration, nur gegen den Rettungspfad "/ninlight_<target>_ota.img".
|
||||||
|
|
||||||
|
def _both_rescue_sets(imgdir):
|
||||||
|
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
|
||||||
|
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
|
||||||
|
b"OHNE" * 100)
|
||||||
|
(imgdir / "rescue-wlan").mkdir(exist_ok=True)
|
||||||
|
make_image(imgdir / "rescue-wlan" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
|
||||||
|
b"MITW" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_default_image_set_is_the_one_without_wifi_credentials(server):
|
||||||
|
"""Wie bei der Migration: ohne Umschalterdatei gilt die sichere Vorgabe - ein
|
||||||
|
Fehlgriff darf niemandem das eigene WLAN-Passwort in ein fremdes Geraet schreiben.
|
||||||
|
Vor diesem Umschalter bekam eine Rettung IMMER das Abbild MIT Zugangsdaten; das war
|
||||||
|
genau die Nebenwirkung, die der Eigentuemer sichtbar gemacht haben wollte."""
|
||||||
|
srv, imgdir = server
|
||||||
|
_both_rescue_sets(imgdir)
|
||||||
|
r = srv.get("/ninlight_g95_cw_ota.img")
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.body.startswith(b"OHNE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_switch_decides_which_file_is_actually_delivered(server):
|
||||||
|
srv, imgdir = server
|
||||||
|
_both_rescue_sets(imgdir)
|
||||||
|
srv.set_image_set("mit-wlan")
|
||||||
|
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"MITW")
|
||||||
|
# ... und wieder zurueck, ohne Dienstneustart.
|
||||||
|
srv.set_image_set("ohne-wlan")
|
||||||
|
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"OHNE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_missing_selected_set_delivers_nothing(server):
|
||||||
|
"""Der gewaehlte Rettungs-Bildsatz liegt nicht auf dem Pi: NICHTS geht raus -
|
||||||
|
insbesondere nicht das gleichnamige Abbild des anderen, danebenliegenden Satzes.
|
||||||
|
Auf den anderen Satz auszuweichen waere genau der Fehlgriff, gegen den der
|
||||||
|
Umschalter da ist (siehe rescue_dir() in server.py)."""
|
||||||
|
srv, imgdir = server
|
||||||
|
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
|
||||||
|
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
|
||||||
|
b"OHNE" * 100)
|
||||||
|
srv.set_image_set("mit-wlan") # rescue-wlan/ existiert nicht
|
||||||
|
|
||||||
|
r = srv.get("/ninlight_g95_cw_ota.img")
|
||||||
|
assert r.status == 404
|
||||||
|
assert b"OHNE" not in r.body
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["served"] == 0
|
||||||
|
assert st["recent"][-1]["status"] == "notfound"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_old_flat_layout_is_no_longer_served(server):
|
||||||
|
"""Rueckwaertskompatibilitaet bewusst NICHT gegeben (server.py, rescue_dir()): ein Pi
|
||||||
|
mit dem alten, flachen Bestand (vor diesem Umschalter, immer MIT Zugangsdaten aus
|
||||||
|
build/) muss einmal per deploy.sh neu bestueckt werden. Wuerde image_path() den
|
||||||
|
flachen Altbestand noch lesen, wuerde die Vorgabe "ohne-wlan" NICHTS daran aendern -
|
||||||
|
genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend fortgesetzt."""
|
||||||
|
srv, imgdir = server
|
||||||
|
make_image(imgdir / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", b"ALTBESTAND" * 50)
|
||||||
|
r = srv.get("/ninlight_g95_cw_ota.img")
|
||||||
|
assert r.status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_image_set_defaults_without_a_file(tmp_path):
|
||||||
|
module = _load_server_module()
|
||||||
|
assert module.read_image_set(None) == "ohne-wlan"
|
||||||
|
assert module.read_image_set(tmp_path / "fehlt") == "ohne-wlan"
|
||||||
|
p = tmp_path / "imageset"
|
||||||
|
p.write_text("mit-wlan\n")
|
||||||
|
assert module.read_image_set(p) == "mit-wlan"
|
||||||
|
|||||||
Executable
+1142
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,41 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
|||||||
cmake -S "$ROOT/test" -B "$ROOT/build/host" -G Ninja >/dev/null
|
cmake -S "$ROOT/test" -B "$ROOT/build/host" -G Ninja >/dev/null
|
||||||
cmake --build "$ROOT/build/host"
|
cmake --build "$ROOT/build/host"
|
||||||
ctest --test-dir "$ROOT/build/host" --output-on-failure "$@"
|
ctest --test-dir "$ROOT/build/host" --output-on-failure "$@"
|
||||||
|
|
||||||
|
if python3 -c 'import pytest' 2>/dev/null; then
|
||||||
|
# tools/mkimage als GANZES Verzeichnis, nicht nur tools/mkimage/tests: die 19 Tests in
|
||||||
|
# tools/mkimage/test_nl_mkimage.py liegen direkt daneben und liefen sonst in keiner
|
||||||
|
# Suite mit - darunter test_stays_python38_compatible, die Wache fuer die bindende
|
||||||
|
# 3.8-Randbedingung des Docker-Build-Containers.
|
||||||
|
#
|
||||||
|
# Diese Wache braucht das Paket "vermin" und schlaegt ohne es HART fehl (kein Skip):
|
||||||
|
# eine Wache, die sich beim Fehlen ihres Werkzeugs stillschweigend abschaltet, haette
|
||||||
|
# den Build-Bruch von damals genauso durchgelassen wie gar kein Test. Fehlt vermin,
|
||||||
|
# nachinstallieren:
|
||||||
|
# pip install vermin (oder: pip3 install --user vermin)
|
||||||
|
python3 -m pytest "$ROOT/tools/mkimage" "$ROOT/tools/station/tests" -q
|
||||||
|
else
|
||||||
|
echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2
|
||||||
|
echo " Installation: pip install pytest vermin (vermin braucht" >&2
|
||||||
|
echo " tools/mkimage/test_nl_mkimage.py::test_stays_python38_compatible)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Alle Shell-Suiten - test_deploy.sh fehlte hier und lief damit nur bei Handaufruf
|
||||||
|
# (Task 6b F1, I3): dieselbe Klasse von Luecke wie die aus keiner Suite gefallene
|
||||||
|
# test_nl_mkimage.py, nur eine Ebene hoeher. Mit ihr liefen 95 Zusagen nirgends mit,
|
||||||
|
# darunter die komplette Deploy-Haelfte der Anzeige-Auslieferung.
|
||||||
|
#
|
||||||
|
# Als ausdrueckliche Liste, nicht als lose Folge von Aufrufen (Task 6b F2): so gibt es
|
||||||
|
# EINE Stelle, die vollstaendig sein muss, und
|
||||||
|
# tools/mkimage/tests/test_suite_coverage.py kann sie gegen das Dateisystem pruefen,
|
||||||
|
# statt im Skripttext nach Dateinamen zu suchen - eine Textsuche haelt schon ein
|
||||||
|
# nachgestellter Kommentar zum Narren.
|
||||||
|
SHELL_SUITES=(
|
||||||
|
"$ROOT/tools/station/tests/test_cli.sh"
|
||||||
|
"$ROOT/tools/station/tests/test_deploy.sh"
|
||||||
|
"$ROOT/tools/station/tests/test_setup_pi.sh"
|
||||||
|
"$ROOT/tools/tests/test_release.sh"
|
||||||
|
)
|
||||||
|
for suite in "${SHELL_SUITES[@]}"; do
|
||||||
|
"$suite"
|
||||||
|
done
|
||||||
|
|||||||
Executable
+663
@@ -0,0 +1,663 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stub-Tests fuer tools/release.sh (und tools/build_nocreds.sh) - ohne Docker, ohne
|
||||||
|
# echten Firmwarebau ausfuehrbar.
|
||||||
|
#
|
||||||
|
# Gestubbt wird nur die UNTERSTE Ebene: NINLIGHT_DOCKER zeigt auf ein Skript, das sich
|
||||||
|
# wie der Bau im Container verhaelt (relative Pfade ab Repo-Wurzel, legt _gz.img und
|
||||||
|
# _ota.img ab). Alles darueber - das Beiseitelegen von config.local.cmake, das
|
||||||
|
# Signieren, README/SHA256SUMS, das Packen - laeuft echt. Wichtig dabei: der Stub
|
||||||
|
# verhaelt sich wie ein Compiler und SCHREIBT die Werte aus config.local.cmake in die
|
||||||
|
# Images, wenn die Datei zum Bauzeitpunkt noch da ist. Nur so pruefen die Faelle 5/6
|
||||||
|
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
|
||||||
|
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug.
|
||||||
|
#
|
||||||
|
# Und zwar an der Stelle, an der sie beim echten Bau auch landen: IM KOMPRIMIERTEN
|
||||||
|
# RUMPF. Ein Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann - beim _ota.img - 512
|
||||||
|
# Byte Trailer. Frueher schrieb der Stub reinen Klartext in eine Datei, die er nur
|
||||||
|
# "..._ota.img" nannte; damit fand die rohe Bytesuche in release.sh die Werte, und Fall
|
||||||
|
# 5 war gruen, obwohl der Pruefkoerper an echten Abbildern nie irgendetwas gefunden
|
||||||
|
# haette. Ein Testartefakt, das dem Pruefling recht gibt, ist schlimmer als kein Test.
|
||||||
|
#
|
||||||
|
# Aufruf: tools/tests/test_release.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
TOOLS_DIR=$(cd "$HERE/.." && pwd)
|
||||||
|
REPO=$(cd "$TOOLS_DIR/.." && pwd)
|
||||||
|
|
||||||
|
# Sicherheitsnetz (wie in tools/station/tests/test_deploy.sh): NINLIGHT_SIGN_DIR zeigt
|
||||||
|
# auf diesem Rechner auf ECHTE Signierschluessel. Sie darf nicht aus der Umgebung in
|
||||||
|
# diese Suite durchsickern - jedes Szenario setzt sie hart, Testschluessel kommen ueber
|
||||||
|
# die separate Variable TEST_SIGN_DIR.
|
||||||
|
unset NINLIGHT_SIGN_DIR
|
||||||
|
unset TEST_SIGN_DIR
|
||||||
|
unset NINLIGHT_NOCREDS_BUILD_REL
|
||||||
|
unset NINLIGHT_DOCKER
|
||||||
|
|
||||||
|
# GNU vor BSD: auf dem Pi/Debian gibt es sha256sum, auf dem Mac nur shasum.
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
SHA_CHECK=(sha256sum -c)
|
||||||
|
else
|
||||||
|
SHA_CHECK=(shasum -a 256 -c)
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMPROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMPROOT"' EXIT
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
pass() { echo "ok - $1"; }
|
||||||
|
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
|
||||||
|
|
||||||
|
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||||
|
|
||||||
|
# Wegwerf-Zugangsdaten - niemals die echten aus dem Repo.
|
||||||
|
SECRET_SSID="TestWLAN-4b7f-geheim"
|
||||||
|
SECRET_PASS="testpasswort-4b7f"
|
||||||
|
SECRET_TOKEN="0badc0de4b7f0badc0de4b7f"
|
||||||
|
|
||||||
|
# --- Sandbox-Repo: release.sh, build_nocreds.sh, nl_sign.py, bruno/, config.local.cmake ---
|
||||||
|
setup_sandbox() {
|
||||||
|
local sandbox=$1
|
||||||
|
rm -rf "$sandbox"
|
||||||
|
mkdir -p "$sandbox/tools/mkimage" "$sandbox/bruno" "$sandbox/dist"
|
||||||
|
cp "$TOOLS_DIR/release.sh" "$sandbox/tools/release.sh"
|
||||||
|
cp "$TOOLS_DIR/build_nocreds.sh" "$sandbox/tools/build_nocreds.sh"
|
||||||
|
chmod +x "$sandbox/tools/release.sh" "$sandbox/tools/build_nocreds.sh"
|
||||||
|
# Das ECHTE Signierwerkzeug, nur die Schluessel sind Wegwerf-Testschluessel.
|
||||||
|
cp "$TOOLS_DIR/mkimage/nl_sign.py" "$sandbox/tools/mkimage/nl_sign.py"
|
||||||
|
# Die ECHTE Bruno-Collection: die Zusage ist, dass sie im Archiv liegt - mit einer
|
||||||
|
# erfundenen Attrappe wuerde der Test nur sich selbst pruefen.
|
||||||
|
cp "$REPO/bruno/ninlight.yml" "$sandbox/bruno/ninlight.yml"
|
||||||
|
cat >"$sandbox/config.local.cmake" <<EOF
|
||||||
|
set(NINLIGHT_WIFI_SSID "$SECRET_SSID")
|
||||||
|
set(NINLIGHT_WIFI_PASSWORD "$SECRET_PASS")
|
||||||
|
set(NINLIGHT_TOKEN "$SECRET_TOKEN")
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Docker-Stub: verhaelt sich wie der Bau im Container ---
|
||||||
|
# Steuerbar ueber:
|
||||||
|
# STUB_FAIL_TARGET bricht beim Bau dieses Ziels mit Exit 1 ab
|
||||||
|
# STUB_TERM_TARGET schickt beim Bau dieses Ziels SIGTERM an den Aufrufer
|
||||||
|
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
|
||||||
|
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
|
||||||
|
# config.local.cmake beiseite liegt (prueft den doppelten Boden)
|
||||||
|
# STUB_TRUNC_TARGET schneidet beim _ota.img dieses Ziels den gzip-Strom ab - das
|
||||||
|
# Abbild laesst sich dann nicht entpacken. Das _gz.img bleibt heil,
|
||||||
|
# damit das Signieren davor nicht schon abbricht.
|
||||||
|
setup_stub_docker() {
|
||||||
|
local stub=$1 sandbox=$2
|
||||||
|
cat >"$stub" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Erwartet "cmake -S . -B <rel>/<ziel> ..." bzw. "cmake --build <rel>/<ziel>".
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$NINLIGHT_STUB_ROOT"
|
||||||
|
mode=""
|
||||||
|
outdir=""
|
||||||
|
args=("$@")
|
||||||
|
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||||
|
case "${args[$i]}" in
|
||||||
|
--build) mode=build; outdir=${args[$((i + 1))]} ;;
|
||||||
|
-B) mode=${mode:-configure}; outdir=${args[$((i + 1))]} ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$outdir" ] || { echo "Stub: kein Build-Verzeichnis in '$*'" >&2; exit 1; }
|
||||||
|
target=$(basename "$outdir")
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
[ "$mode" = "build" ] || exit 0
|
||||||
|
|
||||||
|
if [ "${STUB_TERM_TARGET:-}" = "$target" ]; then
|
||||||
|
kill -TERM "$PPID"
|
||||||
|
sleep 5
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${STUB_FAIL_TARGET:-}" = "$target" ]; then
|
||||||
|
echo "Stub: Bau von $target fehlgeschlagen" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "Compiler": die Zugangsdaten aus config.local.cmake landen im Abzug - genau wie beim
|
||||||
|
# echten Bau, wo CMakeLists.txt sie per set() einkompiliert.
|
||||||
|
creds=""
|
||||||
|
if [ -f config.local.cmake ]; then
|
||||||
|
creds=$(sed -n 's/.*set(NINLIGHT_[A-Z_]* "\(.*\)").*/\1/p' config.local.cmake | tr '\n' ' ')
|
||||||
|
fi
|
||||||
|
if [ "${STUB_LEAK:-}" = "1" ]; then
|
||||||
|
creds="$STUB_LEAK_VALUE"
|
||||||
|
fi
|
||||||
|
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" TRUNC="${STUB_TRUNC_TARGET:-}" python3 - <<'PY'
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
|
||||||
|
|
||||||
|
# Der Rumpf ist ~100 kB gleichfoermiger Text - so wie echte Firmware gross genug, dass
|
||||||
|
# deflate ihn WIRKLICH komprimiert. Zwei Klippen liegen hier dicht nebeneinander:
|
||||||
|
# zu kleiner Rumpf -> gzip-Strom unter 1024 Byte, und nl_sign.py lehnt ab;
|
||||||
|
# unkomprimierbarer Rumpf (etwa Zufallsbytes) -> deflate legt ihn als "stored block"
|
||||||
|
# UNVERAENDERT ab, der Klartext stuende wieder roh in der Datei und der Test prueft
|
||||||
|
# erneut eine Welt, die es nicht gibt.
|
||||||
|
body = bytearray(
|
||||||
|
b"".join(b"ninlight-%s-block-%06d\n" % (target.encode(), i) for i in range(4000)))
|
||||||
|
tag = ("firmware-%s-" % target).encode()
|
||||||
|
body[0:len(tag)] = tag
|
||||||
|
blob = creds.encode()
|
||||||
|
if blob:
|
||||||
|
# Die Zugangsdaten liegen IM Rumpf - also nach dem Packen im komprimierten Strom
|
||||||
|
# und nicht im Klartext in der Datei. Genau wie beim echten Compiler.
|
||||||
|
body[100:100 + len(blob)] = blob
|
||||||
|
|
||||||
|
# Aufbau wie beim echten Bau: 56 Byte Kopf (wm_tool), dann der gzip-Strom.
|
||||||
|
header = bytearray(b"\x9f\xff\xff\xa0" + b"\x00" * 52)
|
||||||
|
header[4:8] = b"STUB"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
# mtime=0: sonst haengt der Dateiinhalt an der Uhrzeit und Vergleiche werden wacklig.
|
||||||
|
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gf:
|
||||||
|
gf.write(bytes(body))
|
||||||
|
stream = buf.getvalue()
|
||||||
|
raw = bytes(header) + stream
|
||||||
|
# nl_sign.py verlangt eine durch 4 teilbare Laenge von mindestens 1024 Byte. Die
|
||||||
|
# Fuellbytes stehen hinter dem gzip-Strom und stoeren das Entpacken nicht.
|
||||||
|
raw += b"\x00" * (-len(raw) % 4)
|
||||||
|
assert len(raw) >= 1024, "Stub-Abbild zu kurz fuer nl_sign.py"
|
||||||
|
# Selbstkontrolle gegen genau den Fehler, der diese Suite jahrelang wertlos machte.
|
||||||
|
assert not blob or blob not in raw, \
|
||||||
|
"Zugangsdaten stehen roh in der Datei - der Rumpf wurde nicht wirklich komprimiert"
|
||||||
|
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
|
||||||
|
fh.write(raw) # roh, ohne NINL-Trailer - das signiert nl_sign.py
|
||||||
|
trailer = bytearray(b"\xff" * 512)
|
||||||
|
trailer[0:4] = b"NINL"
|
||||||
|
ota_body = raw
|
||||||
|
if os.environ.get("TRUNC") == target:
|
||||||
|
# Abgeschnittener gzip-Strom: decompressobj liefert dafuer stillschweigend nur den
|
||||||
|
# lesbaren Teil, wenn niemand dec.eof abfragt.
|
||||||
|
ota_body = bytes(header) + stream[:len(stream) // 2]
|
||||||
|
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
|
||||||
|
fh.write(ota_body + bytes(trailer))
|
||||||
|
PY
|
||||||
|
STUB
|
||||||
|
chmod +x "$stub"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wegwerf-Testschluessel ueber nl_sign.generate_test_key() - nie echte Schluessel.
|
||||||
|
make_test_keys() {
|
||||||
|
local keydir=$1 with_prod=$2 with_dev=$3
|
||||||
|
mkdir -p "$keydir/QCX_Device_CA"
|
||||||
|
PYTHONPATH="$TOOLS_DIR/mkimage" python3 - "$keydir" "$with_prod" "$with_dev" <<'PY'
|
||||||
|
import sys
|
||||||
|
import nl_sign
|
||||||
|
|
||||||
|
keydir, with_prod, with_dev = sys.argv[1], sys.argv[2] == "1", sys.argv[3] == "1"
|
||||||
|
if with_prod:
|
||||||
|
k = nl_sign.generate_test_key()
|
||||||
|
with open(keydir + "/QCX_Device_CA/qcx_firmware_signing_key.pem", "wb") as fh:
|
||||||
|
fh.write(k.private_pem)
|
||||||
|
with open(keydir + "/prod_pub.der", "wb") as fh:
|
||||||
|
fh.write(k.public_der)
|
||||||
|
if with_dev:
|
||||||
|
k = nl_sign.generate_test_key()
|
||||||
|
with open(keydir + "/fw_sign.pem", "wb") as fh:
|
||||||
|
fh.write(k.private_pem)
|
||||||
|
with open(keydir + "/dev_pub.der", "wb") as fh:
|
||||||
|
fh.write(k.public_der)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ruft release.sh in der Sandbox auf. NINLIGHT_SIGN_DIR wird hier HART gesetzt, nie aus
|
||||||
|
# der geerbten Umgebung uebernommen (siehe Sicherheitsnetz oben).
|
||||||
|
run_release() {
|
||||||
|
local sandbox=$1 version=$2
|
||||||
|
local sign_dir="${TEST_SIGN_DIR:-$sandbox/kein-schluesselverzeichnis}"
|
||||||
|
env NINLIGHT_SIGN_DIR="$sign_dir" \
|
||||||
|
NINLIGHT_DOCKER="$sandbox/stub-docker" \
|
||||||
|
NINLIGHT_STUB_ROOT="$sandbox" \
|
||||||
|
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
|
||||||
|
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
|
||||||
|
STUB_LEAK="${STUB_LEAK:-}" \
|
||||||
|
STUB_TRUNC_TARGET="${STUB_TRUNC_TARGET:-}" \
|
||||||
|
STUB_LEAK_VALUE="$SECRET_SSID" \
|
||||||
|
"$sandbox/tools/release.sh" "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sucht die Zugangsdaten in jeder Datei unterhalb eines Verzeichnisses - in den
|
||||||
|
# Rohbytes UND im entpackten Rumpf jedes Abbilds. Nur roh zu suchen hiesse, die
|
||||||
|
# Zusage "keine Zugangsdaten im Archiv" gar nicht zu pruefen: im gzip-Strom steht
|
||||||
|
# kein Klartext. Ein Abbild, das sich nicht entpacken laesst, wird hier gemeldet und
|
||||||
|
# nicht verschwiegen - sonst haette die Gegenprobe ein stilles Loch.
|
||||||
|
# $2 (optional) = "roh": nur die Rohbytes durchsuchen, nichts entpacken.
|
||||||
|
grep_secrets() {
|
||||||
|
local dir=$1 mode=${2:-entpackt}
|
||||||
|
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
|
||||||
|
python3 - "$dir" "$mode" <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
HEADER_LEN = 56
|
||||||
|
|
||||||
|
root, mode = sys.argv[1], sys.argv[2]
|
||||||
|
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
|
||||||
|
for dirpath, _dirs, files in os.walk(root):
|
||||||
|
for fn in sorted(files):
|
||||||
|
p = os.path.join(dirpath, fn)
|
||||||
|
rel = os.path.relpath(p, root)
|
||||||
|
if os.path.islink(p):
|
||||||
|
continue
|
||||||
|
with open(p, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
views = [data]
|
||||||
|
if mode != "roh" and fn.endswith(".img"):
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
try:
|
||||||
|
plain = dec.decompress(data[HEADER_LEN:])
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom unvollstaendig")
|
||||||
|
views.append(plain)
|
||||||
|
except Exception as exc:
|
||||||
|
print("NICHT ENTPACKBAR %s (%s)" % (rel, exc))
|
||||||
|
for name, needle in needles.items():
|
||||||
|
if any(needle in view for view in views):
|
||||||
|
print("%s in %s" % (name, rel))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
sha_of() { python3 -c "
|
||||||
|
import hashlib,sys
|
||||||
|
print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; }
|
||||||
|
|
||||||
|
echo "=== Szenario 1: Erfolgslauf mit beiden Schluesseln ==="
|
||||||
|
SANDBOX="$TMPROOT/s1"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
KEYS_FULL="$TMPROOT/keys-full"
|
||||||
|
make_test_keys "$KEYS_FULL" 1 1
|
||||||
|
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
|
||||||
|
set +e
|
||||||
|
OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC: $OUT"; fi
|
||||||
|
TGZ="$SANDBOX/dist/ninlight-0.9.9.tar.gz"
|
||||||
|
if [ -f "$TGZ" ]; then pass "Archiv entstanden"; else fail "kein Archiv unter $TGZ"; fi
|
||||||
|
|
||||||
|
EXTRACT="$TMPROOT/x1"
|
||||||
|
mkdir -p "$EXTRACT"
|
||||||
|
tar xzf "$TGZ" -C "$EXTRACT"
|
||||||
|
A="$EXTRACT/ninlight-0.9.9"
|
||||||
|
if [ -d "$A" ]; then pass "Archiv entpackt nach ninlight-0.9.9/"; else fail "Verzeichnis ninlight-0.9.9/ fehlt"; fi
|
||||||
|
|
||||||
|
echo "--- Fall 1/2/3: Verzeichnisse und Dateizahlen ---"
|
||||||
|
for d in ota migrate flash bruno; do
|
||||||
|
if [ -d "$A/$d" ]; then pass "$d/ vorhanden"; else fail "$d/ fehlt"; fi
|
||||||
|
done
|
||||||
|
for f in SHA256SUMS README.md onboard.sh bruno/ninlight.yml; do
|
||||||
|
if [ -f "$A/$f" ]; then pass "$f vorhanden"; else fail "$f fehlt"; fi
|
||||||
|
done
|
||||||
|
if [ -x "$A/onboard.sh" ]; then pass "onboard.sh ist ausfuehrbar"; else fail "onboard.sh ist nicht ausfuehrbar"; fi
|
||||||
|
N_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ')
|
||||||
|
N_FLASH=$(find "$A/flash" -type f | wc -l | tr -d ' ')
|
||||||
|
N_MIG=$(find "$A/migrate" -type f | wc -l | tr -d ' ')
|
||||||
|
[ "$N_OTA" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}"
|
||||||
|
[ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}"
|
||||||
|
[ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))"
|
||||||
|
MISS=""
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
[ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t"
|
||||||
|
[ -f "$A/flash/ninlight_${t}_gz.img" ] || MISS="$MISS flash/$t"
|
||||||
|
[ -f "$A/migrate/${t}_prod.img" ] || MISS="$MISS migrate/${t}_prod"
|
||||||
|
[ -f "$A/migrate/${t}_dev.img" ] || MISS="$MISS migrate/${t}_dev"
|
||||||
|
done
|
||||||
|
[ -z "$MISS" ] && pass "je Ziel genau die erwarteten Dateinamen" || fail "fehlende Dateien:$MISS"
|
||||||
|
|
||||||
|
echo "--- Migrationsimages sind echt signiert (und zwar das _gz.img) ---"
|
||||||
|
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/prod_pub.der" \
|
||||||
|
--in "$A/migrate/evk_prod.img" >/dev/null 2>&1; then
|
||||||
|
pass "evk_prod.img traegt eine gueltige Produktionssignatur"
|
||||||
|
else
|
||||||
|
fail "evk_prod.img besteht die Signaturpruefung nicht"
|
||||||
|
fi
|
||||||
|
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/dev_pub.der" \
|
||||||
|
--in "$A/migrate/g95_cw_dev.img" >/dev/null 2>&1; then
|
||||||
|
pass "g95_cw_dev.img traegt eine gueltige Entwicklungssignatur"
|
||||||
|
else
|
||||||
|
fail "g95_cw_dev.img besteht die Signaturpruefung nicht"
|
||||||
|
fi
|
||||||
|
# Die Signatur belegt denselben 512-Byte-Block wie der Trailer: signiert werden muss das
|
||||||
|
# rohe _gz.img. Ein versehentlich signiertes _ota.img waere 512 Byte laenger.
|
||||||
|
GZ_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/flash/ninlight_evk_gz.img")
|
||||||
|
MIG_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/migrate/evk_prod.img")
|
||||||
|
if [ "$MIG_SIZE" -eq $((GZ_SIZE + 512)) ]; then
|
||||||
|
pass "Migrationsimage = _gz.img + 512 Byte Signatur (nicht das _ota.img signiert)"
|
||||||
|
else
|
||||||
|
fail "Migrationsimage hat $MIG_SIZE Byte, erwartet $((GZ_SIZE + 512))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Fall 4: SHA256SUMS deckt jede Datei ab und stimmt ---"
|
||||||
|
LISTED=$(awk '{print $2}' "$A/SHA256SUMS" | sed 's|^\*||' | sort)
|
||||||
|
PRESENT=$( (cd "$A" && find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort) )
|
||||||
|
if [ "$LISTED" = "$PRESENT" ]; then
|
||||||
|
pass "SHA256SUMS listet genau die Dateien des Archivs ($(echo "$PRESENT" | wc -l | tr -d ' '))"
|
||||||
|
else
|
||||||
|
fail "SHA256SUMS und Archivinhalt weichen ab:
|
||||||
|
$(diff <(echo "$LISTED") <(echo "$PRESENT") || true)"
|
||||||
|
fi
|
||||||
|
if (cd "$A" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
|
||||||
|
pass "Pruefsummen stimmen"
|
||||||
|
else
|
||||||
|
fail "Pruefsummenpruefung fehlgeschlagen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Fall 5: keine Zugangsdaten im Archiv (roh UND im entpackten Rumpf gesucht) ---"
|
||||||
|
HITS=$(grep_secrets "$A")
|
||||||
|
if [ -z "$HITS" ]; then
|
||||||
|
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
|
||||||
|
else
|
||||||
|
fail "Zugangsdaten im Archiv gefunden (oder ein Abbild liess sich nicht entpacken):
|
||||||
|
$HITS"
|
||||||
|
fi
|
||||||
|
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
|
||||||
|
# Verschleierung - aber eine unkomprimierbare Kopie wuerde hier auffallen).
|
||||||
|
if python3 -c "
|
||||||
|
import gzip,sys
|
||||||
|
data = gzip.open(sys.argv[1],'rb').read()
|
||||||
|
sys.exit(0 if not any(s.encode() in data for s in sys.argv[2:]) else 1)" \
|
||||||
|
"$TGZ" "$SECRET_SSID" "$SECRET_PASS" "$SECRET_TOKEN"; then
|
||||||
|
pass "auch im entpackten tar-Strom keine Zugangsdaten"
|
||||||
|
else
|
||||||
|
fail "Zugangsdaten im tar-Strom des Archivs"
|
||||||
|
fi
|
||||||
|
# Der Stub verhaelt sich nur dann wie ein Compiler, wenn er die Werte ueberhaupt
|
||||||
|
# einbauen KANN - sonst waere Fall 5 wertlos. Gegenprobe: mit vorhandener
|
||||||
|
# config.local.cmake landen sie im Abzug.
|
||||||
|
mkdir -p "$TMPROOT/probe"
|
||||||
|
env NINLIGHT_STUB_ROOT="$SANDBOX" "$SANDBOX/stub-docker" cmake --build "$TMPROOT/probe/evk" >/dev/null
|
||||||
|
if grep_secrets "$TMPROOT/probe" | grep -q SSID; then
|
||||||
|
pass "Gegenprobe: der Stub baut die Zugangsdaten ein, solange config.local.cmake da ist"
|
||||||
|
else
|
||||||
|
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
|
||||||
|
fi
|
||||||
|
# Und zwar realistisch: IM komprimierten Rumpf, nicht als Klartext in der Datei. Faende
|
||||||
|
# die rohe Suche sie, prueften alle Faelle oben eine Welt, die es nicht gibt - an echten
|
||||||
|
# Abbildern findet eine rohe Suche nie etwas.
|
||||||
|
if grep_secrets "$TMPROOT/probe" roh | grep -q .; then
|
||||||
|
fail "Gegenprobe: der Stub legt die Zugangsdaten im Klartext ab - unrealistisches Abbild"
|
||||||
|
else
|
||||||
|
pass "Gegenprobe: im Rohbyteinhalt des Stub-Abbilds steht kein Klartext (nur im gzip-Rumpf)"
|
||||||
|
fi
|
||||||
|
# Und das Abbild hat wirklich den Aufbau des echten: 56 Byte Kopf, gzip, 512 Byte
|
||||||
|
# Trailer mit NINL am Anfang.
|
||||||
|
if python3 - "$TMPROOT/probe/evk" <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
d = sys.argv[1]
|
||||||
|
ota = open(os.path.join(d, "ninlight_evk_ota.img"), "rb").read()
|
||||||
|
gz = open(os.path.join(d, "ninlight_evk_gz.img"), "rb").read()
|
||||||
|
assert ota[-512:][:4] == b"NINL", "kein NINL am Trailer-Anfang"
|
||||||
|
assert ota[:-512] == gz, "_ota.img ist nicht _gz.img + 512 Byte Trailer"
|
||||||
|
assert gz[56:58] == b"\x1f\x8b", "kein gzip-Strom ab Byte 56"
|
||||||
|
for raw in (ota, gz):
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
dec.decompress(raw[56:])
|
||||||
|
assert dec.eof, "gzip-Strom unvollstaendig"
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
pass "Gegenprobe: Stub-Abbild hat den echten Aufbau (56 Byte Kopf, gzip, NINL-Trailer)"
|
||||||
|
else
|
||||||
|
fail "Gegenprobe: Stub-Abbild hat nicht den Aufbau eines echten Abbilds"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Meldung der Zugangsdatenpruefung nennt Dateien und entpackte Abbilder ---"
|
||||||
|
# 9 ota + 9 flash + 18 migrate + bruno/ninlight.yml + README.md + onboard.sh = 39,
|
||||||
|
# davon 36 Abbilder. SHA256SUMS entsteht erst nach der Pruefung.
|
||||||
|
#
|
||||||
|
# Die Zahlen haengen an der Laenge von TARGETS: mit ledshield_rgb kamen vier
|
||||||
|
# Dateien dazu (je ein _ota, ein _gz und zwei signierte Migrationsabzuege), alle
|
||||||
|
# vier sind Abbilder. Wer ein Ziel ergaenzt, passt sie hier mit an - der Merge
|
||||||
|
# des Shield-Zweigs lief konfliktfrei durch und liess trotzdem 35/32 stehen.
|
||||||
|
if echo "$OUT" | grep -qF "sauber (39 Dateien geprueft, davon 36 Abbilder entpackt)"; then
|
||||||
|
pass "Meldung nennt Dateizahl und Zahl der entpackten Abbilder"
|
||||||
|
else
|
||||||
|
fail "Meldung der Zugangsdatenpruefung unerwartet: $(echo "$OUT" | grep -i sauber || echo '(keine sauber-Zeile)')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---"
|
||||||
|
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||||
|
pass "config.local.cmake liegt unveraendert an seinem Platz"
|
||||||
|
else
|
||||||
|
fail "config.local.cmake fehlt oder wurde veraendert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- README nennt die Vertrauensgrenze und die Verzeichnisse ---"
|
||||||
|
for phrase in "kein kryptografisches Signaturverfahren" "ohne TLS" "onboard.sh" "migrate/" "flash/"; do
|
||||||
|
if grep -qF "$phrase" "$A/README.md"; then pass "README nennt '$phrase'"; else fail "README nennt '$phrase' nicht"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== Szenario 2: Bau bricht mittendrin ab (Fall 6) ==="
|
||||||
|
SANDBOX="$TMPROOT/s2"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
|
||||||
|
set +e
|
||||||
|
OUT=$(STUB_FAIL_TARGET=e27_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein halbes Archiv zurueckgelassen"; else fail "Archiv trotz Abbruch entstanden"; fi
|
||||||
|
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||||
|
pass "config.local.cmake nach dem Abbruch unveraendert an seinem Platz"
|
||||||
|
else
|
||||||
|
fail "config.local.cmake nach dem Abbruch verschwunden oder veraendert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Szenario 3: Abbruch per Signal (SIGTERM mitten im Bau) ==="
|
||||||
|
SANDBOX="$TMPROOT/s3"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
|
||||||
|
set +e
|
||||||
|
OUT=$(STUB_TERM_TARGET=gu10_rgb TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
|
||||||
|
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||||
|
pass "config.local.cmake nach dem Signalabbruch unveraendert an seinem Platz"
|
||||||
|
else
|
||||||
|
fail "config.local.cmake nach dem Signalabbruch verschwunden oder veraendert"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv nach dem Signalabbruch"; else fail "Archiv trotz Signalabbruch entstanden"; fi
|
||||||
|
|
||||||
|
echo "=== Szenario 4: ohne Schluesselverzeichnis (Fall 3) ==="
|
||||||
|
SANDBOX="$TMPROOT/s4"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1) # kein TEST_SIGN_DIR -> Verzeichnis gibt es nicht
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0 - das Archiv entsteht trotzdem"; else fail "Exit-Code $RC: $OUT"; fi
|
||||||
|
if echo "$OUT" | grep -q "Schluesselverzeichnis"; then pass "deutliche Meldung zum fehlenden Schluesselverzeichnis"; else fail "keine Meldung zum fehlenden Schluesselverzeichnis"; fi
|
||||||
|
EXTRACT="$TMPROOT/x4"; mkdir -p "$EXTRACT"
|
||||||
|
tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT"
|
||||||
|
A4="$EXTRACT/ninlight-0.9.9"
|
||||||
|
if [ ! -d "$A4/migrate" ]; then pass "migrate/ liegt nicht vor"; else fail "migrate/ trotzdem vorhanden"; fi
|
||||||
|
if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "${#TARGETS[@]}" ]; then
|
||||||
|
pass "ota/ trotzdem vollstaendig"
|
||||||
|
else
|
||||||
|
fail "ota/ unvollstaendig"
|
||||||
|
fi
|
||||||
|
if grep -q "migrate/\` fehlt in diesem Archiv" "$A4/README.md"; then
|
||||||
|
pass "README.md sagt, warum migrate/ fehlt"
|
||||||
|
else
|
||||||
|
fail "README.md erklaert das fehlende migrate/ nicht"
|
||||||
|
fi
|
||||||
|
if (cd "$A4" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
|
||||||
|
pass "SHA256SUMS stimmt auch ohne migrate/"
|
||||||
|
else
|
||||||
|
fail "SHA256SUMS stimmt ohne migrate/ nicht"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Szenario 5: zweiter Lauf mit derselben Version bricht ab (Fall 7) ==="
|
||||||
|
BEFORE=$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")
|
||||||
|
set +e
|
||||||
|
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "zweiter Lauf war erfolgreich"; fi
|
||||||
|
if echo "$OUT" | grep -q "existiert bereits"; then pass "Meldung 'existiert bereits'"; else fail "keine Meldung zum vorhandenen Archiv: $OUT"; fi
|
||||||
|
if [ "$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")" = "$BEFORE" ]; then
|
||||||
|
pass "vorhandenes Archiv unveraendert"
|
||||||
|
else
|
||||||
|
fail "vorhandenes Archiv wurde ueberschrieben"
|
||||||
|
fi
|
||||||
|
# Und eine andere Version laeuft weiterhin durch.
|
||||||
|
set +e
|
||||||
|
OUT=$(run_release "$SANDBOX" 0.9.10 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -eq 0 ] && [ -f "$SANDBOX/dist/ninlight-0.9.10.tar.gz" ]; then
|
||||||
|
pass "andere Version laeuft normal durch"
|
||||||
|
else
|
||||||
|
fail "andere Version schlaegt fehl: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im KOMPRIMIERTEN Rumpf brechen den Lauf ab ==="
|
||||||
|
# Das ist der Kern: der Stub legt die SSID im gzip-Rumpf jedes Abbilds ab, wie es der
|
||||||
|
# echte Compiler taete. Ein Pruefkoerper, der nur die Rohbytes durchsucht, laesst diesen
|
||||||
|
# Lauf anstandslos durch und packt Michaels WLAN-Daten ins Archiv.
|
||||||
|
SANDBOX="$TMPROOT/s6"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
set +e
|
||||||
|
OUT=$(STUB_LEAK=1 TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Zugangsdaten im Abzug erfolgreich"; fi
|
||||||
|
if echo "$OUT" | grep -q "Zugangsdaten aus config.local.cmake im Archiv gefunden"; then
|
||||||
|
pass "Meldung benennt den Fund"
|
||||||
|
else
|
||||||
|
fail "keine Meldung zum Fund: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -qF "$SECRET_SSID"; then
|
||||||
|
fail "die Meldung druckt den geheimen Wert selbst"
|
||||||
|
else
|
||||||
|
pass "die Meldung druckt den Wert nicht, nur den Namen"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
|
||||||
|
if echo "$OUT" | grep -q "NINLIGHT_WIFI_SSID in ota/ninlight_evk_ota.img"; then
|
||||||
|
pass "Meldung nennt den Namen des Wertes und die Datei"
|
||||||
|
else
|
||||||
|
fail "Meldung nennt Name und Datei nicht: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Szenario 6b: Klartext in einer Nicht-Abbild-Datei bricht den Lauf ab ==="
|
||||||
|
# Nicht jede Datei im Archiv ist ein Abbild: README, onboard.sh und die Bruno-Collection
|
||||||
|
# sind Text. Fuer sie bleibt die rohe Bytesuche die einzige Pruefung - sie darf durch das
|
||||||
|
# Entpacken nicht verlorengehen.
|
||||||
|
SANDBOX="$TMPROOT/s6b"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
printf '\n# versehentlich notiert: %s\n' "$SECRET_PASS" >>"$SANDBOX/bruno/ninlight.yml"
|
||||||
|
set +e
|
||||||
|
OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Klartext in bruno/ninlight.yml erfolgreich"; fi
|
||||||
|
if echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD in bruno/ninlight.yml"; then
|
||||||
|
pass "Meldung nennt NINLIGHT_WIFI_PASSWORD und die Datei"
|
||||||
|
else
|
||||||
|
fail "Meldung nennt den Fund in bruno/ninlight.yml nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -qF "$SECRET_PASS"; then
|
||||||
|
fail "die Meldung druckt das Passwort selbst"
|
||||||
|
else
|
||||||
|
pass "die Meldung druckt das Passwort nicht, nur den Namen"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
|
||||||
|
|
||||||
|
echo "=== Szenario 6c: ein nicht entpackbares Abbild bricht den Lauf ab ==="
|
||||||
|
# Ein abgeschnittener gzip-Strom ist der heimtueckische Fall: decompressobj liefert
|
||||||
|
# stillschweigend nur den lesbaren Teil. Was dahinter steht, bliebe ungeprueft - und
|
||||||
|
# genau dort koennte das Passwort liegen. Ungeprueft durchlassen ist keine Option.
|
||||||
|
SANDBOX="$TMPROOT/s6c"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
set +e
|
||||||
|
OUT=$(STUB_TRUNC_TARGET=e14_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf mit unentpackbarem Abbild war erfolgreich"; fi
|
||||||
|
if echo "$OUT" | grep -q "ota/ninlight_e14_cw_ota.img"; then
|
||||||
|
pass "Meldung nennt die betroffene Datei"
|
||||||
|
else
|
||||||
|
fail "Meldung nennt die betroffene Datei nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -q "laesst sich nicht entpacken"; then
|
||||||
|
pass "Meldung sagt, dass das Abbild nicht entpackbar ist"
|
||||||
|
else
|
||||||
|
fail "Meldung erklaert den Grund nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz unentpackbarem Abbild gepackt"; fi
|
||||||
|
|
||||||
|
echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
|
||||||
|
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"
|
||||||
|
STUBBIN="$TMPROOT/onb-bin"
|
||||||
|
mkdir -p "$STUBBIN"
|
||||||
|
CURLLOG="$TMPROOT/curl.log"
|
||||||
|
cat >"$STUBBIN/curl" <<'CURL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
{ for a in "$@"; do printf '%s\n' "$a"; done; echo "--"; } >>"$CURL_LOG"
|
||||||
|
echo '{}'
|
||||||
|
CURL
|
||||||
|
chmod +x "$STUBBIN/curl"
|
||||||
|
: >"$CURLLOG"
|
||||||
|
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" "MeinWLAN" "geheim123" >/dev/null
|
||||||
|
if grep -q "^http://192.168.4.1:3333/api/v1/system/settings$" "$CURLLOG"; then
|
||||||
|
pass "PUT geht an system/settings im Onboarding-AP"
|
||||||
|
else
|
||||||
|
fail "falsche URL: $(cat "$CURLLOG")"
|
||||||
|
fi
|
||||||
|
if grep -q '^PUT$' "$CURLLOG"; then pass "Methode PUT"; else fail "keine PUT-Methode"; fi
|
||||||
|
if grep -qF '{"type": "wifi", "ssid": "MeinWLAN", "password": "geheim123"}' "$CURLLOG"; then
|
||||||
|
pass "Rumpf entspricht der Bruno-Anfrage 'Set WiFi'"
|
||||||
|
else
|
||||||
|
fail "falscher Rumpf: $(cat "$CURLLOG")"
|
||||||
|
fi
|
||||||
|
if grep -q "^http://192.168.4.1:3333/api/v1/system/info$" "$CURLLOG"; then
|
||||||
|
pass "danach wird system/info abgefragt"
|
||||||
|
else
|
||||||
|
fail "system/info wird nicht abgefragt"
|
||||||
|
fi
|
||||||
|
if grep -q "^Authorization:" "$CURLLOG"; then
|
||||||
|
fail "ohne --token wird trotzdem ein Authorization-Header geschickt"
|
||||||
|
else
|
||||||
|
pass "ohne --token kein Authorization-Header (offener Onboarding-AP)"
|
||||||
|
fi
|
||||||
|
: >"$CURLLOG"
|
||||||
|
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" --token abc12345 "MeinWLAN" "geheim123" >/dev/null
|
||||||
|
if grep -q "^Authorization: abc12345$" "$CURLLOG"; then
|
||||||
|
pass "mit --token wird der Header geschickt (Fallback-AP)"
|
||||||
|
else
|
||||||
|
fail "--token kommt nicht als Authorization-Header an"
|
||||||
|
fi
|
||||||
|
HELP=$("$ONB" --help)
|
||||||
|
if echo "$HELP" | grep -qi "von hand" && echo "$HELP" | grep -q "ninlight-<mac4>"; then
|
||||||
|
pass "Hilfetext nennt die manuellen Schritte und den AP-Namen"
|
||||||
|
else
|
||||||
|
fail "Hilfetext erklaert die manuellen Schritte nicht: $HELP"
|
||||||
|
fi
|
||||||
|
set +e
|
||||||
|
"$ONB" >/dev/null 2>&1
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "ohne SSID bricht onboard.sh ab"; else fail "ohne SSID lief onboard.sh durch"; fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
|
echo "alle Zusagen erfuellt"
|
||||||
|
else
|
||||||
|
echo "es gab Fehler" >&2
|
||||||
|
fi
|
||||||
|
exit "$FAILED"
|
||||||
Reference in New Issue
Block a user