Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe5efd2c0 | ||
|
|
b198fead77 | ||
|
|
71cede19a4 | ||
|
|
ba8f2a10a0 | ||
|
|
bc3eff1a41 | ||
|
|
08ea01f944 | ||
|
|
ef321b37ef | ||
|
|
813d861cd1 | ||
|
|
a022d8ae3e | ||
|
|
20c26d16d7 | ||
|
|
fda2552e50 | ||
|
|
ded09a3516 | ||
|
|
24c4e38e4e | ||
|
|
18eb7b5c01 | ||
|
|
e36add5030 | ||
|
|
416307083f | ||
|
|
8469b92338 | ||
|
|
906a6d55f2 | ||
|
|
1c29735eaf | ||
|
|
d4b683de8f | ||
|
|
543123a60d | ||
|
|
7fd279721f | ||
|
|
e10147b70f | ||
|
|
5cbf425e49 | ||
|
|
d7810c6cff | ||
|
|
bd467b5e52 | ||
|
|
f31ef879ee | ||
|
|
1562a7983a | ||
|
|
e67fc53b20 | ||
|
|
6d0f79a6ac | ||
|
|
ac8dfb1077 | ||
|
|
eec9fa5a21 | ||
|
|
9ee1983dff | ||
|
|
363aee12ef | ||
|
|
4231444a93 | ||
|
|
fdded43ab9 | ||
|
|
4400ea5b42 | ||
|
|
fbdf0c40ef | ||
|
|
2c7ebac6d1 | ||
|
|
d82df96655 | ||
|
|
60b662ff20 | ||
|
|
15074fb58f | ||
|
|
8f877f18d0 | ||
|
|
fd61f151d7 | ||
|
|
aa0ed770eb | ||
|
|
9b80ab0e0d | ||
|
|
c70b6db4b8 | ||
|
|
5c17c94dbc | ||
|
|
993b7c1575 | ||
|
|
f2cae9b7ce | ||
|
|
c547a9f2b2 | ||
|
|
545ca30699 | ||
|
|
021089a68b | ||
|
|
e892b441ce | ||
|
|
882cb55297 | ||
|
|
fae22f8c64 | ||
|
|
6be0473f18 | ||
|
|
33d085cbed | ||
|
|
f09a51088a | ||
|
|
cdbbe31831 |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Firmware flashen (Standard: App-Image):
|
|||||||
|
|
||||||
tools/flash.sh <device> <port>
|
tools/flash.sh <device> <port>
|
||||||
|
|
||||||
Das Skript druckt `Bitte bei "please manually reset the device" die Reset-Taste drücken.` — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Für blank/erased Chips: `tools/flash.sh <device> <port> full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet).
|
Das Skript druckt `Bitte bei "please manually reset the device" die Reset-Taste drücken.` — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Das Skript ruft `wm_tool` mit `-rs none` auf, es löst also **keinen** Reset aus: Der Reset muss von Hand in dem Zeitfenster kommen, in dem `wm_tool` auf den Sync wartet. Ein automatischer Reset über die Steuerleitungen (`-rs rts` o. ä.) funktioniert an diesem Board nicht. Für blank/erased Chips: `tools/flash.sh <device> <port> full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet).
|
||||||
|
|
||||||
Serielle Konsole (115200 8N1, lokales Echo):
|
Serielle Konsole (115200 8N1, lokales Echo):
|
||||||
|
|
||||||
@@ -65,12 +65,96 @@ 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. die Typen `sacn` und `standalone`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
|
||||||
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
|
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
|
||||||
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
|
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
|
||||||
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
|
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
|
||||||
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
|
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
|
||||||
|
|
||||||
|
### sACN / E1.31
|
||||||
|
|
||||||
|
Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf
|
||||||
|
`239.255.<universe_hi>.<universe_lo>` und per Unicast direkt an ihre IP. Nach einem
|
||||||
|
Update ist der Empfang aktiv: Universe 1, Startslot 1, Belegung `rgb`.
|
||||||
|
|
||||||
|
Es gibt zwei Slot-Belegungen, umschaltbar über `layout`:
|
||||||
|
|
||||||
|
| `layout` | Slots ab `start_slot` | wofür |
|
||||||
|
|---|---|---|
|
||||||
|
| `dimmer` | Dimmer, R, G, B, CW, WW — ohne RGB: Dimmer, CW, WW | DMX-Pulte, die einen Master-Dimmer erwarten |
|
||||||
|
| `rgb` (Vorgabe) | R, G, B (3 Slots auf jedem Board) | pixelorientierte Werkzeuge wie LedFx, die je Pixel drei Kanäle schreiben |
|
||||||
|
|
||||||
|
Im `rgb`-Layout ist jede Lampe genau ein Pixel: Lampe 1 auf Offset 0, Lampe 2 auf Offset 3,
|
||||||
|
und so weiter. Ein Board ohne Farbkanäle nimmt den größten der drei Werte auf beide
|
||||||
|
Weißkanäle — es folgt damit der Helligkeit des Effekts.
|
||||||
|
Wieviele es sind, steht als `slots` in `GET system/settings`.
|
||||||
|
|
||||||
|
Während Pakete fließen, hat sACN Vorrang; die Werte gehen ohne Fade direkt an die
|
||||||
|
Kanäle, und der per API gesetzte Zustand bleibt unangetastet. 2,5 s nach dem letzten
|
||||||
|
Paket endet die Übernahme — je nach `on_timeout` fällt die Lampe auf den per API
|
||||||
|
gesetzten Zustand zurück (`release`, Vorgabe) oder behält die letzten Werte (`hold`).
|
||||||
|
Ein Befehl an `device/state` holt die Kontrolle in beiden Fällen sofort zurück.
|
||||||
|
|
||||||
|
Umstellen:
|
||||||
|
|
||||||
|
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
|
||||||
|
-H 'Authorization: <token>' \
|
||||||
|
-d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}'
|
||||||
|
|
||||||
|
Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im
|
||||||
|
WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts
|
||||||
|
speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`.
|
||||||
|
|
||||||
|
Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`.
|
||||||
|
|
||||||
|
### Standalone-AP
|
||||||
|
|
||||||
|
Statt sich in ein WLAN einzuwählen, kann die Lampe dauerhaft einen eigenen Zugangspunkt aufspannen
|
||||||
|
(z. B. für Bühne oder Gartenhaus ohne Router). Der Modus überlebt Neustarts: die Lampe startet
|
||||||
|
direkt im eigenen AP, ohne Onboarding und ohne Onboarding-Pulsen. Die Lampe ist dann unter
|
||||||
|
`192.168.4.1` erreichbar, per mDNS als `<hostname>.local`.
|
||||||
|
|
||||||
|
Einschalten:
|
||||||
|
|
||||||
|
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
|
||||||
|
-H 'Authorization: <token>' \
|
||||||
|
-d '{"type":"standalone","enabled":true,"ssid":"Buehne","password":"geheim123","channel":6}'
|
||||||
|
|
||||||
|
Antwort `202 {"status":"standalone"}`. Anders als der unkonfigurierte Onboarding-AP ist der
|
||||||
|
Standalone-AP nicht token-frei: der Tokenschutz gilt dort wie im Normalbetrieb (am Gerät noch nicht
|
||||||
|
nachgemessen, siehe Abnahmeliste). Felder:
|
||||||
|
|
||||||
|
| Feld | Pflicht | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `enabled` | ja | `true` schaltet den Modus ein, `false` aus |
|
||||||
|
| `password` | beim Einschalten, solange noch keines gespeichert ist | AP-Passwort, 8..63 Zeichen |
|
||||||
|
| `ssid` | nein | AP-Name, 1..32 Zeichen; ein leerer String wird mit 400 `invalid ssid` abgelehnt. Weggelassen bleibt der gespeicherte Name erhalten; `ninlight-<mac4>` gilt nur, solange nie ein eigener gesetzt wurde — über die API führt kein Weg zurück zum Vorgabenamen |
|
||||||
|
| `channel` | nein | Funkkanal 1..13, Vorgabe 6 |
|
||||||
|
| `pulse` | nein | Startpuls beim Hochfahren der Lampe an/aus, Vorgabe an (läuft rund 5 s, danach gilt das normale Einschaltverhalten) |
|
||||||
|
|
||||||
|
Ausschalten braucht kein Passwort: `{"type":"standalone","enabled":false}`. Hat die Lampe gespeicherte
|
||||||
|
WLAN-Daten, wählt sie sich damit wieder ins WLAN ein; ohne solche öffnet sie den unkonfigurierten
|
||||||
|
Onboarding-AP `ninlight-<mac4>`.
|
||||||
|
|
||||||
|
`GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **gewünschten** AP-Namen
|
||||||
|
(`ssid`: der gespeicherte Name, ohne eigenen Namen der Hostname), `channel` und `pulse` — das
|
||||||
|
Passwort steht nie darin. `GET system/info` meldet `wifi.standalone` und im AP-Betrieb (`mode: "ap"`,
|
||||||
|
also eigener AP oder Onboarding-AP) unter `wifi.ssid` den **laufenden AP-Namen** statt der
|
||||||
|
gespeicherten Stations-SSID — der Name des Heimnetzes steht damit nicht in einer tokenfrei lesbaren
|
||||||
|
Antwort.
|
||||||
|
|
||||||
|
Die beiden Namen können auseinanderlaufen, und das ist Absicht. Scheitert der AP-Aufbau mehrfach,
|
||||||
|
fällt der Treiber auf die zuletzt funktionierenden Daten zurück, und gibt es die nicht, auf Name und
|
||||||
|
Kanal der Vorgaben (das Nutzerpasswort bleibt); die gespeicherte Einstellung behält dabei den
|
||||||
|
Wunschnamen. Nach `{"enabled":false}` behält sie `ap_ssid` ebenfalls, der laufende AP heißt dann
|
||||||
|
wieder `ninlight-<mac4>`. Genau diese Abweichung zwischen `system/settings` (gewünscht) und
|
||||||
|
`system/info` (läuft) ist das Erkennungsmerkmal für einen Rückfall.
|
||||||
|
|
||||||
|
Der Startpuls übermalt als Überlagerung die sACN-Ausgabe: solange er läuft, ist von sACN nichts
|
||||||
|
zu sehen. Beim Ausprobieren von sACN über den eigenen AP den Puls mit `pulse: false` abschalten oder
|
||||||
|
abwarten. Außerdem muss der Sender bei zwei Default-Routen (LAN und AP) das Interface für Multicast
|
||||||
|
festlegen (`IP_MULTICAST_IF`), sonst sendet er ins eigene LAN statt in den AP.
|
||||||
|
|
||||||
## Firmware-Update
|
## Firmware-Update
|
||||||
|
|
||||||
Der Build erzeugt pro Gerät zwei Images:
|
Der Build erzeugt pro Gerät zwei Images:
|
||||||
|
|||||||
+23
-1
@@ -328,10 +328,32 @@ items:
|
|||||||
timeout: 0
|
timeout: 0
|
||||||
followRedirects: true
|
followRedirects: true
|
||||||
maxRedirects: 5
|
maxRedirects: 5
|
||||||
|
- info:
|
||||||
|
name: Set sACN
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"type": "sacn",
|
||||||
|
"enabled": true,
|
||||||
|
"universe": 1,
|
||||||
|
"start_slot": 1,
|
||||||
|
"on_timeout": "release"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
- info:
|
- info:
|
||||||
name: Commands
|
name: Commands
|
||||||
type: folder
|
type: folder
|
||||||
seq: 7
|
seq: 8
|
||||||
items:
|
items:
|
||||||
- info:
|
- info:
|
||||||
name: Ping
|
name: Ping
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Abnahme sACN/E1.31
|
||||||
|
|
||||||
|
Datum: 2026-09-20, nachts
|
||||||
|
Lampe: Klyqa E27 RGB CW/WW, `192.168.2.204`, MAC `28:6d:cd:5c:7f:5b`, Board `e27_rgb` (6 Slots)
|
||||||
|
Firmware vorher: `0.3.8-36-gca9bb` — nachher: `0.3.8-61-g9b80a`
|
||||||
|
Sender: `tools/sacn_send.py`, ausgefuehrt vom Pi `ninlight-station` (192.168.2.96), also aus
|
||||||
|
demselben Netz wie die Lampen.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
Bestanden, mit einer benannten Einschraenkung: **Multicast erreicht die Lampe nicht**,
|
||||||
|
Unicast funktioniert vollstaendig. Siehe den eigenen Abschnitt unten.
|
||||||
|
|
||||||
|
## Einzelne Schritte
|
||||||
|
|
||||||
|
| # | Pruefpunkt | Wie geprueft | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | OTA aus Home Assistant | `update.install` auf der Update-Entitaet, Abbild aus dem Firmware-Ordner der Produktiv-HA | bestanden; Lampe nach dem Neustart mit Uptime 12 s zurueck, WLAN unveraendert |
|
||||||
|
| 0a | Versionsvergleich der Integration | HA meldete `0.3.8-36-gca9bb` -> `0.3.8-61-g9b80a` als Update | bestanden; HAs eingebauter Vergleich haette diese Git-Versionen falsch sortiert, der eigene greift |
|
||||||
|
| 0b | Vorgaben nach dem Update | `GET system/settings` | bestanden: `enabled true`, Universe 1, Startslot 1, `release`, `slots 6` |
|
||||||
|
| 1 | Uebernahme, Farbe | Gruen per Unicast, 60 s | bestanden (Sichtpruefung Michael: "leuchtet gruen") |
|
||||||
|
| 2 | Dimmer-Rampe | 0..255 und zurueck, 25 fps | bestanden nach Wechsel des Senders auf den Pi, siehe "Ruckeln" unten |
|
||||||
|
| 3 | Timeout, Modus `release` | 30 s Blau, dann Strom beenden | bestanden: nach 2,5 s zurueck auf den HA-Zustand (rot, 21 %), Sicht + `device/state` |
|
||||||
|
| 4 | Timeout, Modus `hold` | Nullen senden, dann Strom beenden | bestanden: Lampe blieb dunkel, obwohl HA "an, rot" hielt |
|
||||||
|
| 5 | HA holt die Kontrolle zurueck | `light.turn_on` waehrend die Lampe im `hold` dunkel festhing | bestanden: sofort gelb/80 %, `sacn.active` aus |
|
||||||
|
| 6 | Abbruch-Bit | Zweimal gemessen, mit und ohne `--terminate` | bestanden: **0,1 s** mit Abbruch-Bit gegen **2,5 s** ueber den Timeout |
|
||||||
|
| 7 | Universe im Betrieb wechseln | auf 12 umgestellt, dann auf 1 und auf 12 gesendet | bestanden: altes Universe wirkungslos, neues uebernimmt, kein Neustart |
|
||||||
|
|
||||||
|
Nicht auf Hardware geprueft: ein Board **ohne RGB** (3 Slots, Dimmer/CW/WW). Dafuer haette
|
||||||
|
eine zweite Lampe geflasht werden muessen. Abgedeckt ist der Fall durch die Host-Tests
|
||||||
|
(`test_dmx.c`, `test_board_without_rgb_uses_three_slots`) und die board-abhaengige
|
||||||
|
Slotgrenze in `test_api.c`.
|
||||||
|
|
||||||
|
## Befund: Multicast erreicht die Lampe nicht - Ursache liegt im WLAN
|
||||||
|
|
||||||
|
Unicast an die Lampen-IP funktioniert durchgaengig. Multicast auf `239.255.0.1` kommt nicht
|
||||||
|
an, auch nicht von einem Sender im selben Netz.
|
||||||
|
|
||||||
|
**Geklaert am 2026-09-20 mit einer Diagnose-Firmware** (`0.3.8-63-gfd61f`, Commit fd61f15):
|
||||||
|
`system/info` meldet seitdem `sacn.multicast`, also das Ergebnis von `IP_ADD_MEMBERSHIP`.
|
||||||
|
Die Lampe liefert **`multicast: true`** - der Beitritt zur Gruppe gelingt. Die Pakete kommen
|
||||||
|
trotzdem nicht an. Damit liegt es nicht an der Firmware, sondern am Netz: das WLAN reicht
|
||||||
|
die Gruppe `239.255.0.1` nicht an die Lampe weiter.
|
||||||
|
|
||||||
|
Dass mDNS funktioniert, widerspricht dem nicht: `224.0.0.251` liegt im link-lokalen Bereich
|
||||||
|
`224.0.0.0/24`, den Switches und Access Points per Norm von der IGMP-Filterung ausnehmen und
|
||||||
|
immer fluten. `239.255.0.1` unterliegt dagegen der Filterung.
|
||||||
|
|
||||||
|
**Netzseite in derselben Nacht durchprobiert - ohne Erfolg:**
|
||||||
|
|
||||||
|
| Zustand im UniFi | Multicast kommt an? |
|
||||||
|
|---|---|
|
||||||
|
| Snooping aus, Multicast-zu-Unicast aus, kein Blocker (Ausgangslage) | nein |
|
||||||
|
| Multicast-zu-Unicast an | nein |
|
||||||
|
| zusaetzlich IGMP-Snooping an, Querier "Aus (Drittanbieter)" | nein |
|
||||||
|
| zusaetzlich Querier "Primaer/Failover" | nein |
|
||||||
|
| dasselbe, danach frischer Beitritt der Lampe erzwungen | nein (`multicast: true` gemeldet) |
|
||||||
|
|
||||||
|
Unicast funktionierte in jeder dieser Varianten sofort. Der Multicast- und Broadcast-Blocker
|
||||||
|
war durchgehend aus, Geraete- und L3-Isolierung ebenfalls.
|
||||||
|
|
||||||
|
**Damit zeigt der Verdacht auf die Lampe.** Wichtig fuer den naechsten Versuch: Das Feld
|
||||||
|
`sacn.multicast` ist NICHT so aussagekraeftig, wie es aussieht. Es meldet nur, dass
|
||||||
|
`IP_ADD_MEMBERSHIP` erfolgreich zurueckkam. Die SDK-Funktion, die den Hardware-Filter des
|
||||||
|
W600 programmiert (`ethernetif_igmp_mac_filter` in
|
||||||
|
`sdk/Src/Network/lwip2.0.3/netif/ethernetif.c`), steigt jedoch ganz oben mit
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (netif != tls_get_netif())
|
||||||
|
return ERR_OK;
|
||||||
|
```
|
||||||
|
|
||||||
|
aus - sie meldet also Erfolg, OHNE den Filter gesetzt zu haben, wenn die Schnittstelle nicht
|
||||||
|
die vom SDK als aktuell gefuehrte ist. Dann nimmt der Chip die Frames nie an.
|
||||||
|
|
||||||
|
### Was in derselben Nacht noch geprueft und AUSGESCHLOSSEN wurde
|
||||||
|
|
||||||
|
- **Der Ausstieg in `ethernetif_igmp_mac_filter` greift nicht.** `tls_get_netif()`
|
||||||
|
(`sdk/Src/Network/lwip2.0.3/netif/wm_ethernet.c:473`) liefert `nif`, und das ist das
|
||||||
|
STA-Interface - genau das, auf dem der Beitritt laeuft. `netif != tls_get_netif()` ist
|
||||||
|
also falsch, der Hardware-Schluessel wird gesetzt. Meine erste Theorie war damit falsch.
|
||||||
|
- **Die Lampe empfaengt Multicast.** Sie beantwortet eine mDNS-Anfrage, die ausschliesslich
|
||||||
|
als Multicast an 224.0.0.251 ging.
|
||||||
|
- **Die Lampe sendet Multicast.** Nach einem Neustart sah der Pi ihre mDNS-Ankuendigung auf
|
||||||
|
224.0.0.251 - Multicast von WLAN-Geraet zu WLAN-Geraet ist also sichtbar und die Messung
|
||||||
|
taugt.
|
||||||
|
- **Im IGMP-Mitschnitt schweigt die Lampe.** Querier-Abfragen (vom Gateway) und Meldungen
|
||||||
|
anderer Geraete sind zu sehen, von der Lampe kommt weder eine Beitrittsmeldung noch eine
|
||||||
|
Antwort auf die Abfrage. ABER: Bei aktivem Snooping leiten Switches Meldungen nur Richtung
|
||||||
|
Router weiter - das Schweigen ist damit ein Indiz, kein Beweis.
|
||||||
|
- **`tls_filter_mcast_mac(gruppen_mac, 1)` nach dem Beitritt aendert nichts.** Die zweite,
|
||||||
|
im SDK-Header dokumentierte Filterebene wird aus dem Quelltext nie aufgerufen; sie
|
||||||
|
ausdruecklich zu oeffnen wurde gebaut, aufgespielt und getestet - Multicast kam weiterhin
|
||||||
|
nicht an. Der Versuch wurde danach zurueckgenommen, die Lampe laeuft wieder auf `main`.
|
||||||
|
- **Stromsparen/DTIM scheidet aus**: Waere das die Ursache, kaeme auch mDNS nicht an.
|
||||||
|
|
||||||
|
### Naechste Schritte, wenn es weitergeht
|
||||||
|
|
||||||
|
1. Serielle Konsole an eine Lampe haengen: sieht man beim Beitritt einen Sendeversuch der
|
||||||
|
IGMP-Meldung? Das ist die einzige Messung, die das verbleibende Indiz zum Beweis macht.
|
||||||
|
2. Mitschnitt im Monitor-Modus (Laptop auf demselben Kanal) - zeigt, was wirklich ueber die
|
||||||
|
Luft geht, unabhaengig davon, was der AP weiterreicht.
|
||||||
|
3. IGMP-Snooping einmal ganz aus und dabei mitschneiden: dann fluten Switches auch
|
||||||
|
IGMP-Meldungen, und das Schweigen der Lampe waere eindeutig zu belegen oder zu widerlegen.
|
||||||
|
4. `sacn.multicast` ehrlicher benennen: es meldet den Rueckgabewert von
|
||||||
|
`IP_ADD_MEMBERSHIP`, nicht die tatsaechliche Empfangsbereitschaft.
|
||||||
|
|
||||||
|
Zwei Fehlversuche auf dem Weg dorthin, damit sie niemand wiederholt:
|
||||||
|
|
||||||
|
- Der erste Multicast-Test lief vom Mac (192.168.1.131) gegen eine Lampe in 192.168.2.x.
|
||||||
|
Multicast wird zwischen den Netzen nicht geroutet - der Test konnte gar nicht gelingen.
|
||||||
|
- Der zweite liess den Pi in die Gruppe eintreten und an sie senden. Ein Access Point
|
||||||
|
schickt einem Sender seine eigenen Multicast-Frames nicht zurueck; das Ergebnis war
|
||||||
|
aussagelos, nicht negativ.
|
||||||
|
|
||||||
|
## Befund: Ruckeln bei der Dimmer-Rampe
|
||||||
|
|
||||||
|
Die erste Rampe kam sichtbar ruckelig an ("ruckelt sehr"). Gesendet wurde vom Mac, der in
|
||||||
|
einem anderen Netz haengt als die Lampen; jedes Paket lief ueber den Router. Dieselbe Rampe
|
||||||
|
vom Pi aus, im selben Netz wie die Lampe, lief deutlich glatter ("viel besser").
|
||||||
|
|
||||||
|
Fuer den Einsatz heisst das: Die sACN-Quelle gehoert in dasselbe Netz wie die Lampen.
|
||||||
|
|
||||||
|
## Nicht geprueft, weil dafuer eine alte Firmware noetig waere
|
||||||
|
|
||||||
|
Der Migrationspfad aus dem kritischen Befund des Schlussreviews (ein vor sACN geschriebener
|
||||||
|
208-Byte-Datensatz darf `sacn_enabled` nicht ueberschreiben) wurde auf dieser Lampe nicht
|
||||||
|
durchlaufen: Sie trug keinen gespeicherten Einstellungssatz, alle Werte standen auf Vorgabe.
|
||||||
|
Abgedeckt ist der Fall durch den Host-Test in `test_settings.c`, der vor dem Fix fehlschlug
|
||||||
|
und danach besteht. Wer ihn auf Hardware sehen will, nimmt eine Lampe, deren
|
||||||
|
Einschaltverhalten vom Vorgabewert abweicht — die traegt einen echten alten Datensatz.
|
||||||
|
|
||||||
|
## Nachtrag 2026-09-20, 03:40 - Belegung "rgb"
|
||||||
|
|
||||||
|
Umgesetzt und an derselben E27 RGB geprueft (Firmware `0.3.8-70-gfbdf0`):
|
||||||
|
|
||||||
|
| Pruefpunkt | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| `PUT {"type":"sacn","layout":"rgb"}` | bestanden; `GET` meldet `layout: rgb`, `slots: 3` |
|
||||||
|
| Paket mit NUR drei Slots | bestanden: wird angenommen (mit Dimmer-Belegung waere es verworfen worden) |
|
||||||
|
| Reines Rot (255,0,0) | bestanden (Sichtpruefung Michael: "leuchtet rot") - genau der Fall, der mit der Dimmer-Belegung Dunkelheit ergeben haette |
|
||||||
|
|
||||||
|
Die Lampe bleibt im `rgb`-Layout stehen, damit sie fuer LedFx vorbereitet ist. Die anderen
|
||||||
|
Lampen tragen noch die alte Firmware und damit nur die Dimmer-Belegung.
|
||||||
|
|
||||||
|
Nicht auf Hardware geprueft: das `rgb`-Layout auf einem Board OHNE Farbkanaele (groesster
|
||||||
|
der drei Werte auf beide Weisskanaele). Abgedeckt durch `test_dmx.c`.
|
||||||
|
|
||||||
|
## Nachtrag 2026-09-21 - Multicast GEFUNDEN und behoben
|
||||||
|
|
||||||
|
Die Ursache lag die ganze Zeit bei uns, nicht im Netz: Die Lampe trat der **falschen Gruppe**
|
||||||
|
bei. Aus Universe 1 wurde `239.255.239.255` statt `239.255.0.1`.
|
||||||
|
|
||||||
|
Schuld ist ein nicht geklammertes Makro im SDK
|
||||||
|
(`sdk/Include/Net/wm_sockets2.0.3.h`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
#define htonl(n) (((n & 0xff) << 24) | ((n & 0xff00) << 8) | ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Stand die Adressrechnung direkt im Aufruf, traf die Maske nach der Textersetzung nur den
|
||||||
|
letzten Term, weil `&` staerker bindet als `|`. Fuer Universe 1 ergibt das exakt `0xefffefff` -
|
||||||
|
genau der Wert, der am EVK auf der seriellen Konsole stand.
|
||||||
|
|
||||||
|
Damit erklaert sich alles, was vorher widerspruechlich aussah: Der Beitritt gelang (deshalb
|
||||||
|
`multicast: true`), die Lampe meldete ihn auch brav per IGMP - nur eben fuer eine Gruppe, auf
|
||||||
|
die niemand sendete. Unicast war davon nicht betroffen, mDNS auch nicht (das joint das SDK
|
||||||
|
selbst mit einer festen Adresse).
|
||||||
|
|
||||||
|
**Gefunden am EVK ueber die serielle Konsole**, nachdem vier Netz-Konfigurationen und fuenf
|
||||||
|
andere Theorien nichts ergeben hatten. LwIPs `igmp.c` liegt als Quelltext im Baum und laesst
|
||||||
|
sich instrumentieren - das war der Hebel, der zwei Naechte lang fehlte.
|
||||||
|
|
||||||
|
### Abnahme des Fixes
|
||||||
|
|
||||||
|
| Pruefpunkt | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| EVK, Gruppe laut Konsole | `join efff0001`, MAC-Filter `01:00:5e:7f:00:01`, Report `type=0x16` |
|
||||||
|
| EVK, Multicast-Uebernahme | bestanden |
|
||||||
|
| EVK, Universe-Wechsel ueber Multicast | bestanden: Sender auf 1 wirkungslos, Sender auf 12 uebernimmt |
|
||||||
|
| E27 RGB (Produktivlampe), Multicast im `rgb`-Layout | bestanden |
|
||||||
|
| Farbzuordnung: `255,0,255` | lila (Sichtpruefung) - R und B an, G aus |
|
||||||
|
| Farbzuordnung: `0,255,0` | gruen (Sichtpruefung) - nur der mittlere Kanal |
|
||||||
|
|
||||||
|
### Fuer die Netzseite
|
||||||
|
|
||||||
|
Die vier UniFi-Umstellungen vom Vortag (Multicast-zu-Unicast, IGMP-Snooping, Querier) waren
|
||||||
|
fuer dieses Problem nicht noetig. Der Querier auf "Primaer/Failover" gehoert trotzdem so -
|
||||||
|
Snooping ohne Querier ist unabhaengig davon eine schlechte Kombination.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hardware-Checkliste (EVK)
|
# Hardware-Checkliste (EVK)
|
||||||
|
|
||||||
Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.sh evk <port>`.
|
Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.sh evk <port>` — das Skript nutzt `-rs none`, die Lampe braucht also einen **manuellen** Reset in dem Zeitfenster, in dem `wm_tool` auf den Sync wartet; ein automatischer Reset über die Steuerleitungen funktioniert an diesem Board nicht.
|
||||||
|
|
||||||
## Licht (Plan 1, Task 11)
|
## Licht (Plan 1, Task 11)
|
||||||
- [x] Nach dem Boot blendet warmweiß 100 % in 0,5 s ein
|
- [x] Nach dem Boot blendet warmweiß 100 % in 0,5 s ein
|
||||||
@@ -65,6 +65,24 @@ Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in
|
|||||||
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: unteres Bereichsende deutlich warm, oberes deutlich kühler (damals noch 2500–5000 K, inzwischen 2000–5000 K), Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: unteres Bereichsende deutlich warm, oberes deutlich kühler (damals noch 2500–5000 K, inzwischen 2000–5000 K), Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
||||||
- [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.*
|
- [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.*
|
||||||
|
|
||||||
|
## Standalone-AP-Modus (Abnahme am EVK, MAC 28:6d:cd:5c:68:1f)
|
||||||
|
|
||||||
|
- [x] `type: standalone` per `PUT system/settings` einschalten → 202 `{"status":"standalone"}`, `system/info` meldet `wifi.standalone: true` — 2026-10-02
|
||||||
|
- [x] Neustart → Lampe startet direkt im eigenen AP: Konsole `wifi: standalone AP, hostname ninlight-681f`, keine Onboarding-Zeile, kein Onboarding-Pulsen (den gibt es nur im Zustand `AP_ONBOARDING`, aus dem Code ableitbar; der Standalone-Startpuls ist davon getrennt und unten offen) — 2026-10-02
|
||||||
|
- [x] Geänderte AP-Daten → `wifi: AP data changed, rebuilding`, danach `wifi: AP ninlight-681f (ch 6) up` — 2026-10-02
|
||||||
|
- [x] Standalone mit exakt den Vorgabedaten auf einem bereits stehenden Onboarding-AP (kein Neuaufbau) löst die mDNS-Bekanntmachung ebenfalls aus — 2026-10-02
|
||||||
|
- [x] mDNS im eigenen AP: Konsole `mdns: ninlight-681f.local _ninlight._tcp up on en1 192.168.4.1`, `dns-sd -G v4 ninlight-681f.local` löst auf `192.168.4.1` auf, `ping` 0 % Verlust — 2026-10-02
|
||||||
|
- [x] REST über den AP antwortet auf `192.168.4.1:3333` — 2026-10-02
|
||||||
|
- [x] Startpuls als Sichtprüfung: Michael hat zugesehen — der Puls lief nach dem Start, hörte nach rund 5 s auf, danach stand die voreingestellte Farbe wieder. Das Einschaltverhalten greift vor dem Netz-Task (`app_main.c:224` vs. `:250`), die Lampe zeigt deshalb erst ihre Farbe und der Puls legt sich darüber — 2026-10-02
|
||||||
|
- [x] REST über den AP verlangt das Token: im Standalone-AP `GET system/settings` ohne Token → 401 `unauthorized`, mit Werkstoken → 200; `GET system/info` ohne Token → 200. Gegenprobe nach dem Werksreset im unkonfigurierten Onboarding-AP: derselbe Aufruf ohne Token → **200** — 2026-10-02
|
||||||
|
- [x] `{"enabled":false}` **ohne** gespeicherte WLAN-Daten: 202, danach heißt der AP wieder `ninlight-681f` (Beitritt mit dem Vorgabepasswort gelang, Name **und** Passwort sind also umgestellt), `system/info` meldet `wifi.ssid: ninlight-681f` und `standalone: false`, `GET system/settings` ohne Token → 200. Der gespeicherte Wunschname bleibt in `system/settings` als `standalone.ssid: Buehne` stehen — 2026-10-02
|
||||||
|
- [x] Standalone-Daten ändern, während der Aufrufer **über den AP** verbunden ist: die 202 kommt an. Direkter Vorher/Nachher-Beleg — derselbe Aufruf lieferte auf dem Stand **vor** der I6-Pause `HTTP 000` (Verbindung riss ab, bevor die Antwort raus war), mit der Pause `{"status":"standalone"} HTTP 202` — 2026-10-02
|
||||||
|
- [x] WLAN eingeben, falsches Passwort → Gerät bleibt im eigenen AP mit eigenen Daten: 202 `connecting`, nach dem Fehlschlag `wifi.error: auth`, `standalone: true`, AP weiterhin `Buehne` mit dem Nutzerpasswort, `GET system/settings` ohne Token weiterhin **401** — 2026-10-02
|
||||||
|
- [x] WLAN eingeben, richtiges Passwort → Gerät im Heimnetz (192.168.2.58, `standalone: false`), nach einem Neustart `mode: sta` und **nicht** wieder im AP — 2026-10-02
|
||||||
|
- [x] Im Standalone-Betrieb meldet `GET system/info` unter `wifi.ssid` den AP-Namen: an einer Lampe mit gespeichertem Heimnetz (`settings.wifi.ssid: ninIOT, source: stored`) meldete die **tokenfreie** Antwort `wifi.ssid: Buehne`, nicht `ninIOT`. Vor dem Wechsel in den Standalone-Modus stand dort korrekt die Stations-SSID — 2026-10-02
|
||||||
|
- [x] Werksreset über 5× ein/aus → wieder unkonfigurierter Onboarding-AP: alte Heimnetz-IP antwortet nicht mehr, AP `ninlight-681f` mit Vorgabepasswort, `settings` ohne Token → 200, alle Werte auf Vorgabe (`name: ''`, `standalone.enabled: false`) — 2026-10-02
|
||||||
|
- [x] sACN über den Standalone-AP, sichtbar an der Lampe: Michael hat grün (unicast an 192.168.4.1) und blau (multicast 239.255.0.1, Sender mit `IP_MULTICAST_IF` auf 192.168.4.2) gesehen, `sacn.active` jeweils true — 2026-10-02
|
||||||
|
|
||||||
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||||
|
|
||||||
Beim Test mit dem verfälschten Image meldete lwIP einmalig
|
Beim Test mit dem verfälschten Image meldete lwIP einmalig
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""The Ninlight integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .const import CONF_TOKEN, DEFAULT_PORT, PLATFORMS
|
|
||||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
|
|
||||||
"""Set up a Ninlight bulb from a config entry."""
|
|
||||||
coordinator = NinlightCoordinator(hass, entry)
|
|
||||||
|
|
||||||
# Register the teardown before the first refresh: HA runs the on_unload callbacks both
|
|
||||||
# when setup fails and when the entry unloads, and awaits the coroutine they return.
|
|
||||||
# That covers every exit path, so no bulb client can be left with an open connection
|
|
||||||
# slot. The bulb serves one request per connection, so this matters.
|
|
||||||
entry.async_on_unload(coordinator.async_shutdown)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
except Exception:
|
|
||||||
# ConfigEntryNotReady / ConfigEntryAuthFailed propagate to HA, which retries or
|
|
||||||
# raises reauth. Close now rather than waiting for the retry.
|
|
||||||
await coordinator.async_shutdown()
|
|
||||||
raise
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
|
||||||
"""Migrate an entry written by an earlier version of this integration."""
|
|
||||||
if config_entry.version > 1:
|
|
||||||
return False
|
|
||||||
if "port" not in config_entry.data or CONF_TOKEN not in config_entry.data:
|
|
||||||
new = dict(config_entry.data)
|
|
||||||
new.setdefault("port", DEFAULT_PORT)
|
|
||||||
new.setdefault(CONF_TOKEN, new.pop("api_key", ""))
|
|
||||||
hass.config_entries.async_update_entry(config_entry, data=new, version=1)
|
|
||||||
_LOGGER.debug("Migrated ninlight entry %s to v1", config_entry.entry_id)
|
|
||||||
return True
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
"""Asynchronous client for the Ninlight REST API.
|
|
||||||
|
|
||||||
The firmware (src/net/nl_api.c) speaks plain HTTP/1.1 on port 3333 with a raw token in
|
|
||||||
the `Authorization` header - no "Bearer" scheme prefix, no TLS, no sessions. The bulb
|
|
||||||
serves one request per connection and accepts at most four queued connections, so the
|
|
||||||
client must not keep a hot connection pool open.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field, replace as _dc_replace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .const import DEFAULT_PORT
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightError(Exception):
|
|
||||||
"""Base error, raised on connection problems and non-JSON replies."""
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightAuthError(NinlightError):
|
|
||||||
"""The bulb rejected our token (HTTP 401)."""
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightUnavailable(NinlightError):
|
|
||||||
"""The bulb could not be reached at all (connection refused or timeout).
|
|
||||||
|
|
||||||
A bulb that is switched off or has lost Wi-Fi is an expected state, not a fault,
|
|
||||||
so the coordinator treats this differently from protocol errors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightBadRequest(NinlightError):
|
|
||||||
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, reason: str | None) -> None:
|
|
||||||
super().__init__(f"{message}: {reason}" if reason else message)
|
|
||||||
self.reason = reason
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class NinlightState:
|
|
||||||
"""A parsed `GET /api/v1/device/state` plus the identity from `system/info`."""
|
|
||||||
|
|
||||||
on: bool
|
|
||||||
brightness: int # 1..100, the firmware's native unit
|
|
||||||
mode: str # "color" | "white"
|
|
||||||
r: int
|
|
||||||
g: int
|
|
||||||
b: int
|
|
||||||
color_temp: int # kelvin
|
|
||||||
ct_min: int
|
|
||||||
ct_max: int
|
|
||||||
has_rgb: bool
|
|
||||||
# Settings echoed by the same reply; the coordinator exposes them as entities.
|
|
||||||
power_on_behaviour: str
|
|
||||||
fade_in: int
|
|
||||||
fade_out: int
|
|
||||||
transition_default: int
|
|
||||||
wake_on_change: bool
|
|
||||||
# From system/info, merged in by the coordinator.
|
|
||||||
device_type: str | None = None
|
|
||||||
product_name: str | None = None
|
|
||||||
fw_version: str | None = None
|
|
||||||
mac: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
rssi: int | None = None
|
|
||||||
ip: str | None = None
|
|
||||||
# Segment model only (ninstrip): the raw segment list; the light maps onto
|
|
||||||
# segment 0 while a scene across the remaining segments stays untouched.
|
|
||||||
segments: list[dict[str, Any]] = field(default_factory=list)
|
|
||||||
# True for ninstrip firmware, whose light state lives in segments. Decided by
|
|
||||||
# presence of `segment_count` in device/state and `product` in system/info -
|
|
||||||
# both keys are ninstrip-only (nl_api_device.c / nl_api_system.c). Bulb
|
|
||||||
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
|
|
||||||
# name is not a signal, but the keys are.
|
|
||||||
strip: bool = False
|
|
||||||
# False when the last poll could not reach the bulb (powered off / offline).
|
|
||||||
# Kept separate from `on` so the entity can show "unavailable" instead of an
|
|
||||||
# error, while the light keeps its last known appearance.
|
|
||||||
available: bool = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def unavailable_copy(cls, last: "NinlightState") -> "NinlightState":
|
|
||||||
"""The last known state, flagged unavailable.
|
|
||||||
|
|
||||||
Keeps the light's appearance (on/off, color) at its last real value while the
|
|
||||||
entity shows as unavailable, instead of falling back to defaults.
|
|
||||||
"""
|
|
||||||
return _dc_replace(last, available=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_api(
|
|
||||||
cls,
|
|
||||||
state: dict[str, Any],
|
|
||||||
info: dict[str, Any] | None = None,
|
|
||||||
segments: list[dict[str, Any]] | None = None,
|
|
||||||
) -> NinlightState:
|
|
||||||
color = state.get("color") or {}
|
|
||||||
caps = state.get("capabilities") or {}
|
|
||||||
ct = caps.get("color_temp") or [2700, 6500]
|
|
||||||
wifi = (info or {}).get("wifi") or {}
|
|
||||||
seg = (segments or [None])[0] or {}
|
|
||||||
# `segment_count` and `product` exist only on ninstrip firmware; the bulb's
|
|
||||||
# device_type is a board id and carries no signal either way.
|
|
||||||
strip = "segment_count" in state or "product" in (info or {})
|
|
||||||
# On a strip the light lives in segment 0; mode "off" carries no color at all,
|
|
||||||
# so mode/color fall back to white there and is_on decides darkness anyway.
|
|
||||||
seg_mode = seg.get("mode")
|
|
||||||
return cls(
|
|
||||||
on=bool(state.get("on", False)),
|
|
||||||
brightness=int(state.get("brightness", 0)),
|
|
||||||
mode="color" if seg_mode == "solid" else str(state.get("mode", "white")),
|
|
||||||
r=int((seg.get("color") or color).get("r", 0)),
|
|
||||||
g=int((seg.get("color") or color).get("g", 0)),
|
|
||||||
b=int((seg.get("color") or color).get("b", 0)),
|
|
||||||
color_temp=int(seg.get("color_temp", state.get("color_temp", 2700))),
|
|
||||||
ct_min=int(ct[0]),
|
|
||||||
ct_max=int(ct[1]),
|
|
||||||
has_rgb=bool(caps.get("color", True)) if strip else bool(caps.get("color", False)),
|
|
||||||
power_on_behaviour=str(state.get("power_on_behaviour", "last")),
|
|
||||||
fade_in=int(state.get("fade_in", 0)),
|
|
||||||
fade_out=int(state.get("fade_out", 0)),
|
|
||||||
transition_default=int(state.get("transition_default", 0)),
|
|
||||||
wake_on_change=bool(state.get("wake_on_change", False)),
|
|
||||||
device_type=(info or {}).get("device_type"),
|
|
||||||
product_name=(info or {}).get("product_name"),
|
|
||||||
fw_version=(info or {}).get("fw_version"),
|
|
||||||
mac=(info or {}).get("mac"),
|
|
||||||
name=(info or {}).get("name"),
|
|
||||||
rssi=wifi.get("rssi"),
|
|
||||||
ip=wifi.get("sta_ip") or wifi.get("ip"),
|
|
||||||
segments=list(segments or []),
|
|
||||||
strip=strip,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightClient:
|
|
||||||
"""One client per bulb (per config entry)."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
port: int = DEFAULT_PORT,
|
|
||||||
session: httpx.AsyncClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._base = f"http://{host}:{port}/api/v1"
|
|
||||||
# Raw token, not "Bearer <token>": nl_http_parse hands the header value straight to
|
|
||||||
# token_ok(), which compares it against the stored token byte for byte.
|
|
||||||
self._headers = {"Authorization": token}
|
|
||||||
self._owns_session = session is None
|
|
||||||
self._session: httpx.AsyncClient = session or httpx.AsyncClient(
|
|
||||||
headers=self._headers,
|
|
||||||
timeout=httpx.Timeout(8.0, connect=4.0),
|
|
||||||
# keepalive_expiry of ~0 makes httpx close after each response instead of
|
|
||||||
# holding one of the bulb's four listen-queue slots open between polls.
|
|
||||||
limits=httpx.Limits(max_connections=1, max_keepalive_connections=0, keepalive_expiry=0.1),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
if self._owns_session:
|
|
||||||
await self._session.aclose()
|
|
||||||
|
|
||||||
async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
|
|
||||||
except httpx.InvalidURL as err: # programming error, not a transient one
|
|
||||||
raise NinlightError(f"invalid bulb URL: {err}") from err
|
|
||||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as err:
|
|
||||||
# No answer at all: bulb powered off, rebooting, or gone from the WLAN.
|
|
||||||
raise NinlightUnavailable(f"bulb unreachable: {err}") from err
|
|
||||||
except httpx.HTTPError as err:
|
|
||||||
raise NinlightError(f"request to bulb failed: {err}") from err
|
|
||||||
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise NinlightAuthError("bulb rejected the API token")
|
|
||||||
if resp.status_code == 400:
|
|
||||||
raise NinlightBadRequest("bulb rejected the request", _error_field(resp))
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise NinlightError(f"bulb answered {resp.status_code}: {_error_field(resp)}")
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except ValueError as err:
|
|
||||||
raise NinlightError("bulb sent a non-JSON body") from err
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise NinlightError("bulb sent an unexpected JSON shape")
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def get_state(self) -> dict[str, Any]:
|
|
||||||
return await self._request("GET", "/device/state")
|
|
||||||
|
|
||||||
async def get_segments(self) -> dict[str, Any]:
|
|
||||||
"""Segment model of the ninstrip firmware (bulbs have no such endpoint)."""
|
|
||||||
return await self._request("GET", "/device/segments")
|
|
||||||
|
|
||||||
async def set_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]:
|
|
||||||
"""Replace the whole segment list. ninstrip answers with `{"status": "ok"}`."""
|
|
||||||
return await self._request("PUT", "/device/segments", {"segments": segments})
|
|
||||||
|
|
||||||
async def patch_segment(self, index: int, patch: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Update one segment; omitted fields keep their current value."""
|
|
||||||
return await self._request("PUT", f"/device/segments/{index}", patch)
|
|
||||||
|
|
||||||
async def get_info(self) -> dict[str, Any]:
|
|
||||||
return await self._request("GET", "/system/info")
|
|
||||||
|
|
||||||
async def get_settings(self) -> dict[str, Any]:
|
|
||||||
"""`system/settings` is the one endpoint that always requires the token.
|
|
||||||
|
|
||||||
`system/info` and a state GET answer without auth unless `auth_all` is set
|
|
||||||
(nl_api.c: the state branch tests `s.auth_all && !authed`, the info branch tests
|
|
||||||
nothing), so only settings proves the token is right.
|
|
||||||
"""
|
|
||||||
return await self._request("GET", "/system/settings")
|
|
||||||
|
|
||||||
async def set_state(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return await self._request("POST", "/device/state", payload)
|
|
||||||
|
|
||||||
async def ping(self) -> dict[str, Any]:
|
|
||||||
return await self._request("PUT", "/system/command", {"command": {"type": "ping"}})
|
|
||||||
|
|
||||||
async def reboot(self) -> dict[str, Any]:
|
|
||||||
return await self._request("PUT", "/system/command", {"command": {"type": "reboot"}})
|
|
||||||
|
|
||||||
|
|
||||||
def _error_field(resp: httpx.Response) -> str | None:
|
|
||||||
try:
|
|
||||||
body = resp.json()
|
|
||||||
except ValueError:
|
|
||||||
return resp.text[:120] or None
|
|
||||||
if isinstance(body, dict):
|
|
||||||
err = body.get("error")
|
|
||||||
return str(err) if err is not None else None
|
|
||||||
return None
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""Config flow for Ninlight."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|
||||||
from homeassistant.data_entry_flow import AbortFlow
|
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
|
||||||
|
|
||||||
from .client import NinlightAuthError, NinlightClient, NinlightError
|
|
||||||
from .const import CONF_TOKEN, DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("host"): str,
|
|
||||||
vol.Optional("port", default=DEFAULT_PORT): vol.All(vol.Coerce(int), vol.Range(1, 65535)),
|
|
||||||
vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): vol.All(str, vol.Length(min=1, max=64)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_mac(mac: str) -> str:
|
|
||||||
"""Keep one canonical form for the unique id (zeroconf and system/info both use `aa:bb:...`)."""
|
|
||||||
return mac.strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_full_mac(mac: str) -> bool:
|
|
||||||
"""True for a colon-separated 12-hex-digit MAC, the only TXT form usable as unique id.
|
|
||||||
|
|
||||||
ninstrip devices publish the 4-byte suffix (`eb4b98`, nl_wifi.c's mac4) in their TXT
|
|
||||||
record; two bulbs sharing a last octet would collide, so those need a probe instead.
|
|
||||||
"""
|
|
||||||
parts = mac.strip().lower().split(":")
|
|
||||||
return len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts)
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Ninlight."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
async def _probe_mac(self, host: str, port: int) -> str:
|
|
||||||
"""Return the bulb's MAC. Needs no token: `system/info` is unauthenticated."""
|
|
||||||
client = NinlightClient(host, DEFAULT_TOKEN, port=port)
|
|
||||||
try:
|
|
||||||
info = await client.get_info()
|
|
||||||
finally:
|
|
||||||
await client.close()
|
|
||||||
mac = info.get("mac")
|
|
||||||
if not isinstance(mac, str) or not mac:
|
|
||||||
raise NinlightError("bulb did not report a MAC address")
|
|
||||||
return _normalize_mac(mac)
|
|
||||||
|
|
||||||
async def _probe_mac_and_token(self, host: str, port: int, token: str) -> str:
|
|
||||||
"""As above, plus verify the token against `system/settings`.
|
|
||||||
|
|
||||||
Only that endpoint authenticates, so a probe that stops at system/info would
|
|
||||||
happily accept a wrong token (nl_api.c's info branch never checks `authed`).
|
|
||||||
"""
|
|
||||||
client = NinlightClient(host, token, port=port)
|
|
||||||
try:
|
|
||||||
info = await client.get_info()
|
|
||||||
await client.get_settings()
|
|
||||||
finally:
|
|
||||||
await client.close()
|
|
||||||
mac = info.get("mac")
|
|
||||||
if not isinstance(mac, str) or not mac:
|
|
||||||
raise NinlightError("bulb did not report a MAC address")
|
|
||||||
return _normalize_mac(mac)
|
|
||||||
|
|
||||||
async def _entry_from_data(self, data: dict[str, Any], title: str) -> ConfigFlowResult:
|
|
||||||
await self.async_set_unique_id(data["mac"])
|
|
||||||
# Addresses come from DHCP, so rediscovering a known MAC at a new IP must move the
|
|
||||||
# existing entry rather than abort a re-add.
|
|
||||||
self._abort_if_unique_id_configured(updates=data)
|
|
||||||
return self.async_create_entry(title=title, data=data)
|
|
||||||
|
|
||||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
if user_input is not None:
|
|
||||||
host = user_input["host"].strip()
|
|
||||||
port = int(user_input["port"])
|
|
||||||
token = user_input[CONF_TOKEN].strip()
|
|
||||||
try:
|
|
||||||
mac = await self._probe_mac_and_token(host, port, token)
|
|
||||||
except NinlightAuthError:
|
|
||||||
errors[CONF_TOKEN] = "invalid_auth"
|
|
||||||
except NinlightError:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
else:
|
|
||||||
return await self._entry_from_data(
|
|
||||||
{"host": host, "port": port, CONF_TOKEN: token, "mac": mac}, title=host
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors)
|
|
||||||
|
|
||||||
async def async_step_zeroconf(
|
|
||||||
self, discovery_info: ZeroconfServiceInfo
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle discovery of a `_ninlight._tcp` service."""
|
|
||||||
props = {str(k).lower(): str(v) for k, v in discovery_info.properties.items()}
|
|
||||||
port = discovery_info.port or DEFAULT_PORT
|
|
||||||
mac = props.get("mac", "")
|
|
||||||
# A TXT record may hold no MAC at all (bulb still announcing) or only the short
|
|
||||||
# 4-byte suffix form ninstrip publishes (`eb4b98`). Unique ids are full MACs, so
|
|
||||||
# anything that is not one colon-form is replaced by a probe of system/info.
|
|
||||||
if not _is_full_mac(mac):
|
|
||||||
try:
|
|
||||||
mac = await self._probe_mac(discovery_info.host, port)
|
|
||||||
except NinlightError:
|
|
||||||
raise AbortFlow("cannot_connect") from None
|
|
||||||
mac = _normalize_mac(mac)
|
|
||||||
|
|
||||||
await self.async_set_unique_id(mac)
|
|
||||||
updates = {
|
|
||||||
"host": discovery_info.host,
|
|
||||||
"port": port,
|
|
||||||
"mac": mac,
|
|
||||||
"device_type": props.get("type", ""),
|
|
||||||
}
|
|
||||||
self._abort_if_unique_id_configured(updates=updates)
|
|
||||||
|
|
||||||
# Instance name is `ninlight-<mac4>` until someone names the bulb; a name set on
|
|
||||||
# the device is published in the TXT record and wins.
|
|
||||||
title = props.get("name") or discovery_info.name.split(".", 1)[0]
|
|
||||||
self.context["title_placeholders"] = {"name": title}
|
|
||||||
# Create rather than prompt. A confirm step would leave the flow parked in
|
|
||||||
# `in_progress`, where it blocks a manual add of the same bulb with
|
|
||||||
# already_in_progress - and a bulb that is reachable but not announcing mDNS
|
|
||||||
# (two of ours) has to be added manually, so that collision is not theoretical.
|
|
||||||
return self.async_create_entry(title=title, data={**updates, CONF_TOKEN: DEFAULT_TOKEN})
|
|
||||||
|
|
||||||
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
|
|
||||||
return await self.async_step_reauth_confirm()
|
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
|
||||||
assert entry is not None
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
if user_input is not None:
|
|
||||||
token = user_input[CONF_TOKEN].strip()
|
|
||||||
try:
|
|
||||||
await self._probe_mac_and_token(entry.data["host"], entry.data.get("port", DEFAULT_PORT), token)
|
|
||||||
except NinlightAuthError:
|
|
||||||
errors[CONF_TOKEN] = "invalid_auth"
|
|
||||||
except NinlightError:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
else:
|
|
||||||
self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_TOKEN: token})
|
|
||||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
return self.async_abort(reason="reauth_successful")
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="reauth_confirm",
|
|
||||||
data_schema=vol.Schema({vol.Required(CONF_TOKEN, default=entry.data.get(CONF_TOKEN, "")): str}),
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders={"name": entry.title},
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Constants for the Ninlight integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
|
|
||||||
DOMAIN = "ninlight"
|
|
||||||
|
|
||||||
PLATFORMS: Final = [Platform.LIGHT]
|
|
||||||
|
|
||||||
CONF_TOKEN = "token"
|
|
||||||
|
|
||||||
DEFAULT_PORT = 3333
|
|
||||||
DEFAULT_TOKEN = "aabbccddeeff0011223344"
|
|
||||||
|
|
||||||
# The firmware has no push channel, so state is polled. A bulb answers in well under
|
|
||||||
# a second on the LAN; 30 s keeps the UI honest without adding noticeable REST traffic
|
|
||||||
# for a nine bulb setup.
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
|
||||||
|
|
||||||
# Mirror of NL_MAX_TRANSITION_MS in src/light/nl_control.h.
|
|
||||||
MAX_TRANSITION_MS = 60000
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Data coordinator for a single Ninlight bulb."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import suppress
|
|
||||||
from typing import Any, TypeAlias
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .client import (
|
|
||||||
NinlightAuthError,
|
|
||||||
NinlightBadRequest,
|
|
||||||
NinlightClient,
|
|
||||||
NinlightError,
|
|
||||||
NinlightState,
|
|
||||||
NinlightUnavailable,
|
|
||||||
)
|
|
||||||
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightCoordinator(DataUpdateCoordinator[NinlightState]):
|
|
||||||
"""Polls one bulb. One coordinator, and one HTTP client, per config entry.
|
|
||||||
|
|
||||||
`system/info` is fetched on the first successful refresh only. It carries identity
|
|
||||||
(MAC, product name, firmware) that does not change while the bulb runs, and the bulb
|
|
||||||
accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so
|
|
||||||
skipping it on every poll keeps one of those slots free for the UI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
config_entry: NinlightConfigEntry
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
config_entry=entry,
|
|
||||||
name=f"{DOMAIN} {entry.title}",
|
|
||||||
update_interval=SCAN_INTERVAL,
|
|
||||||
)
|
|
||||||
self.client = NinlightClient(
|
|
||||||
entry.data["host"],
|
|
||||||
entry.data.get(CONF_TOKEN, ""),
|
|
||||||
port=entry.data.get("port", DEFAULT_PORT),
|
|
||||||
)
|
|
||||||
self.info: dict[str, Any] | None = None
|
|
||||||
self._identity_ok = False
|
|
||||||
self._is_strip = False
|
|
||||||
|
|
||||||
async def async_shutdown(self) -> None:
|
|
||||||
"""Close the HTTP client alongside the coordinator's own teardown."""
|
|
||||||
await self.client.close()
|
|
||||||
await super().async_shutdown()
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> NinlightState:
|
|
||||||
try:
|
|
||||||
state = await self.client.get_state()
|
|
||||||
except NinlightAuthError as err:
|
|
||||||
# Only possible when the bulb has `auth_all` set; a wrong token will not fix
|
|
||||||
# itself, so surface a re-auth flow instead of retrying every interval.
|
|
||||||
raise ConfigEntryAuthFailed(str(err)) from err
|
|
||||||
except NinlightUnavailable as err:
|
|
||||||
# A bulb that is switched off or off the WLAN is an expected state, not a
|
|
||||||
# fault. Keep the last known data with `available=False` so the entity shows
|
|
||||||
# "unavailable" and the config entry stays loaded - no error log, no retry
|
|
||||||
# backoff, no reload. The next poll picks the bulb up when it answers again.
|
|
||||||
_LOGGER.debug("%s unreachable (assumed powered off): %s", self.name, err)
|
|
||||||
if isinstance(self.data, NinlightState):
|
|
||||||
return NinlightState.unavailable_copy(self.data)
|
|
||||||
# No successful poll yet (e.g. HA started while the bulb was off): a default
|
|
||||||
# state carries no identity, but the entry still loads cleanly and the
|
|
||||||
# identity fetch below reruns on the first poll that reaches the bulb.
|
|
||||||
return NinlightState.unavailable_copy(NinlightState.from_api({}))
|
|
||||||
except NinlightBadRequest as err:
|
|
||||||
# A poll sends no body, so this can only mean a firmware we do not match.
|
|
||||||
raise UpdateFailed(str(err)) from err
|
|
||||||
except NinlightError as err:
|
|
||||||
raise UpdateFailed(str(err)) from err
|
|
||||||
|
|
||||||
if not self._identity_ok:
|
|
||||||
# One extra pair of requests, then never again: the bulb accepts at most four
|
|
||||||
# queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll
|
|
||||||
# path to a single GET leaves slots free for the UI.
|
|
||||||
with suppress(NinlightError):
|
|
||||||
# system/info carries identity that only changes on a reboot or an OTA.
|
|
||||||
self.info = await self.client.get_info()
|
|
||||||
try:
|
|
||||||
# system/settings always demands the token, so it is the only call that
|
|
||||||
# proves ours is right. A failure here on a bulb whose state endpoint
|
|
||||||
# answered happily means the token is wrong while `auth_all` is off - the
|
|
||||||
# light would work and silently never be able to be renamed or updated.
|
|
||||||
await self.client.get_settings()
|
|
||||||
except NinlightAuthError as err:
|
|
||||||
raise ConfigEntryAuthFailed(str(err)) from err
|
|
||||||
# `product` is an ninstrip-only key in system/info (bulbs send
|
|
||||||
# product_name); once known, the heavier segment poll follows every refresh.
|
|
||||||
self._is_strip = "product" in (self.info or {})
|
|
||||||
self._identity_ok = True
|
|
||||||
|
|
||||||
segments: list[dict[str, Any]] | None = None
|
|
||||||
if self._is_strip:
|
|
||||||
# The light lives in the segment list; the plain state GET above carries
|
|
||||||
# only on/brightness for it. A firmware without the endpoint (misdetected
|
|
||||||
# strip) must not fail the whole poll for that.
|
|
||||||
try:
|
|
||||||
segments = (await self.client.get_segments()).get("segments")
|
|
||||||
except NinlightError as err:
|
|
||||||
_LOGGER.debug("segment poll failed: %s", err)
|
|
||||||
|
|
||||||
return NinlightState.from_api(state, self.info, segments)
|
|
||||||
|
|
||||||
|
|
||||||
# Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the
|
|
||||||
# project's 3.11 tooling and runs on HA's 3.14.
|
|
||||||
NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Base entity for Ninlight devices."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from .const import DEFAULT_PORT, DOMAIN
|
|
||||||
from .coordinator import NinlightCoordinator, NinlightConfigEntry
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
|
|
||||||
"""Common device info for every entity of one bulb."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
|
|
||||||
super().__init__(coordinator, context=entry.entry_id)
|
|
||||||
mac = (entry.unique_id or "unknown").lower()
|
|
||||||
info = coordinator.info or {}
|
|
||||||
# Deliberately no `connections={("mac", mac)}`: HA treats a MAC in `connections`
|
|
||||||
# as a network-topology link, so a bulb the FRITZ!Box tracker already knows merged
|
|
||||||
# into that router-adjacent device and its entity id fell back to the IP. The MAC
|
|
||||||
# still identifies the device via unique_id; identifiers are the sole identity here.
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, mac)},
|
|
||||||
# The bulb itself has no user-visible name field that survives rediscovery
|
|
||||||
# cleanly, so the entry title (set from mDNS or by the user) names the device.
|
|
||||||
name=entry.title,
|
|
||||||
manufacturer="Klyqa",
|
|
||||||
model=info.get("product_name") or info.get("product"),
|
|
||||||
model_id=info.get("device_type"),
|
|
||||||
sw_version=info.get("fw_version"),
|
|
||||||
configuration_url=f"http://{entry.data['host']}:{entry.data.get('port', DEFAULT_PORT)}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_mac(entry: NinlightConfigEntry) -> str:
|
|
||||||
mac: str = entry.unique_id or "unknown"
|
|
||||||
return mac.lower()
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""Light platform for Ninlight bulbs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.components.light import (
|
|
||||||
ATTR_BRIGHTNESS,
|
|
||||||
ATTR_COLOR_TEMP_KELVIN,
|
|
||||||
ATTR_RGB_COLOR,
|
|
||||||
ATTR_TRANSITION,
|
|
||||||
ColorMode,
|
|
||||||
LightEntity,
|
|
||||||
LightEntityFeature,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
||||||
|
|
||||||
from .client import NinlightBadRequest, NinlightError, NinlightState
|
|
||||||
from .const import MAX_TRANSITION_MS
|
|
||||||
from .coordinator import NinlightConfigEntry, NinlightCoordinator
|
|
||||||
from .entity import NinlightEntity
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: NinlightConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the light for a Ninlight bulb config entry."""
|
|
||||||
async_add_entities([NinlightLight(entry.runtime_data, entry)])
|
|
||||||
|
|
||||||
|
|
||||||
def _to_ha_brightness(pct: int) -> int:
|
|
||||||
"""Bulb brightness is 1..100; HA brightness is 0..255."""
|
|
||||||
return max(1, min(255, round(pct * 255 / 100)))
|
|
||||||
|
|
||||||
|
|
||||||
def _to_bulb_brightness(level: int) -> int:
|
|
||||||
"""The bulb rejects brightness 0 and anything above 100 (invalid brightness)."""
|
|
||||||
return max(1, min(100, round(level * 100 / 255)))
|
|
||||||
|
|
||||||
|
|
||||||
class NinlightLight(NinlightEntity, LightEntity):
|
|
||||||
"""A Klyqa bulb running the Ninlight firmware."""
|
|
||||||
|
|
||||||
# No translation_key: with has_entity_name and a single entity per device, HA names the
|
|
||||||
# light after the device, giving light.schreibtisch_links rather than ..._bulb.
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
|
|
||||||
super().__init__(coordinator, entry)
|
|
||||||
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
|
|
||||||
self._attr_supported_features = LightEntityFeature.TRANSITION
|
|
||||||
|
|
||||||
# Capabilities come from the polled state, not from a one-time __init__ snapshot: an
|
|
||||||
# entry set up while the bulb was powered off starts from defaults and must pick up
|
|
||||||
# the real RGB/CT range as soon as the bulb answers again.
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
# An unreachable (powered-off) bulb is an expected state: the entity goes
|
|
||||||
# unavailable instead of the config entry erroring.
|
|
||||||
return self.coordinator.last_update_success and self.coordinator.data.available
|
|
||||||
|
|
||||||
@property
|
|
||||||
def supported_color_modes(self) -> set[ColorMode]:
|
|
||||||
state = self.coordinator.data
|
|
||||||
# Both modes for RGB bulbs: the firmware keeps the last color and the last color
|
|
||||||
# temperature and picks between them with `mode`, exactly how HA models a
|
|
||||||
# color_temp + rgb light.
|
|
||||||
return {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {ColorMode.COLOR_TEMP}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def min_color_temp_kelvin(self) -> int:
|
|
||||||
return self.coordinator.data.ct_min
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_color_temp_kelvin(self) -> int:
|
|
||||||
return self.coordinator.data.ct_max
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_on(self) -> bool:
|
|
||||||
return self.coordinator.data.on
|
|
||||||
|
|
||||||
@property
|
|
||||||
def brightness(self) -> int | None:
|
|
||||||
return _to_ha_brightness(self.coordinator.data.brightness)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def color_mode(self) -> ColorMode:
|
|
||||||
state = self.coordinator.data
|
|
||||||
if not state.on:
|
|
||||||
# Nothing is lit; reporting a mode would contradict is_on=False.
|
|
||||||
return ColorMode.UNKNOWN
|
|
||||||
if state.has_rgb and state.mode == "color":
|
|
||||||
return ColorMode.RGB
|
|
||||||
return ColorMode.COLOR_TEMP
|
|
||||||
|
|
||||||
@property
|
|
||||||
def color_temp_kelvin(self) -> int | None:
|
|
||||||
state = self.coordinator.data
|
|
||||||
return state.color_temp if self.color_mode == ColorMode.COLOR_TEMP else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
|
||||||
state = self.coordinator.data
|
|
||||||
if self.color_mode == ColorMode.RGB:
|
|
||||||
return (state.r, state.g, state.b)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
|
||||||
state = self.coordinator.data
|
|
||||||
payload: dict[str, Any] = {"on": True}
|
|
||||||
|
|
||||||
if (level := kwargs.get(ATTR_BRIGHTNESS)) is not None:
|
|
||||||
payload["brightness"] = _to_bulb_brightness(int(level))
|
|
||||||
elif not state.on:
|
|
||||||
# HA expects turn_on without a brightness to restore the previous level. The
|
|
||||||
# firmware already does that (it keeps `brightness` while off), so sending it
|
|
||||||
# would only re-assert a value the bulb may have clamped itself - but a bulb
|
|
||||||
# that has never been on reports brightness 0, which its API rejects. Mirror
|
|
||||||
# the last known value only when the bulb actually has one.
|
|
||||||
if state.brightness < 1:
|
|
||||||
payload["brightness"] = 100
|
|
||||||
|
|
||||||
# The firmware rejects color and color_temp in one request with
|
|
||||||
# "color and color_temp are exclusive", so pick one. HA only sends both when a
|
|
||||||
# caller passes them together; color is the more specific intent and wins.
|
|
||||||
kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
|
||||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
|
||||||
seg: dict[str, Any] | None = None
|
|
||||||
if rgb is not None and state.has_rgb:
|
|
||||||
r, g, b = (int(max(0, min(255, v))) for v in rgb)
|
|
||||||
if state.strip:
|
|
||||||
seg = {"mode": "solid", "color": {"r": r, "g": g, "b": b}}
|
|
||||||
else:
|
|
||||||
payload["color"] = {"r": r, "g": g, "b": b}
|
|
||||||
elif kelvin is not None:
|
|
||||||
ct = max(state.ct_min, min(state.ct_max, int(kelvin)))
|
|
||||||
if state.strip:
|
|
||||||
seg = {"mode": "white", "color_temp": ct}
|
|
||||||
else:
|
|
||||||
payload["color_temp"] = ct
|
|
||||||
|
|
||||||
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
|
|
||||||
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
|
|
||||||
|
|
||||||
await self._apply(payload, seg)
|
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
|
||||||
payload: dict[str, Any] = {"on": False}
|
|
||||||
if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
|
|
||||||
payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000)))
|
|
||||||
await self._apply(payload, None)
|
|
||||||
|
|
||||||
async def _apply(self, payload: dict[str, Any], seg_patch: dict[str, Any] | None) -> None:
|
|
||||||
"""Send one command and update immediately.
|
|
||||||
|
|
||||||
POST /device/state answers with the full new state, so the coordinator does not
|
|
||||||
need a refresh round-trip and the UI settles on the real bulb state at once.
|
|
||||||
|
|
||||||
On the segment-model firmware the color goes into segment 0 as a separate PATCH
|
|
||||||
(`PUT /device/segments/0`, fields omitted keep their value); the master on and
|
|
||||||
brightness stay in the state POST, because the firmware's fade rides on them.
|
|
||||||
Segment brightness stays untouched so master scaling remains the single knob.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await self.coordinator.client.set_state(payload)
|
|
||||||
if seg_patch is not None:
|
|
||||||
segs = self.coordinator.data.segments
|
|
||||||
start = segs[0]["start"] if segs else 0
|
|
||||||
end = segs[0]["end"] if segs else max(0, int(data.get("pixel_count", 1)) - 1)
|
|
||||||
await self.coordinator.client.patch_segment(
|
|
||||||
0, {**seg_patch, "start": start, "end": end}
|
|
||||||
)
|
|
||||||
except NinlightBadRequest as err:
|
|
||||||
# A rejected command is a bug or a firmware mismatch, not a network fault:
|
|
||||||
# surface it to the caller and leave the entity's state alone.
|
|
||||||
raise HomeAssistantError(str(err)) from err
|
|
||||||
except NinlightError as err:
|
|
||||||
self.coordinator.async_set_update_error(err)
|
|
||||||
raise HomeAssistantError(str(err)) from err
|
|
||||||
|
|
||||||
# Push the device's own reply through the coordinator so every entity of this
|
|
||||||
# device (light, sensors) settles on the real state at once.
|
|
||||||
segments = None
|
|
||||||
if self.coordinator.data.strip:
|
|
||||||
try:
|
|
||||||
segments = (await self.coordinator.client.get_segments()).get("segments")
|
|
||||||
except NinlightError:
|
|
||||||
pass # the next poll recovers it; the state reply above is still right
|
|
||||||
self.coordinator.async_set_updated_data(
|
|
||||||
NinlightState.from_api(data, self.coordinator.info, segments)
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "ninlight",
|
|
||||||
"name": "Ninlight",
|
|
||||||
"codeowners": [],
|
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://github.com/ninharp/ninlight-w600",
|
|
||||||
"integration_type": "device",
|
|
||||||
"iot_class": "local_polling",
|
|
||||||
"requirements": [],
|
|
||||||
"version": "0.1.0",
|
|
||||||
"zeroconf": ["_ninlight._tcp.local.", "_ninstrip._tcp.local."]
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"steps": {
|
|
||||||
"user": {
|
|
||||||
"title": "Ninlight bulb",
|
|
||||||
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
|
|
||||||
"data": {
|
|
||||||
"host": "Host",
|
|
||||||
"port": "Port",
|
|
||||||
"token": "API token"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reauth_confirm": {
|
|
||||||
"title": "Reauthenticate {name}",
|
|
||||||
"description": "The bulb rejected the stored API token. Enter its current token.",
|
|
||||||
"data": {
|
|
||||||
"token": "API token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
|
|
||||||
"invalid_auth": "Wrong API token."
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "This bulb is already configured.",
|
|
||||||
"already_in_progress": "A setup flow for this bulb is already running.",
|
|
||||||
"cannot_connect": "Could not reach the bulb.",
|
|
||||||
"reauth_successful": "The API token was updated."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"steps": {
|
|
||||||
"user": {
|
|
||||||
"title": "Ninlight bulb",
|
|
||||||
"description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-<mac>.local`.",
|
|
||||||
"data": {
|
|
||||||
"host": "Host",
|
|
||||||
"port": "Port",
|
|
||||||
"token": "API token"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"token": "The bulb's api.token setting. Defaults to the firmware's built-in token."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reauth_confirm": {
|
|
||||||
"title": "Reauthenticate {name}",
|
|
||||||
"description": "The bulb rejected the stored API token. Enter its current token.",
|
|
||||||
"data": {
|
|
||||||
"token": "API token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.",
|
|
||||||
"invalid_auth": "Wrong API token."
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "This bulb is already configured.",
|
|
||||||
"already_in_progress": "A setup flow for this bulb is already running.",
|
|
||||||
"cannot_connect": "Could not reach the bulb.",
|
|
||||||
"reauth_successful": "The API token was updated."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# dnsmasq for the qconnex-firmware listener (no service integration)
|
|
||||||
interface=wlan0
|
|
||||||
bind-interfaces
|
|
||||||
listen-address=192.168.8.1
|
|
||||||
no-resolv
|
|
||||||
server=192.168.178.1
|
|
||||||
address=/#/192.168.8.1
|
|
||||||
dhcp-range=192.168.8.10,192.168.8.200,255.255.255.0,10m
|
|
||||||
dhcp-option=3,192.168.8.1
|
|
||||||
dhcp-option=6,192.168.8.1
|
|
||||||
log-dhcp
|
|
||||||
log-queries
|
|
||||||
no-ping
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tear down the qconnex-firmware migration listener.
|
|
||||||
set -u
|
|
||||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
SUDO=${SUDO:-sudo -n}
|
|
||||||
[ -f "$HERE/otaserve.pid" ] && $SUDO pkill -f "python3 $HERE/otaserve.py" 2>/dev/null; rm -f "$HERE/otaserve.pid"
|
|
||||||
[ -f "$HERE/dnsmasq.pid" ] && $SUDO kill "$(cat "$HERE/dnsmasq.pid")" 2>/dev/null && rm -f "$HERE/dnsmasq.pid"
|
|
||||||
$SUDO pkill -f "hostapd -B -f $HERE/hostapd.log" 2>/dev/null
|
|
||||||
$SUDO ip addr flush dev wlan0 2>/dev/null
|
|
||||||
$SUDO nmcli dev set wlan0 managed yes 2>/dev/null || true
|
|
||||||
echo "listener down"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# qconnex-firmware listener AP — temporary experiment, do not enable as a service
|
|
||||||
interface=wlan0
|
|
||||||
driver=nl80211
|
|
||||||
ssid=qconnex-firmware
|
|
||||||
hw_mode=g
|
|
||||||
channel=6
|
|
||||||
ieee80211n=0
|
|
||||||
auth_algs=1
|
|
||||||
# WPA2 with the password the firmware's bootup-OTA STA config expects.
|
|
||||||
# (Open AP worked for the W600 bulbs; ESP32-C3 STA threshold is WPA_PSK.)
|
|
||||||
wpa=2
|
|
||||||
wpa_passphrase=qconnex-firmware
|
|
||||||
wpa_pairwise=CCMP
|
|
||||||
rsn_pairwise=CCMP
|
|
||||||
wmm_enabled=0
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Serve qcx OTA images from ./images with HTTP Range support, plus request logging."""
|
|
||||||
import http.server, os, re, sys, datetime
|
|
||||||
|
|
||||||
ROOT = os.path.join(sys.path[0], "images")
|
|
||||||
LOG = os.path.join(sys.path[0], "qcx-requests.log")
|
|
||||||
|
|
||||||
|
|
||||||
class H(http.server.BaseHTTPRequestHandler):
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def log_message(self, *a):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.lstrip("/")
|
|
||||||
with open(LOG, "a") as f:
|
|
||||||
f.write("=== GET %s (%s) %s\nPATH: %s\n" % (datetime.datetime.now(), self.client_address[0],
|
|
||||||
dict(self.headers), path))
|
|
||||||
fp = os.path.join(ROOT, path)
|
|
||||||
# any bootup-OTA request for a qcx image that doesn't exist on purpose
|
|
||||||
# (unknown product/version combo) -> serve the ninlight migrate image
|
|
||||||
if not os.path.isfile(fp) and path.startswith("qcx_firmware_"):
|
|
||||||
if "ledshield" in path:
|
|
||||||
guess = "ninlight_ledshield_rgb_migrate_dev.img"
|
|
||||||
elif "rgbc3" in path:
|
|
||||||
guess = "ninlight_ninstrip-rgbc3_migrate_dev.img"
|
|
||||||
elif ".gu10" in path:
|
|
||||||
guess = "ninlight_gu10_rgb_migrate_dev.img"
|
|
||||||
else:
|
|
||||||
guess = "ninlight_e27_rgb_migrate_dev.img"
|
|
||||||
fp = os.path.join(ROOT, guess)
|
|
||||||
if not os.path.isfile(fp):
|
|
||||||
p = b"not found\n"
|
|
||||||
self.send_response(404)
|
|
||||||
self.send_header("Content-Length", str(len(p)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(p)
|
|
||||||
return
|
|
||||||
size = os.path.getsize(fp)
|
|
||||||
start, rng = 0, self.headers.get("Range")
|
|
||||||
if rng and (m := re.match(r"bytes=(\d+)-(\d*)$", rng)):
|
|
||||||
start = int(m.group(1))
|
|
||||||
end = int(m.group(2)) if m.group(2) else size - 1
|
|
||||||
else:
|
|
||||||
end = size - 1
|
|
||||||
end = min(end, size - 1)
|
|
||||||
length = max(0, end - start + 1)
|
|
||||||
self.send_response(206 if rng else 200)
|
|
||||||
self.send_header("Content-Length", str(length))
|
|
||||||
self.send_header("Content-Type", "application/octet-stream")
|
|
||||||
if rng:
|
|
||||||
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
|
|
||||||
self.end_headers()
|
|
||||||
with open(fp, "rb") as f:
|
|
||||||
f.seek(start)
|
|
||||||
left = length
|
|
||||||
while left > 0:
|
|
||||||
chunk = f.read(min(8192, left))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
self.wfile.write(chunk)
|
|
||||||
left -= len(chunk)
|
|
||||||
print("served %s %d-%d to %s" % (path, start, end, self.client_address[0]), flush=True)
|
|
||||||
|
|
||||||
do_POST = do_HEAD = do_PUT = do_GET
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
os.makedirs(ROOT, exist_ok=True)
|
|
||||||
print("qcx ota server on 0.0.0.0:80 root=%s" % ROOT, flush=True)
|
|
||||||
http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bring up the qconnex-firmware migration listener (AP + DHCP/DNS + OTA server).
|
|
||||||
# Takes wlan0 as an open AP "qconnex-firmware" on 192.168.8.1. Machine stays
|
|
||||||
# online via ethernet. Teardown: ./down.sh
|
|
||||||
set -euo pipefail
|
|
||||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
SUDO=${SUDO:-sudo -n}
|
|
||||||
|
|
||||||
# wlan0 must be free of any NetworkManager claim
|
|
||||||
$SUDO nmcli dev set wlan0 managed no 2>/dev/null || true
|
|
||||||
$SUDO ip link set wlan0 up
|
|
||||||
$SUDO ip addr replace 192.168.8.1/24 dev wlan0
|
|
||||||
|
|
||||||
$SUDO dnsmasq --conf-file="$HERE/dnsmasq-qcx.conf" --pid-file="$HERE/dnsmasq.pid"
|
|
||||||
$SUDO hostapd -B -f "$HERE/hostapd.log" "$HERE/hostapd.conf"
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# serve images on :80 (needs root to bind <1024)
|
|
||||||
nohup $SUDO python3 "$HERE/otaserve.py" >/dev/null 2>&1 &
|
|
||||||
echo $! > "$HERE/otaserve.pid"
|
|
||||||
|
|
||||||
echo "AP qconnex-firmware up on 192.168.8.1; serving $HERE/images (log: $HERE/qcx-requests.log)"
|
|
||||||
+57
-10
@@ -13,10 +13,12 @@
|
|||||||
#include "drivers/nl_led.h"
|
#include "drivers/nl_led.h"
|
||||||
#include "light/nl_boot_state.h"
|
#include "light/nl_boot_state.h"
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_http_server.h"
|
#include "net/nl_http_server.h"
|
||||||
#include "net/nl_mdns.h"
|
#include "net/nl_mdns.h"
|
||||||
#include "net/nl_recovery.h"
|
#include "net/nl_recovery.h"
|
||||||
|
#include "net/nl_sacn.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
#include "sys/nl_app_api.h"
|
#include "sys/nl_app_api.h"
|
||||||
@@ -109,6 +111,19 @@ int nl_app_set_wifi_credentials(const char *ssid, const char *pass)
|
|||||||
return nl_app_save_settings();
|
return nl_app_save_settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int nl_app_clear_standalone(void)
|
||||||
|
{
|
||||||
|
bool was_set;
|
||||||
|
|
||||||
|
nl_app_lock();
|
||||||
|
was_set = s_settings.standalone != 0;
|
||||||
|
s_settings.standalone = 0;
|
||||||
|
nl_app_unlock();
|
||||||
|
if (!was_set)
|
||||||
|
return 0; /* nichts zu schreiben - der Flash-Satz steht schon richtig */
|
||||||
|
return nl_app_save_settings();
|
||||||
|
}
|
||||||
|
|
||||||
void nl_app_factory_reset(void)
|
void nl_app_factory_reset(void)
|
||||||
{
|
{
|
||||||
printf("factory reset\r\n");
|
printf("factory reset\r\n");
|
||||||
@@ -147,6 +162,7 @@ static void light_task(void *arg)
|
|||||||
now = nl_app_now_ms();
|
now = nl_app_now_ms();
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
nl_engine_tick(now - last);
|
nl_engine_tick(now - last);
|
||||||
|
nl_dmx_tick(now);
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
last = now;
|
last = now;
|
||||||
s_light_beat++;
|
s_light_beat++;
|
||||||
@@ -160,9 +176,10 @@ static void app_task(void *arg)
|
|||||||
nl_engine_cfg_t cfg;
|
nl_engine_cfg_t cfg;
|
||||||
nl_light_state_t last, initial, pending;
|
nl_light_state_t last, initial, pending;
|
||||||
bool have_last, after_ota;
|
bool have_last, after_ota;
|
||||||
bool light_alive = false, net_alive = false, http_alive = false;
|
bool light_alive = false, net_alive = false, http_alive = false, sacn_alive = false;
|
||||||
|
bool sacn_required;
|
||||||
uint8_t boots;
|
uint8_t boots;
|
||||||
uint32_t boot_ms, light_seen, net_seen, http_seen;
|
uint32_t boot_ms, light_seen, net_seen, http_seen, sacn_seen;
|
||||||
|
|
||||||
(void)arg;
|
(void)arg;
|
||||||
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
|
/* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery
|
||||||
@@ -205,6 +222,7 @@ static void app_task(void *arg)
|
|||||||
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_ms,
|
nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_ms,
|
||||||
s_settings.wake_on_change != 0);
|
s_settings.wake_on_change != 0);
|
||||||
nl_ctrl_boot(&initial);
|
nl_ctrl_boot(&initial);
|
||||||
|
nl_dmx_init(board);
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
|
|
||||||
tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0);
|
tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0);
|
||||||
@@ -223,17 +241,41 @@ static void app_task(void *arg)
|
|||||||
boot = s_settings;
|
boot = s_settings;
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
nl_mdns_set_name(boot.name);
|
nl_mdns_set_name(boot.name);
|
||||||
if (boot.wifi_ssid[0])
|
if (boot.standalone)
|
||||||
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD);
|
/* Kein Verbindungsversuch: die Lampe steht ausdruecklich allein. Die gespeicherten
|
||||||
|
* Zugangsdaten gehen trotzdem mit - der Standalone-Zweig der Zustandsmaschine
|
||||||
|
* verbindet nicht, aber sie zaehlen wieder, sobald der Modus ueber die API
|
||||||
|
* zurueckgenommen wird; ohne sie landete die Lampe dann im offenen
|
||||||
|
* Onboarding-AP, obwohl ihr WLAN bekannt ist. */
|
||||||
|
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass,
|
||||||
|
boot.ap_pass[0] ? boot.ap_pass : NINLIGHT_AP_PASSWORD,
|
||||||
|
boot.ap_ssid, boot.ap_channel, true, boot.standalone_pulse != 0);
|
||||||
|
else if (boot.wifi_ssid[0])
|
||||||
|
nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD, "", 0, false, false);
|
||||||
else
|
else
|
||||||
nl_wifi_start(NINLIGHT_WIFI_SSID, NINLIGHT_WIFI_PASSWORD, NINLIGHT_AP_PASSWORD);
|
nl_wifi_start(NINLIGHT_WIFI_SSID, NINLIGHT_WIFI_PASSWORD, NINLIGHT_AP_PASSWORD,
|
||||||
|
"", 0, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
nl_http_server_start(nl_app_api_ctx());
|
nl_http_server_start(nl_app_api_ctx());
|
||||||
|
|
||||||
|
{
|
||||||
|
static nl_settings_t sacn_boot; /* copy: the network task may change s_settings later */
|
||||||
|
nl_app_lock();
|
||||||
|
sacn_boot = s_settings;
|
||||||
|
nl_app_unlock();
|
||||||
|
nl_sacn_apply_settings(&sacn_boot);
|
||||||
|
}
|
||||||
|
nl_sacn_start();
|
||||||
|
/* Nur ein tatsaechlich gestarteter Task darf den Wachhund mitbestimmen: ohne Task
|
||||||
|
* bewegt sich nl_sacn_beat nie, die Bedingung waere nie erfuellt und die Lampe liefe
|
||||||
|
* in eine endlose Reset-Schleife. Ein Zusatzdienst faellt dann eben aus. */
|
||||||
|
sacn_required = nl_sacn_running();
|
||||||
|
|
||||||
light_seen = s_light_beat;
|
light_seen = s_light_beat;
|
||||||
net_seen = nl_net_beat;
|
net_seen = nl_net_beat;
|
||||||
http_seen = nl_http_beat;
|
http_seen = nl_http_beat;
|
||||||
|
sacn_seen = nl_sacn_beat;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint32_t now;
|
uint32_t now;
|
||||||
bool due;
|
bool due;
|
||||||
@@ -262,9 +304,10 @@ static void app_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feed the watchdog only once the light, network and HTTP tasks have each made
|
/* Feed the watchdog only once the light, network, HTTP and sACN tasks have each
|
||||||
* progress since the last feed - a single stuck task (e.g. nl_app_lock() held
|
* made progress since the last feed - a single stuck task (e.g. nl_app_lock() held
|
||||||
* forever) must still starve the watchdog, not just the light task. */
|
* forever) must still starve the watchdog, not just the light task. Ausnahme: der
|
||||||
|
* sACN-Task, wenn er gar nicht erst angelegt werden konnte (sacn_required). */
|
||||||
if (s_light_beat != light_seen) {
|
if (s_light_beat != light_seen) {
|
||||||
light_seen = s_light_beat;
|
light_seen = s_light_beat;
|
||||||
light_alive = true;
|
light_alive = true;
|
||||||
@@ -277,8 +320,12 @@ static void app_task(void *arg)
|
|||||||
http_seen = nl_http_beat;
|
http_seen = nl_http_beat;
|
||||||
http_alive = true;
|
http_alive = true;
|
||||||
}
|
}
|
||||||
if (light_alive && net_alive && http_alive) {
|
if (nl_sacn_beat != sacn_seen) {
|
||||||
light_alive = net_alive = http_alive = false;
|
sacn_seen = nl_sacn_beat;
|
||||||
|
sacn_alive = true;
|
||||||
|
}
|
||||||
|
if (light_alive && net_alive && http_alive && (sacn_alive || !sacn_required)) {
|
||||||
|
light_alive = net_alive = http_alive = sacn_alive = false;
|
||||||
tls_watchdog_clr();
|
tls_watchdog_clr();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,10 @@ const nl_board_t *nl_board_get(void)
|
|||||||
return nl_board_find(NINLIGHT_DEVICE_ID);
|
return nl_board_find(NINLIGHT_DEVICE_ID);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
uint8_t nl_board_dmx_slots(const nl_board_t *b, bool rgb_layout)
|
||||||
|
{
|
||||||
|
if (rgb_layout)
|
||||||
|
return 3u; /* R,G,B - unabhaengig vom Board, damit eine Lampe ein Pixel ist */
|
||||||
|
return (b != NULL && b->has_rgb) ? 6u : 3u;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,4 +41,11 @@ const nl_board_t *nl_board_find(const char *id);
|
|||||||
/* Board selected at build time via NINLIGHT_DEVICE_ID. */
|
/* Board selected at build time via NINLIGHT_DEVICE_ID. */
|
||||||
const nl_board_t *nl_board_get(void);
|
const nl_board_t *nl_board_get(void);
|
||||||
|
|
||||||
|
/* Slots, die eine Lampe im DMX-Universe belegt. Steht hier, damit API, Uebernahme-Schicht
|
||||||
|
* und Doku dieselbe Zahl benutzen.
|
||||||
|
* rgb_layout = false: Dimmer + R,G,B,CW,WW (6) bzw. Dimmer + CW,WW (3)
|
||||||
|
* rgb_layout = true: R,G,B (3) auf JEDEM Board - so ist eine Lampe genau ein Pixel
|
||||||
|
* fuer pixelorientierte Werkzeuge wie LedFx. */
|
||||||
|
uint8_t nl_board_dmx_slots(const nl_board_t *b, bool rgb_layout);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -29,15 +29,45 @@ typedef struct {
|
|||||||
|
|
||||||
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
|
/* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly
|
||||||
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
|
* behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal
|
||||||
* while sizeof(nl_settings_t) is a multiple of 4 (204 today). Adding a single uint16_t would move
|
* while sizeof(nl_settings_t) is a multiple of 4 (216 today, 208 before sACN). Adding a single
|
||||||
* offsetof(rec_t, crc) two bytes further than the reader looks and every stored record would
|
* uint16_t would move offsetof(rec_t, crc) two bytes further than the reader looks and every
|
||||||
* silently fail its CRC on the next boot - Wi-Fi, token and name gone on every lamp. */
|
* stored record would silently fail its CRC on the next boot - Wi-Fi, token and name gone on
|
||||||
|
* every lamp. Aus demselben Grund gilt fuer neue Felder: sie beginnen bei oder hinter dem
|
||||||
|
* vorherigen sizeof (also nicht unterhalb von 208), denn ein alter Datensatz ist genau
|
||||||
|
* sizeof lang und traegt seine Fuellbytes mit - ein Feld darunter wuerde beim Lesen von
|
||||||
|
* diesen Fuellbytes (Null) ueberschrieben statt seine Vorgabe zu behalten. */
|
||||||
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
|
#define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len))
|
||||||
|
|
||||||
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload
|
/* The CRC runs over header and payload as one contiguous block on both sides, so the payload
|
||||||
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
|
* really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */
|
||||||
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
|
typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1];
|
||||||
|
|
||||||
|
/* Die Satzlaenge muss ein Vielfaches von 4 bleiben - sonst zeigt NL_SETTINGS_CRC_OFF() woanders
|
||||||
|
* hin als offsetof(rec_t, crc), und jeder gespeicherte Datensatz faellt beim naechsten Boot
|
||||||
|
* durch die CRC-Pruefung. Wer hier anstoesst, fuegt ein Fuellbyte hinzu, statt die Pruefung
|
||||||
|
* zu entfernen. */
|
||||||
|
typedef char nl_settings_size_check[(sizeof(nl_settings_t) % 4u == 0u) ? 1 : -1];
|
||||||
|
|
||||||
|
/* Das erste mit sACN angehaengte Feld muss genau dort beginnen, wo der alte Satz aufhoerte:
|
||||||
|
* bei 208, dem sizeof(nl_settings_t) der Firmware vor sACN. Saetze, die eine solche Firmware
|
||||||
|
* geschrieben hat, sind 208 Byte lang und enthalten hinter wake_on_change (Offset 206) noch
|
||||||
|
* ein Fuellbyte. read_rec() kopiert hdr.length Bytes ueber die Vorgaben - ein Feld unterhalb
|
||||||
|
* von 208 wuerde also von diesem Fuellbyte (Null) ueberschrieben und seine Vorgabe verlieren.
|
||||||
|
* Bei sacn_enabled hiesse das: sACN nach dem Update auf jeder Lampe aus. */
|
||||||
|
typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 208 ? 1 : -1];
|
||||||
|
|
||||||
|
/* Dasselbe fuer das naechste angehaengte Feld: sacn_layout muss bei 216 beginnen, dem
|
||||||
|
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
|
||||||
|
* eingefuegt - dann kaeme es aus einem aelteren Satz als Fuellbyte zurueck, nicht als
|
||||||
|
* Vorgabe. */
|
||||||
|
typedef char nl_settings_layout_offset_check[offsetof(nl_settings_t, sacn_layout) == 216 ? 1 : -1];
|
||||||
|
|
||||||
|
/* Dasselbe fuer die Standalone-Felder: standalone muss bei 220 beginnen, dem
|
||||||
|
* sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh
|
||||||
|
* eingefuegt - dann kaeme standalone_pulse aus einem aelteren Satz als Fuellbyte (0)
|
||||||
|
* zurueck statt als Vorgabe (1), und der Startpuls waere still aus. */
|
||||||
|
typedef char nl_settings_standalone_check[offsetof(nl_settings_t, standalone) == 220 ? 1 : -1];
|
||||||
|
|
||||||
static uint32_t s_addr[2];
|
static uint32_t s_addr[2];
|
||||||
|
|
||||||
void nl_settings_defaults(nl_settings_t *s)
|
void nl_settings_defaults(nl_settings_t *s)
|
||||||
@@ -49,6 +79,12 @@ void nl_settings_defaults(nl_settings_t *s)
|
|||||||
s->fade_out_ms = 500;
|
s->fade_out_ms = 500;
|
||||||
s->transition_ms = 300;
|
s->transition_ms = 300;
|
||||||
s->wake_on_change = 0;
|
s->wake_on_change = 0;
|
||||||
|
s->sacn_enabled = 1;
|
||||||
|
s->sacn_universe = 1;
|
||||||
|
s->sacn_start_slot = 1;
|
||||||
|
s->sacn_hold = 0;
|
||||||
|
s->sacn_layout = 1;
|
||||||
|
s->standalone_pulse = 1; /* standalone, ap_channel, ap_ssid, ap_pass bleiben auf 0/leer */
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
|
void nl_settings_init(uint32_t addr_a, uint32_t addr_b)
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ typedef struct {
|
|||||||
* switching on or off. */
|
* switching on or off. */
|
||||||
uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was
|
uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was
|
||||||
* off, unless the same call says "on": false. */
|
* off, unless the same call says "on": false. */
|
||||||
|
uint8_t _legacy_pad; /* war Fuellbyte im 208-Byte-Satz: neue Felder muessen
|
||||||
|
* bei oder hinter dem vorherigen sizeof beginnen, sonst
|
||||||
|
* ueberschreibt das Fuellbyte eines alten Datensatzes sie */
|
||||||
|
uint8_t sacn_enabled; /* Offset 208 - sACN/E1.31-Empfang aktiv (Vorgabe 1) */
|
||||||
|
uint16_t sacn_universe; /* 1..63999 (Vorgabe 1) */
|
||||||
|
uint16_t sacn_start_slot; /* 1..512 (Vorgabe 1) */
|
||||||
|
uint8_t sacn_hold; /* 0 = nach dem Timeout zurueck zum HA-Zustand, 1 = halten */
|
||||||
|
uint8_t _padding[1]; /* haelt sizeof auf einem Vielfachen von 4 */
|
||||||
|
uint8_t sacn_layout; /* Offset 216 - 0 = Dimmer voran, 1 = nur R,G,B (Vorgabe 1).
|
||||||
|
* Dieselbe Regel wie oben: neue Felder beginnen bei oder hinter
|
||||||
|
* dem vorherigen sizeof (216), sonst ueberschreibt das Fuellbyte
|
||||||
|
* eines aelteren Satzes sie. */
|
||||||
|
uint8_t _padding2[3]; /* haelt sizeof auf einem Vielfachen von 4 */
|
||||||
|
uint8_t standalone; /* Offset 220 - 1 = eigener AP statt Station. Dieselbe Regel
|
||||||
|
* wie oben: neue Felder beginnen bei oder hinter dem
|
||||||
|
* vorherigen sizeof (220), sonst ueberschreibt das
|
||||||
|
* Fuellbyte eines aelteren Satzes sie. */
|
||||||
|
uint8_t ap_channel; /* 0 = Vorgabe (6), sonst 1..13 */
|
||||||
|
uint8_t standalone_pulse; /* 1 = beim Start kurz pulsen (Vorgabe) */
|
||||||
|
uint8_t _padding3;
|
||||||
|
char ap_ssid[NL_SSID_LEN]; /* leer = "ninlight-<mac4>" */
|
||||||
|
char ap_pass[NL_PASS_LEN]; /* leer = einkompiliertes AP-Passwort. Taucht in keiner
|
||||||
|
* Antwort und in keiner Protokollzeile auf. */
|
||||||
|
uint8_t _padding4[2]; /* haelt sizeof auf einem Vielfachen von 4 (324) */
|
||||||
} nl_settings_t;
|
} nl_settings_t;
|
||||||
|
|
||||||
void nl_settings_defaults(nl_settings_t *s);
|
void nl_settings_defaults(nl_settings_t *s);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
#define P_SETTINGS "/api/v1/system/settings"
|
#define P_SETTINGS "/api/v1/system/settings"
|
||||||
#define P_COMMAND "/api/v1/system/command"
|
#define P_COMMAND "/api/v1/system/command"
|
||||||
|
|
||||||
|
/* Obergrenze fuer das AP-Passwort des Standalone-Modus (WPA2-ASCII-Passphrase). */
|
||||||
|
#define NL_AP_PASS_MAX 63
|
||||||
|
|
||||||
static const char *const POB_NAMES[] = {"last", "on", "off"};
|
static const char *const POB_NAMES[] = {"last", "on", "off"};
|
||||||
|
|
||||||
static void reply(nl_api_response_t *r, int status, const char *json)
|
static void reply(nl_api_response_t *r, int status, const char *json)
|
||||||
@@ -259,12 +262,33 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
|
|||||||
cJSON_AddStringToObject(w, "error", in.wifi_error);
|
cJSON_AddStringToObject(w, "error", in.wifi_error);
|
||||||
else
|
else
|
||||||
cJSON_AddNullToObject(w, "error");
|
cJSON_AddNullToObject(w, "error");
|
||||||
|
/* "mode" allein reicht nicht: im Standalone-Betrieb ist es "ap", genau wie beim offenen
|
||||||
|
* Onboarding-AP. Ohne dieses Feld boete die App einer fertig eingerichteten Lampe ein
|
||||||
|
* Onboarding an. */
|
||||||
|
cJSON_AddBoolToObject(w, "standalone", in.wifi_standalone);
|
||||||
cJSON_AddNumberToObject(o, "uptime", in.uptime_s);
|
cJSON_AddNumberToObject(o, "uptime", in.uptime_s);
|
||||||
cJSON_AddNumberToObject(o, "free_heap", in.free_heap);
|
cJSON_AddNumberToObject(o, "free_heap", in.free_heap);
|
||||||
if (in.last_ota_error)
|
if (in.last_ota_error)
|
||||||
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
|
cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error);
|
||||||
else
|
else
|
||||||
cJSON_AddNullToObject(o, "last_ota_error");
|
cJSON_AddNullToObject(o, "last_ota_error");
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
|
||||||
|
ctx->get_settings(&s);
|
||||||
|
cJSON_AddBoolToObject(sa, "active", in.sacn_active);
|
||||||
|
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
|
||||||
|
/* Sagt, ob der Beitritt zur Multicast-Gruppe gelungen ist. Ohne dieses Feld war bei
|
||||||
|
* der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die
|
||||||
|
* Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */
|
||||||
|
cJSON_AddBoolToObject(sa, "multicast", in.sacn_joined);
|
||||||
|
/* Ob der Empfangs-Task ueberhaupt laeuft. Ohne dieses Feld war ein toter Task von
|
||||||
|
* "es sendet gerade niemand" nicht zu unterscheiden - beides meldete nur
|
||||||
|
* active=false, und "multicast" bleibt vom letzten gelungenen Beitritt true, auch
|
||||||
|
* wenn der Socket laengst zu ist. Das hat beim Montana-Black-Shield eine halbe
|
||||||
|
* Stunde Suche gekostet. */
|
||||||
|
cJSON_AddBoolToObject(sa, "running", in.sacn_running);
|
||||||
|
}
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +310,27 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
|
|||||||
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
|
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
|
||||||
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
|
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
|
||||||
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
||||||
|
{
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "sacn");
|
||||||
|
cJSON_AddBoolToObject(sa, "enabled", s.sacn_enabled != 0);
|
||||||
|
cJSON_AddNumberToObject(sa, "universe", s.sacn_universe);
|
||||||
|
cJSON_AddNumberToObject(sa, "start_slot", s.sacn_start_slot);
|
||||||
|
cJSON_AddStringToObject(sa, "on_timeout", s.sacn_hold ? "hold" : "release");
|
||||||
|
/* Informativ und bewusst aus dem Board, nicht aus dem Datensatz: so steht beim
|
||||||
|
* Adressieren direkt da, wieviel Platz diese Lampe im Universe belegt. */
|
||||||
|
cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer");
|
||||||
|
cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board, s.sacn_layout != 0));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
cJSON *sa = cJSON_AddObjectToObject(o, "standalone");
|
||||||
|
cJSON_AddBoolToObject(sa, "enabled", s.standalone != 0);
|
||||||
|
/* Der WIRKSAME Name, nicht der gespeicherte: ohne eigenen Namen heisst der AP
|
||||||
|
* wie der Hostname. Wer hier einen leeren String saehe, muesste die Vorgabe
|
||||||
|
* selbst nachbilden. Das AP-Passwort steht bewusst nicht hier. */
|
||||||
|
cJSON_AddStringToObject(sa, "ssid", s.ap_ssid[0] ? s.ap_ssid : ctx->hostname);
|
||||||
|
cJSON_AddNumberToObject(sa, "channel", s.ap_channel ? s.ap_channel : 6);
|
||||||
|
cJSON_AddBoolToObject(sa, "pulse", s.standalone_pulse != 0);
|
||||||
|
}
|
||||||
reply_json(resp, 200, o);
|
reply_json(resp, 200, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +362,67 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
|
|||||||
cJSON_Delete(j);
|
cJSON_Delete(j);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (strcmp(type, "standalone") == 0) {
|
||||||
|
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
|
||||||
|
const cJSON *pu = cJSON_GetObjectItemCaseSensitive(j, "pulse");
|
||||||
|
const char *as = NULL, *ap = NULL;
|
||||||
|
long ch = 0;
|
||||||
|
int asp, app, chp;
|
||||||
|
bool will_enable;
|
||||||
|
nl_settings_t prev = s; /* Stand vor der Aenderung, zum Zurueckrollen bei 503 */
|
||||||
|
|
||||||
|
will_enable = cJSON_IsBool(en) && cJSON_IsTrue(en);
|
||||||
|
asp = get_string(j, "ssid", 1, NL_SSID_LEN - 1, &as);
|
||||||
|
/* 63, nicht NL_PASS_LEN - 1 (64): eine WPA2-ASCII-Passphrase darf hoechstens 63
|
||||||
|
* Zeichen haben. Bei 64 schriebe der Treiber keyinfo.key[64] randvoll ohne
|
||||||
|
* Abschlussbyte, und ob libwlan.a das annimmt, sieht man dem Binaerobjekt nicht an -
|
||||||
|
* ein abgelehnter AP-Aufbau ist hier aber eine ueber die API erreichbare Sackgasse.
|
||||||
|
* Der "wifi"-Zweig bleibt bewusst bei 64: dort haengt kein eigener AP daran. */
|
||||||
|
app = get_string(j, "password", 8, NL_AP_PASS_MAX, &ap);
|
||||||
|
chp = get_int(j, "channel", 1, 13, &ch);
|
||||||
|
|
||||||
|
if (!cJSON_IsBool(en)) {
|
||||||
|
err = "invalid enabled";
|
||||||
|
} else if (asp < 0) {
|
||||||
|
err = "invalid ssid";
|
||||||
|
} else if (app < 0) {
|
||||||
|
/* Die Meldung nennt das Feld, nie den Wert: ein zu kurzes Passwort darf
|
||||||
|
* nicht im Protokoll des Aufrufers landen. */
|
||||||
|
err = "invalid password";
|
||||||
|
} else if (chp < 0) {
|
||||||
|
err = "invalid channel";
|
||||||
|
} else if (will_enable && app != 1 && s.ap_pass[0] == '\0') {
|
||||||
|
err = "password required";
|
||||||
|
} else if (pu && !cJSON_IsBool(pu)) {
|
||||||
|
err = "invalid pulse";
|
||||||
|
} else {
|
||||||
|
if (asp == 1)
|
||||||
|
strcpy(s.ap_ssid, as);
|
||||||
|
if (app == 1)
|
||||||
|
strcpy(s.ap_pass, ap);
|
||||||
|
if (chp == 1)
|
||||||
|
s.ap_channel = (uint8_t)ch;
|
||||||
|
if (pu)
|
||||||
|
s.standalone_pulse = cJSON_IsTrue(pu) ? 1 : 0;
|
||||||
|
s.standalone = will_enable ? 1 : 0;
|
||||||
|
/* Wie bei "wifi" kehrt dieser Zweig hier zurueck statt ans gemeinsame Ende zu
|
||||||
|
* laufen (das antwortet 200): der Wechsel zieht der Verbindung das Netz weg, die
|
||||||
|
* Antwort ist 202 und muss vorher ausgefuellt sein. */
|
||||||
|
if (ctx->save_settings(&s) != 0) {
|
||||||
|
reply_error(resp, 500, "save failed");
|
||||||
|
} else if (ctx->standalone_configure(will_enable, s.ap_ssid, s.ap_pass,
|
||||||
|
s.ap_channel, s.standalone_pulse != 0) != 0) {
|
||||||
|
/* 503 heisst "nicht uebernommen": was schon im Flash steht, muss zurueck,
|
||||||
|
* sonst startet die Lampe nach einem Neustart doch im neuen Modus. Best
|
||||||
|
* effort - scheitert auch das, bleibt nur die Meldung. */
|
||||||
|
(void)ctx->save_settings(&prev);
|
||||||
|
reply_error(resp, 503, "wifi busy");
|
||||||
|
} else {
|
||||||
|
reply(resp, 202, "{\"status\":\"standalone\"}");
|
||||||
|
}
|
||||||
|
cJSON_Delete(j);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (strcmp(type, "device_name") == 0) {
|
} else if (strcmp(type, "device_name") == 0) {
|
||||||
if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1)
|
if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1)
|
||||||
err = "invalid name";
|
err = "invalid name";
|
||||||
@@ -333,6 +439,62 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
|
|||||||
err = "invalid all";
|
err = "invalid all";
|
||||||
else
|
else
|
||||||
s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
|
s.auth_all = cJSON_IsTrue(all) ? 1 : 0;
|
||||||
|
} else if (strcmp(type, "sacn") == 0) {
|
||||||
|
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
|
||||||
|
const char *ot = NULL, *lo = NULL;
|
||||||
|
long un = 0, st = 0;
|
||||||
|
int unp, stp, otp, lop;
|
||||||
|
bool rgb;
|
||||||
|
uint8_t slots;
|
||||||
|
uint16_t want_start;
|
||||||
|
|
||||||
|
/* Das Layout wird ZUERST ausgewertet: es bestimmt die Slotzahl und damit die
|
||||||
|
* Obergrenze fuer start_slot. Wer in derselben Anfrage beides setzt, soll gegen das
|
||||||
|
* neue Layout geprueft werden, nicht gegen das alte. */
|
||||||
|
lop = get_string(j, "layout", 1, 16, &lo);
|
||||||
|
if (lop < 0 || (lop == 1 && strcmp(lo, "dimmer") != 0 && strcmp(lo, "rgb") != 0)) {
|
||||||
|
err = "invalid layout";
|
||||||
|
lop = 0; /* die Zweige unten duerfen den Wert dann nicht mehr benutzen */
|
||||||
|
rgb = s.sacn_layout != 0;
|
||||||
|
} else {
|
||||||
|
rgb = (lop == 1) ? (strcmp(lo, "rgb") == 0) : (s.sacn_layout != 0);
|
||||||
|
}
|
||||||
|
slots = nl_board_dmx_slots(ctx->board, rgb);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
/* invalid layout steht schon fest */
|
||||||
|
} else if (en && !cJSON_IsBool(en)) {
|
||||||
|
err = "invalid enabled";
|
||||||
|
} else if ((unp = get_int(j, "universe", 1, 63999, &un)) < 0) {
|
||||||
|
err = "invalid universe";
|
||||||
|
/* Upper bound folds the board's slot count in: the highest start_slot that still
|
||||||
|
* leaves room for all of them is 512 - slots + 1. */
|
||||||
|
} else if ((stp = get_int(j, "start_slot", 1, 512 - slots + 1, &st)) < 0) {
|
||||||
|
err = "invalid start_slot";
|
||||||
|
} else if ((otp = get_string(j, "on_timeout", 1, 16, &ot)) < 0 ||
|
||||||
|
(otp == 1 && strcmp(ot, "release") != 0 && strcmp(ot, "hold") != 0)) {
|
||||||
|
err = "invalid on_timeout";
|
||||||
|
} else {
|
||||||
|
/* Ein Wechsel auf ein breiteres Layout kann einen bisher zulaessigen start_slot
|
||||||
|
* unzulaessig machen. Das wird abgelehnt statt still verschoben: eine Lampe, die
|
||||||
|
* nach einer Einstellung woanders im Universe liegt als angegeben, findet niemand
|
||||||
|
* wieder. */
|
||||||
|
want_start = (stp == 1) ? (uint16_t)st : s.sacn_start_slot;
|
||||||
|
if ((uint32_t)want_start + slots - 1u > 512u) {
|
||||||
|
err = "invalid start_slot";
|
||||||
|
} else {
|
||||||
|
if (en)
|
||||||
|
s.sacn_enabled = cJSON_IsTrue(en) ? 1 : 0;
|
||||||
|
if (unp == 1)
|
||||||
|
s.sacn_universe = (uint16_t)un;
|
||||||
|
if (stp == 1)
|
||||||
|
s.sacn_start_slot = (uint16_t)st;
|
||||||
|
if (otp == 1)
|
||||||
|
s.sacn_hold = strcmp(ot, "hold") == 0 ? 1 : 0;
|
||||||
|
if (lop == 1)
|
||||||
|
s.sacn_layout = rgb ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
err = "invalid type";
|
err = "invalid type";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,27 @@ typedef struct {
|
|||||||
uint32_t uptime_s;
|
uint32_t uptime_s;
|
||||||
uint32_t free_heap;
|
uint32_t free_heap;
|
||||||
const char *last_ota_error; /* NULL until OTA exists */
|
const char *last_ota_error; /* NULL until OTA exists */
|
||||||
|
bool sacn_active; /* true, solange die Lampe ferngesteuert wird */
|
||||||
|
bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */
|
||||||
|
bool sacn_running; /* Empfangs-Task lebt und haelt einen Socket */
|
||||||
|
bool wifi_standalone; /* eigener Dauer-AP, nicht der offene Onboarding-AP */
|
||||||
} nl_sys_info_t;
|
} nl_sys_info_t;
|
||||||
|
|
||||||
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const nl_board_t *board;
|
const nl_board_t *board;
|
||||||
const char *fw_version;
|
const char *fw_version;
|
||||||
|
const char *hostname; /* "ninlight-<mac4>", Vorgabe fuer den AP-Namen */
|
||||||
const char *build_date;
|
const char *build_date;
|
||||||
void (*get_state)(nl_light_state_t *out);
|
void (*get_state)(nl_light_state_t *out);
|
||||||
nl_ctrl_err_t (*apply)(const nl_light_change_t *c);
|
nl_ctrl_err_t (*apply)(const nl_light_change_t *c);
|
||||||
void (*get_settings)(nl_settings_t *out);
|
void (*get_settings)(nl_settings_t *out);
|
||||||
int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */
|
int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */
|
||||||
int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */
|
int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */
|
||||||
|
/* 0 = uebernommen (Antwort 202), sonst 503. Wie wifi_configure wirkt der Wechsel
|
||||||
|
erst nach der Antwort - die Verbindung bricht sonst vor dem Senden weg. */
|
||||||
|
int (*standalone_configure)(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse);
|
||||||
bool (*open_access)(void); /* unconfigured onboarding AP */
|
bool (*open_access)(void); /* unconfigured onboarding AP */
|
||||||
void (*get_info)(nl_sys_info_t *out);
|
void (*get_info)(nl_sys_info_t *out);
|
||||||
void (*factory_reset)(void); /* request only */
|
void (*factory_reset)(void); /* request only */
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#include "net/nl_e131.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static uint16_t be16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); }
|
||||||
|
|
||||||
|
static uint32_t be32(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flags (obere 4 Bit) muessen 0x7 sein, die Laenge (untere 12 Bit) muss exakt den Rest des
|
||||||
|
* Datagramms ab `from` beschreiben. Ein Paket, dessen Laengenfelder nicht zueinander passen,
|
||||||
|
* ist entweder verstuemmelt oder ein Angriffsversuch - in beiden Faellen: weg damit. */
|
||||||
|
static bool pdu_len_ok(const uint8_t *p, size_t len, size_t from)
|
||||||
|
{
|
||||||
|
uint16_t fl = be16(p + from);
|
||||||
|
if ((fl & 0xf000u) != 0x7000u)
|
||||||
|
return false;
|
||||||
|
return (size_t)(fl & 0x0fffu) == len - from;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bewusste Abweichung von E1.31-2018 Abschnitt 6.2.4.1: die Synchronisationsadresse
|
||||||
|
* (Offset 109-110) und das Options-Bit "force synchronization" werden ueberlesen, das Paket
|
||||||
|
* wird sofort ausgegeben. Eine einzelne Lampe hat nichts zu synchronisieren, und auf ein
|
||||||
|
* Sync-Paket zu warten hiesse, bei einem Sender mit gesetzter Adresse dunkel zu bleiben.
|
||||||
|
* Das ist kein Versehen - fuer mehrere gemeinsam getaktete Empfaenger muesste es nachgeruestet
|
||||||
|
* werden. */
|
||||||
|
bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out)
|
||||||
|
{
|
||||||
|
static const uint8_t acn_pid[12] = { 'A', 'S', 'C', '-', 'E', '1', '.', '1', '7', 0, 0, 0 };
|
||||||
|
uint16_t pvc;
|
||||||
|
|
||||||
|
if (len < NL_E131_MIN_PACKET || len > NL_E131_MIN_PACKET + 512u)
|
||||||
|
return false;
|
||||||
|
if (be16(buf) != 0x0010u || be16(buf + 2) != 0x0000u)
|
||||||
|
return false;
|
||||||
|
if (memcmp(buf + 4, acn_pid, sizeof acn_pid) != 0)
|
||||||
|
return false;
|
||||||
|
if (!pdu_len_ok(buf, len, 16) || be32(buf + 18) != 0x00000004u)
|
||||||
|
return false;
|
||||||
|
if (!pdu_len_ok(buf, len, 38) || be32(buf + 40) != 0x00000002u)
|
||||||
|
return false;
|
||||||
|
if (!pdu_len_ok(buf, len, 115) || buf[117] != 0x02u)
|
||||||
|
return false;
|
||||||
|
if (buf[118] != 0xa1u || be16(buf + 119) != 0x0000u || be16(buf + 121) != 0x0001u)
|
||||||
|
return false;
|
||||||
|
pvc = be16(buf + 123);
|
||||||
|
if (pvc < 1u || (size_t)(pvc - 1u) != len - NL_E131_MIN_PACKET)
|
||||||
|
return false;
|
||||||
|
if (buf[125] != 0x00u) /* nur Start-Code 0 ist Lichtinhalt (0xdd waere Kanalprioritaet) */
|
||||||
|
return false;
|
||||||
|
if (buf[112] & 0x80u) /* Preview-Daten duerfen laut E1.31 nicht ausgegeben werden */
|
||||||
|
return false;
|
||||||
|
|
||||||
|
/* Universe must be in range 1..63999; 0 and values >63999 are reserved. */
|
||||||
|
uint16_t universe = be16(buf + 113);
|
||||||
|
if (universe == 0u || universe > 63999u)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
out->universe = universe;
|
||||||
|
out->priority = buf[108]; /* taken as-is without range check; priority is only compared
|
||||||
|
* relatively in later tasks, never against absolute bounds.
|
||||||
|
* strict validation would silently drop light data from slightly
|
||||||
|
* non-conformant senders, worse than accepting 0..255 */
|
||||||
|
out->sequence = buf[111];
|
||||||
|
out->terminated = (buf[112] & 0x40u) != 0u;
|
||||||
|
out->slot_count = (uint16_t)(pvc - 1u);
|
||||||
|
out->slots = buf + NL_E131_MIN_PACKET;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t nl_e131_group_addr(uint16_t universe)
|
||||||
|
{
|
||||||
|
return 0xefff0000u | ((uint32_t)(universe >> 8) << 8) | (uint32_t)(universe & 0xffu);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#ifndef NL_E131_H
|
||||||
|
#define NL_E131_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define NL_E131_PORT 5568u
|
||||||
|
#define NL_E131_MAX_PACKET 640u /* ein volles Paket ist 638 Byte; 640 laesst Luft zum Erkennen
|
||||||
|
* ueberlanger Datagramme, ohne den Puffer unnoetig aufzublaehen */
|
||||||
|
#define NL_E131_MIN_PACKET 126u
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint16_t universe;
|
||||||
|
uint8_t priority;
|
||||||
|
uint8_t sequence;
|
||||||
|
bool terminated; /* Options-Bit "stream terminated" */
|
||||||
|
uint16_t slot_count; /* Nutzdaten ohne Start-Code, 0..512 */
|
||||||
|
const uint8_t *slots; /* zeigt in den uebergebenen Puffer, wird nicht kopiert */
|
||||||
|
} nl_e131_packet_t;
|
||||||
|
|
||||||
|
/* true = gueltiges DMX-Datenpaket (Start-Code 0, kein Preview). Alles andere: false.
|
||||||
|
* `out` wird nur bei true beschrieben. */
|
||||||
|
bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out);
|
||||||
|
|
||||||
|
/* Die Multicast-Gruppe eines Universe nach E1.31 Anhang A: 239.255.<hi>.<lo>, als Zahl in
|
||||||
|
* HOST-Byte-Reihenfolge. Steht hier und nicht im Empfangs-Task, damit sie im Host-Test
|
||||||
|
* nachgerechnet werden kann - der Wert war schon einmal falsch, und man sieht es einer
|
||||||
|
* Lampe nicht an: sie tritt dann sauber einer Gruppe bei, auf die niemand sendet. */
|
||||||
|
uint32_t nl_e131_group_addr(uint16_t universe);
|
||||||
|
|
||||||
|
#endif
|
||||||
+65
-6
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "wm_include.h"
|
#include "wm_include.h"
|
||||||
#include "lwip/apps/mdns.h"
|
#include "lwip/apps/mdns.h"
|
||||||
|
#include "lwip/netif.h"
|
||||||
#include "lwip/tcpip.h"
|
#include "lwip/tcpip.h"
|
||||||
|
|
||||||
#include "config/nl_settings.h"
|
#include "config/nl_settings.h"
|
||||||
@@ -16,8 +17,13 @@
|
|||||||
#define NL_MDNS_TTL 120
|
#define NL_MDNS_TTL 120
|
||||||
|
|
||||||
static bool s_inited;
|
static bool s_inited;
|
||||||
|
static struct netif *s_nif; /* Netif, an dem Antwortdienst und Dienst haengen */
|
||||||
static bool s_netif_added;
|
static bool s_netif_added;
|
||||||
static bool s_service_added;
|
/* Vom Netz-Task gelesen, im tcpip-Thread geschrieben: volatile, und s_announce_pending deckt
|
||||||
|
* das Fenster zwischen dem Anstossen und dem Lauf von announce() ab. Ohne das meldete
|
||||||
|
* nl_mdns_service_up() beim Wechsel Station -> eigener AP noch den Dienst des alten Netifs. */
|
||||||
|
static volatile bool s_service_added;
|
||||||
|
static volatile bool s_announce_pending;
|
||||||
static char s_name[NL_NAME_LEN];
|
static char s_name[NL_NAME_LEN];
|
||||||
static char s_mac[18];
|
static char s_mac[18];
|
||||||
|
|
||||||
@@ -45,15 +51,53 @@ static void txt_cb(struct mdns_service *service, void *data)
|
|||||||
add_txt(service, "name", name);
|
add_txt(service, "name", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Das SDK fuehrt zwei Netifs (wm_ethernet.c, Tcpip_stack_init): tls_get_netif() liefert das
|
||||||
|
* der STATION; das des Softap wurde zuerst angelegt und haengt als ->next dahinter (dieselbe
|
||||||
|
* Annahme treffen DHCPS_Start/DNSS_Start im SDK mit nif->next, ethernetif_input() mit
|
||||||
|
* netif->next). Im Standalone-Modus gibt es keine Station, die Antworten muessen ueber das
|
||||||
|
* AP-Netif gehen, sonst haengt der Dienst an einem Netif ohne Adresse und Funkstrecke. */
|
||||||
|
static struct netif *pick_netif(void)
|
||||||
|
{
|
||||||
|
struct netif *sta = tls_get_netif();
|
||||||
|
struct netif *n;
|
||||||
|
|
||||||
|
if (!sta)
|
||||||
|
return NULL;
|
||||||
|
if (!nl_wifi_is_standalone())
|
||||||
|
return sta;
|
||||||
|
/* Erst der bekannte Platz, dann zur Sicherheit die lwip-Kette: das erste Netif mit
|
||||||
|
* IPv4-Adresse, das nicht die Station ist (Loopback gibt es in diesem SDK nicht). */
|
||||||
|
if (sta->next && !ip4_addr_isany_val(*netif_ip4_addr(sta->next)))
|
||||||
|
return sta->next;
|
||||||
|
for (n = netif_list; n != NULL; n = n->next)
|
||||||
|
if (n != sta && !ip4_addr_isany_val(*netif_ip4_addr(n)))
|
||||||
|
return n;
|
||||||
|
return NULL; /* AP-Netif noch ohne Adresse: lieber nichts melden als am falschen Netif */
|
||||||
|
}
|
||||||
|
|
||||||
/* tcpip thread */
|
/* tcpip thread */
|
||||||
static void announce(void *arg)
|
static void announce(void *arg)
|
||||||
{
|
{
|
||||||
struct netif *nif = tls_get_netif();
|
struct netif *nif = pick_netif();
|
||||||
bool attempted = false;
|
bool attempted = false;
|
||||||
|
|
||||||
(void)arg;
|
(void)arg;
|
||||||
if (!nif)
|
if (!nif) {
|
||||||
|
printf("mdns: no usable netif yet\r\n");
|
||||||
|
s_announce_pending = false;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anderes Netif als bisher (Station <-> eigener AP): ein blosses
|
||||||
|
* mdns_resp_netif_settings_changed() wuerde am alten haengen bleiben, denn Antwortdienst
|
||||||
|
* und Dienst sind je Netif angelegt. Also am alten Netif abmelden - remove_netif gibt auch
|
||||||
|
* dessen Dienste und die Multicast-Gruppe frei - und am neuen frisch anmelden. */
|
||||||
|
if (s_netif_added && s_nif != nif) {
|
||||||
|
mdns_resp_remove_netif(s_nif);
|
||||||
|
s_netif_added = false;
|
||||||
|
s_service_added = false;
|
||||||
|
}
|
||||||
|
s_nif = nif;
|
||||||
|
|
||||||
if (!s_inited) {
|
if (!s_inited) {
|
||||||
mdns_resp_init();
|
mdns_resp_init();
|
||||||
@@ -69,10 +113,19 @@ static void announce(void *arg)
|
|||||||
NL_MDNS_TTL, txt_cb, NULL) == ERR_OK;
|
NL_MDNS_TTL, txt_cb, NULL) == ERR_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempted)
|
if (attempted) {
|
||||||
printf("mdns: %s.local _ninlight._tcp %s\r\n", nl_wifi_hostname(), s_service_added ? "up" : "failed");
|
/* Welches Netif benutzt wurde, steht in der Zeile selbst: Namenskuerzel, Nummer, IPv4.
|
||||||
else
|
* Der rohe netif-Zeiger stand hier frueher mit (%p) dabei; waehrend der Fehlersuche war
|
||||||
|
* er nuetzlich, in einer Release-Firmware ist er Rauschen - Kuerzel und Adresse
|
||||||
|
* unterscheiden Station und eigenen AP bereits eindeutig, und eine Speicheradresse in
|
||||||
|
* einem Protokoll, das Nutzer weiterreichen, hat keinen Nutzen. */
|
||||||
|
const ip4_addr_t *ip = netif_ip4_addr(nif);
|
||||||
|
printf("mdns: %s.local _ninlight._tcp %s on %c%c%u %u.%u.%u.%u\r\n", nl_wifi_hostname(),
|
||||||
|
s_service_added ? "up" : "failed", nif->name[0], nif->name[1], (unsigned)nif->num,
|
||||||
|
ip4_addr1(ip), ip4_addr2(ip), ip4_addr3(ip), ip4_addr4(ip));
|
||||||
|
} else
|
||||||
mdns_resp_netif_settings_changed(nif); /* re-announce: new IP or TXT */
|
mdns_resp_netif_settings_changed(nif); /* re-announce: new IP or TXT */
|
||||||
|
s_announce_pending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_mdns_on_ip_up(void)
|
void nl_mdns_on_ip_up(void)
|
||||||
@@ -80,9 +133,15 @@ void nl_mdns_on_ip_up(void)
|
|||||||
uint8_t mac[6];
|
uint8_t mac[6];
|
||||||
nl_wifi_mac(mac);
|
nl_wifi_mac(mac);
|
||||||
nl_names_mac_str(mac, s_mac);
|
nl_names_mac_str(mac, s_mac);
|
||||||
|
s_announce_pending = true;
|
||||||
tcpip_callback(announce, NULL);
|
tcpip_callback(announce, NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool nl_mdns_service_up(void)
|
||||||
|
{
|
||||||
|
return !s_announce_pending && s_service_added;
|
||||||
|
}
|
||||||
|
|
||||||
void nl_mdns_set_name(const char *name)
|
void nl_mdns_set_name(const char *name)
|
||||||
{
|
{
|
||||||
u32 sr = tls_os_set_critical();
|
u32 sr = tls_os_set_critical();
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
#ifndef NL_MDNS_H
|
#ifndef NL_MDNS_H
|
||||||
#define NL_MDNS_H
|
#define NL_MDNS_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
/* Called by the network task once the station got an IP (again). */
|
/* Called by the network task once the station got an IP (again). */
|
||||||
void nl_mdns_on_ip_up(void);
|
void nl_mdns_on_ip_up(void);
|
||||||
|
/* True, sobald der Dienst wirklich angemeldet ist - und erst, nachdem die zuletzt
|
||||||
|
* angestossene Bekanntmachung im tcpip-Thread auch gelaufen ist. Der Netz-Task stoesst
|
||||||
|
* im eigenen AP so lange erneut an, bis das hier wahr ist (I5). */
|
||||||
|
bool nl_mdns_service_up(void);
|
||||||
/* Device name for the TXT record; re-announces when mDNS is running. */
|
/* Device name for the TXT record; re-announces when mDNS is running. */
|
||||||
void nl_mdns_set_name(const char *name);
|
void nl_mdns_set_name(const char *name);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "wm_include.h"
|
||||||
|
#include "wm_sockets.h"
|
||||||
|
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
|
#include "net/nl_e131.h"
|
||||||
|
#include "net/nl_sacn.h"
|
||||||
|
#include "net/nl_wifi.h"
|
||||||
|
#include "sys/nl_app.h"
|
||||||
|
|
||||||
|
#define NL_SACN_PRIO 41 /* zwischen Licht (30) und HTTP (42) */
|
||||||
|
#define NL_SACN_RECV_TIMEOUT_S 2
|
||||||
|
|
||||||
|
static u32 s_stack[512]; /* 2 KB: Parser ohne Heap, Puffer liegt statisch daneben */
|
||||||
|
static uint8_t s_buf[NL_E131_MAX_PACKET];
|
||||||
|
|
||||||
|
/* Vom HTTP-Task geschrieben, vom Empfangs-Task gelesen. Jedes Feld passt in einen
|
||||||
|
* atomaren Zugriff; s_dirty wird zuletzt gesetzt und zuerst geloescht, damit der Task
|
||||||
|
* keine halb uebernommene Konfiguration sieht. */
|
||||||
|
static volatile uint8_t s_enabled = 1;
|
||||||
|
static volatile uint16_t s_universe = 1;
|
||||||
|
static volatile bool s_dirty = true;
|
||||||
|
|
||||||
|
volatile uint32_t nl_sacn_beat;
|
||||||
|
|
||||||
|
/* Faellt nur aus, wenn der Task gar nicht erst entsteht. app_main fragt das ab: ohne Task
|
||||||
|
* bewegt sich nl_sacn_beat nie, und eine Wachhund-Bedingung, die trotzdem darauf besteht,
|
||||||
|
* wuerde die Lampe im 20-s-Takt endlos zuruecksetzen - ein Zusatzdienst darf sie nicht
|
||||||
|
* unbrauchbar machen. */
|
||||||
|
static volatile bool s_running = true;
|
||||||
|
/* Ergebnis des letzten Multicast-Beitritts; ueber system/info sichtbar. */
|
||||||
|
static volatile bool s_joined;
|
||||||
|
|
||||||
|
void nl_sacn_apply_settings(const nl_settings_t *s)
|
||||||
|
{
|
||||||
|
nl_dmx_cfg_t c;
|
||||||
|
|
||||||
|
c.start_slot = s->sacn_start_slot;
|
||||||
|
c.hold = s->sacn_hold != 0;
|
||||||
|
c.release_fade_ms = s->fade_in_ms;
|
||||||
|
c.rgb_layout = s->sacn_layout != 0;
|
||||||
|
nl_app_lock();
|
||||||
|
nl_dmx_configure(&c);
|
||||||
|
nl_app_unlock();
|
||||||
|
|
||||||
|
s_enabled = s->sacn_enabled;
|
||||||
|
s_universe = s->sacn_universe;
|
||||||
|
s_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legt den Socket an und tritt der Universe-Gruppe bei. -1 bei Fehlschlag. */
|
||||||
|
static int open_socket(uint16_t universe, char ip_seen[16])
|
||||||
|
{
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
struct ip_mreq mreq;
|
||||||
|
struct timeval tv;
|
||||||
|
nl_wifi_status_t w;
|
||||||
|
uint32_t group;
|
||||||
|
int on = 1;
|
||||||
|
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
|
||||||
|
if (fd < 0)
|
||||||
|
return -1;
|
||||||
|
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
|
||||||
|
memset(&addr, 0, sizeof addr);
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(NL_E131_PORT);
|
||||||
|
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||||
|
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0) {
|
||||||
|
closesocket(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* Ohne Verkehr wuerde recvfrom() sonst ewig blockieren; dieser Timeout haelt den
|
||||||
|
* Herzschlag am Leben - derselbe Grund, aus dem der HTTP-Server accept() begrenzt. */
|
||||||
|
tv.tv_sec = NL_SACN_RECV_TIMEOUT_S;
|
||||||
|
tv.tv_usec = 0;
|
||||||
|
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
||||||
|
|
||||||
|
nl_wifi_status(&w);
|
||||||
|
strncpy(ip_seen, w.sta_ip, 15);
|
||||||
|
ip_seen[15] = '\0';
|
||||||
|
|
||||||
|
/* 239.255.<hi>.<lo> gemaess E1.31 Anhang A. Ein fehlgeschlagener Beitritt ist kein
|
||||||
|
* Abbruchgrund: Unicast an die Lampen-IP kommt auf demselben Socket weiterhin an.
|
||||||
|
*
|
||||||
|
* Das Ergebnis wird aber festgehalten und ueber system/info sichtbar gemacht. Bei der
|
||||||
|
* Abnahme am 2026-09-20 kam Multicast nicht an, waehrend Unicast lief - und weil der
|
||||||
|
* Rueckgabewert damals verworfen wurde, liess sich nicht unterscheiden, ob die Lampe
|
||||||
|
* nicht beitritt oder das WLAN die Gruppe nicht weiterreicht. Genau diese Frage
|
||||||
|
* beantwortet das Feld. */
|
||||||
|
memset(&mreq, 0, sizeof mreq);
|
||||||
|
/* Die Gruppenadresse wird ZUERST in eine Variable gerechnet und erst dann umgedreht.
|
||||||
|
* Das ist kein Schoenheitsfehler: htonl() ist in diesem SDK ein Makro, das sein
|
||||||
|
* Argument NICHT klammert (sdk/Include/Net/wm_sockets2.0.3.h):
|
||||||
|
*
|
||||||
|
* #define htonl(n) (((n & 0xff) << 24) | ((n & 0xff00) << 8) | ...)
|
||||||
|
*
|
||||||
|
* Ein Ausdruck mit | wird dabei zerrissen, weil & staerker bindet als | - die Maske
|
||||||
|
* trifft nur den letzten Term. Frueher stand der ganze Ausdruck direkt im Aufruf, und
|
||||||
|
* fuer Universe 1 kam 239.255.239.255 statt 239.255.0.1 heraus. Die Lampe trat also
|
||||||
|
* sauber einer Gruppe bei, auf die niemand sendete: Multicast kam nie an, waehrend
|
||||||
|
* Unicast lief, und der Beitritt meldete trotzdem Erfolg. Gefunden am 2026-09-21 am
|
||||||
|
* EVK ueber die serielle Konsole. */
|
||||||
|
group = nl_e131_group_addr(universe);
|
||||||
|
mreq.imr_multiaddr.s_addr = htonl(group);
|
||||||
|
mreq.imr_interface.s_addr = ip_seen[0] ? inet_addr(ip_seen) : htonl(INADDR_ANY);
|
||||||
|
s_joined = setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) == 0;
|
||||||
|
printf("sacn: universe %u, multicast join %s\r\n", (unsigned)universe,
|
||||||
|
s_joined ? "ok" : "FEHLGESCHLAGEN");
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sacn_task(void *arg)
|
||||||
|
{
|
||||||
|
int fd = -1;
|
||||||
|
char ip_seen[16] = "";
|
||||||
|
uint16_t bound_universe = 0;
|
||||||
|
|
||||||
|
(void)arg;
|
||||||
|
for (;;) {
|
||||||
|
nl_wifi_status_t w;
|
||||||
|
nl_e131_packet_t p;
|
||||||
|
uint32_t started_ms;
|
||||||
|
int n;
|
||||||
|
|
||||||
|
nl_sacn_beat++;
|
||||||
|
|
||||||
|
if (!s_enabled) {
|
||||||
|
if (fd >= 0) {
|
||||||
|
closesocket(fd);
|
||||||
|
fd = -1;
|
||||||
|
}
|
||||||
|
s_dirty = false;
|
||||||
|
tls_os_time_delay(HZ / 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neu aufbauen, wenn die Konfiguration sich geaendert hat oder die Station eine
|
||||||
|
* andere IP bekommen hat - nach einem WLAN-Wechsel gilt die alte Mitgliedschaft
|
||||||
|
* nicht mehr, und ohne Neubeitritt kaeme nie wieder ein Multicast-Paket an. */
|
||||||
|
nl_wifi_status(&w);
|
||||||
|
if (fd >= 0 && (s_dirty || bound_universe != s_universe ||
|
||||||
|
strncmp(ip_seen, w.sta_ip, sizeof ip_seen) != 0)) {
|
||||||
|
closesocket(fd);
|
||||||
|
fd = -1;
|
||||||
|
}
|
||||||
|
if (fd < 0) {
|
||||||
|
s_dirty = false;
|
||||||
|
bound_universe = s_universe;
|
||||||
|
fd = open_socket(bound_universe, ip_seen);
|
||||||
|
if (fd < 0) {
|
||||||
|
tls_os_time_delay(HZ); /* z.B. noch kein netif: spaeter erneut versuchen */
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
started_ms = nl_app_now_ms();
|
||||||
|
n = recvfrom(fd, s_buf, sizeof s_buf, 0, NULL, NULL);
|
||||||
|
if (n < 0) {
|
||||||
|
/* Die Zeitueberschreitung ist der Normalfall ohne Verkehr und laeuft die vollen
|
||||||
|
* zwei Sekunden. Kommt der Fehler deutlich frueher, ist der Socket hart kaputt
|
||||||
|
* (das lwIP dieser Version reicht kein errno heraus, die Laufzeit ist das
|
||||||
|
* einzige Unterscheidungsmerkmal): neu aufbauen statt heiss zu drehen. */
|
||||||
|
if (nl_app_now_ms() - started_ms < NL_SACN_RECV_TIMEOUT_S * 1000u / 2u) {
|
||||||
|
closesocket(fd);
|
||||||
|
fd = -1;
|
||||||
|
tls_os_time_delay(HZ);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!nl_e131_parse(s_buf, (size_t)n, &p))
|
||||||
|
continue;
|
||||||
|
if (p.universe != bound_universe)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
/* nl_dmx teilt sich seinen Zustand mit der Licht-Engine und ist nicht threadsicher. */
|
||||||
|
nl_app_lock();
|
||||||
|
nl_dmx_feed(&p, nl_app_now_ms());
|
||||||
|
nl_app_unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void nl_sacn_start(void)
|
||||||
|
{
|
||||||
|
if (tls_os_task_create(NULL, "nl_sacn", sacn_task, NULL, (u8 *)s_stack, sizeof(s_stack),
|
||||||
|
NL_SACN_PRIO, 0) != TLS_OS_SUCCESS) {
|
||||||
|
s_running = false;
|
||||||
|
printf("sacn: task create failed - receiver off\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nl_sacn_running(void)
|
||||||
|
{
|
||||||
|
return s_running;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nl_sacn_joined(void) { return s_joined; }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#ifndef NL_SACN_H
|
||||||
|
#define NL_SACN_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "config/nl_settings.h"
|
||||||
|
|
||||||
|
/* Wie nl_net_beat und nl_http_beat: app_main fuettert den Watchdog erst, wenn sich jeder
|
||||||
|
* Herzschlag bewegt hat - ein haengender Empfangs-Task muss den Chip zuruecksetzen. */
|
||||||
|
extern volatile uint32_t nl_sacn_beat;
|
||||||
|
|
||||||
|
/* Uebernimmt enabled/universe/start_slot/hold und fade_in_ms. Darf jederzeit aufgerufen
|
||||||
|
* werden; der Task baut seinen Socket beim naechsten Durchlauf neu auf. */
|
||||||
|
void nl_sacn_apply_settings(const nl_settings_t *s);
|
||||||
|
void nl_sacn_start(void);
|
||||||
|
|
||||||
|
/* false, wenn der Empfangs-Task nicht angelegt werden konnte. Dann bewegt sich
|
||||||
|
* nl_sacn_beat nie und der Wachhund darf ihn nicht mehr verlangen. */
|
||||||
|
bool nl_sacn_running(void);
|
||||||
|
/* true, wenn der Beitritt zur Multicast-Gruppe des eingestellten Universe gelungen ist.
|
||||||
|
* Unicast an die Lampen-IP funktioniert unabhaengig davon. */
|
||||||
|
bool nl_sacn_joined(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
+13
-2
@@ -19,8 +19,12 @@ typedef struct {
|
|||||||
* sure the network task is still alive, not just the light task. */
|
* sure the network task is still alive, not just the light task. */
|
||||||
extern volatile uint32_t nl_net_beat;
|
extern volatile uint32_t nl_net_beat;
|
||||||
|
|
||||||
/* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied. */
|
/* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied.
|
||||||
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass);
|
* Ein leerer ap_ssid heisst "wie der Hostname", ap_channel 0 heisst Kanal 6. Mit
|
||||||
|
* standalone == true wird gar nicht erst verbunden: die Lampe oeffnet ihren eigenen,
|
||||||
|
* passwortgeschuetzten AP, pulse laesst sie dabei kurz als Lebenszeichen pulsen. */
|
||||||
|
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass,
|
||||||
|
const char *ap_ssid, uint8_t ap_channel, bool standalone, bool pulse);
|
||||||
/* Switches the SDK's own auto-connect off and drops any connection it may already have brought
|
/* Switches the SDK's own auto-connect off and drops any connection it may already have brought
|
||||||
* up on its own. nl_wifi_start() does this too, but the boot recovery path runs before that and
|
* up on its own. nl_wifi_start() does this too, but the boot recovery path runs before that and
|
||||||
* must not have the SDK joining its stored AP in parallel (I3, final review). Safe to call any
|
* must not have the SDK joining its stored AP in parallel (I3, final review). Safe to call any
|
||||||
@@ -34,6 +38,13 @@ void nl_wifi_disable_sdk_auto_connect(void);
|
|||||||
bool nl_wifi_join_blocking(const char *ssid, const char *pass, uint32_t timeout_ms);
|
bool nl_wifi_join_blocking(const char *ssid, const char *pass, uint32_t timeout_ms);
|
||||||
/* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */
|
/* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */
|
||||||
int nl_wifi_request_onboard(const char *ssid, const char *pass);
|
int nl_wifi_request_onboard(const char *ssid, const char *pass);
|
||||||
|
/* Schaltet den Standalone-Modus zur Laufzeit ein oder aus (aus dem HTTP-Task). Die Werte
|
||||||
|
* werden kopiert und erst im Netz-Task wirksam, nachdem die Antwort raus ist; ein leeres
|
||||||
|
* ssid/pass bzw. channel == 0 bedeutet jeweils die Vorgabe. 0 = uebernommen, sonst 503. */
|
||||||
|
int nl_wifi_request_standalone(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse);
|
||||||
|
/* True, solange die Lampe in ihrem eigenen Dauer-AP steht. */
|
||||||
|
bool nl_wifi_is_standalone(void);
|
||||||
/* True while the unconfigured onboarding AP is open (API without token). */
|
/* True while the unconfigured onboarding AP is open (API without token). */
|
||||||
bool nl_wifi_open_access(void);
|
bool nl_wifi_open_access(void);
|
||||||
void nl_wifi_status(nl_wifi_status_t *out);
|
void nl_wifi_status(nl_wifi_status_t *out);
|
||||||
|
|||||||
+109
-5
@@ -40,7 +40,15 @@ static void arm_ap_stop(nl_wifi_sm_t *sm, uint32_t at)
|
|||||||
static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wifi_actions_t *a)
|
static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wifi_actions_t *a)
|
||||||
{
|
{
|
||||||
sm->error = err;
|
sm->error = err;
|
||||||
if (sm->have_creds) {
|
if (sm->standalone) {
|
||||||
|
/* Zurueck in den EIGENEN AP. Der offene Onboarding-AP waere hier doppelt falsch:
|
||||||
|
* er verloere Name und Passwort des Nutzers, und open_access() machte die Lampe
|
||||||
|
* ohne Token flashbar. Ein Tippfehler im WLAN-Passwort darf sie nicht entsichern. */
|
||||||
|
sm->state = NL_WIFI_ST_STANDALONE;
|
||||||
|
sm->ap_up = true;
|
||||||
|
a->disconnect = true;
|
||||||
|
a->start_ap = true;
|
||||||
|
} else if (sm->have_creds) {
|
||||||
sm->state = NL_WIFI_ST_APSTA_FALLBACK;
|
sm->state = NL_WIFI_ST_APSTA_FALLBACK;
|
||||||
sm->since = now;
|
sm->since = now;
|
||||||
sm->backoff = NL_WIFI_BACKOFF_MIN_MS;
|
sm->backoff = NL_WIFI_BACKOFF_MIN_MS;
|
||||||
@@ -54,13 +62,26 @@ static void fail_test(nl_wifi_sm_t *sm, nl_wifi_error_t err, uint32_t now, nl_wi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, uint32_t now)
|
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, bool standalone, bool pulse, uint32_t now)
|
||||||
{
|
{
|
||||||
nl_wifi_actions_t a = none();
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
memset(sm, 0, sizeof *sm);
|
memset(sm, 0, sizeof *sm);
|
||||||
sm->have_creds = have_creds;
|
sm->have_creds = have_creds;
|
||||||
if (have_creds) {
|
sm->standalone = standalone;
|
||||||
|
if (standalone) {
|
||||||
|
/* No connect attempt, not even with stored credentials: the mode is the
|
||||||
|
* user's explicit choice. */
|
||||||
|
sm->state = NL_WIFI_ST_STANDALONE;
|
||||||
|
sm->ap_up = true;
|
||||||
|
a.start_ap = true;
|
||||||
|
if (pulse) {
|
||||||
|
sm->pulsing = true;
|
||||||
|
sm->pulse_armed = true;
|
||||||
|
sm->pulse_stop_at = now + NL_WIFI_STANDALONE_PULSE_MS;
|
||||||
|
a.pulse_on = true;
|
||||||
|
}
|
||||||
|
} else if (have_creds) {
|
||||||
begin_connecting(sm, now);
|
begin_connecting(sm, now);
|
||||||
a.connect = true;
|
a.connect = true;
|
||||||
sm->awaiting = true;
|
sm->awaiting = true;
|
||||||
@@ -101,6 +122,10 @@ nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi
|
|||||||
case NL_WIFI_ST_ONBOARD_TEST:
|
case NL_WIFI_ST_ONBOARD_TEST:
|
||||||
if (ev == NL_WIFI_EV_CONNECTED) {
|
if (ev == NL_WIFI_EV_CONNECTED) {
|
||||||
a.save_test_creds = true;
|
a.save_test_creds = true;
|
||||||
|
if (sm->standalone) {
|
||||||
|
sm->standalone = false;
|
||||||
|
a.clear_standalone = true;
|
||||||
|
}
|
||||||
sm->have_creds = true;
|
sm->have_creds = true;
|
||||||
sm->error = NL_WIFI_ERR_NONE;
|
sm->error = NL_WIFI_ERR_NONE;
|
||||||
sm->state = NL_WIFI_ST_STA_CONNECTED;
|
sm->state = NL_WIFI_ST_STA_CONNECTED;
|
||||||
@@ -118,6 +143,10 @@ nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi
|
|||||||
if (ev == NL_WIFI_EV_CONNECTED)
|
if (ev == NL_WIFI_EV_CONNECTED)
|
||||||
a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */
|
a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */
|
||||||
break;
|
break;
|
||||||
|
case NL_WIFI_ST_STANDALONE:
|
||||||
|
if (ev == NL_WIFI_EV_CONNECTED)
|
||||||
|
a.disconnect = true; /* stray success of an old attempt */
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -128,6 +157,11 @@ nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now)
|
|||||||
{
|
{
|
||||||
nl_wifi_actions_t a = none();
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
|
if (sm->pulse_armed && due(now, sm->pulse_stop_at)) {
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
sm->pulsing = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
if (sm->ap_stop_armed && due(now, sm->ap_stop_at)) {
|
if (sm->ap_stop_armed && due(now, sm->ap_stop_at)) {
|
||||||
sm->ap_stop_armed = false;
|
sm->ap_stop_armed = false;
|
||||||
sm->ap_up = false;
|
sm->ap_up = false;
|
||||||
@@ -173,20 +207,90 @@ nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now)
|
|||||||
sm->deadline = now + NL_WIFI_ONBOARD_TIMEOUT_MS;
|
sm->deadline = now + NL_WIFI_ONBOARD_TIMEOUT_MS;
|
||||||
sm->retry_armed = false;
|
sm->retry_armed = false;
|
||||||
sm->ap_stop_armed = false;
|
sm->ap_stop_armed = false;
|
||||||
|
if (sm->pulse_armed) {
|
||||||
|
/* Der Startpuls gehoert nicht in den Test: sonst feuerte spaeter ein pulse_off, das zu
|
||||||
|
* keinem Puls mehr gehoert. Hier abraeumen; pulsing auch, damit er nicht haengen bleibt. */
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
sm->pulsing = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
a.disconnect = true; /* M1: drop any stale station attempt so its events don't count */
|
a.disconnect = true; /* M1: drop any stale station attempt so its events don't count */
|
||||||
a.connect = true;
|
a.connect = true;
|
||||||
a.use_test_creds = true;
|
a.use_test_creds = true;
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_standalone(nl_wifi_sm_t *sm, bool pulse, uint32_t now)
|
||||||
|
{
|
||||||
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
|
sm->standalone = true;
|
||||||
|
sm->state = NL_WIFI_ST_STANDALONE;
|
||||||
|
sm->error = NL_WIFI_ERR_NONE;
|
||||||
|
sm->retry_armed = false;
|
||||||
|
sm->ap_stop_armed = false;
|
||||||
|
sm->awaiting = false;
|
||||||
|
a.disconnect = true; /* eine laufende Stationsverbindung aufgeben */
|
||||||
|
if (!sm->ap_up) {
|
||||||
|
sm->ap_up = true;
|
||||||
|
a.start_ap = true;
|
||||||
|
}
|
||||||
|
if (pulse) {
|
||||||
|
sm->pulsing = true;
|
||||||
|
sm->pulse_armed = true;
|
||||||
|
sm->pulse_stop_at = now + NL_WIFI_STANDALONE_PULSE_MS;
|
||||||
|
a.pulse_on = true;
|
||||||
|
} else if (sm->pulsing) {
|
||||||
|
/* Ein laufender unbefristeter Puls (aus dem Onboarding-AP) hat im eigenen AP keinen
|
||||||
|
* Timer mehr, der ihn beendet; er uebermalte dauerhaft die normale Ausgabe. */
|
||||||
|
sm->pulsing = false;
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_leave_standalone(nl_wifi_sm_t *sm, bool have_creds, uint32_t now)
|
||||||
|
{
|
||||||
|
nl_wifi_actions_t a = none();
|
||||||
|
|
||||||
|
sm->standalone = false;
|
||||||
|
sm->have_creds = have_creds;
|
||||||
|
sm->pulse_armed = false;
|
||||||
|
if (have_creds) {
|
||||||
|
begin_connecting(sm, now);
|
||||||
|
a.connect = true;
|
||||||
|
sm->awaiting = true;
|
||||||
|
sm->connect_at = now;
|
||||||
|
if (sm->pulsing) {
|
||||||
|
sm->pulsing = false;
|
||||||
|
a.pulse_off = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* Ohne Zugangsdaten bliebe die Lampe sonst unerreichbar: der offene
|
||||||
|
* Onboarding-AP ist hier der gewollte Ausweg. */
|
||||||
|
sm->state = NL_WIFI_ST_AP_ONBOARDING;
|
||||||
|
sm->ap_up = true;
|
||||||
|
sm->pulsing = true;
|
||||||
|
a.start_ap = true;
|
||||||
|
a.pulse_on = true;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm)
|
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm)
|
||||||
{
|
{
|
||||||
return sm->state == NL_WIFI_ST_AP_ONBOARDING || (sm->state == NL_WIFI_ST_ONBOARD_TEST && !sm->have_creds);
|
/* !standalone is NOT redundant: during an onboarding test started from standalone mode
|
||||||
|
* the state is ONBOARD_TEST with have_creds == false, which the second clause alone would
|
||||||
|
* call open. The user's password-protected AP is up then, so the token check must stay on
|
||||||
|
* (up to NL_WIFI_ONBOARD_TIMEOUT_MS), same as in normal operation. */
|
||||||
|
return !sm->standalone &&
|
||||||
|
(sm->state == NL_WIFI_ST_AP_ONBOARDING || (sm->state == NL_WIFI_ST_ONBOARD_TEST && !sm->have_creds));
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm)
|
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm)
|
||||||
{
|
{
|
||||||
if (sm->state == NL_WIFI_ST_AP_ONBOARDING)
|
if (sm->state == NL_WIFI_ST_AP_ONBOARDING || sm->state == NL_WIFI_ST_STANDALONE)
|
||||||
return "ap";
|
return "ap";
|
||||||
return sm->ap_up ? "apsta" : "sta";
|
return sm->ap_up ? "apsta" : "sta";
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -11,13 +11,15 @@
|
|||||||
#define NL_WIFI_BACKOFF_MIN_MS 1000u
|
#define NL_WIFI_BACKOFF_MIN_MS 1000u
|
||||||
#define NL_WIFI_BACKOFF_MAX_MS 60000u
|
#define NL_WIFI_BACKOFF_MAX_MS 60000u
|
||||||
#define NL_WIFI_CONNECT_TIMEOUT_MS 30000u /* no event after a connect -> treat as failed */
|
#define NL_WIFI_CONNECT_TIMEOUT_MS 30000u /* no event after a connect -> treat as failed */
|
||||||
|
#define NL_WIFI_STANDALONE_PULSE_MS 5000u /* short sign-of-life pulse, then normal light */
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
NL_WIFI_ST_AP_ONBOARDING = 0,
|
NL_WIFI_ST_AP_ONBOARDING = 0,
|
||||||
NL_WIFI_ST_STA_CONNECTING,
|
NL_WIFI_ST_STA_CONNECTING,
|
||||||
NL_WIFI_ST_STA_CONNECTED,
|
NL_WIFI_ST_STA_CONNECTED,
|
||||||
NL_WIFI_ST_APSTA_FALLBACK,
|
NL_WIFI_ST_APSTA_FALLBACK,
|
||||||
NL_WIFI_ST_ONBOARD_TEST
|
NL_WIFI_ST_ONBOARD_TEST,
|
||||||
|
NL_WIFI_ST_STANDALONE
|
||||||
} nl_wifi_state_t;
|
} nl_wifi_state_t;
|
||||||
|
|
||||||
typedef enum { NL_WIFI_ERR_NONE = 0, NL_WIFI_ERR_AUTH, NL_WIFI_ERR_NOT_FOUND, NL_WIFI_ERR_TIMEOUT } nl_wifi_error_t;
|
typedef enum { NL_WIFI_ERR_NONE = 0, NL_WIFI_ERR_AUTH, NL_WIFI_ERR_NOT_FOUND, NL_WIFI_ERR_TIMEOUT } nl_wifi_error_t;
|
||||||
@@ -33,6 +35,7 @@ typedef struct {
|
|||||||
bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */
|
bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */
|
||||||
bool pulse_on;
|
bool pulse_on;
|
||||||
bool pulse_off;
|
bool pulse_off;
|
||||||
|
bool clear_standalone; /* Onboarding gelungen: den Standalone-Modus dauerhaft loeschen */
|
||||||
} nl_wifi_actions_t;
|
} nl_wifi_actions_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -44,18 +47,26 @@ typedef struct {
|
|||||||
bool retry_armed;
|
bool retry_armed;
|
||||||
bool ap_stop_armed;
|
bool ap_stop_armed;
|
||||||
bool awaiting; /* waiting for response to a connect */
|
bool awaiting; /* waiting for response to a connect */
|
||||||
|
bool standalone; /* stays set across an onboarding test */
|
||||||
|
bool pulse_armed; /* start pulse still running */
|
||||||
uint32_t since; /* start of the current connecting phase */
|
uint32_t since; /* start of the current connecting phase */
|
||||||
uint32_t retry_at;
|
uint32_t retry_at;
|
||||||
uint32_t backoff;
|
uint32_t backoff;
|
||||||
uint32_t deadline; /* onboarding test */
|
uint32_t deadline; /* onboarding test */
|
||||||
uint32_t ap_stop_at;
|
uint32_t ap_stop_at;
|
||||||
uint32_t connect_at; /* when the pending connect was issued */
|
uint32_t connect_at; /* when the pending connect was issued */
|
||||||
|
uint32_t pulse_stop_at;
|
||||||
} nl_wifi_sm_t;
|
} nl_wifi_sm_t;
|
||||||
|
|
||||||
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_start(nl_wifi_sm_t *sm, bool have_creds, bool standalone, bool pulse, uint32_t now);
|
||||||
nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi_error_t err, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_event(nl_wifi_sm_t *sm, nl_wifi_event_t ev, nl_wifi_error_t err, uint32_t now);
|
||||||
nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_tick(nl_wifi_sm_t *sm, uint32_t now);
|
||||||
nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now);
|
nl_wifi_actions_t nl_wifi_sm_onboard(nl_wifi_sm_t *sm, uint32_t now);
|
||||||
|
/* Schaltet zur Laufzeit in den Standalone-Modus (aus der API). */
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_standalone(nl_wifi_sm_t *sm, bool pulse, uint32_t now);
|
||||||
|
/* Nimmt ihn zurueck: mit gespeicherten Zugangsdaten ins WLAN, sonst in den
|
||||||
|
offenen Onboarding-AP. */
|
||||||
|
nl_wifi_actions_t nl_wifi_sm_leave_standalone(nl_wifi_sm_t *sm, bool have_creds, uint32_t now);
|
||||||
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm);
|
bool nl_wifi_sm_open_access(const nl_wifi_sm_t *sm);
|
||||||
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm);
|
const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm);
|
||||||
const char *nl_wifi_error_name(nl_wifi_error_t e);
|
const char *nl_wifi_error_name(nl_wifi_error_t e);
|
||||||
|
|||||||
+309
-18
@@ -16,8 +16,12 @@
|
|||||||
|
|
||||||
#define NL_NET_PRIO 35
|
#define NL_NET_PRIO 35
|
||||||
#define NL_NET_QUEUE 8
|
#define NL_NET_QUEUE 8
|
||||||
#define MSG_NETIF 0x100u /* | netif status */
|
#define MSG_NETIF 0x100u /* | netif status */
|
||||||
#define MSG_ONBOARD 0x200u
|
#define MSG_ONBOARD 0x200u
|
||||||
|
/* 0x400, nicht 0x300: die netif-Meldungen werden weiter unten mit (m & MSG_NETIF) erkannt,
|
||||||
|
* und 0x300 traegt das 0x100-Bit mit - die Standalone-Meldung waere als netif-Ereignis
|
||||||
|
* protokolliert worden. */
|
||||||
|
#define MSG_STANDALONE 0x400u
|
||||||
|
|
||||||
static u32 s_stack[768];
|
static u32 s_stack[768];
|
||||||
static tls_os_queue_t *s_queue;
|
static tls_os_queue_t *s_queue;
|
||||||
@@ -29,6 +33,34 @@ static char s_hostname[24];
|
|||||||
static char s_ssid[NL_SSID_LEN], s_pass[NL_PASS_LEN];
|
static char s_ssid[NL_SSID_LEN], s_pass[NL_PASS_LEN];
|
||||||
static char s_test_ssid[NL_SSID_LEN], s_test_pass[NL_PASS_LEN];
|
static char s_test_ssid[NL_SSID_LEN], s_test_pass[NL_PASS_LEN];
|
||||||
static char s_ap_pass[NL_PASS_LEN];
|
static char s_ap_pass[NL_PASS_LEN];
|
||||||
|
static char s_ap_ssid[NL_SSID_LEN]; /* leer = Hostname */
|
||||||
|
static uint8_t s_ap_channel = 6;
|
||||||
|
/* Die Daten, mit denen der laufende AP tatsaechlich aufgebaut wurde. Nur so ist zu sehen,
|
||||||
|
* ob ein Wunsch aus der API den AP wirklich veraendert - die Zustandsmaschine liefert kein
|
||||||
|
* start_ap, wenn der AP schon steht, und die alten Daten blieben sonst still in Kraft. */
|
||||||
|
static char s_ap_live_ssid[NL_SSID_LEN], s_ap_live_pass[NL_PASS_LEN];
|
||||||
|
static uint8_t s_ap_live_channel;
|
||||||
|
static bool s_ap_live;
|
||||||
|
/* mDNS im eigenen AP haengt am ZUSTAND (Standalone + AP steht), nicht am Erzeugungsereignis:
|
||||||
|
* betritt die Lampe Standalone auf einem schon laufenden, unveraenderten AP, entsteht kein
|
||||||
|
* SOFTAP_SUCCESS mehr. Beides nur im Netz-Task angefasst. */
|
||||||
|
static bool s_ap_event_seen; /* zum aktuell stehenden AP kam SOFTAP_SUCCESS an */
|
||||||
|
static bool s_ap_mdns_done; /* fuer den aktuell stehenden AP steht der mDNS-Dienst wirklich */
|
||||||
|
/* I5: s_ap_mdns_done haengt am ERFOLG, nicht am Versuch. nl_mdns_on_ip_up() reicht die Arbeit
|
||||||
|
* nur an den tcpip-Thread weiter; findet announce() dort noch kein Netif mit Adresse, gibt es
|
||||||
|
* auf. Bis der Dienst steht, wird alle NL_AP_MDNS_RETRY_MS erneut angestossen. */
|
||||||
|
#define NL_AP_MDNS_RETRY_MS 5000u
|
||||||
|
static uint32_t s_ap_mdns_retry_at;
|
||||||
|
/* Drei Versuche, dann der Rueckfall auf Daten, die schon einmal getragen haben: einer koennte
|
||||||
|
* ein Zufall sein (das Funkteil raeumt gerade den alten AP ab), aber drei hintereinander
|
||||||
|
* heissen, dass das Funkteil genau diese Daten nicht annimmt. Und 3 x 5 s hoechstens
|
||||||
|
* 15 s ohne AP ist kuerzer als jede Fehlersuche vor Ort. */
|
||||||
|
#define NL_AP_CREATE_TRIES 3
|
||||||
|
static uint8_t s_ap_create_fails;
|
||||||
|
/* Vom HTTP-Task gefuellt, vom Netz-Task uebernommen; beides unter s_lock. */
|
||||||
|
static char s_req_ap_ssid[NL_SSID_LEN], s_req_ap_pass[NL_PASS_LEN];
|
||||||
|
static uint8_t s_req_ap_channel;
|
||||||
|
static bool s_req_enabled, s_req_pulse, s_req_pending;
|
||||||
static bool s_ap_create_pending; /* M3: tls_wifi_softap_create() failed, retry it */
|
static bool s_ap_create_pending; /* M3: tls_wifi_softap_create() failed, retry it */
|
||||||
static uint32_t s_ap_create_retry_at;
|
static uint32_t s_ap_create_retry_at;
|
||||||
|
|
||||||
@@ -88,18 +120,77 @@ void nl_wifi_disable_sdk_auto_connect(void)
|
|||||||
tls_wifi_disconnect();
|
tls_wifi_disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Setzt NUR Name und Kanal des naechsten AP auf die Vorgaben zurueck: "ninlight-<mac4>" auf
|
||||||
|
* Kanal 6. Das Passwort bleibt stehen - I1: ein Rueckfall darf das Passwort des Nutzers nicht
|
||||||
|
* durch das einkompilierte ersetzen, das in der README oeffentlich dokumentiert ist. Beruehrt
|
||||||
|
* einen LAUFENDEN AP nicht - der behaelt seine Daten, bis er abgebaut und neu aufgebaut wird. */
|
||||||
|
static void reset_ap_identity(void)
|
||||||
|
{
|
||||||
|
s_ap_ssid[0] = '\0';
|
||||||
|
s_ap_channel = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* I2: Vergisst, mit welchen Daten der zuletzt stehende AP aufgebaut wurde. Verlaesst die Lampe
|
||||||
|
* den Standalone-Modus, duerfen Name und Passwort des Nutzers nicht als "hat schon einmal
|
||||||
|
* getragen" liegenbleiben: der erste Rueckfallzweig in start_ap() holte sie sonst Minuten
|
||||||
|
* spaeter zurueck (etwa am Rueckfall-AP nach fuenf Minuten ohne Router), und die Lampe boete
|
||||||
|
* periodisch einen AP an, den der Nutzer laengst abgeschaltet hat. */
|
||||||
|
static void forget_ap_live(void)
|
||||||
|
{
|
||||||
|
s_ap_live_ssid[0] = s_ap_live_pass[0] = '\0';
|
||||||
|
s_ap_live_channel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vollstaendige Rueckstellung auf die Vorgaben, einschliesslich des einkompilierten Passworts.
|
||||||
|
* Nur fuer den Weg AUS dem Standalone-Modus heraus, wo der Wunsch des Nutzers endet. */
|
||||||
|
static void reset_ap_defaults(void)
|
||||||
|
{
|
||||||
|
reset_ap_identity();
|
||||||
|
copy(s_ap_pass, NINLIGHT_AP_PASSWORD, sizeof s_ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reisst den AP ab und raeumt dabei auch die Buchfuehrung auf. Laeuft nur im Netz-Task
|
||||||
|
* (oder vor dessen Start) und nie unter s_lock - clear_softap_param() fasst SDK-Zustand an. */
|
||||||
|
static void destroy_ap(void)
|
||||||
|
{
|
||||||
|
tls_wifi_softap_destroy();
|
||||||
|
s_ap_create_pending = false;
|
||||||
|
s_ap_live = false;
|
||||||
|
s_ap_event_seen = s_ap_mdns_done = false;
|
||||||
|
/* I3: Der Zaehler gehoert zu EINEM Datensatz. Ohne diese Nullung addieren sich die
|
||||||
|
* Fehlschlaege mit den alten Daten zu denen mit den neuen, und nach einer Korrektur
|
||||||
|
* (Kanal 13 -> Kanal 6) loeste schon ein einziger Fehlschlag den Rueckfall aus. */
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */
|
||||||
|
}
|
||||||
|
|
||||||
static void start_ap(void)
|
static void start_ap(void)
|
||||||
{
|
{
|
||||||
struct tls_softap_info_t ap;
|
struct tls_softap_info_t ap;
|
||||||
struct tls_ip_info_t ip;
|
struct tls_ip_info_t ip;
|
||||||
|
const char *ssid = s_ap_ssid[0] ? s_ap_ssid : s_hostname;
|
||||||
size_t n = strlen(s_ap_pass);
|
size_t n = strlen(s_ap_pass);
|
||||||
int r;
|
int r;
|
||||||
|
|
||||||
|
/* Steht derselbe AP schon, bleibt er stehen: die Zustandsmaschine verlangt start_ap
|
||||||
|
* auch dann, wenn sie den AP gar nicht erst abgebaut hat (fehlgeschlagener
|
||||||
|
* Onboarding-Versuch aus dem Standalone-Modus), und ein Neuaufbau wuerfe dort alle
|
||||||
|
* Clients ohne Not heraus. */
|
||||||
|
if (s_ap_live && strcmp(s_ap_live_ssid, ssid) == 0 && strcmp(s_ap_live_pass, s_ap_pass) == 0 &&
|
||||||
|
s_ap_live_channel == s_ap_channel)
|
||||||
|
return;
|
||||||
|
/* Umgekehrt: Name, Passwort oder Kanal haben sich geaendert, waehrend der AP steht.
|
||||||
|
* Ohne eigenen Abriss liefe er mit den alten Daten weiter. */
|
||||||
|
if (s_ap_live) {
|
||||||
|
printf("wifi: AP data changed, rebuilding\r\n");
|
||||||
|
destroy_ap();
|
||||||
|
}
|
||||||
|
|
||||||
memset(&ap, 0, sizeof ap);
|
memset(&ap, 0, sizeof ap);
|
||||||
memset(&ip, 0, sizeof ip);
|
memset(&ip, 0, sizeof ip);
|
||||||
copy((char *)ap.ssid, s_hostname, sizeof ap.ssid);
|
copy((char *)ap.ssid, ssid, sizeof ap.ssid);
|
||||||
ap.encrypt = IEEE80211_ENCRYT_CCMP_WPA2;
|
ap.encrypt = IEEE80211_ENCRYT_CCMP_WPA2;
|
||||||
ap.channel = 6;
|
ap.channel = s_ap_channel;
|
||||||
ap.keyinfo.format = 1; /* ascii */
|
ap.keyinfo.format = 1; /* ascii */
|
||||||
ap.keyinfo.index = 1;
|
ap.keyinfo.index = 1;
|
||||||
ap.keyinfo.key_len = (u8)n;
|
ap.keyinfo.key_len = (u8)n;
|
||||||
@@ -108,10 +199,45 @@ static void start_ap(void)
|
|||||||
ip.netmask[0] = 255; ip.netmask[1] = 255; ip.netmask[2] = 255; ip.netmask[3] = 0;
|
ip.netmask[0] = 255; ip.netmask[1] = 255; ip.netmask[2] = 255; ip.netmask[3] = 0;
|
||||||
memcpy(ip.dnsname, "local", 6);
|
memcpy(ip.dnsname, "local", 6);
|
||||||
r = tls_wifi_softap_create(&ap, &ip);
|
r = tls_wifi_softap_create(&ap, &ip);
|
||||||
printf("wifi: AP %s %s\r\n", s_hostname, r == WM_SUCCESS ? "up" : "failed");
|
printf("wifi: AP %s (ch %u) %s\r\n", ssid, (unsigned)s_ap_channel,
|
||||||
|
r == WM_SUCCESS ? "up" : "failed");
|
||||||
/* M3: retry a failed AP create instead of leaving onboarding/fallback without an AP. */
|
/* M3: retry a failed AP create instead of leaving onboarding/fallback without an AP. */
|
||||||
s_ap_create_pending = r != WM_SUCCESS;
|
s_ap_create_pending = r != WM_SUCCESS;
|
||||||
s_ap_create_retry_at = nl_app_now_ms() + 5000u;
|
s_ap_create_retry_at = nl_app_now_ms() + 5000u;
|
||||||
|
s_ap_live = r == WM_SUCCESS;
|
||||||
|
if (s_ap_live) {
|
||||||
|
/* Neuer AP: erst das Ereignis abwarten, dann bekanntmachen. */
|
||||||
|
s_ap_event_seen = s_ap_mdns_done = false;
|
||||||
|
copy(s_ap_live_ssid, ssid, sizeof s_ap_live_ssid);
|
||||||
|
copy(s_ap_live_pass, s_ap_pass, sizeof s_ap_live_pass);
|
||||||
|
s_ap_live_channel = s_ap_channel;
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Nimmt das Funkteil diese Daten dauerhaft nicht an, steht die Lampe ohne AP da - und mit
|
||||||
|
* in den Einstellungen gespeicherten Wunschdaten auch nach einem Neustart wieder. Dann
|
||||||
|
* lieber ein AP unter unerwartetem Namen: der ist sichtbar und reparierbar. */
|
||||||
|
if (++s_ap_create_fails < NL_AP_CREATE_TRIES)
|
||||||
|
return;
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
if (s_ap_live_ssid[0] && (strcmp(s_ap_live_ssid, ssid) != 0 ||
|
||||||
|
strcmp(s_ap_live_pass, s_ap_pass) != 0 ||
|
||||||
|
s_ap_live_channel != s_ap_channel)) {
|
||||||
|
printf("wifi: AP create keeps failing, falling back to the last working data (%s, ch %u)\r\n",
|
||||||
|
s_ap_live_ssid, (unsigned)s_ap_live_channel);
|
||||||
|
copy(s_ap_ssid, s_ap_live_ssid, sizeof s_ap_ssid);
|
||||||
|
copy(s_ap_pass, s_ap_live_pass, sizeof s_ap_pass);
|
||||||
|
s_ap_channel = s_ap_live_channel;
|
||||||
|
} else if (s_ap_ssid[0] || s_ap_channel != 6) {
|
||||||
|
/* I1: nur Name und Kanal. Das Passwort ist der unwahrscheinlichste Grund fuer ein
|
||||||
|
* abgelehntes tls_wifi_softap_create() - die Laenge prueft die API selbst, Kanal und
|
||||||
|
* SSID sind die plausiblen Kandidaten. Ein Rueckfall auf NINLIGHT_AP_PASSWORD stellte
|
||||||
|
* eine Standalone-Lampe mit einem oeffentlich dokumentierten Passwort ins Funkfeld,
|
||||||
|
* und sACN hoert dort unauthentifiziert mit. */
|
||||||
|
printf("wifi: AP create keeps failing, falling back to %s on ch 6\r\n", s_hostname);
|
||||||
|
reset_ap_identity();
|
||||||
|
}
|
||||||
|
/* Sonst scheitern schon die Vorgaben - dann bleibt nur, es weiter zu versuchen. */
|
||||||
}
|
}
|
||||||
|
|
||||||
static void connect_station(bool test)
|
static void connect_station(bool test)
|
||||||
@@ -149,6 +275,24 @@ static void execute(nl_wifi_actions_t a)
|
|||||||
printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid);
|
printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid);
|
||||||
nl_app_set_wifi_credentials(ssid, pass);
|
nl_app_set_wifi_credentials(ssid, pass);
|
||||||
}
|
}
|
||||||
|
if (a.clear_standalone) {
|
||||||
|
/* Sonst stuende die Lampe nach dem naechsten Neustart wieder in ihrem eigenen AP,
|
||||||
|
* obwohl sie gerade erfolgreich ins WLAN gewechselt ist. */
|
||||||
|
printf("wifi: standalone mode cleared\r\n");
|
||||||
|
/* Dieselbe Rueckstellung wie beim Zurueckschalten ueber die API: sonst kaeme ein
|
||||||
|
* spaeterer Rueckfall-AP (5 min ohne Station) mit Namen und Passwort des Nutzers
|
||||||
|
* hoch, obwohl standalone in den Einstellungen schon 0 ist. forget_ap_live() raeumt
|
||||||
|
* dazu die Buchfuehrung ueber den zuletzt tragenden AP weg (I2) - sonst holte ihn
|
||||||
|
* der erste Rueckfallzweig in start_ap() nach drei abgelehnten Aufbauten zurueck.
|
||||||
|
* Der noch laufende Standalone-AP bleibt stehen, bis die Zustandsmaschine ihn
|
||||||
|
* abbaut: dieses Aktionsbuendel enthaelt kein start_ap. */
|
||||||
|
reset_ap_defaults();
|
||||||
|
forget_ap_live();
|
||||||
|
/* Schlaegt das Speichern fehl, steht die Lampe jetzt im WLAN, nach dem naechsten
|
||||||
|
* Neustart aber wieder in ihrem eigenen AP - ohne jede Spur. Wenigstens melden. */
|
||||||
|
if (nl_app_clear_standalone() != 0)
|
||||||
|
printf("wifi: could not persist the cleared standalone flag\r\n");
|
||||||
|
}
|
||||||
if (a.pulse_on)
|
if (a.pulse_on)
|
||||||
pulse(true);
|
pulse(true);
|
||||||
if (a.pulse_off)
|
if (a.pulse_off)
|
||||||
@@ -157,9 +301,7 @@ static void execute(nl_wifi_actions_t a)
|
|||||||
start_ap();
|
start_ap();
|
||||||
if (a.stop_ap) {
|
if (a.stop_ap) {
|
||||||
printf("wifi: AP down\r\n");
|
printf("wifi: AP down\r\n");
|
||||||
tls_wifi_softap_destroy();
|
destroy_ap();
|
||||||
s_ap_create_pending = false;
|
|
||||||
clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */
|
|
||||||
}
|
}
|
||||||
if (a.disconnect) {
|
if (a.disconnect) {
|
||||||
printf("wifi: dropping stray/failed station attempt\r\n");
|
printf("wifi: dropping stray/failed station attempt\r\n");
|
||||||
@@ -185,7 +327,9 @@ static void net_task(void *arg)
|
|||||||
void *msg = NULL;
|
void *msg = NULL;
|
||||||
nl_wifi_actions_t ev, tick;
|
nl_wifi_actions_t ev, tick;
|
||||||
uint32_t m = 0, now;
|
uint32_t m = 0, now;
|
||||||
bool got_ip_event = false, connected_now, ap_wanted, unexpected_softap;
|
bool got_ip_event = false, connected_now, ap_wanted, unexpected_softap,
|
||||||
|
standalone_now, open_now;
|
||||||
|
bool recheck_ap = false, rebuild_ap = false, ignored_off = false;
|
||||||
|
|
||||||
memset(&ev, 0, sizeof ev);
|
memset(&ev, 0, sizeof ev);
|
||||||
if (tls_os_queue_receive(s_queue, &msg, 0, HZ / 10) == TLS_OS_SUCCESS)
|
if (tls_os_queue_receive(s_queue, &msg, 0, HZ / 10) == TLS_OS_SUCCESS)
|
||||||
@@ -202,6 +346,38 @@ static void net_task(void *arg)
|
|||||||
ev = nl_wifi_sm_event(&s_sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, now);
|
ev = nl_wifi_sm_event(&s_sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, now);
|
||||||
} else if (m == MSG_ONBOARD) {
|
} else if (m == MSG_ONBOARD) {
|
||||||
ev = nl_wifi_sm_onboard(&s_sm, now);
|
ev = nl_wifi_sm_onboard(&s_sm, now);
|
||||||
|
} else if (m == MSG_STANDALONE) {
|
||||||
|
/* Die Wunschwerte erst hier uebernehmen: der HTTP-Task hat sie abgelegt, der
|
||||||
|
* Netz-Task ist der einzige, der s_ap_* schreibt und start_ap() aufruft. */
|
||||||
|
if (s_req_enabled) {
|
||||||
|
copy(s_ap_ssid, s_req_ap_ssid, sizeof s_ap_ssid);
|
||||||
|
copy(s_ap_pass, s_req_ap_pass[0] ? s_req_ap_pass : NINLIGHT_AP_PASSWORD,
|
||||||
|
sizeof s_ap_pass);
|
||||||
|
s_ap_channel = s_req_ap_channel ? s_req_ap_channel : 6;
|
||||||
|
ev = nl_wifi_sm_standalone(&s_sm, s_req_pulse, now);
|
||||||
|
/* Steht der AP schon, liefert die Zustandsmaschine kein start_ap. Ob die
|
||||||
|
* neuen Daten einen Neuaufbau verlangen, entscheidet start_ap() selbst. */
|
||||||
|
recheck_ap = true;
|
||||||
|
} else if (!s_sm.standalone) {
|
||||||
|
/* War die Lampe gar nicht standalone, gibt es nichts abzuschalten. Ohne diese
|
||||||
|
* Vorpruefung riefe leave_standalone() auf einer gewoehnlichen Stationslampe
|
||||||
|
* begin_connecting() und erzwaenge eine Neuverbindung ohne Anlass. Die Antwort
|
||||||
|
* bleibt 202: der gewuenschte Zustand gilt ja bereits. (standalone bleibt auch
|
||||||
|
* waehrend eines Onboarding-Tests gesetzt, der Test faellt also nicht hierher.) */
|
||||||
|
ignored_off = true;
|
||||||
|
} else {
|
||||||
|
/* Zurueck auf die Vorgaben: ein spaeterer Rueckfall- oder Onboarding-AP ist
|
||||||
|
* wieder der gewohnte "ninlight-<mac4>" mit dem einkompilierten Passwort,
|
||||||
|
* genau wie bei einer Lampe, die nie standalone war. */
|
||||||
|
reset_ap_defaults();
|
||||||
|
forget_ap_live();
|
||||||
|
ev = nl_wifi_sm_leave_standalone(&s_sm, s_ssid[0] != '\0', now);
|
||||||
|
rebuild_ap = true;
|
||||||
|
}
|
||||||
|
/* I3: Ein neuer Wunsch ist ein neuer Datensatz - die Fehlschlaege des alten
|
||||||
|
* duerfen nicht mitzaehlen. */
|
||||||
|
s_ap_create_fails = 0;
|
||||||
|
s_req_pending = false;
|
||||||
}
|
}
|
||||||
tick = nl_wifi_sm_tick(&s_sm, now);
|
tick = nl_wifi_sm_tick(&s_sm, now);
|
||||||
/* I1: a late/stray IP-up must not announce mDNS unless the SM actually settled on STA_CONNECTED. */
|
/* I1: a late/stray IP-up must not announce mDNS unless the SM actually settled on STA_CONNECTED. */
|
||||||
@@ -213,20 +389,77 @@ static void net_task(void *arg)
|
|||||||
* clear_softap_param() touch the SDK's own state and must not run while s_lock
|
* clear_softap_param() touch the SDK's own state and must not run while s_lock
|
||||||
* (which only guards s_sm and the credential buffers) is held. */
|
* (which only guards s_sm and the credential buffers) is held. */
|
||||||
unexpected_softap = m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS) && !ap_wanted;
|
unexpected_softap = m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS) && !ap_wanted;
|
||||||
|
/* Nur die Lage merken; der Aufruf nach aussen folgt hinter der Entsperrung. */
|
||||||
|
standalone_now = s_sm.state == NL_WIFI_ST_STANDALONE;
|
||||||
|
open_now = nl_wifi_sm_open_access(&s_sm);
|
||||||
unlock();
|
unlock();
|
||||||
|
|
||||||
if (unexpected_softap) {
|
if (unexpected_softap) {
|
||||||
printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n");
|
printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n");
|
||||||
tls_wifi_softap_destroy();
|
destroy_ap();
|
||||||
clear_softap_param();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m & MSG_NETIF)
|
if (m & MSG_NETIF)
|
||||||
printf("wifi: netif event %u\r\n", (unsigned)(m & 0xFFu));
|
printf("wifi: netif event %u\r\n", (unsigned)(m & 0xFFu));
|
||||||
|
/* I6: Die 202 auf den Standalone-Wunsch schreibt der HTTP-Task erst, nachdem
|
||||||
|
* nl_api_handle() zurueck ist - die Warteschlangennachricht geht aber schon darin
|
||||||
|
* raus, und dieser Task hat die hoehere Prioritaet (35 gegen 42, kleinere Zahl heisst
|
||||||
|
* hoeher). Er verdraengt also sofort und risse das Netz ab, bevor send_all() lief.
|
||||||
|
* Beim Wechsel AUS dem WLAN ging das bisher gut, weil tls_wifi_disconnect() asynchron
|
||||||
|
* arbeitet; ueber den eigenen AP ist tls_wifi_softap_destroy() der haertere Abriss.
|
||||||
|
* Die Pause ist billige Versicherung - gemessen ist der AP-Fall damit nicht, er steht
|
||||||
|
* als offener Punkt in der Abnahmeliste. */
|
||||||
|
if (m == MSG_STANDALONE)
|
||||||
|
tls_os_time_delay(HZ / 4); /* ~250 ms */
|
||||||
|
if (ignored_off)
|
||||||
|
printf("wifi: standalone was already off, nothing to do\r\n");
|
||||||
|
/* C1: Beim Verlassen des Standalone-Modus OHNE gespeicherte WLAN-Daten geht die
|
||||||
|
* Zustandsmaschine in den Onboarding-AP - dort liefert open_access() true, waehrend
|
||||||
|
* der AP noch unveraendert mit Name und Passwort des Nutzers steht. Wer das
|
||||||
|
* AP-Passwort kennt, koennte dann ohne Token system/settings und system/command
|
||||||
|
* benutzen. Also hier hart abreissen und mit den Vorgabedaten neu aufbauen; damit
|
||||||
|
* stimmt auch die README-Zusage wieder, der AP heisse danach "ninlight-<mac4>".
|
||||||
|
* Nicht ueber die Aktionsstruktur: execute() fuehrt start_ap VOR stop_ap aus, ein
|
||||||
|
* Buendel mit beiden Flags baute erst auf und risse dann ab. Und VOR execute(ev),
|
||||||
|
* damit dessen start_ap in die Gleichheitspruefung von start_ap() faellt, statt den
|
||||||
|
* eben gebauten AP ein zweites Mal abzureissen. */
|
||||||
|
if (rebuild_ap && ap_wanted && open_now) {
|
||||||
|
printf("wifi: left standalone without credentials, rebuilding the AP with the defaults\r\n");
|
||||||
|
destroy_ap();
|
||||||
|
start_ap();
|
||||||
|
}
|
||||||
execute(ev);
|
execute(ev);
|
||||||
|
/* Nach einem Wechsel in den Standalone-Modus: start_ap() baut den AP neu auf, wenn
|
||||||
|
* Name, Passwort oder Kanal sich geaendert haben, und tut sonst nichts. */
|
||||||
|
if (recheck_ap && ap_wanted)
|
||||||
|
start_ap();
|
||||||
execute(tick);
|
execute(tick);
|
||||||
if (got_ip_event && connected_now)
|
if (got_ip_event && connected_now)
|
||||||
nl_mdns_on_ip_up();
|
nl_mdns_on_ip_up();
|
||||||
|
/* Im eigenen AP gibt es kein Stationsereignis, an dem mDNS haengen koennte.
|
||||||
|
* Ohne diesen Aufruf findet die App ein Standalone-Geraet nicht - am
|
||||||
|
* 2026-10-01 am EVK gemessen: REST auf der AP-IP lief, ninlight-681f.local
|
||||||
|
* war weder aufloesbar noch sichtbar. */
|
||||||
|
if (m == (MSG_NETIF | NETIF_WIFI_SOFTAP_SUCCESS))
|
||||||
|
s_ap_event_seen = true;
|
||||||
|
/* Zustandsgebunden: auch ein Wechsel auf einen unveraendert stehenden AP loest es aus.
|
||||||
|
* Mehrfaches Rufen waere harmlos, s_ap_mdns_done spart es trotzdem. Faellt der Zustand
|
||||||
|
* weg, gilt die Bekanntmachung beim naechsten Eintritt neu. */
|
||||||
|
if (!standalone_now) {
|
||||||
|
s_ap_mdns_done = false;
|
||||||
|
s_ap_mdns_retry_at = now; /* beim Eintritt sofort anstossen */
|
||||||
|
} else if (s_ap_live && s_ap_event_seen && !s_ap_mdns_done) {
|
||||||
|
/* I5: Erst der Erfolg beendet die Sache. Wer den blossen Versuch als erledigt
|
||||||
|
* verbucht, laesst die Lampe fuer die ganze Standzeit des AP unauffindbar,
|
||||||
|
* sobald announce() einmal "no usable netif yet" gemeldet hat. Deshalb immer
|
||||||
|
* erst anstossen und dann den gemeldeten Zustand abfragen - nl_mdns_service_up()
|
||||||
|
* ist erst wahr, wenn die Bekanntmachung im tcpip-Thread auch gelaufen ist. */
|
||||||
|
if (due_now(now, s_ap_mdns_retry_at)) {
|
||||||
|
s_ap_mdns_retry_at = now + NL_AP_MDNS_RETRY_MS;
|
||||||
|
nl_mdns_on_ip_up();
|
||||||
|
} else
|
||||||
|
s_ap_mdns_done = nl_mdns_service_up();
|
||||||
|
}
|
||||||
|
|
||||||
/* M3: retry a softap create that failed, as long as the SM still wants the AP up. */
|
/* M3: retry a softap create that failed, as long as the SM still wants the AP up. */
|
||||||
if (s_ap_create_pending && ap_wanted && due_now(now, s_ap_create_retry_at)) {
|
if (s_ap_create_pending && ap_wanted && due_now(now, s_ap_create_retry_at)) {
|
||||||
@@ -237,7 +470,8 @@ static void net_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass,
|
||||||
|
const char *ap_ssid, uint8_t ap_channel, bool standalone, bool pulse)
|
||||||
{
|
{
|
||||||
nl_wifi_actions_t a;
|
nl_wifi_actions_t a;
|
||||||
struct netif *nif;
|
struct netif *nif;
|
||||||
@@ -248,7 +482,11 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
|||||||
nl_names_hostname(s_mac, s_hostname, sizeof s_hostname);
|
nl_names_hostname(s_mac, s_hostname, sizeof s_hostname);
|
||||||
copy(s_ssid, ssid, sizeof s_ssid);
|
copy(s_ssid, ssid, sizeof s_ssid);
|
||||||
copy(s_pass, pass, sizeof s_pass);
|
copy(s_pass, pass, sizeof s_pass);
|
||||||
copy(s_ap_pass, ap_pass, sizeof s_ap_pass);
|
/* Wie in nl_wifi_request_standalone(): NULL ist hier zwar von keinem Aufrufer zu
|
||||||
|
* erwarten, aber copy() wuerde darueber stolpern. */
|
||||||
|
copy(s_ap_pass, ap_pass && ap_pass[0] ? ap_pass : NINLIGHT_AP_PASSWORD, sizeof s_ap_pass);
|
||||||
|
copy(s_ap_ssid, ap_ssid ? ap_ssid : "", sizeof s_ap_ssid); /* leer = Hostname */
|
||||||
|
s_ap_channel = ap_channel ? ap_channel : 6;
|
||||||
|
|
||||||
/* The SDK would otherwise reconnect on its own with credentials from its parameter area. */
|
/* The SDK would otherwise reconnect on its own with credentials from its parameter area. */
|
||||||
nl_wifi_disable_sdk_auto_connect();
|
nl_wifi_disable_sdk_auto_connect();
|
||||||
@@ -256,8 +494,7 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
|||||||
/* C2: the SDK already brought up its own softap (e.g. from NETIF_IP_NET_UP before
|
/* C2: the SDK already brought up its own softap (e.g. from NETIF_IP_NET_UP before
|
||||||
* we got a chance to run) - tear it down, our state machine owns AP lifecycle. */
|
* we got a chance to run) - tear it down, our state machine owns AP lifecycle. */
|
||||||
printf("wifi: destroying SDK-created softap left over from boot\r\n");
|
printf("wifi: destroying SDK-created softap left over from boot\r\n");
|
||||||
tls_wifi_softap_destroy();
|
destroy_ap();
|
||||||
clear_softap_param();
|
|
||||||
}
|
}
|
||||||
nif = tls_get_netif();
|
nif = tls_get_netif();
|
||||||
if (nif)
|
if (nif)
|
||||||
@@ -268,10 +505,11 @@ void nl_wifi_start(const char *ssid, const char *pass, const char *ap_pass)
|
|||||||
tls_netif_add_status_event(netif_event);
|
tls_netif_add_status_event(netif_event);
|
||||||
|
|
||||||
lock();
|
lock();
|
||||||
a = nl_wifi_sm_start(&s_sm, s_ssid[0] != '\0', nl_app_now_ms());
|
a = nl_wifi_sm_start(&s_sm, s_ssid[0] != '\0', standalone, pulse, nl_app_now_ms());
|
||||||
unlock();
|
unlock();
|
||||||
s_started = true;
|
s_started = true;
|
||||||
printf("wifi: %s, hostname %s\r\n", s_ssid[0] ? "station" : "onboarding AP", s_hostname);
|
printf("wifi: %s, hostname %s\r\n",
|
||||||
|
standalone ? "standalone AP" : (s_ssid[0] ? "station" : "onboarding AP"), s_hostname);
|
||||||
execute(a);
|
execute(a);
|
||||||
tls_os_task_create(NULL, "nl_net", net_task, NULL, (u8 *)s_stack, sizeof(s_stack), NL_NET_PRIO, 0);
|
tls_os_task_create(NULL, "nl_net", net_task, NULL, (u8 *)s_stack, sizeof(s_stack), NL_NET_PRIO, 0);
|
||||||
}
|
}
|
||||||
@@ -294,6 +532,50 @@ int nl_wifi_request_onboard(const char *ssid, const char *pass)
|
|||||||
return tls_os_queue_send(s_queue, (void *)MSG_ONBOARD, 0) == TLS_OS_SUCCESS ? 0 : -1;
|
return tls_os_queue_send(s_queue, (void *)MSG_ONBOARD, 0) == TLS_OS_SUCCESS ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int nl_wifi_request_standalone(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse)
|
||||||
|
{
|
||||||
|
bool busy;
|
||||||
|
|
||||||
|
if (!s_started)
|
||||||
|
return -1;
|
||||||
|
lock();
|
||||||
|
/* Wie beim Onboarding: nicht auf einen laufenden Test draufsatteln, und keinen zweiten
|
||||||
|
* Wunsch annehmen, solange der erste noch in der Warteschlange steht - er ueberschriebe
|
||||||
|
* dessen Werte, die Antwort haette aber schon 202 gesagt. */
|
||||||
|
busy = s_sm.state == NL_WIFI_ST_ONBOARD_TEST || s_req_pending;
|
||||||
|
if (!busy) {
|
||||||
|
copy(s_req_ap_ssid, ssid ? ssid : "", sizeof s_req_ap_ssid);
|
||||||
|
copy(s_req_ap_pass, pass ? pass : "", sizeof s_req_ap_pass);
|
||||||
|
s_req_ap_channel = channel;
|
||||||
|
s_req_enabled = enabled;
|
||||||
|
s_req_pulse = pulse;
|
||||||
|
s_req_pending = true;
|
||||||
|
}
|
||||||
|
unlock();
|
||||||
|
if (busy)
|
||||||
|
return -1;
|
||||||
|
if (tls_os_queue_send(s_queue, (void *)MSG_STANDALONE, 0) != TLS_OS_SUCCESS) {
|
||||||
|
lock();
|
||||||
|
s_req_pending = false;
|
||||||
|
unlock();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nl_wifi_is_standalone(void)
|
||||||
|
{
|
||||||
|
bool r;
|
||||||
|
|
||||||
|
if (!s_started)
|
||||||
|
return false;
|
||||||
|
lock();
|
||||||
|
r = s_sm.state == NL_WIFI_ST_STANDALONE;
|
||||||
|
unlock();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
bool nl_wifi_open_access(void)
|
bool nl_wifi_open_access(void)
|
||||||
{
|
{
|
||||||
bool r;
|
bool r;
|
||||||
@@ -316,7 +598,16 @@ void nl_wifi_status(nl_wifi_status_t *out)
|
|||||||
lock();
|
lock();
|
||||||
out->mode = nl_wifi_sm_mode(&s_sm);
|
out->mode = nl_wifi_sm_mode(&s_sm);
|
||||||
out->error = nl_wifi_error_name(s_sm.error);
|
out->error = nl_wifi_error_name(s_sm.error);
|
||||||
copy(out->ssid, s_ssid, sizeof out->ssid);
|
/* I4: Im eigenen AP (Standalone oder Onboarding) ist die Stations-SSID nicht der Name
|
||||||
|
* des Netzes, auf dem die Lampe antwortet - und ein Heimnetzname hat in einer
|
||||||
|
* tokenfrei lesbaren Antwort ohnehin nichts verloren. Gemeldet wird der WIRKSAME
|
||||||
|
* AP-Name; damit wird auch ein Rueckfall auf andere AP-Daten ueber die API sichtbar,
|
||||||
|
* ohne ein neues Feld. "apsta" bleibt bei der Station: dort ist sie die Hauptsache. */
|
||||||
|
if (strcmp(nl_wifi_sm_mode(&s_sm), "ap") == 0)
|
||||||
|
copy(out->ssid, s_ap_live_ssid[0] ? s_ap_live_ssid : (s_ap_ssid[0] ? s_ap_ssid : s_hostname),
|
||||||
|
sizeof out->ssid);
|
||||||
|
else
|
||||||
|
copy(out->ssid, s_ssid, sizeof out->ssid);
|
||||||
ap_up = s_sm.ap_up || s_sm.state == NL_WIFI_ST_AP_ONBOARDING;
|
ap_up = s_sm.ap_up || s_sm.state == NL_WIFI_ST_AP_ONBOARDING;
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ void nl_app_request_factory_reset(void);
|
|||||||
/* Stores new station credentials (from a successful onboarding test) and persists them. */
|
/* Stores new station credentials (from a successful onboarding test) and persists them. */
|
||||||
int nl_app_set_wifi_credentials(const char *ssid, const char *pass);
|
int nl_app_set_wifi_credentials(const char *ssid, const char *pass);
|
||||||
|
|
||||||
|
/* Loescht den Standalone-Modus dauerhaft (gelungenes Onboarding aus dem eigenen AP heraus).
|
||||||
|
* Name, Passwort und Kanal des AP bleiben stehen - sie zaehlen wieder, sobald der Modus
|
||||||
|
* erneut eingeschaltet wird. */
|
||||||
|
int nl_app_clear_standalone(void);
|
||||||
|
|
||||||
/* Clears the power-cycle counter ahead of the regular 5 s deadline (spec 5.5), idempotent.
|
/* Clears the power-cycle counter ahead of the regular 5 s deadline (spec 5.5), idempotent.
|
||||||
* Used by the boot recovery path once it knows this boot is a recovery boot: a successful
|
* Used by the boot recovery path once it knows this boot is a recovery boot: a successful
|
||||||
* recovery never returns to the app task, so without this the counter would keep growing
|
* recovery never returns to the app task, so without this the counter would keep growing
|
||||||
|
|||||||
+33
-1
@@ -5,9 +5,11 @@
|
|||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_mdns.h"
|
#include "net/nl_mdns.h"
|
||||||
#include "net/nl_names.h"
|
#include "net/nl_names.h"
|
||||||
|
#include "net/nl_sacn.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
#include "sys/nl_app_api.h"
|
#include "sys/nl_app_api.h"
|
||||||
@@ -29,6 +31,9 @@ static nl_ctrl_err_t apply(const nl_light_change_t *c)
|
|||||||
{
|
{
|
||||||
nl_ctrl_err_t e;
|
nl_ctrl_err_t e;
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
|
/* Ein Befehl aus HA oder per curl holt die Kontrolle zurueck - auch im Modus "hold",
|
||||||
|
* in dem der Override sonst stehen bliebe. */
|
||||||
|
nl_dmx_release();
|
||||||
e = nl_ctrl_apply(c, nl_app_now_ms());
|
e = nl_ctrl_apply(c, nl_app_now_ms());
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
return e;
|
return e;
|
||||||
@@ -46,8 +51,10 @@ static void get_settings(nl_settings_t *out)
|
|||||||
static int save_settings(const nl_settings_t *s)
|
static int save_settings(const nl_settings_t *s)
|
||||||
{
|
{
|
||||||
nl_settings_t *cur;
|
nl_settings_t *cur;
|
||||||
|
nl_settings_t applied; /* copy taken under the lock: nl_sacn_apply_settings() takes it itself */
|
||||||
char name[NL_NAME_LEN];
|
char name[NL_NAME_LEN];
|
||||||
bool name_changed;
|
bool name_changed;
|
||||||
|
int r;
|
||||||
|
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
cur = nl_app_settings();
|
cur = nl_app_settings();
|
||||||
@@ -60,15 +67,27 @@ static int save_settings(const nl_settings_t *s)
|
|||||||
}
|
}
|
||||||
nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0);
|
nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0);
|
||||||
memcpy(name, cur->name, sizeof name);
|
memcpy(name, cur->name, sizeof name);
|
||||||
|
applied = *cur;
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
if (name_changed)
|
if (name_changed)
|
||||||
nl_mdns_set_name(name);
|
nl_mdns_set_name(name);
|
||||||
return nl_app_save_settings();
|
r = nl_app_save_settings();
|
||||||
|
if (r == 0)
|
||||||
|
nl_sacn_apply_settings(&applied); /* neues Universe/Startslot/hold sofort wirksam */
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); }
|
static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); }
|
||||||
static bool open_access(void) { return nl_wifi_open_access(); }
|
static bool open_access(void) { return nl_wifi_open_access(); }
|
||||||
|
|
||||||
|
/* Der Treiber nimmt den Wunsch nur entgegen; wirksam wird er im Netz-Task, nachdem die
|
||||||
|
* Antwort raus ist. 0 = uebernommen, sonst 503. */
|
||||||
|
static int standalone_configure(bool enabled, const char *ssid, const char *pass, uint8_t channel,
|
||||||
|
bool pulse)
|
||||||
|
{
|
||||||
|
return nl_wifi_request_standalone(enabled, ssid, pass, channel, pulse);
|
||||||
|
}
|
||||||
|
|
||||||
static void get_info(nl_sys_info_t *out)
|
static void get_info(nl_sys_info_t *out)
|
||||||
{
|
{
|
||||||
nl_wifi_status_t w;
|
nl_wifi_status_t w;
|
||||||
@@ -79,7 +98,14 @@ static void get_info(nl_sys_info_t *out)
|
|||||||
nl_names_mac_str(mac, out->mac);
|
nl_names_mac_str(mac, out->mac);
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
memcpy(out->name, nl_app_settings()->name, sizeof out->name);
|
memcpy(out->name, nl_app_settings()->name, sizeof out->name);
|
||||||
|
/* Gleicher Sperrabschnitt wie der Name: nl_dmx ist nicht threadsicher, und
|
||||||
|
* nl_app_lock() ist nicht rekursiv - also hier mit erledigen statt ein zweites Mal
|
||||||
|
* zu sperren. */
|
||||||
|
out->sacn_active = nl_dmx_active();
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
|
/* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */
|
||||||
|
out->sacn_joined = nl_sacn_joined();
|
||||||
|
out->sacn_running = nl_sacn_running();
|
||||||
nl_wifi_status(&w);
|
nl_wifi_status(&w);
|
||||||
out->wifi_mode = w.mode;
|
out->wifi_mode = w.mode;
|
||||||
memcpy(out->ssid, w.ssid, sizeof out->ssid);
|
memcpy(out->ssid, w.ssid, sizeof out->ssid);
|
||||||
@@ -90,6 +116,7 @@ static void get_info(nl_sys_info_t *out)
|
|||||||
out->uptime_s = nl_app_now_ms() / 1000u;
|
out->uptime_s = nl_app_now_ms() / 1000u;
|
||||||
out->free_heap = tls_mem_get_avail_heapsize();
|
out->free_heap = tls_mem_get_avail_heapsize();
|
||||||
out->last_ota_error = nl_ota_last_error();
|
out->last_ota_error = nl_ota_last_error();
|
||||||
|
out->wifi_standalone = nl_wifi_is_standalone();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void factory_reset(void) { nl_app_request_factory_reset(); }
|
static void factory_reset(void) { nl_app_request_factory_reset(); }
|
||||||
@@ -113,12 +140,17 @@ const nl_api_ctx_t *nl_app_api_ctx(void)
|
|||||||
cJSON_InitHooks(&hooks);
|
cJSON_InitHooks(&hooks);
|
||||||
ctx.board = nl_board_get();
|
ctx.board = nl_board_get();
|
||||||
ctx.fw_version = NINLIGHT_FW_VERSION;
|
ctx.fw_version = NINLIGHT_FW_VERSION;
|
||||||
|
/* Zeigerziel ist ein statischer Puffer im Treiber und gilt ab nl_wifi_start(), das im
|
||||||
|
* Startpfad vor dem HTTP-Server laeuft. Ohne diese Zeile dereferenziert der
|
||||||
|
* Einstellungs-Handler NULL, wenn kein eigener AP-Name gesetzt ist. */
|
||||||
|
ctx.hostname = nl_wifi_hostname();
|
||||||
ctx.build_date = NINLIGHT_BUILD_DATE;
|
ctx.build_date = NINLIGHT_BUILD_DATE;
|
||||||
ctx.get_state = get_state;
|
ctx.get_state = get_state;
|
||||||
ctx.apply = apply;
|
ctx.apply = apply;
|
||||||
ctx.get_settings = get_settings;
|
ctx.get_settings = get_settings;
|
||||||
ctx.save_settings = save_settings;
|
ctx.save_settings = save_settings;
|
||||||
ctx.wifi_configure = wifi_configure;
|
ctx.wifi_configure = wifi_configure;
|
||||||
|
ctx.standalone_configure = standalone_configure;
|
||||||
ctx.open_access = open_access;
|
ctx.open_access = open_access;
|
||||||
ctx.get_info = get_info;
|
ctx.get_info = get_info;
|
||||||
ctx.factory_reset = factory_reset;
|
ctx.factory_reset = factory_reset;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "wm_watchdog.h"
|
#include "wm_watchdog.h"
|
||||||
|
|
||||||
#include "light/nl_control.h"
|
#include "light/nl_control.h"
|
||||||
|
#include "light/nl_dmx.h"
|
||||||
#include "light/nl_engine.h"
|
#include "light/nl_engine.h"
|
||||||
#include "net/nl_wifi.h"
|
#include "net/nl_wifi.h"
|
||||||
#include "sys/nl_app.h"
|
#include "sys/nl_app.h"
|
||||||
@@ -123,6 +124,10 @@ static void exec(const nl_cmd_t *c)
|
|||||||
}
|
}
|
||||||
|
|
||||||
nl_app_lock();
|
nl_app_lock();
|
||||||
|
/* Wie in nl_app_api.c: ein Befehl von Hand holt die Kontrolle vom sACN-Sender zurueck.
|
||||||
|
* Die serielle Konsole ist der Rettungsweg - ohne das Freigeben bliebe eine im Modus
|
||||||
|
* "hold" dunkel geparkte Lampe dunkel und meldete trotzdem "ok". */
|
||||||
|
nl_dmx_release();
|
||||||
err = nl_ctrl_apply(&ch, nl_app_now_ms());
|
err = nl_ctrl_apply(&ch, nl_app_now_ms());
|
||||||
nl_app_unlock();
|
nl_app_unlock();
|
||||||
if (err == NL_CTRL_OK)
|
if (err == NL_CTRL_OK)
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ nl_test(test_reset_counter test_reset_counter.c fake_flash.c ${NL_SRC}/config/nl
|
|||||||
nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c)
|
nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c)
|
||||||
nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c)
|
nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c)
|
||||||
nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c)
|
nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c)
|
||||||
|
nl_test(test_e131 test_e131.c ${NL_SRC}/net/nl_e131.c)
|
||||||
|
nl_test(test_dmx test_dmx.c ${NL_SRC}/light/nl_dmx.c ${NL_SRC}/light/nl_engine.c
|
||||||
|
${NL_SRC}/light/nl_color.c ${NL_SRC}/board/nl_board.c ${NL_SRC}/net/nl_e131.c)
|
||||||
nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c
|
nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c
|
||||||
${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c)
|
${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c)
|
||||||
target_link_libraries(test_api PRIVATE nl_cjson)
|
target_link_libraries(test_api PRIVATE nl_cjson)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "fake_api_ctx.h"
|
#include "fake_api_ctx.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "light/nl_boot_state.h"
|
#include "light/nl_boot_state.h"
|
||||||
@@ -12,6 +13,10 @@ nl_ctrl_err_t fake_apply_result;
|
|||||||
int fake_apply_calls, fake_save_calls, fake_save_result;
|
int fake_apply_calls, fake_save_calls, fake_save_result;
|
||||||
int fake_wifi_calls, fake_wifi_result;
|
int fake_wifi_calls, fake_wifi_result;
|
||||||
char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
||||||
|
int fake_standalone_calls, fake_standalone_result;
|
||||||
|
bool fake_standalone_enabled, fake_standalone_pulse;
|
||||||
|
char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
|
||||||
|
uint8_t fake_standalone_channel;
|
||||||
int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
||||||
char fake_fw_url[256];
|
char fake_fw_url[256];
|
||||||
bool fake_open;
|
bool fake_open;
|
||||||
@@ -49,6 +54,18 @@ static int wifi_configure(const char *ssid, const char *pass)
|
|||||||
return fake_wifi_result;
|
return fake_wifi_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int fake_standalone_configure(bool enabled, const char *ssid, const char *pass,
|
||||||
|
uint8_t channel, bool pulse)
|
||||||
|
{
|
||||||
|
fake_standalone_calls++;
|
||||||
|
fake_standalone_enabled = enabled;
|
||||||
|
fake_standalone_pulse = pulse;
|
||||||
|
fake_standalone_channel = channel;
|
||||||
|
snprintf(fake_standalone_ssid, sizeof fake_standalone_ssid, "%s", ssid ? ssid : "");
|
||||||
|
snprintf(fake_standalone_pass, sizeof fake_standalone_pass, "%s", pass ? pass : "");
|
||||||
|
return fake_standalone_result;
|
||||||
|
}
|
||||||
|
|
||||||
static bool open_access(void) { return fake_open; }
|
static bool open_access(void) { return fake_open; }
|
||||||
static void get_info(nl_sys_info_t *out) { *out = fake_info; }
|
static void get_info(nl_sys_info_t *out) { *out = fake_info; }
|
||||||
static void factory_reset(void) { fake_factory_calls++; }
|
static void factory_reset(void) { fake_factory_calls++; }
|
||||||
@@ -83,6 +100,10 @@ void fake_api_reset(const char *board_id)
|
|||||||
fake_apply_calls = fake_save_calls = fake_save_result = 0;
|
fake_apply_calls = fake_save_calls = fake_save_result = 0;
|
||||||
fake_wifi_calls = fake_wifi_result = 0;
|
fake_wifi_calls = fake_wifi_result = 0;
|
||||||
fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0';
|
fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0';
|
||||||
|
fake_standalone_calls = fake_standalone_result = 0;
|
||||||
|
fake_standalone_enabled = fake_standalone_pulse = false;
|
||||||
|
fake_standalone_ssid[0] = fake_standalone_pass[0] = '\0';
|
||||||
|
fake_standalone_channel = 0;
|
||||||
fake_factory_calls = fake_ping_calls = 0;
|
fake_factory_calls = fake_ping_calls = 0;
|
||||||
fake_fw_result = -1;
|
fake_fw_result = -1;
|
||||||
fake_fw_url[0] = '\0';
|
fake_fw_url[0] = '\0';
|
||||||
@@ -96,6 +117,8 @@ void fake_api_reset(const char *board_id)
|
|||||||
fake_ctx.get_settings = get_settings;
|
fake_ctx.get_settings = get_settings;
|
||||||
fake_ctx.save_settings = save_settings;
|
fake_ctx.save_settings = save_settings;
|
||||||
fake_ctx.wifi_configure = wifi_configure;
|
fake_ctx.wifi_configure = wifi_configure;
|
||||||
|
fake_ctx.standalone_configure = fake_standalone_configure;
|
||||||
|
fake_ctx.hostname = "ninlight-681f";
|
||||||
fake_ctx.open_access = open_access;
|
fake_ctx.open_access = open_access;
|
||||||
fake_ctx.get_info = get_info;
|
fake_ctx.get_info = get_info;
|
||||||
fake_ctx.factory_reset = factory_reset;
|
fake_ctx.factory_reset = factory_reset;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ extern nl_ctrl_err_t fake_apply_result;
|
|||||||
extern int fake_apply_calls, fake_save_calls, fake_save_result;
|
extern int fake_apply_calls, fake_save_calls, fake_save_result;
|
||||||
extern int fake_wifi_calls, fake_wifi_result;
|
extern int fake_wifi_calls, fake_wifi_result;
|
||||||
extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
||||||
|
extern int fake_standalone_calls, fake_standalone_result;
|
||||||
|
extern bool fake_standalone_enabled, fake_standalone_pulse;
|
||||||
|
extern char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
|
||||||
|
extern uint8_t fake_standalone_channel;
|
||||||
extern int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
extern int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
||||||
extern char fake_fw_url[256];
|
extern char fake_fw_url[256];
|
||||||
extern bool fake_open;
|
extern bool fake_open;
|
||||||
|
|||||||
+490
@@ -346,6 +346,149 @@ static void test_settings_name_token_auth(void)
|
|||||||
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- system/settings: sacn --- */
|
||||||
|
static const cJSON *sacn_field(const char *name)
|
||||||
|
{
|
||||||
|
const cJSON *s = cJSON_GetObjectItemCaseSensitive(doc, "sacn");
|
||||||
|
return cJSON_GetObjectItemCaseSensitive(s, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_settings_exposes_sacn_block(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("release", cJSON_GetStringValue(sacn_field("on_timeout")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_settings_sacn_block_reflects_stored_values(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_enabled = 0;
|
||||||
|
fake_settings.sacn_universe = 12;
|
||||||
|
fake_settings.sacn_start_slot = 7;
|
||||||
|
fake_settings.sacn_hold = 1;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(12, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(7, (int)cJSON_GetNumberValue(sacn_field("start_slot")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("hold", cJSON_GetStringValue(sacn_field("on_timeout")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_all_fields(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"enabled\":false,\"universe\":300,"
|
||||||
|
"\"start_slot\":100,\"on_timeout\":\"hold\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, fake_settings.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(100, fake_settings.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_single_field_leaves_rest(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_universe = 9;
|
||||||
|
fake_settings.sacn_hold = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"start_slot\":50}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(50, fake_settings.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(9, fake_settings.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_rejects_bad_values(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"enabled\":\"ja\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":64000}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"on_timeout\":\"halten\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid on_timeout", error_text());
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls); /* nichts davon darf gespeichert werden */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* universe/start_slot muessen ueber get_int() laufen wie jedes andere Zahlenfeld in dieser
|
||||||
|
* Datei - ein Bruchwert darf nicht klammheimlich auf einen Integer abgeschnitten werden. */
|
||||||
|
static void test_put_sacn_rejects_fractional_numbers(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":12.5}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid universe", error_text());
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":2.5}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Board mit RGB braucht 6 Slots: ab 508 passt der Satz nicht mehr ins Universe. */
|
||||||
|
static void test_put_sacn_start_slot_must_leave_room_for_all_slots(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_layout = 0; /* die Vorgabe ist rgb mit 3 Slots - hier geht es um 6 */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":507}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":508}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_start_slot_limit_follows_the_board(void)
|
||||||
|
{
|
||||||
|
fake_api_reset("g95_cw");
|
||||||
|
fake_settings.sacn_layout = 0; /* im dimmer-Layout haengt die Slotzahl am Board */
|
||||||
|
/* Ein Board ohne Farbe hat auch mit Dimmer nur drei Slots: 510 passt noch. */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":511}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_info_exposes_sacn_state(void)
|
||||||
|
{
|
||||||
|
fake_info.sacn_active = true;
|
||||||
|
fake_info.sacn_joined = true;
|
||||||
|
fake_info.sacn_running = true;
|
||||||
|
fake_settings.sacn_universe = 5;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(5, (int)cJSON_GetNumberValue(sacn_field("universe")));
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("multicast")));
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("running")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein toter Empfangs-Task sah von aussen genauso aus wie "es sendet gerade niemand":
|
||||||
|
* beides meldete nur active=false. Beim Montana-Black-Shield (2026-09-26) hat das eine
|
||||||
|
* halbe Stunde Suche gekostet - "multicast" half nicht weiter, weil es den gemerkten
|
||||||
|
* Erfolg des LETZTEN Beitritts zeigt und auch dann true bleibt, wenn der Socket laengst
|
||||||
|
* zu ist. */
|
||||||
|
static void test_info_reports_a_dead_sacn_task(void)
|
||||||
|
{
|
||||||
|
fake_info.sacn_running = false;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("running")));
|
||||||
|
}
|
||||||
|
|
||||||
/* --- system/command --- */
|
/* --- system/command --- */
|
||||||
static void test_commands(void)
|
static void test_commands(void)
|
||||||
{
|
{
|
||||||
@@ -384,9 +527,330 @@ static void test_commands(void)
|
|||||||
TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
|
TEST_ASSERT_EQUAL_STRING("invalid command", error_text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- sacn: Belegung --- */
|
||||||
|
|
||||||
|
static void test_settings_reports_the_layout_and_matching_slots(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
/* Vorgabe: rgb - und darin belegt auch ein Board mit Farbe nur drei Slots. */
|
||||||
|
TEST_ASSERT_EQUAL_STRING("rgb", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
|
||||||
|
fake_settings.sacn_layout = 0;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("dimmer", cJSON_GetStringValue(sacn_field("layout")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(sacn_field("slots")));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_layout(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"rgb\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
|
||||||
|
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_rejects_an_unknown_layout(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"pixel\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid layout", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Obergrenze fuer start_slot haengt am Layout. Im rgb-Layout (3 Slots) ist 510 erlaubt,
|
||||||
|
* mit Dimmer (6 Slots) nicht mehr - ein Wechsel darf einen unzulaessigen Zustand also nicht
|
||||||
|
* still herstellen. */
|
||||||
|
static void test_put_sacn_start_slot_limit_follows_the_layout(void)
|
||||||
|
{
|
||||||
|
fake_settings.sacn_layout = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||||
|
|
||||||
|
/* Zurueck auf dimmer wuerde 510 + 6 - 1 = 515 bedeuten: abgelehnt. */
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"layout\":\"dimmer\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout); /* nichts uebernommen */
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_put_sacn_layout_and_start_slot_together(void)
|
||||||
|
{
|
||||||
|
/* Beides in einer Anfrage: geprueft wird gegen das NEUE Layout. */
|
||||||
|
fake_settings.sacn_layout = 0;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"sacn\",\"layout\":\"rgb\",\"start_slot\":510}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_layout);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- standalone --- */
|
||||||
|
|
||||||
|
static void test_standalone_requires_a_password(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("password required", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein gespeichertes Passwort genuegt - man soll den Kanal aendern koennen, ohne das
|
||||||
|
* Passwort erneut zu schicken. */
|
||||||
|
static void test_standalone_keeps_a_stored_password(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":11}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(11, fake_standalone_channel);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rejects_a_short_password(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"1234567\"}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid password", error_text());
|
||||||
|
/* Die Fehlermeldung nennt das Feld, nie den Wert. */
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "1234567"));
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rejects_a_channel_outside_the_band(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":14}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":0}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_accepts_a_full_request(void)
|
||||||
|
{
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\","
|
||||||
|
"\"ssid\":\"Wohnzimmerlampe\",\"channel\":6,\"pulse\":false}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("standalone", cJSON_GetStringValue(field("status")));
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
|
||||||
|
TEST_ASSERT_TRUE(fake_standalone_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", fake_standalone_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, fake_standalone_channel);
|
||||||
|
TEST_ASSERT_FALSE(fake_standalone_pulse);
|
||||||
|
/* Gespeichert wird auch: sonst ist der Modus nach dem Neustart weg. */
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", fake_settings.ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Abschalten braucht kein Passwort. */
|
||||||
|
static void test_standalone_can_be_switched_off(void)
|
||||||
|
{
|
||||||
|
fake_settings.standalone = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":false}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
|
||||||
|
TEST_ASSERT_FALSE(fake_standalone_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_busy_radio_answers_503_and_rolls_back(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
fake_settings.standalone = 0;
|
||||||
|
fake_standalone_result = -1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(503, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("wifi busy", error_text());
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
/* "nicht uebernommen" gilt auch im Flash. */
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_INT(2, fake_save_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rollback_restores_every_changed_field(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "altpass123");
|
||||||
|
strcpy(fake_settings.ap_ssid, "alt");
|
||||||
|
fake_settings.ap_channel = 3;
|
||||||
|
fake_settings.standalone_pulse = 1;
|
||||||
|
fake_standalone_result = -1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"neupass123\","
|
||||||
|
"\"ssid\":\"neu\",\"channel\":11,\"pulse\":false}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(503, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("altpass123", fake_settings.ap_pass);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", fake_settings.ap_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, fake_settings.ap_channel);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone_pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_failed_save_answers_500_without_switching(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
fake_save_result = -1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nicht angegebene Felder behalten ihren gespeicherten Wert. */
|
||||||
|
static void test_standalone_unspecified_fields_keep_their_stored_values(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
strcpy(fake_settings.ap_ssid, "Gespeichert");
|
||||||
|
fake_settings.ap_channel = 11;
|
||||||
|
fake_settings.standalone_pulse = 0;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Gespeichert", fake_standalone_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(11, fake_standalone_channel);
|
||||||
|
TEST_ASSERT_FALSE(fake_standalone_pulse);
|
||||||
|
|
||||||
|
fake_settings.standalone_pulse = 1;
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||||
|
TEST_ASSERT_TRUE(fake_standalone_pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_length_limits(void)
|
||||||
|
{
|
||||||
|
char body[256], pw[80], ss[48];
|
||||||
|
|
||||||
|
/* 63 ist die Obergrenze, nicht 64: eine WPA2-ASCII-Passphrase ist hoechstens 63 Zeichen
|
||||||
|
* lang, und ein vom Funkteil abgelehnter AP-Aufbau waere hier eine Sackgasse. */
|
||||||
|
memset(pw, 'p', 63); pw[63] = '\0';
|
||||||
|
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING(pw, fake_standalone_pass);
|
||||||
|
memset(pw, 'p', 64); pw[64] = '\0';
|
||||||
|
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, pw));
|
||||||
|
memset(pw, 'p', 65); pw[65] = '\0';
|
||||||
|
snprintf(body, sizeof body, "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"%s\"}", pw);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, pw));
|
||||||
|
|
||||||
|
memset(ss, 's', 32); ss[32] = '\0';
|
||||||
|
snprintf(body, sizeof body,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"ssid\":\"%s\"}", ss);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_STRING(ss, fake_standalone_ssid);
|
||||||
|
memset(ss, 's', 33); ss[33] = '\0';
|
||||||
|
snprintf(body, sizeof body,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"ssid\":\"%s\"}", ss);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, body);
|
||||||
|
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_accepts_the_band_edges(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":13}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(13, fake_standalone_channel);
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||||
|
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":1}");
|
||||||
|
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, fake_standalone_channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_rejects_bad_types_of_the_other_fields(void)
|
||||||
|
{
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":\"yes\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"pulse\":1}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid pulse", error_text());
|
||||||
|
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"ssid\":\"\"}");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("invalid ssid", error_text());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das AP-Passwort darf nirgends in der Antwort auftauchen. */
|
||||||
|
static void test_settings_show_standalone_without_the_password(void)
|
||||||
|
{
|
||||||
|
cJSON *o, *sa;
|
||||||
|
fake_settings.standalone = 1;
|
||||||
|
fake_settings.ap_channel = 11;
|
||||||
|
fake_settings.standalone_pulse = 0;
|
||||||
|
strcpy(fake_settings.ap_ssid, "Wohnzimmerlampe");
|
||||||
|
strcpy(fake_settings.ap_pass, "geheim123");
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||||
|
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "enabled")));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe",
|
||||||
|
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
|
||||||
|
TEST_ASSERT_EQUAL_INT(11, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
|
||||||
|
TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "pulse")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ohne eigenen Namen zeigt die Antwort den WIRKSAMEN, nicht einen leeren String -
|
||||||
|
* sonst muesste jeder Aufrufer die Vorgabe selbst nachbilden. */
|
||||||
|
static void test_settings_show_the_effective_ap_name(void)
|
||||||
|
{
|
||||||
|
cJSON *o, *sa;
|
||||||
|
fake_settings.ap_ssid[0] = '\0';
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ninlight-681f",
|
||||||
|
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
|
||||||
|
/* Kanal 0 im Datensatz heisst 6. */
|
||||||
|
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "ap" allein reicht nicht: denselben Wert meldet der Onboarding-AP. Ohne dieses Feld
|
||||||
|
* boete die App einer fertig eingerichteten Lampe ein Onboarding an. */
|
||||||
|
static void test_info_tells_standalone_from_onboarding(void)
|
||||||
|
{
|
||||||
|
cJSON *o, *w;
|
||||||
|
fake_info.wifi_mode = "ap";
|
||||||
|
fake_info.wifi_standalone = true;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
|
||||||
|
fake_info.wifi_standalone = false;
|
||||||
|
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||||
|
o = cJSON_Parse(resp.body);
|
||||||
|
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
|
||||||
|
TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
|
||||||
|
cJSON_Delete(o);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_settings_reports_the_layout_and_matching_slots);
|
||||||
|
RUN_TEST(test_put_sacn_layout);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_an_unknown_layout);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_layout);
|
||||||
|
RUN_TEST(test_put_sacn_layout_and_start_slot_together);
|
||||||
RUN_TEST(test_unknown_path_and_method);
|
RUN_TEST(test_unknown_path_and_method);
|
||||||
RUN_TEST(test_get_state);
|
RUN_TEST(test_get_state);
|
||||||
RUN_TEST(test_white_board_capabilities);
|
RUN_TEST(test_white_board_capabilities);
|
||||||
@@ -406,6 +870,32 @@ int main(void)
|
|||||||
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
||||||
RUN_TEST(test_settings_wifi);
|
RUN_TEST(test_settings_wifi);
|
||||||
RUN_TEST(test_settings_name_token_auth);
|
RUN_TEST(test_settings_name_token_auth);
|
||||||
|
RUN_TEST(test_settings_exposes_sacn_block);
|
||||||
|
RUN_TEST(test_settings_sacn_block_reflects_stored_values);
|
||||||
|
RUN_TEST(test_put_sacn_all_fields);
|
||||||
|
RUN_TEST(test_put_sacn_single_field_leaves_rest);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_bad_values);
|
||||||
|
RUN_TEST(test_put_sacn_rejects_fractional_numbers);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_must_leave_room_for_all_slots);
|
||||||
|
RUN_TEST(test_put_sacn_start_slot_limit_follows_the_board);
|
||||||
|
RUN_TEST(test_info_exposes_sacn_state);
|
||||||
|
RUN_TEST(test_info_reports_a_dead_sacn_task);
|
||||||
RUN_TEST(test_commands);
|
RUN_TEST(test_commands);
|
||||||
|
RUN_TEST(test_standalone_requires_a_password);
|
||||||
|
RUN_TEST(test_standalone_keeps_a_stored_password);
|
||||||
|
RUN_TEST(test_standalone_rejects_a_short_password);
|
||||||
|
RUN_TEST(test_standalone_rejects_a_channel_outside_the_band);
|
||||||
|
RUN_TEST(test_standalone_accepts_a_full_request);
|
||||||
|
RUN_TEST(test_standalone_can_be_switched_off);
|
||||||
|
RUN_TEST(test_standalone_busy_radio_answers_503_and_rolls_back);
|
||||||
|
RUN_TEST(test_standalone_rollback_restores_every_changed_field);
|
||||||
|
RUN_TEST(test_standalone_failed_save_answers_500_without_switching);
|
||||||
|
RUN_TEST(test_standalone_unspecified_fields_keep_their_stored_values);
|
||||||
|
RUN_TEST(test_standalone_length_limits);
|
||||||
|
RUN_TEST(test_standalone_accepts_the_band_edges);
|
||||||
|
RUN_TEST(test_standalone_rejects_bad_types_of_the_other_fields);
|
||||||
|
RUN_TEST(test_settings_show_standalone_without_the_password);
|
||||||
|
RUN_TEST(test_settings_show_the_effective_ap_name);
|
||||||
|
RUN_TEST(test_info_tells_standalone_from_onboarding);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ static void test_known_values_from_old_firmware(void)
|
|||||||
TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch);
|
TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_dmx_slots(void)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("gu10_rgb"), false));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"), false));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("evk"), false));
|
||||||
|
/* Im rgb-Layout belegt JEDES Board drei Slots - eine Lampe ist ein Pixel. */
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("gu10_rgb"), true));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"), true));
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -125,5 +135,6 @@ int main(void)
|
|||||||
RUN_TEST(test_pwm_boards_consistent);
|
RUN_TEST(test_pwm_boards_consistent);
|
||||||
RUN_TEST(test_sm_boards_consistent);
|
RUN_TEST(test_sm_boards_consistent);
|
||||||
RUN_TEST(test_known_values_from_old_firmware);
|
RUN_TEST(test_known_values_from_old_firmware);
|
||||||
|
RUN_TEST(test_dmx_slots);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+412
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,153 @@ static void test_erase(void)
|
|||||||
TEST_ASSERT_FALSE(nl_settings_load(&out));
|
TEST_ASSERT_FALSE(nl_settings_load(&out));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_sacn_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, s.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sacn_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("dmx"), out;
|
||||||
|
in.sacn_enabled = 0;
|
||||||
|
in.sacn_universe = 4711;
|
||||||
|
in.sacn_start_slot = 300;
|
||||||
|
in.sacn_hold = 1;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(4711, out.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(300, out.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Datensatz aus einer Firmware VOR sACN: die vier Felder muessen als echte Vorgaben
|
||||||
|
* zurueckkommen, nicht als Null - sonst waere sACN auf jeder aktualisierten Lampe aus.
|
||||||
|
* Die Laenge ist bewusst die Literalzahl 208: das war sizeof(nl_settings_t) vor diesem
|
||||||
|
* Zweig und damit die Laenge, die auf jeder ausgelieferten Lampe im Flash steht. Ein
|
||||||
|
* offsetof() auf ein heutiges Feld wuerde mit der Struktur mitwandern und genau den Fall
|
||||||
|
* verfehlen, um den es hier geht. Das letzte Byte (207) war im 208-Byte-Satz reines
|
||||||
|
* Fuellbyte und daher 0 - es wird hier nachgestellt, denn genau dieses Byte legt sich
|
||||||
|
* beim Lesen ueber das erste neue Feld. */
|
||||||
|
static void test_record_without_sacn_gets_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("alt"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 208u; /* sizeof(nl_settings_t) vor sACN */
|
||||||
|
fake_flash_mem[NL_FL_SETTINGS_A + 12 + 207] = 0; /* Fuellbyte des alten Satzes */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ninHome", out.wifi_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_universe);
|
||||||
|
TEST_ASSERT_EQUAL_UINT16(1, out.sacn_start_slot);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Satzlaenge muss durch 4 teilbar bleiben, sonst laufen Schreiber und Leser beim CRC
|
||||||
|
* auseinander (siehe NL_SETTINGS_CRC_OFF in nl_settings.c). */
|
||||||
|
static void test_sacn_layout_defaults_to_rgb(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sacn_layout_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("layout"), out;
|
||||||
|
in.sacn_layout = 0; /* weg von der Vorgabe, sonst prueft der Test nur diese */
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Derselbe Fallstrick wie bei sacn_enabled, eine Feldgeneration weiter: ein Satz, der von
|
||||||
|
* einer Firmware MIT sACN, aber OHNE layout geschrieben wurde, ist 216 Byte lang. Laege
|
||||||
|
* sacn_layout darunter, kaeme es als Fuellbyte dieses Satzes zurueck statt als Vorgabe. */
|
||||||
|
static void test_record_without_layout_gets_the_default(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("vor-layout"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
in.sacn_layout = 0; /* der abgeschnittene Satz darf diese 0 nicht zurueckliefern */
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 216u; /* sizeof(nl_settings_t) vor dem Layout-Feld */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("vor-layout", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t s;
|
||||||
|
TEST_ASSERT_FALSE(nl_settings_load(&s));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, s.ap_channel); /* 0 heisst "Vorgabe 6" */
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, s.standalone_pulse);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", s.ap_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", s.ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_roundtrip(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("ap"), out;
|
||||||
|
in.standalone = 1;
|
||||||
|
in.ap_channel = 11;
|
||||||
|
in.standalone_pulse = 0;
|
||||||
|
strcpy(in.ap_ssid, "Wohnzimmerlampe");
|
||||||
|
strcpy(in.ap_pass, "geheim123");
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in));
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(11, out.ap_channel);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.standalone_pulse);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", out.ap_ssid);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("geheim123", out.ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Datensatz aus einer Firmware VOR dem Standalone-Modus: die neuen Felder muessen
|
||||||
|
* als echte Vorgaben zurueckkommen, nicht als Null. Sonst stuende standalone_pulse nach
|
||||||
|
* dem Update auf jeder Lampe auf 0 - der Startpuls waere still abgeschaltet. */
|
||||||
|
static void test_record_without_standalone_gets_defaults(void)
|
||||||
|
{
|
||||||
|
nl_settings_t in = sample("alt"), out;
|
||||||
|
uint32_t crc;
|
||||||
|
nl_settings_save(&in);
|
||||||
|
{
|
||||||
|
uint16_t shorter = 220u; /* sizeof(nl_settings_t) vor diesem Zweig */
|
||||||
|
memset(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + 217], 0, 3); /* Fuellbytes */
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2);
|
||||||
|
crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter);
|
||||||
|
memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(nl_settings_load(&out));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("alt", out.name);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0, out.standalone);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(1, out.standalone_pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_record_size_stays_aligned(void)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -196,5 +343,15 @@ int main(void)
|
|||||||
RUN_TEST(test_ota_pending_default_and_roundtrip);
|
RUN_TEST(test_ota_pending_default_and_roundtrip);
|
||||||
RUN_TEST(test_crc_offset_matches_reader);
|
RUN_TEST(test_crc_offset_matches_reader);
|
||||||
RUN_TEST(test_erase);
|
RUN_TEST(test_erase);
|
||||||
|
RUN_TEST(test_sacn_defaults);
|
||||||
|
RUN_TEST(test_sacn_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_sacn_gets_defaults);
|
||||||
|
RUN_TEST(test_sacn_layout_defaults_to_rgb);
|
||||||
|
RUN_TEST(test_sacn_layout_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_layout_gets_the_default);
|
||||||
|
RUN_TEST(test_record_size_stays_aligned);
|
||||||
|
RUN_TEST(test_standalone_defaults);
|
||||||
|
RUN_TEST(test_standalone_roundtrip);
|
||||||
|
RUN_TEST(test_record_without_standalone_gets_defaults);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+284
-12
@@ -10,7 +10,7 @@ void tearDown(void) {}
|
|||||||
|
|
||||||
static void test_start_without_credentials_opens_ap(void)
|
static void test_start_without_credentials_opens_ap(void)
|
||||||
{
|
{
|
||||||
a = nl_wifi_sm_start(&sm, false, 0);
|
a = nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
TEST_ASSERT_TRUE(a.start_ap);
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
TEST_ASSERT_TRUE(a.pulse_on);
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
TEST_ASSERT_FALSE(a.connect);
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
@@ -21,7 +21,7 @@ static void test_start_without_credentials_opens_ap(void)
|
|||||||
|
|
||||||
static void test_start_with_credentials_connects(void)
|
static void test_start_with_credentials_connects(void)
|
||||||
{
|
{
|
||||||
a = nl_wifi_sm_start(&sm, true, 0);
|
a = nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
TEST_ASSERT_TRUE(a.connect);
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
TEST_ASSERT_FALSE(a.use_test_creds);
|
TEST_ASSERT_FALSE(a.use_test_creds);
|
||||||
TEST_ASSERT_FALSE(a.start_ap);
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
@@ -33,7 +33,7 @@ static void test_start_with_credentials_connects(void)
|
|||||||
|
|
||||||
static void test_retry_with_backoff(void)
|
static void test_retry_with_backoff(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 1000);
|
||||||
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).connect);
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).connect);
|
||||||
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000).connect);
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000).connect);
|
||||||
@@ -50,7 +50,7 @@ static void test_retry_with_backoff(void)
|
|||||||
|
|
||||||
static void test_fallback_after_five_minutes(void)
|
static void test_fallback_after_five_minutes(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_NOT_FOUND, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_NOT_FOUND, 1000);
|
||||||
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1);
|
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1);
|
||||||
TEST_ASSERT_FALSE(a.start_ap);
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
@@ -74,7 +74,7 @@ static void test_fallback_after_five_minutes(void)
|
|||||||
|
|
||||||
static void test_disconnect_while_connected(void)
|
static void test_disconnect_while_connected(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, 100000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_DISCONNECTED, NL_WIFI_ERR_NONE, 100000);
|
||||||
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
|
||||||
@@ -86,7 +86,7 @@ static void test_disconnect_while_connected(void)
|
|||||||
|
|
||||||
static void test_onboarding_success(void)
|
static void test_onboarding_success(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
a = nl_wifi_sm_onboard(&sm, 5000);
|
a = nl_wifi_sm_onboard(&sm, 5000);
|
||||||
TEST_ASSERT_FALSE(a.start_ap); /* already up */
|
TEST_ASSERT_FALSE(a.start_ap); /* already up */
|
||||||
TEST_ASSERT_TRUE(a.connect);
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
@@ -106,7 +106,7 @@ static void test_onboarding_success(void)
|
|||||||
|
|
||||||
static void test_onboarding_failure_without_credentials(void)
|
static void test_onboarding_failure_without_credentials(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000);
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000);
|
||||||
TEST_ASSERT_FALSE(a.pulse_off);
|
TEST_ASSERT_FALSE(a.pulse_off);
|
||||||
@@ -121,7 +121,7 @@ static void test_onboarding_failure_without_credentials(void)
|
|||||||
|
|
||||||
static void test_onboarding_timeout(void)
|
static void test_onboarding_timeout(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
||||||
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS - 1);
|
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS - 1);
|
||||||
@@ -136,7 +136,7 @@ static void test_onboarding_connected_event_while_ap_onboarding_is_stray(void)
|
|||||||
{
|
{
|
||||||
/* I1: fail_test() returns to AP_ONBOARDING; a late CONNECTED for the timed-out
|
/* I1: fail_test() returns to AP_ONBOARDING; a late CONNECTED for the timed-out
|
||||||
* attempt must be treated as stray and disconnected, not accepted. */
|
* attempt must be treated as stray and disconnected, not accepted. */
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS); /* -> AP_ONBOARDING */
|
nl_wifi_sm_tick(&sm, 5000 + NL_WIFI_ONBOARD_TIMEOUT_MS); /* -> AP_ONBOARDING */
|
||||||
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 40000);
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 40000);
|
||||||
@@ -146,7 +146,7 @@ static void test_onboarding_connected_event_while_ap_onboarding_is_stray(void)
|
|||||||
|
|
||||||
static void test_onboarding_while_connected_falls_back_to_old_credentials(void)
|
static void test_onboarding_while_connected_falls_back_to_old_credentials(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
a = nl_wifi_sm_onboard(&sm, 2000);
|
a = nl_wifi_sm_onboard(&sm, 2000);
|
||||||
TEST_ASSERT_TRUE(a.start_ap);
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
@@ -167,7 +167,7 @@ static void test_onboard_while_test_running_disconnects_stale_attempt(void)
|
|||||||
{
|
{
|
||||||
/* M1: a repeated onboarding request while a test connect is already in flight
|
/* M1: a repeated onboarding request while a test connect is already in flight
|
||||||
* must disconnect first so stale events from the previous attempt don't count. */
|
* must disconnect first so stale events from the previous attempt don't count. */
|
||||||
nl_wifi_sm_start(&sm, false, 0);
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
nl_wifi_sm_onboard(&sm, 5000);
|
nl_wifi_sm_onboard(&sm, 5000);
|
||||||
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
||||||
a = nl_wifi_sm_onboard(&sm, 6000);
|
a = nl_wifi_sm_onboard(&sm, 6000);
|
||||||
@@ -185,7 +185,7 @@ static void test_error_names(void)
|
|||||||
|
|
||||||
static void test_connect_without_any_event_retries(void)
|
static void test_connect_without_any_event_retries(void)
|
||||||
{
|
{
|
||||||
nl_wifi_sm_start(&sm, true, 0);
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 29999).connect);
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 29999).connect);
|
||||||
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 30000).connect); /* retry scheduled at 31000 */
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 30000).connect); /* retry scheduled at 31000 */
|
||||||
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 31000).connect);
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 31000).connect);
|
||||||
@@ -202,6 +202,257 @@ static void test_connect_without_any_event_retries(void)
|
|||||||
TEST_ASSERT_TRUE(a.connect);
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_standalone_opens_ap_without_connecting(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ap", nl_wifi_sm_mode(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Standalone-AP ist KEIN offener Onboarding-AP. Haengt diese Zusicherung nicht,
|
||||||
|
* koennte jeder in Funkreichweite die Lampe ohne Token flashen oder zuruecksetzen. */
|
||||||
|
static void test_standalone_is_not_open_access(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das Fenster, das der Ruhezustand nicht abdeckt: Onboarding-Test aus dem Standalone-Modus.
|
||||||
|
* Dann ist state == ONBOARD_TEST und have_creds == false - ohne !standalone in open_access()
|
||||||
|
* fiele der Tokenschutz bis zu 30 s lang, waehrend der passwortgeschuetzte AP oben steht. */
|
||||||
|
static void test_standalone_onboard_test_is_not_open_access(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state);
|
||||||
|
TEST_ASSERT_FALSE(sm.have_creds);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gespeicherte WLAN-Daten aendern nichts: wer standalone gewaehlt hat, will keinen
|
||||||
|
* Verbindungsversuch, auch wenn von frueher noch Zugangsdaten herumliegen. */
|
||||||
|
static void test_standalone_wins_over_stored_credentials(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kein Rueckfall und kein Wiederholungsversuch: die Maschine darf im Standalone-Modus
|
||||||
|
* nie von selbst anfangen, ein WLAN zu suchen. */
|
||||||
|
static void test_standalone_never_falls_back(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS + 60000);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(a.stop_ap);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein verirrter Erfolg eines alten Versuchs darf die Lampe nicht aus ihrem AP holen. */
|
||||||
|
static void test_standalone_drops_a_stray_connect(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Puls ist zeitlich begrenzt, und das ist keine Kosmetik: nl_engine_pulse() legt sich
|
||||||
|
* ueber die normale Ausgabe und uebermalt auch sACN. Am 2026-10-01 am EVK beobachtet -
|
||||||
|
* ein gueltiger sACN-Strom kam an, waehrend die Lampe weiter den Puls zeigte. Ein
|
||||||
|
* dauerhafter Puls machte sACN im Standalone-Modus unbrauchbar. */
|
||||||
|
static void test_standalone_pulse_stops_by_itself(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, false, true, true, 0);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS - 1).pulse_off);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
/* und danach nicht noch einmal */
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS + 5000).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_standalone_without_pulse_stays_dark(void)
|
||||||
|
{
|
||||||
|
a = nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_on);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erfolg: die Lampe wechselt ins WLAN UND verlaesst den Standalone-Modus dauerhaft.
|
||||||
|
* Ohne clear_standalone staende sie nach dem naechsten Neustart wieder im eigenen AP. */
|
||||||
|
static void test_onboarding_from_standalone_clears_the_mode(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 2000);
|
||||||
|
TEST_ASSERT_TRUE(a.save_test_creds);
|
||||||
|
TEST_ASSERT_TRUE(a.clear_standalone);
|
||||||
|
TEST_ASSERT_FALSE(sm.standalone);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTED, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fehlschlag: zurueck in den EIGENEN AP, nicht in den offenen Onboarding-AP. Sonst
|
||||||
|
* verloere ein Tippfehler im WLAN-Passwort den vom Nutzer gesetzten AP-Namen samt
|
||||||
|
* Passwort und entsicherte die Lampe, weil open_access() dann wieder true waere. */
|
||||||
|
static void test_failed_onboarding_returns_to_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(sm.standalone);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
TEST_ASSERT_FALSE(a.clear_standalone);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ERR_AUTH, sm.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auch der Zeitablauf des Tests fuehrt zurueck in den eigenen AP. */
|
||||||
|
static void test_onboarding_timeout_returns_to_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_ONBOARD_TIMEOUT_MS);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ERR_TIMEOUT, sm.error);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der bisherige Weg bleibt unberuehrt: ohne Standalone und ohne gespeicherte Daten
|
||||||
|
* landet ein gescheiterter Test weiterhin im offenen Onboarding-AP. */
|
||||||
|
static void test_failed_onboarding_without_standalone_opens_ap(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_AP_ONBOARDING, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aus dem laufenden Stationsbetrieb in den eigenen AP. */
|
||||||
|
static void test_switching_to_standalone_at_runtime(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, false, false, 0);
|
||||||
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 1000);
|
||||||
|
a = nl_wifi_sm_standalone(&sm, true, 2000);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(sm.standalone);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_tick(&sm, 2000 + NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zuruecknehmen MIT gespeicherten Zugangsdaten: ab ins WLAN, nicht in den offenen AP. */
|
||||||
|
static void test_leaving_standalone_with_credentials_connects(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
a = nl_wifi_sm_leave_standalone(&sm, true, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.connect);
|
||||||
|
TEST_ASSERT_FALSE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
TEST_ASSERT_FALSE(sm.standalone);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STA_CONNECTING, sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zuruecknehmen OHNE Zugangsdaten: offener Onboarding-AP, damit man wieder herankommt. */
|
||||||
|
static void test_leaving_standalone_without_credentials_opens_onboarding(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, false, 0);
|
||||||
|
a = nl_wifi_sm_leave_standalone(&sm, false, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_on);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_AP_ONBOARDING, sm.state);
|
||||||
|
TEST_ASSERT_TRUE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Laeuft beim Start eines Onboarding-Tests noch der Startpuls, raeumt onboard() ihn ab:
|
||||||
|
* pulse_off sofort, danach kein verwaister pulse_off mehr -- weder nach einem
|
||||||
|
* gescheiterten noch nach einem gelungenen Test. */
|
||||||
|
static void test_onboard_cancels_running_start_pulse(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, true, true, 0);
|
||||||
|
a = nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_off);
|
||||||
|
TEST_ASSERT_FALSE(sm.pulse_armed);
|
||||||
|
TEST_ASSERT_FALSE(sm.pulsing);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 6000);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_off);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 20000).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der eigentliche Spec-Fall: gespeicherte Zugangsdaten UND Standalone, der Test scheitert.
|
||||||
|
* Die Lampe darf nicht in den Rueckfall (APSTA_FALLBACK) mit den alten Daten gehen und
|
||||||
|
* damit ihren eigenen AP verlassen, sondern bleibt im Standalone-AP. */
|
||||||
|
static void test_failed_onboarding_with_credentials_stays_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 2000);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_TRUE(a.start_ap);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_onboarding_timeout_with_credentials_stays_standalone(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, true, true, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
a = nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_ONBOARD_TIMEOUT_MS);
|
||||||
|
TEST_ASSERT_EQUAL(NL_WIFI_ST_STANDALONE, sm.state);
|
||||||
|
TEST_ASSERT_FALSE(a.connect);
|
||||||
|
TEST_ASSERT_TRUE(a.disconnect);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_open_access(&sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nach gelungenem Onboarding laeuft noch der AP-Stopptimer. Schaltet man jetzt auf
|
||||||
|
* Standalone, muss er weg sein, sonst schaltet tick() den AP ab und die Lampe ist
|
||||||
|
* ueber nichts mehr erreichbar. */
|
||||||
|
static void test_standalone_cancels_pending_ap_stop(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
nl_wifi_sm_event(&sm, NL_WIFI_EV_CONNECTED, NL_WIFI_ERR_NONE, 2000);
|
||||||
|
nl_wifi_sm_standalone(&sm, false, 3000);
|
||||||
|
a = nl_wifi_sm_tick(&sm, 2000 + NL_WIFI_AP_CLOSE_ONBOARD_MS);
|
||||||
|
TEST_ASSERT_FALSE(a.stop_ap);
|
||||||
|
TEST_ASSERT_TRUE(sm.ap_up);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein unbefristeter Puls aus dem Onboarding-AP muss beim Wechsel ohne Puls enden, denn
|
||||||
|
* im Standalone-Modus gibt es keinen Timer mehr, der ihn beendete. */
|
||||||
|
static void test_standalone_without_pulse_ends_running_pulse(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
TEST_ASSERT_TRUE(sm.pulsing);
|
||||||
|
a = nl_wifi_sm_standalone(&sm, false, 1000);
|
||||||
|
TEST_ASSERT_TRUE(a.pulse_off);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_on);
|
||||||
|
TEST_ASSERT_FALSE(sm.pulsing);
|
||||||
|
TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1000 + NL_WIFI_STANDALONE_PULSE_MS).pulse_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* onboard() aus dem Onboarding-AP laesst den unbefristeten Puls in Ruhe. */
|
||||||
|
static void test_onboard_from_ap_onboarding_keeps_pulse(void)
|
||||||
|
{
|
||||||
|
nl_wifi_sm_start(&sm, false, false, false, 0);
|
||||||
|
a = nl_wifi_sm_onboard(&sm, 1000);
|
||||||
|
TEST_ASSERT_FALSE(a.pulse_off);
|
||||||
|
TEST_ASSERT_TRUE(sm.pulsing);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -218,5 +469,26 @@ int main(void)
|
|||||||
RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt);
|
RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt);
|
||||||
RUN_TEST(test_error_names);
|
RUN_TEST(test_error_names);
|
||||||
RUN_TEST(test_connect_without_any_event_retries);
|
RUN_TEST(test_connect_without_any_event_retries);
|
||||||
|
RUN_TEST(test_standalone_opens_ap_without_connecting);
|
||||||
|
RUN_TEST(test_standalone_is_not_open_access);
|
||||||
|
RUN_TEST(test_standalone_onboard_test_is_not_open_access);
|
||||||
|
RUN_TEST(test_standalone_wins_over_stored_credentials);
|
||||||
|
RUN_TEST(test_standalone_never_falls_back);
|
||||||
|
RUN_TEST(test_standalone_drops_a_stray_connect);
|
||||||
|
RUN_TEST(test_standalone_pulse_stops_by_itself);
|
||||||
|
RUN_TEST(test_standalone_without_pulse_stays_dark);
|
||||||
|
RUN_TEST(test_onboarding_from_standalone_clears_the_mode);
|
||||||
|
RUN_TEST(test_failed_onboarding_returns_to_standalone);
|
||||||
|
RUN_TEST(test_onboarding_timeout_returns_to_standalone);
|
||||||
|
RUN_TEST(test_failed_onboarding_without_standalone_opens_ap);
|
||||||
|
RUN_TEST(test_switching_to_standalone_at_runtime);
|
||||||
|
RUN_TEST(test_leaving_standalone_with_credentials_connects);
|
||||||
|
RUN_TEST(test_leaving_standalone_without_credentials_opens_onboarding);
|
||||||
|
RUN_TEST(test_onboard_cancels_running_start_pulse);
|
||||||
|
RUN_TEST(test_failed_onboarding_with_credentials_stays_standalone);
|
||||||
|
RUN_TEST(test_onboarding_timeout_with_credentials_stays_standalone);
|
||||||
|
RUN_TEST(test_standalone_cancels_pending_ap_stop);
|
||||||
|
RUN_TEST(test_standalone_without_pulse_ends_running_pulse);
|
||||||
|
RUN_TEST(test_onboard_from_ap_onboarding_keeps_pulse);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-6
@@ -254,15 +254,37 @@ README
|
|||||||
|
|
||||||
echo "== Auf Zugangsdaten pruefen =="
|
echo "== Auf Zugangsdaten pruefen =="
|
||||||
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
|
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
|
||||||
# wird, wird JEDE Datei des Archivs binaer gegen die echten Werte aus
|
# wird, wird JEDE Datei des Archivs gegen die echten Werte aus config.local.cmake
|
||||||
# config.local.cmake geprueft. Lieber ein abgebrochener Release als ein Archiv mit
|
# geprueft. Lieber ein abgebrochener Release als ein Archiv mit Michaels WLAN-Passwort
|
||||||
# Michaels WLAN-Passwort bei Dritten.
|
# bei Dritten - dieses Archiv geht an Fremde, und wiederholen laesst sich das nicht.
|
||||||
|
#
|
||||||
|
# Gesucht wird in ZWEI Sichten:
|
||||||
|
# roh - Kopf, Trailer und die Nicht-Abbilder (README.md, onboard.sh, bruno/) sind
|
||||||
|
# unkomprimiert; eine versehentlich notierte Zeile faellt nur so auf.
|
||||||
|
# entpackt- jedes Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann (beim _ota.img
|
||||||
|
# bzw. den signierten migrate/-Bildern) 512 Byte Trailer bzw. Signatur. Im
|
||||||
|
# komprimierten Strom steht kein Klartext. Die rohe Suche allein meldete
|
||||||
|
# deshalb IMMER "sauber" - auch fuer Abzuege aus build/, die die
|
||||||
|
# Zugangsdaten nachweislich tragen. Sie hat nie etwas geprueft.
|
||||||
|
#
|
||||||
|
# Entpackt wird ab Byte 56 OHNE das Dateiende abzuschneiden: die drei Bildarten im
|
||||||
|
# Archiv haben unterschiedliche Enden (flash/_gz.img traegt gar keinen Trailer,
|
||||||
|
# ota/_ota.img einen 512-Byte-Trailer, migrate/*.img eine 512-Byte-Signatur). Ein
|
||||||
|
# festes raw[56:-512] wie in tools/station/deploy.sh - wo nur Abbilder MIT Trailer
|
||||||
|
# vorkommen - wuerde hier jedes _gz.img fuer unentpackbar halten und jeden Release
|
||||||
|
# abbrechen. decompressobj hoert am Ende des gzip-Stroms von selbst auf; was danach
|
||||||
|
# kommt, ist ohnehin schon roh geprueft.
|
||||||
|
#
|
||||||
|
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
|
||||||
|
# "dann eben ungeprueft durchlassen". Das gilt auch fuer einen abgeschnittenen
|
||||||
|
# gzip-Strom, den decompressobj sonst stillschweigend annimmt.
|
||||||
LOCAL="$ROOT/config.local.cmake"
|
LOCAL="$ROOT/config.local.cmake"
|
||||||
if [ -f "$LOCAL" ]; then
|
if [ -f "$LOCAL" ]; then
|
||||||
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
|
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
stage, local = sys.argv[1], sys.argv[2]
|
stage, local = sys.argv[1], sys.argv[2]
|
||||||
text = open(local, encoding="utf-8", errors="replace").read()
|
text = open(local, encoding="utf-8", errors="replace").read()
|
||||||
@@ -271,27 +293,63 @@ text = open(local, encoding="utf-8", errors="replace").read()
|
|||||||
# haette nur falsche Alarme erzeugt.
|
# haette nur falsche Alarme erzeugt.
|
||||||
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||||
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||||
|
# Kopflaenge des vom wm_tool erzeugten Abbilds; danach beginnt der gzip-Strom.
|
||||||
|
HEADER_LEN = 56
|
||||||
needles = []
|
needles = []
|
||||||
for name in SECRETS:
|
for name in SECRETS:
|
||||||
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||||
if m and m.group(1) not in PUBLIC:
|
if m and m.group(1) not in PUBLIC:
|
||||||
needles.append((name, m.group(1).encode()))
|
needles.append((name, m.group(1).encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(raw):
|
||||||
|
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
|
||||||
|
if len(raw) <= HEADER_LEN:
|
||||||
|
raise ValueError("Datei ist kuerzer als der Kopf")
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
body = dec.decompress(raw[HEADER_LEN:])
|
||||||
|
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
|
||||||
|
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
|
||||||
|
# ungeprueft - und genau dort koennte das Passwort stehen.
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom ist unvollstaendig")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
hits = []
|
hits = []
|
||||||
|
broken = []
|
||||||
|
checked = 0
|
||||||
|
images = 0
|
||||||
for root, _dirs, files in os.walk(stage):
|
for root, _dirs, files in os.walk(stage):
|
||||||
for fn in sorted(files):
|
for fn in sorted(files):
|
||||||
path = os.path.join(root, fn)
|
path = os.path.join(root, fn)
|
||||||
|
rel = os.path.relpath(path, stage)
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
data = fh.read()
|
data = fh.read()
|
||||||
|
checked += 1
|
||||||
|
views = [data]
|
||||||
|
if fn.endswith(".img"):
|
||||||
|
try:
|
||||||
|
views.append(unpack(data))
|
||||||
|
except Exception as exc:
|
||||||
|
broken.append("%s (%s)" % (rel, exc))
|
||||||
|
continue
|
||||||
|
images += 1
|
||||||
for name, needle in needles:
|
for name, needle in needles:
|
||||||
if needle in data:
|
if any(needle in view for view in views):
|
||||||
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
|
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
|
||||||
|
hits.append("%s in %s" % (name, rel))
|
||||||
|
if broken:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Abbild laesst sich nicht entpacken, also auch nicht auf Zugangsdaten "
|
||||||
|
"pruefen - nichts gepackt:\n " + "\n ".join(broken) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
if hits:
|
if hits:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
|
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
|
||||||
+ "\n ".join(hits) + "\n")
|
+ "\n ".join(hits) + "\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print(" sauber (%d geprueft)" % len(needles))
|
print(" sauber (%d Dateien geprueft, davon %d Abbilder entpackt)" % (checked, images))
|
||||||
PY
|
PY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
+39
-18
@@ -29,20 +29,21 @@ würde dort nur mit einer Fehlermeldung enden.
|
|||||||
## Bildsatz: mit oder ohne WLAN-Zugangsdaten
|
## Bildsatz: mit oder ohne WLAN-Zugangsdaten
|
||||||
|
|
||||||
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es
|
Die WLAN-Zugangsdaten stecken **zur Bauzeit** in der Firmware (`config.local.cmake`). Es
|
||||||
gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor:
|
gibt deshalb zwei Abzüge je Ziel, und die Station hält beide vor — **für beide
|
||||||
|
Betriebsarten**, Migration **und Rettung**:
|
||||||
|
|
||||||
| Umschalterstellung | Verzeichnis auf dem Pi | gebaut mit | die migrierte Lampe … |
|
| Umschalterstellung | Migration | Rettung | gebaut mit | die Lampe … |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
|
| `ohne-wlan` (**Vorgabe**) | `images/migrate-nocreds/` | `images/rescue-nocreds/` | `tools/build_nocreds.sh` | startet im eigenen Onboarding-Zugangspunkt und wartet auf ein WLAN |
|
||||||
| `mit-wlan` | `images/migrate-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` |
|
| `mit-wlan` | `images/migrate-wlan/` | `images/rescue-wlan/` | `build/` | hängt sofort im WLAN aus `config.local.cmake` |
|
||||||
|
|
||||||
Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
|
Umgeschaltet wird per `ninlight-station bildsatz ohne-wlan|mit-wlan` oder am Taster
|
||||||
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration**: der
|
(Menüeintrag „Umschalten: …"). Das ist ein **Umschalter, keine Abfrage je Migration/
|
||||||
Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts und bleibt, bis
|
Rettung**: der Zustand steht in `/var/lib/ninlight-station/imageset`, überlebt Neustarts
|
||||||
ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der Fußzeile des Panels** —
|
und bleibt, bis ihn jemand ändert. Genau deshalb steht er auch **dauerhaft in der
|
||||||
in jedem Modus, auch bei ausgeschalteter Station: man soll vor jedem Einschalten sehen,
|
Fußzeile des Panels** — in jedem Modus, auch bei ausgeschalteter Station: man soll vor
|
||||||
was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu flashen, weil die
|
jedem Einschalten sehen, was gilt, statt zwanzig Lampen mit den eigenen Zugangsdaten zu
|
||||||
Einstellung von vorletzter Woche noch stand.
|
flashen, weil die Einstellung von vorletzter Woche noch stand.
|
||||||
|
|
||||||
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
|
**Warum `ohne-wlan` die Vorgabe ist** (und warum jeder Zweifelsfall — Datei fehlt,
|
||||||
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
|
unlesbar, unbekannter Inhalt — dorthin führt): im Automatikmodus wird jede alte
|
||||||
@@ -51,14 +52,34 @@ ein Fehlgriff ärgerlich, aber harmlos — die Lampe steht im Onboarding-AP und
|
|||||||
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden
|
dem Abzug mit Zugangsdaten steht das eigene WLAN-Passwort unwiderruflich in einem fremden
|
||||||
Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
Gerät. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
|
||||||
|
|
||||||
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie gelaufen),
|
**Der Umschalter galt bisher nur für die Migration — jetzt auch für die Rettung, und das
|
||||||
wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz. Der Server
|
hat eine spürbare Folge.** Bis zu dieser Erweiterung bekam eine Rettung *immer* ein
|
||||||
antwortet mit 404 und `status: noimageset`, `ninlight-station status` sagt „FEHLT", und auf
|
Abbild **mit** Zugangsdaten (der flache Bestand aus `build/`). Seitdem gilt für die
|
||||||
dem Panel steht „SATZ FEHLT". „Vorhanden" heißt dabei überall dasselbe: Verzeichnis da
|
Rettung dieselbe Vorgabe `ohne-wlan` wie für die Migration: **eine gerettete Lampe steht
|
||||||
**und** mindestens ein Image darin.
|
danach im eigenen Onboarding-Zugangspunkt statt im eigenen WLAN**, bis jemand ihr eines
|
||||||
|
nennt. Das ist gewollt (dieselbe Abwägung wie oben — der teure Fehler darf nie der sein,
|
||||||
|
der ohne Zutun passiert), soll aber niemanden überraschen: die Fußzeile des Panels zeigt
|
||||||
|
den Bildsatz deshalb ausdrücklich auch im Rettungsmodus, und stellt man bewusst auf
|
||||||
|
`mit-wlan` um, gilt das für beide Betriebsarten gemeinsam.
|
||||||
|
|
||||||
`deploy.sh` liefert beide Sätze aus. Fehlt `build-nocreds/`, bleibt der Satz `ohne-wlan`
|
**Fehlt der gewählte Satz** (nie ausgeliefert, oder `tools/build_nocreds.sh` nie
|
||||||
auf dem Pi unangetastet (statt geleert zu werden) und der Lauf meldet, womit er entsteht.
|
gelaufen), wird **nichts** ausgeliefert — kein stilles Ausweichen auf den anderen Satz,
|
||||||
|
und auch nicht auf einen alten, flachen Bestand direkt in `images/` (siehe unten). Der
|
||||||
|
Server antwortet mit 404 (Migration zusätzlich mit `status: noimageset`), und auf dem
|
||||||
|
Panel steht „SATZ FEHLT" — in JEDEM Modus. „Vorhanden" heißt dabei überall dasselbe:
|
||||||
|
Verzeichnis da **und** mindestens ein Image darin.
|
||||||
|
|
||||||
|
`deploy.sh` liefert alle vier Sätze aus (migrate-wlan/migrate-nocreds für die Migration,
|
||||||
|
rescue-wlan/rescue-nocreds für die Rettung). Fehlt `build-nocreds/` oder ist es für ein
|
||||||
|
Ziel unvollständig, bleibt der jeweils betroffene Satz `ohne-wlan` auf dem Pi unangetastet
|
||||||
|
(statt geleert zu werden) und der Lauf meldet, womit er entsteht.
|
||||||
|
|
||||||
|
**Rückwärtskompatibilität bewusst nicht gegeben:** vor dieser Erweiterung lagen
|
||||||
|
Rettungsabbilder flach in `images/` (immer mit Zugangsdaten). Ein Pi mit diesem alten
|
||||||
|
Bestand muss **einmal neu per `deploy.sh` bestückt werden** — der Deploy räumt den
|
||||||
|
flachen Altbestand dabei automatisch weg. Bis dahin liefert die Rettung in der Vorgabe
|
||||||
|
`ohne-wlan` sichtbar nichts aus (statt den alten Bestand mit Zugangsdaten stillschweigend
|
||||||
|
weiter zu bedienen, was den ganzen Sinn dieser Erweiterung unterlaufen würde).
|
||||||
|
|
||||||
## Taster am Gerät
|
## Taster am Gerät
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class Action:
|
|||||||
diesem Moment gar nicht vor Augen hat."""
|
diesem Moment gar nicht vor Augen hat."""
|
||||||
|
|
||||||
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True,
|
def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True,
|
||||||
done_text: str | None = None, confirm_title: str = "RUECKFRAGE",
|
done_text: str | None = None, confirm_title: str = "CONFIRM",
|
||||||
confirm_details=None) -> None:
|
confirm_details=None) -> None:
|
||||||
self.label = label
|
self.label = label
|
||||||
self.argv = tuple(argv)
|
self.argv = tuple(argv)
|
||||||
@@ -154,16 +154,16 @@ class Action:
|
|||||||
|
|
||||||
|
|
||||||
def action_shutdown() -> Action:
|
def action_shutdown() -> Action:
|
||||||
return Action("Herunterfahren", kind="shutdown", confirm=True,
|
return Action("Shut down", kind="shutdown", confirm=True,
|
||||||
done_text="Faehrt herunter")
|
done_text="Shutting down")
|
||||||
|
|
||||||
|
|
||||||
def action_migrate_listen() -> Action:
|
def action_migrate_listen() -> Action:
|
||||||
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus,
|
"""Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus,
|
||||||
bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
|
bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag
|
||||||
vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
|
vor dem Automatikmodus und traegt die uebliche Rueckfrage."""
|
||||||
return Action("Migration: horchen", ("migrate", "on", "--listen"),
|
return Action("Migration: listen", ("migrate", "on", "--listen"),
|
||||||
done_text="Migration: horcht")
|
done_text="Migration: listening")
|
||||||
|
|
||||||
|
|
||||||
def action_migrate_auto() -> Action:
|
def action_migrate_auto() -> Action:
|
||||||
@@ -179,11 +179,11 @@ def action_migrate_auto() -> Action:
|
|||||||
danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der
|
danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der
|
||||||
Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
|
Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der
|
||||||
Rueckfrage selbst."""
|
Rueckfrage selbst."""
|
||||||
return Action("Migration: AUTOMATIK", ("migrate", "on"),
|
return Action("Migration: AUTOMATIC", ("migrate", "on"),
|
||||||
done_text="Migration: automatisch",
|
done_text="Migration: automatic",
|
||||||
confirm_title="ACHTUNG",
|
confirm_title="WARNING",
|
||||||
confirm_details=["Flasht JEDE alte Klyqa-Lampe",
|
confirm_details=["Flashes EVERY old Klyqa lamp",
|
||||||
"in Reichweite - auch fremde"])
|
"in range - other people's too"])
|
||||||
|
|
||||||
|
|
||||||
def action_imageset(current: str) -> Action:
|
def action_imageset(current: str) -> Action:
|
||||||
@@ -196,19 +196,19 @@ def action_imageset(current: str) -> Action:
|
|||||||
Lampe, die sie erwischt."""
|
Lampe, die sie erwischt."""
|
||||||
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
|
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
|
||||||
if target == "mit-wlan":
|
if target == "mit-wlan":
|
||||||
return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"),
|
return Action("Switch to: with Wi-Fi", ("bildsatz", "mit-wlan"),
|
||||||
done_text="Bildsatz: mit WLAN",
|
done_text="Image set: with Wi-Fi",
|
||||||
confirm_title="ACHTUNG",
|
confirm_title="WARNING",
|
||||||
confirm_details=["Jede migrierte Lampe bekommt",
|
confirm_details=["Every migrated lamp receives",
|
||||||
"dein WLAN-Passwort"])
|
"your Wi-Fi password"])
|
||||||
return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"),
|
return Action("Switch to: no Wi-Fi", ("bildsatz", "ohne-wlan"),
|
||||||
done_text="Bildsatz: ohne WLAN")
|
done_text="Image set: no Wi-Fi")
|
||||||
|
|
||||||
|
|
||||||
def action_cancel() -> Action:
|
def action_cancel() -> Action:
|
||||||
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die
|
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die
|
||||||
Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
|
Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
|
||||||
return Action("Station aus", ("off",), confirm=False, done_text="Station aus")
|
return Action("Station off", ("off",), confirm=False, done_text="Station off")
|
||||||
|
|
||||||
|
|
||||||
def menu_actions(state: dict) -> list[Action]:
|
def menu_actions(state: dict) -> list[Action]:
|
||||||
@@ -221,7 +221,7 @@ def menu_actions(state: dict) -> list[Action]:
|
|||||||
|
|
||||||
if not running:
|
if not running:
|
||||||
return [
|
return [
|
||||||
Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"),
|
Action("Start recovery", ("recovery", "on"), done_text="Recovery running"),
|
||||||
# Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
|
# Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der
|
||||||
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo
|
# naheliegende und der harmlose ist - wer nur blaettert und irgendwo
|
||||||
# loslaesst, landet nicht im Automatikmodus.
|
# loslaesst, landet nicht im Automatikmodus.
|
||||||
@@ -237,13 +237,13 @@ def menu_actions(state: dict) -> list[Action]:
|
|||||||
if mode == "migration" and target:
|
if mode == "migration" and target:
|
||||||
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
|
# Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es
|
||||||
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
|
# nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen.
|
||||||
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)),
|
items.append(Action("Arm: {}".format(target), ("arm", str(target)),
|
||||||
done_text="Freigegeben: {}".format(target)))
|
done_text="Armed: {}".format(target)))
|
||||||
items.append(Action("Station aus", ("off",), done_text="Station aus"))
|
items.append(Action("Station off", ("off",), done_text="Station off"))
|
||||||
items.append(action_imageset(imageset))
|
items.append(action_imageset(imageset))
|
||||||
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
|
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
|
||||||
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False,
|
items.append(Action("Refresh display", ("display", "test"), confirm=False,
|
||||||
done_text="Anzeige aufgefrischt"))
|
done_text="Display refreshed"))
|
||||||
items.append(action_shutdown())
|
items.append(action_shutdown())
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -561,32 +561,32 @@ class ButtonUI:
|
|||||||
and self.screen in ("idle", "busy")):
|
and self.screen in ("idle", "busy")):
|
||||||
held = now - self._press_t
|
held = now - self._press_t
|
||||||
if self._hold_stage == 1:
|
if self._hold_stage == 1:
|
||||||
return _view("hold", "STATION AUS",
|
return _view("hold", "STATION OFF",
|
||||||
"Herunterfahren in {} s".format(
|
"Shutting down in {} s".format(
|
||||||
_countdown(SHUTDOWN_HOLD_S, held)),
|
_countdown(SHUTDOWN_HOLD_S, held)),
|
||||||
["Weiter halten - Loslassen bricht ab"])
|
["Keep holding - release to cancel"])
|
||||||
if held >= SHORT_PRESS_MAX_S:
|
if held >= SHORT_PRESS_MAX_S:
|
||||||
return _view("hold", "HALTEN",
|
return _view("hold", "HOLD",
|
||||||
"Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)),
|
"Cancel in {} s".format(_countdown(CANCEL_HOLD_S, held)),
|
||||||
["Weiter halten - Loslassen bricht ab"])
|
["Keep holding - release to cancel"])
|
||||||
if self.screen == "menu" and self.items:
|
if self.screen == "menu" and self.items:
|
||||||
selected = self.items[self.index]
|
selected = self.items[self.index]
|
||||||
rest = self.items[self.index + 1:] + self.items[:self.index]
|
rest = self.items[self.index + 1:] + self.items[:self.index]
|
||||||
return _view("menu", "MENUE", "> " + selected.label,
|
return _view("menu", "MENU", "> " + selected.label,
|
||||||
[a.label for a in rest] + ["kurz: weiter, halten: waehlen"])
|
[a.label for a in rest] + ["tap: next, hold: select"])
|
||||||
if self.screen == "confirm" and self.pending is not None:
|
if self.screen == "confirm" and self.pending is not None:
|
||||||
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
|
# Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei
|
||||||
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
|
# der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des
|
||||||
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
|
# Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird.
|
||||||
details = list(self.pending.confirm_details or [])
|
details = list(self.pending.confirm_details or [])
|
||||||
details += ["halten (1,5 s): ja", "kurz: nein"]
|
details += ["hold (1.5 s): yes", "tap: no"]
|
||||||
return _view("confirm", self.pending.confirm_title, self.pending.label,
|
return _view("confirm", self.pending.confirm_title, self.pending.label,
|
||||||
details)
|
details)
|
||||||
if self.screen == "busy" and self.busy is not None:
|
if self.screen == "busy" and self.busy is not None:
|
||||||
return _view("busy", "BITTE WARTEN", self.busy.label, [])
|
return _view("busy", "PLEASE WAIT", self.busy.label, [])
|
||||||
if self.result is not None:
|
if self.result is not None:
|
||||||
text, ok = self.result
|
text, ok = self.result
|
||||||
return _view("result", "FERTIG" if ok else "FEHLER", text, [])
|
return _view("result", "DONE" if ok else "ERROR", text, [])
|
||||||
return {"view": "idle", "active": False}
|
return {"view": "idle", "active": False}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+230
-47
@@ -13,8 +13,15 @@ MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
|||||||
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
||||||
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
||||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||||
|
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
|
||||||
|
# dieselbe Variable wie dort und in tools/release.sh). Hier schon gebraucht, nicht erst
|
||||||
|
# beim Signieren der Migrationsimages weiter unten: seit der Bildsatz-Umschalter auch die
|
||||||
|
# RETTUNG betrifft, braucht dieser Baum auch fuer die Rettungsabzuege (_ota.img statt
|
||||||
|
# _gz.img) einen eigenen Fundlauf, parallel zu dem fuer build/ unten.
|
||||||
|
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||||
|
|
||||||
declare -a FILES=()
|
declare -a FILES=()
|
||||||
|
declare -a RESCUE_NOCREDS_FILES=()
|
||||||
if [ $# -gt 0 ]; then
|
if [ $# -gt 0 ]; then
|
||||||
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
||||||
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
||||||
@@ -24,6 +31,17 @@ if [ $# -gt 0 ]; then
|
|||||||
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
||||||
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
||||||
FILES+=("$f")
|
FILES+=("$f")
|
||||||
|
# build-nocreds/ ist wie beim Signieren unten OPTIONAL/nachsichtig: ein Deploy darf
|
||||||
|
# nicht daran scheitern, dass tools/build_nocreds.sh fuer dieses Ziel noch nicht
|
||||||
|
# gelaufen ist. Fehlt die Datei, bleibt der Rettungs-Bildsatz "ohne-wlan" fuer GENAU
|
||||||
|
# dieses Ziel auf dem Pi unangetastet (sync_image_set unten arbeitet ohne --delete,
|
||||||
|
# wenn diese Liste nicht jedes Ziel aus FILES abdeckt).
|
||||||
|
nf="$ROOT/$NOCREDS_BUILD_REL/$t/ninlight_${t}_ota.img"
|
||||||
|
if [ -f "$nf" ]; then
|
||||||
|
RESCUE_NOCREDS_FILES+=("$nf")
|
||||||
|
else
|
||||||
|
echo "Hinweis: $nf fehlt - $t bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert" >&2
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
FULL_SYNC=1
|
FULL_SYNC=1
|
||||||
@@ -45,9 +63,24 @@ else
|
|||||||
FILES+=("$f")
|
FILES+=("$f")
|
||||||
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||||
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
||||||
|
# Derselbe Fundlauf fuer build-nocreds/ - genauso nachsichtig wie beim gezielten Deploy
|
||||||
|
# oben: fehlt der Baum komplett (tools/build_nocreds.sh nie gelaufen), bleibt
|
||||||
|
# RESCUE_NOCREDS_FILES einfach leer, kein Abbruch.
|
||||||
|
if [ -d "$ROOT/$NOCREDS_BUILD_REL" ]; then
|
||||||
|
declare -A SEEN_AT_NOCREDS=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
if [ -n "${SEEN_AT_NOCREDS[$base]:-}" ]; then
|
||||||
|
echo "Fehler: $base mehrfach unter $NOCREDS_BUILD_REL gefunden - ${SEEN_AT_NOCREDS[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEEN_AT_NOCREDS[$base]=$f
|
||||||
|
RESCUE_NOCREDS_FILES+=("$f")
|
||||||
|
done < <(find "$ROOT/$NOCREDS_BUILD_REL" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== Images pruefen =="
|
echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) =="
|
||||||
STAGE=$(mktemp -d)
|
STAGE=$(mktemp -d)
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
||||||
@@ -63,9 +96,54 @@ for f in "${FILES[@]}"; do
|
|||||||
set -- $out
|
set -- $out
|
||||||
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||||
NINL_VERSION[$target]="$2"
|
NINL_VERSION[$target]="$2"
|
||||||
cp "$f" "$STAGE/$base"
|
# Landet jetzt in einem eigenen Unterverzeichnis (rescue-wlan/) statt flach in STAGE -
|
||||||
|
# server.py:image_path() folgt seit diesem Umschalter demselben Bildsatz-Umschalter
|
||||||
|
# wie migrate_dir(), und "mit-wlan" ist genau dieser Satz (siehe RESCUE_SET_DIRS dort).
|
||||||
|
mkdir -p "$STAGE/rescue-wlan"
|
||||||
|
cp "$f" "$STAGE/rescue-wlan/$base"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten
|
||||||
|
# Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den
|
||||||
|
# gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine
|
||||||
|
# Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie,
|
||||||
|
# ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer
|
||||||
|
# jedes Ziel gelaufen" (siehe oben).
|
||||||
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then
|
||||||
|
echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) =="
|
||||||
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
||||||
|
base=$(basename "$f")
|
||||||
|
target=${base#ninlight_}; target=${target%_ota.img}
|
||||||
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
||||||
|
echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $out
|
||||||
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
||||||
|
mkdir -p "$STAGE/rescue-nocreds"
|
||||||
|
cp "$f" "$STAGE/rescue-nocreds/$base"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
||||||
|
fi
|
||||||
|
# "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz
|
||||||
|
# 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf
|
||||||
|
# dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die
|
||||||
|
# Migrationsimages weiter unten, nur ohne Signierschritt dazwischen).
|
||||||
|
RESCUE_NOCREDS_COMPLETE=1
|
||||||
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then
|
||||||
|
RESCUE_NOCREDS_COMPLETE=0
|
||||||
|
else
|
||||||
|
declare -A RESCUE_NOCREDS_TARGETS=()
|
||||||
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
||||||
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
||||||
|
RESCUE_NOCREDS_TARGETS[$t]=1
|
||||||
|
done
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
||||||
|
[ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "== Migrationsimages signieren =="
|
echo "== Migrationsimages signieren =="
|
||||||
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
||||||
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
||||||
@@ -89,9 +167,7 @@ MIGRATE_COMPLETE=0
|
|||||||
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
||||||
# einer, darf --delete auf dem Pi nicht laufen.
|
# einer, darf --delete auf dem Pi nicht laufen.
|
||||||
NOCREDS_COMPLETE=0
|
NOCREDS_COMPLETE=0
|
||||||
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
|
# NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
|
||||||
# dieselbe Variable wie dort und in tools/release.sh).
|
|
||||||
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
|
||||||
|
|
||||||
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
||||||
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
||||||
@@ -253,46 +329,115 @@ fi
|
|||||||
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
||||||
# Fehler. Lieber ein abgebrochener Deploy.
|
# Fehler. Lieber ein abgebrochener Deploy.
|
||||||
#
|
#
|
||||||
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
|
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen.
|
||||||
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
|
# Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der
|
||||||
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
|
# Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden:
|
||||||
if [ -d "$STAGE/migrate-nocreds" ]; then
|
# er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an
|
||||||
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
|
# migrate-nocreds/, der Rettungssatz lief ungeprueft durch.
|
||||||
|
# migrate-wlan/ und rescue-wlan/ bleiben ungeprueft: die SOLLEN die Zugangsdaten
|
||||||
|
# enthalten, das ist ihr Zweck.
|
||||||
|
#
|
||||||
|
# Gesucht wird im ENTPACKTEN Rumpf, nicht in der Datei, wie sie auf der Platte liegt:
|
||||||
|
# jedes Abbild (_ota.img wie die signierten _prod.img/_dev.img) ist 56 Byte Kopf, dann
|
||||||
|
# ein gzip-Strom, dann 512 Byte Trailer bzw. Signatur. Im komprimierten Strom steht der
|
||||||
|
# Klartext des Passworts nicht - eine Suche im Rohbyteinhalt meldete deshalb IMMER
|
||||||
|
# "sauber", auch fuer den Satz aus build/, der die Zugangsdaten nachweislich traegt. Die
|
||||||
|
# Pruefung hat bis hierhin also nie irgendetwas geprueft.
|
||||||
|
#
|
||||||
|
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
|
||||||
|
# "dann eben ungeprueft durchlassen". Genau diese Nachsicht waere der Weg, auf dem das
|
||||||
|
# Passwort am Ende doch hinausgeht.
|
||||||
|
#
|
||||||
|
# Der Pruefkoerper in tools/release.sh ist verwandt, aber nicht mehr identisch: dort
|
||||||
|
# wird ein fertiges Archiv aus lauter verschiedenen Dateiarten geprueft, hier ein
|
||||||
|
# Bildsatz aus Firmware-Abbildern.
|
||||||
|
# Als if-Zweige und nicht als "[ -d … ] && CHECK_SETS+=(…)": unter "set -e" waere eine
|
||||||
|
# solche Zeile, wenn das Verzeichnis fehlt, ein fehlgeschlagenes letztes Kommando und
|
||||||
|
# risse den Deploy ab.
|
||||||
|
declare -a CHECK_SETS=()
|
||||||
|
if [ -d "$STAGE/migrate-nocreds" ]; then CHECK_SETS+=(migrate-nocreds); fi
|
||||||
|
if [ -d "$STAGE/rescue-nocreds" ]; then CHECK_SETS+=(rescue-nocreds); fi
|
||||||
|
if [ ${#CHECK_SETS[@]} -gt 0 ]; then
|
||||||
|
echo "== Bildsaetze 'ohne-wlan' auf Zugangsdaten pruefen =="
|
||||||
LOCAL="$ROOT/config.local.cmake"
|
LOCAL="$ROOT/config.local.cmake"
|
||||||
if [ -f "$LOCAL" ]; then
|
if [ -f "$LOCAL" ]; then
|
||||||
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1
|
python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
stage, local = sys.argv[1], sys.argv[2]
|
local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:]
|
||||||
text = open(local, encoding="utf-8", errors="replace").read()
|
text = open(local, encoding="utf-8", errors="replace").read()
|
||||||
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
||||||
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
||||||
# haette nur falsche Alarme erzeugt.
|
# haette nur falsche Alarme erzeugt.
|
||||||
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
||||||
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
||||||
|
# Aufbau eines Abbilds: 56 Byte Kopf (vom wm_tool erzeugt), dann der gzip-Strom mit der
|
||||||
|
# eigentlichen Firmware, dann 512 Byte ninlight-Trailer bzw. RSA-Signatur.
|
||||||
|
HEADER_LEN = 56
|
||||||
|
TRAILER_LEN = 512
|
||||||
needles = []
|
needles = []
|
||||||
for name in SECRETS:
|
for name in SECRETS:
|
||||||
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
||||||
if m and m.group(1) not in PUBLIC:
|
if m and m.group(1) not in PUBLIC:
|
||||||
needles.append((name, m.group(1).encode()))
|
needles.append((name, m.group(1).encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(raw):
|
||||||
|
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
|
||||||
|
if len(raw) <= HEADER_LEN + TRAILER_LEN:
|
||||||
|
raise ValueError("Datei ist kuerzer als Kopf + Trailer")
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
body = dec.decompress(raw[HEADER_LEN:-TRAILER_LEN])
|
||||||
|
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
|
||||||
|
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
|
||||||
|
# ungeprueft - und genau dort koennte das Passwort stehen.
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom ist unvollstaendig")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
hits = []
|
hits = []
|
||||||
for root, _dirs, files in os.walk(stage):
|
broken = []
|
||||||
for fn in sorted(files):
|
images = 0
|
||||||
path = os.path.join(root, fn)
|
for image_set in sets:
|
||||||
with open(path, "rb") as fh:
|
root_dir = os.path.join(stage, image_set)
|
||||||
data = fh.read()
|
for root, _dirs, files in os.walk(root_dir):
|
||||||
for name, needle in needles:
|
for fn in sorted(files):
|
||||||
if needle in data:
|
path = os.path.join(root, fn)
|
||||||
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
|
rel = os.path.relpath(path, stage)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
# Durchsucht werden beide Sichten: der entpackte Rumpf (dort steht die
|
||||||
|
# Firmware) UND die Rohbytes (Kopf und Trailer werden nicht komprimiert,
|
||||||
|
# und Beidateien wie manifest.json gibt es nur roh).
|
||||||
|
views = [raw]
|
||||||
|
if fn.endswith(".img"):
|
||||||
|
images += 1
|
||||||
|
try:
|
||||||
|
views.append(unpack(raw))
|
||||||
|
except Exception as exc:
|
||||||
|
broken.append("%s (%s)" % (rel, exc))
|
||||||
|
continue
|
||||||
|
for name, needle in needles:
|
||||||
|
if any(needle in view for view in views):
|
||||||
|
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
|
||||||
|
hits.append("%s in %s" % (name, rel))
|
||||||
|
if broken:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Fehler: Abbild im Bildsatz 'ohne-wlan' laesst sich nicht entpacken, also auch "
|
||||||
|
"nicht auf Zugangsdaten pruefen - nichts uebertragen:\n "
|
||||||
|
+ "\n ".join(broken) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
if hits:
|
if hits:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
|
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
|
||||||
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print(" sauber (%d geprueft)" % len(needles))
|
print(" sauber (%d Abbilder in %d Saetzen geprueft: %s)"
|
||||||
|
% (images, len(sets), ", ".join(sets)))
|
||||||
CHECKPY
|
CHECKPY
|
||||||
else
|
else
|
||||||
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
||||||
@@ -326,11 +471,12 @@ rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST
|
|||||||
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
||||||
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
||||||
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
||||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" <<'REMOTE'
|
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE'
|
||||||
set -e
|
set -e
|
||||||
FULL_SYNC=$1
|
FULL_SYNC=$1
|
||||||
MIGRATE_COMPLETE=$2
|
MIGRATE_COMPLETE=$2
|
||||||
NOCREDS_COMPLETE=$3
|
NOCREDS_COMPLETE=$3
|
||||||
|
RESCUE_NOCREDS_COMPLETE=$4
|
||||||
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||||
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
||||||
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||||
@@ -358,27 +504,41 @@ if [ "$FULL_SYNC" -eq 1 ]; then
|
|||||||
else
|
else
|
||||||
echo " voller Abgleich - keine Images werden entfernt"
|
echo " voller Abgleich - keine Images werden entfernt"
|
||||||
fi
|
fi
|
||||||
# migrate/ wird gleich in einem eigenen Schritt behandelt (siehe unten) - hier
|
# migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
|
||||||
# ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne
|
# hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
|
||||||
# Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht
|
# Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
|
||||||
# existiert.
|
# unter /tmp/ninlight-images deshalb gar nicht existiert.
|
||||||
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
#
|
||||||
|
# Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die
|
||||||
|
# Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die
|
||||||
|
# Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem
|
||||||
|
# Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle
|
||||||
|
# unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt
|
||||||
|
# noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt.
|
||||||
|
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
||||||
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
else
|
else
|
||||||
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||||
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
||||||
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
||||||
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
||||||
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||||
fi
|
fi
|
||||||
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
||||||
# beschreibt (beide Schluessel, alle Ziele) und --delete deshalb erlaubt ist.
|
# beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
|
||||||
|
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
|
||||||
#
|
#
|
||||||
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
|
# Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
|
||||||
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
|
# seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
|
||||||
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
|
# fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
|
||||||
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
|
# tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
|
||||||
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
|
# geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
|
||||||
# statt beim Ersetzen).
|
# frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
|
||||||
sync_migrate_set() {
|
# diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten
|
||||||
|
# betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die
|
||||||
|
# Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren
|
||||||
|
# greift dort also einfach nicht.
|
||||||
|
sync_image_set() {
|
||||||
set=$1
|
set=$1
|
||||||
complete=$2
|
complete=$2
|
||||||
if [ -d "/tmp/ninlight-images/$set" ]; then
|
if [ -d "/tmp/ninlight-images/$set" ]; then
|
||||||
@@ -455,20 +615,28 @@ MERGE
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Weder "kein Schluesselverzeichnis" noch "Verzeichnis vorhanden, aber leer" laesst
|
# Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis
|
||||||
# sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von
|
# vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
|
||||||
# diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau;
|
# ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
|
||||||
# diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit).
|
# ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
|
||||||
echo " keine Migrationsimages fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
# (Fixrunde 2, Kleinigkeit).
|
||||||
|
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Beide Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem Rechner
|
# Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem
|
||||||
# vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der andere auf
|
# Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der
|
||||||
# dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der fehlt, und die
|
# andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der
|
||||||
# Station liefert in dieser Stellung sichtbar nichts aus.
|
# fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus.
|
||||||
sync_migrate_set migrate-wlan "$MIGRATE_COMPLETE"
|
sync_image_set migrate-wlan "$MIGRATE_COMPLETE"
|
||||||
sync_migrate_set migrate-nocreds "$NOCREDS_COMPLETE"
|
sync_image_set migrate-nocreds "$NOCREDS_COMPLETE"
|
||||||
|
# rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht
|
||||||
|
# 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie
|
||||||
|
# das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein
|
||||||
|
# Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen
|
||||||
|
# Schluesseln.
|
||||||
|
sync_image_set rescue-wlan 1
|
||||||
|
sync_image_set rescue-nocreds "$RESCUE_NOCREDS_COMPLETE"
|
||||||
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
||||||
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
||||||
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
||||||
@@ -476,6 +644,21 @@ if [ -d "$ROOT_DIR/images/migrate" ]; then
|
|||||||
sudo rm -rf "$ROOT_DIR/images/migrate"
|
sudo rm -rf "$ROOT_DIR/images/migrate"
|
||||||
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
||||||
fi
|
fi
|
||||||
|
# Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege
|
||||||
|
# FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest
|
||||||
|
# dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung,
|
||||||
|
# warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die
|
||||||
|
# normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja
|
||||||
|
# nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht
|
||||||
|
# uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser
|
||||||
|
# Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv
|
||||||
|
# (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen
|
||||||
|
# gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten.
|
||||||
|
OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null)
|
||||||
|
if [ -n "$OLD_FLAT_RESCUE" ]; then
|
||||||
|
echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f
|
||||||
|
echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)"
|
||||||
|
fi
|
||||||
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
||||||
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
||||||
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
|
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
|
||||||
@@ -66,10 +67,39 @@ UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"
|
|||||||
# schreibt server.py nichts mehr.
|
# schreibt server.py nichts mehr.
|
||||||
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
||||||
"/var/lib/ninlight-station/imageset"))
|
"/var/lib/ninlight-station/imageset"))
|
||||||
|
# Der Modus, den "recovery on"/"migrate on" zuletzt angefordert haben - fuer die
|
||||||
|
# Fusszeile GENAUSO dauerhaft gebraucht wie der Bildsatz-Umschalter oben, aus demselben
|
||||||
|
# Grund und nach demselben Muster (eigene Datei unter /var/lib, NICHT unter RUN_DIR).
|
||||||
|
#
|
||||||
|
# Fixrunde 1, Befund 3: RUN_DIR/mode taugt dafuer NICHT. Die Unit setzt
|
||||||
|
# RuntimeDirectoryPreserve=restart - das erhaelt /run/ninlight-station nur ueber einen
|
||||||
|
# "systemctl restart" hinweg (das, was "recovery on"/"migrate on" fuer einen bereits
|
||||||
|
# laufenden Dienst ausloesen). "ninlight-station off" aber STOPPT den Dienst
|
||||||
|
# (systemctl stop), und GENAU DANN raeumt systemd das RuntimeDirectory leer, "restart"
|
||||||
|
# deckt einen echten Stop nicht ab. Ein frueherer Docstring hier behauptete das
|
||||||
|
# Gegenteil ("die Datei ueberlebt das Abschalten") - das war schlicht falsch, und der
|
||||||
|
# Fehler war unauffaellig, weil eine fehlende Datei auf "recovery" faellt: genau der
|
||||||
|
# Modus, den viele Tests ohnehin erwarteten, ohne dass die Faelligkeit der Datei
|
||||||
|
# je geprueft wurde. Die Folge am Geraet waere die falsche Beruhigung, gegen die die
|
||||||
|
# Fusszeile ueberhaupt gebaut ist - "in Ordnung" vor einer geplanten Migration, obwohl
|
||||||
|
# der Migrationssatz leer ist, weil das Panel mangels Datei den Rettungssatz geprueft
|
||||||
|
# haette.
|
||||||
|
#
|
||||||
|
# write_mode() im CLI schreibt deshalb NEBEN der ephemeren RUN_DIR/mode (die
|
||||||
|
# ausschliesslich server.py's naechsten Start betrifft) auch hierher - dieselbe
|
||||||
|
# Zeichenkette, zwei Ablagen fuer zwei verschiedene Leser mit verschiedenen
|
||||||
|
# Haltbarkeitsanforderungen.
|
||||||
|
LAST_MODE_FILE = Path(os.environ.get("NINLIGHT_LAST_MODE_FILE",
|
||||||
|
"/var/lib/ninlight-station/mode"))
|
||||||
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
|
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
|
||||||
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
|
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
|
||||||
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||||
IMAGESET_DEFAULT = "ohne-wlan"
|
IMAGESET_DEFAULT = "ohne-wlan"
|
||||||
|
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (server.py: rescue_dir()) - mit
|
||||||
|
# eigenen Verzeichnissen, denn Rettungs- und Migrationsabzuege sind verschieden hergestellt
|
||||||
|
# (siehe server.py fuer die Begruendung). Welches Paar am Panel zu pruefen ist, haengt vom
|
||||||
|
# Modus ab: _read_imageset() bekommt ihn deshalb als Parameter statt fest verdrahtet.
|
||||||
|
RESCUE_IMAGESET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
|
||||||
|
|
||||||
POLL_INTERVAL_S = 5
|
POLL_INTERVAL_S = 5
|
||||||
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
|
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
|
||||||
@@ -170,13 +200,58 @@ def _read_autooff_minutes(run_dir: Path, now: float | None = None):
|
|||||||
return int(whole) + (1 if rest else 0)
|
return int(whole) + (1 if rest else 0)
|
||||||
|
|
||||||
|
|
||||||
def _read_mode_file(run_dir: Path) -> str:
|
# Lebenszeichen: ein kleiner Punkt, der erscheint, sobald irgendeine Anfrage hereinkommt
|
||||||
"""Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die
|
# (auch eine erfolglose - "notfound"/"listen"/"unknown"/"noimageset" zaehlen genauso wie
|
||||||
Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert
|
# eine erfolgreiche Auslieferung), und nach ACTIVITY_WINDOW_S Sekunden von selbst wieder
|
||||||
wie in server.py:resolve_mode()."""
|
# verschwindet. Der Wunsch dahinter (Rueckmeldung eines Bedieners, 2026-09-23): bisher war
|
||||||
|
# nur am Servierzaehler ("Served: N") zu sehen, ob es geklappt HAT - bis dahin war offen,
|
||||||
|
# ob ueberhaupt jemand anklopft. server.py schreibt den Zeitpunkt der letzten Anfrage
|
||||||
|
# jeder Art schon nach status.json (Station.last_activity); hier wird daraus nur noch ein
|
||||||
|
# Ja/Nein fuer "gerade eben".
|
||||||
|
#
|
||||||
|
# Bewusst KEIN eigener Zeittakt/Timer: das Panel pollt status.json ohnehin schon alle
|
||||||
|
# POLL_INTERVAL_S Sekunden, und render.blocks() haengt den Punkt an eine bestehende
|
||||||
|
# Textzeile an (siehe dort) - er faehrt also ueber genau denselben Textvergleich
|
||||||
|
# (RefreshPlanner.decide()) mit, der auch jede andere Aenderung erkennt. Ein Erscheinen
|
||||||
|
# und ein Verschwinden kosten damit je einen gewoehnlichen Teilaufbau, keinen eigenen
|
||||||
|
# Mechanismus und kein zusaetzliches Wachhalten des E-Papers - fuegt sich also in die
|
||||||
|
# vorhandene, fuer E-Paper sparsame Taktung ein, statt eine eigene, potenziell haeufigere
|
||||||
|
# aufzumachen.
|
||||||
|
ACTIVITY_WINDOW_S = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_request(status: dict, now: float | None = None) -> bool:
|
||||||
|
"""War die letzte Anfrage (jeder Art) hoechstens ACTIVITY_WINDOW_S Sekunden her?
|
||||||
|
|
||||||
|
"last_request_ts" fehlt bei einer aelteren server.py-Fassung oder wenn noch nie eine
|
||||||
|
Anfrage einging - dann gilt False, kein Punkt erfunden. Ein Zeitstempel aus der
|
||||||
|
Zukunft (Uhr verstellt, oder Server- und Anzeigerechner-Uhr laufen leicht auseinander)
|
||||||
|
zaehlt ebenfalls als "nicht mehr aktuell" statt als potenziell dauerhaft aktiv."""
|
||||||
|
ts = status.get("last_request_ts")
|
||||||
|
if not isinstance(ts, str):
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
value = (run_dir / "mode").read_text().strip()
|
parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||||
except OSError:
|
except ValueError:
|
||||||
|
return False
|
||||||
|
now_s = time.time() if now is None else now
|
||||||
|
age_s = now_s - parsed.timestamp()
|
||||||
|
return 0 <= age_s <= ACTIVITY_WINDOW_S
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mode_file(path: Path) -> str:
|
||||||
|
"""Zuletzt angeforderter Modus laut `path`. Fehlt die Datei, ist sie unlesbar
|
||||||
|
(inklusive ungueltigem UTF-8 - eine halb geschriebene/beschaedigte Datei ist nicht
|
||||||
|
nur per fehlendem Inhalt kaputt), oder ist ihr Inhalt kein bekannter Modus, gilt
|
||||||
|
"recovery" - derselbe Vorgabewert wie in server.py:resolve_mode(). Wirft nie: eine
|
||||||
|
kaputte Datei darf weder das Panel noch "off" aufhalten.
|
||||||
|
|
||||||
|
`path` ist bewusst eine Datei, kein Verzeichnis (anders als frueher, wo hier
|
||||||
|
`run_dir / "mode"` gelesen wurde) - siehe LAST_MODE_FILE oben, warum die Fusszeile
|
||||||
|
NICHT mehr aus RUN_DIR liest."""
|
||||||
|
try:
|
||||||
|
value = path.read_text().strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
return "recovery"
|
return "recovery"
|
||||||
return value if value in ("recovery", "migration") else "recovery"
|
return value if value in ("recovery", "migration") else "recovery"
|
||||||
|
|
||||||
@@ -209,46 +284,105 @@ def _service_active(service_name: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool]:
|
def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tuple[str, bool]:
|
||||||
"""(Name des eingestellten Bildsatzes, liegt er ueberhaupt auf dem Pi?).
|
"""(Name des eingestellten Bildsatzes, liegt der fuer `mode` PASSENDE Satz ueberhaupt
|
||||||
|
auf dem Pi?).
|
||||||
|
|
||||||
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
|
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
|
||||||
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
|
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
|
||||||
Panel etwas anderes behaupten als der Server tut.
|
Panel etwas anderes behaupten als der Server tut.
|
||||||
|
|
||||||
`ok` ist False, wenn der GEWAEHLTE Satz nicht auf dem Pi liegt - nach derselben
|
`ok` ist False, wenn der GEWAEHLTE Satz fuer den GERADE GELTENDEN Modus nicht auf dem
|
||||||
Regel wie server.py:migrate_set_present(): Verzeichnis vorhanden UND mindestens ein
|
Pi liegt - nach derselben Regel wie server.py:migrate_set_present()/
|
||||||
Image darin. Dann liefert server.py nichts aus (Status "noimageset"), und genau das
|
rescue_set_present(): Verzeichnis vorhanden UND mindestens ein Image darin. Welches
|
||||||
muss am Panel stehen, statt dass der Bediener vor einer Lampe steht, die nichts
|
Verzeichnispaar das ist (migrate-*/rescue-*), entscheidet `mode` - Rettung und
|
||||||
bekommt. Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts
|
Migration liegen seit der Erweiterung des Umschalters auf die Rettung in getrennten
|
||||||
behauptet: dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
|
Verzeichnissen (server.py begruendet das). Eine falsche Zuordnung hier wuerde das
|
||||||
|
Panel in GENAU DEM Modus luegen lassen, fuer den es gerade steht: "ok", obwohl die
|
||||||
|
Rettung nichts ausliefern kann, oder "FEHLT", obwohl sie es koennte.
|
||||||
|
|
||||||
|
Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts behauptet:
|
||||||
|
dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
|
||||||
try:
|
try:
|
||||||
value = imageset_path.read_text().strip()
|
value = imageset_path.read_text().strip()
|
||||||
except OSError:
|
except (OSError, UnicodeDecodeError):
|
||||||
value = ""
|
value = ""
|
||||||
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
|
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
|
||||||
image_dir = conf.get("IMAGE_DIR")
|
image_dir = conf.get("IMAGE_DIR")
|
||||||
if not image_dir:
|
if not image_dir:
|
||||||
return name, True
|
return name, True
|
||||||
set_dir = Path(image_dir) / IMAGESET_DIRS[name]
|
dirs = RESCUE_IMAGESET_DIRS if mode == "recovery" else IMAGESET_DIRS
|
||||||
|
set_dir = Path(image_dir) / dirs[name]
|
||||||
try:
|
try:
|
||||||
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
|
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
|
||||||
except OSError:
|
except OSError:
|
||||||
return name, False
|
return name, False
|
||||||
|
|
||||||
|
|
||||||
|
def _route_lookup() -> str:
|
||||||
|
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt.
|
||||||
|
|
||||||
|
Eigene Funktion, damit die Tests sie ersetzen koennen, ohne einen Unterprozess zu
|
||||||
|
starten - und damit local_address() nur noch das Auswerten macht."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
fertig = subprocess.run(
|
||||||
|
# Bewusst eine dokumentierte Testadresse (RFC 5737) statt eines echten Ziels
|
||||||
|
# wie 8.8.8.8: "route get" fragt nur den Kernel, es geht kein Paket hinaus,
|
||||||
|
# aber es soll auch beim Mitlesen nichts Fremdes anklingen.
|
||||||
|
["ip", "-4", "route", "get", "192.0.2.1"],
|
||||||
|
capture_output=True, text=True, timeout=2, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
return fertig.stdout if fertig.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def local_address() -> tuple[str, str]:
|
||||||
|
"""(Geraet, Adresse), unter der der Pi im Heimnetz erreichbar ist - ("", "") ohne Netz.
|
||||||
|
|
||||||
|
Ueber "ip route get", NICHT ueber einen verbundenen UDP-Socket: die Unit erlaubt dem
|
||||||
|
Anzeigedienst nur AF_UNIX und AF_NETLINK (ninlight-display.service:
|
||||||
|
RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort mit EAFNOSUPPORT, und
|
||||||
|
genau daran ist die Adresszeile am echten Panel gescheitert, waehrend derselbe
|
||||||
|
Aufruf von Hand - ohne Sandbox - die Adresse lieferte (Rueckmeldung 2026-09-23).
|
||||||
|
"ip" spricht Netlink und laeuft damit innerhalb der Erlaubnis. Der Unterprozess
|
||||||
|
kostet einen fork alle POLL_INTERVAL_S Sekunden und faellt nur im Aus-Zustand an,
|
||||||
|
dem einzigen Statusbild mit dieser Zeile.
|
||||||
|
|
||||||
|
Das Geraet kommt mit, weil "LAN" am Panel eine Falschauskunft war: dieser Pi haengt
|
||||||
|
per WLAN im Netz, nicht am Kabel. Der Kernel nennt in derselben Auskunft beides -
|
||||||
|
"192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000".
|
||||||
|
|
||||||
|
Wirft nie: ohne Route gibt es nichts auszuwerten, dann liefert der Aufruf ("", "")
|
||||||
|
und die Anzeige sagt das - eine Ausnahme aus der Adressermittlung darf das Panel
|
||||||
|
nicht aufhalten."""
|
||||||
|
worte = _route_lookup().split()
|
||||||
|
geraet = adresse = ""
|
||||||
|
for i, w in enumerate(worte):
|
||||||
|
if w == "dev" and i + 1 < len(worte) and not geraet:
|
||||||
|
geraet = worte[i + 1]
|
||||||
|
elif w == "src" and i + 1 < len(worte) and not adresse:
|
||||||
|
adresse = worte[i + 1]
|
||||||
|
# Ohne Adresse ist auch der Geraetename wertlos - er wuerde ein Netz vortaeuschen,
|
||||||
|
# das nicht steht.
|
||||||
|
return (geraet, adresse) if adresse else ("", "")
|
||||||
|
|
||||||
|
|
||||||
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||||
service_name: str = SERVICE_NAME,
|
service_name: str = SERVICE_NAME,
|
||||||
imageset_path: Path = IMAGESET_FILE) -> dict:
|
imageset_path: Path = IMAGESET_FILE,
|
||||||
|
last_mode_path: Path = LAST_MODE_FILE) -> dict:
|
||||||
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
||||||
|
|
||||||
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
|
Modus kommt vorrangig aus `last_mode_path` (Vorgabe "recovery", wie
|
||||||
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
|
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
|
||||||
zuletzt geschrieben haben und unabhaengig davon verfuegbar, ob/wann der Serverprozess
|
zuletzt geschrieben haben, dauerhaft (siehe LAST_MODE_FILE oben - NICHT die ephemere
|
||||||
seit dem letzten Start schon einmal status.json geschrieben hat. status.json selbst
|
RUN_DIR/mode, die server.py fuer den naechsten Start liest) und unabhaengig davon
|
||||||
liefert nur served/recent/last_seen; sein "mode"-Feld wird bewusst NICHT als
|
verfuegbar, ob/wann der Serverprozess seit dem letzten Start schon einmal
|
||||||
Modusquelle verwendet (Fixrunde 1: die urspruengliche Fassung hatte den Vorrang
|
status.json geschrieben hat. status.json selbst liefert nur served/recent/
|
||||||
umgekehrt).
|
last_seen; sein "mode"-Feld wird bewusst NICHT als Modusquelle verwendet
|
||||||
|
(Fixrunde 1: die urspruengliche Fassung hatte den Vorrang umgekehrt).
|
||||||
|
|
||||||
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
|
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
|
||||||
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
|
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
|
||||||
@@ -257,17 +391,38 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
|||||||
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
|
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
|
||||||
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
|
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
|
||||||
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
|
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
|
||||||
Menue auffaellt, hat genau den Zweck verfehlt."""
|
Menue auffaellt, hat genau den Zweck verfehlt.
|
||||||
|
|
||||||
|
Ob "imageset_ok" den Rettungs- oder den Migrations-Bildsatz prueft, richtet sich nach
|
||||||
|
dem MODUS, in dem die Station beim naechsten Einschalten anlaufen wuerde - also
|
||||||
|
`last_mode_path`, nicht danach, ob sie gerade laeuft. Deshalb wird der Modus HIER,
|
||||||
|
vor der "ist der Dienst aktiv"-Abfrage, gelesen: er ist auch im Zustand "off"
|
||||||
|
verfuegbar (die Datei ist dauerhaft, siehe LAST_MODE_FILE) und genau dort soll die
|
||||||
|
Vorschau ja gelten - "off" zeigt ja noch keinen Modus im engeren Sinn, aber der
|
||||||
|
naechste Start faehrt in genau diesen Modus, und die Fusszeile soll VORHER zeigen,
|
||||||
|
ob dessen Bildsatz bereitliegt.
|
||||||
|
|
||||||
|
"activity" (nur in Recovery/Migration, siehe _recent_request()) sagt, ob die letzte
|
||||||
|
Anfrage jeder Art hoechstens ACTIVITY_WINDOW_S Sekunden her ist - das Lebenszeichen,
|
||||||
|
das render.blocks() an die Servierzeile anhaengt."""
|
||||||
conf = _read_conf(conf_path)
|
conf = _read_conf(conf_path)
|
||||||
imageset, imageset_ok = _read_imageset(imageset_path, conf)
|
mode = _read_mode_file(last_mode_path)
|
||||||
|
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
|
||||||
|
|
||||||
if not _service_active(service_name):
|
if not _service_active(service_name):
|
||||||
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok}
|
# Die IP-Zeile gibt es NUR hier: ist die Station aus, ist der Pi ein gewoehnlicher
|
||||||
|
# Rechner im Heimnetz, und die einzige Auskunft, die man dann am Panel braucht,
|
||||||
|
# ist die Adresse, unter der man per SSH drankommt. Im Betrieb waere sie sinnlos
|
||||||
|
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
|
||||||
|
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
|
||||||
|
geraet, adresse = local_address()
|
||||||
|
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
|
||||||
|
"ip": adresse, "iface": geraet}
|
||||||
|
|
||||||
mode = _read_mode_file(run_dir)
|
|
||||||
status = _read_json(run_dir / "status.json")
|
status = _read_json(run_dir / "status.json")
|
||||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
||||||
"imageset": imageset, "imageset_ok": imageset_ok}
|
"imageset": imageset, "imageset_ok": imageset_ok,
|
||||||
|
"activity": _recent_request(status)}
|
||||||
|
|
||||||
if mode == "recovery":
|
if mode == "recovery":
|
||||||
state["ssid"] = conf.get("SSID")
|
state["ssid"] = conf.get("SSID")
|
||||||
@@ -585,6 +740,23 @@ def run_clear(revision: str) -> None:
|
|||||||
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_read_state(read_state, fallback: dict) -> dict:
|
||||||
|
"""Ruft `read_state()` auf und faengt JEDE Ausnahme ab - nicht nur die, die die
|
||||||
|
einzelnen build_state()-Bausteine schon selbst abfangen (siehe _read_json() etc.
|
||||||
|
oben). run_daemon() laeuft als Endlosschleife, und main() fängt darum nur
|
||||||
|
(DisplayUnavailable, ImportError) ab; ein einzelner, unvorhergesehener Fehler beim
|
||||||
|
Erheben wuerde diese Schleife sonst beenden - und weil E-Paper sein zuletzt
|
||||||
|
gezeichnetes Bild dauerhaft behaelt, saehe das Panel einfach so aus, als sei nichts
|
||||||
|
passiert, nur eben fuer immer eingefroren. `fallback` (der vorherige Zustand, oder
|
||||||
|
beim allerersten Aufruf ein neutraler Platzhalter) gilt dann fuer diesen einen Takt;
|
||||||
|
beim naechsten wird es erneut versucht."""
|
||||||
|
try:
|
||||||
|
return read_state()
|
||||||
|
except Exception as e: # noqa: BLE001 - bewusst pauschal, siehe Docstring
|
||||||
|
log.warning("Zustand konnte nicht gelesen werden, vorheriger Stand bleibt: %s", e)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
||||||
read_state=build_state, read_ui=read_ui) -> None:
|
read_state=build_state, read_ui=read_ui) -> None:
|
||||||
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
|
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
|
||||||
@@ -612,13 +784,25 @@ def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
|||||||
panel = Panel(revision)
|
panel = Panel(revision)
|
||||||
panel.clear()
|
panel.clear()
|
||||||
planner = RefreshPlanner(clock.monotonic())
|
planner = RefreshPlanner(clock.monotonic())
|
||||||
base_state = read_state()
|
base_state = _safe_read_state(read_state, {"mode": None})
|
||||||
last_state_poll = clock.monotonic()
|
last_state_poll = clock.monotonic()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
now = clock.monotonic()
|
now = clock.monotonic()
|
||||||
if now - last_state_poll >= POLL_INTERVAL_S:
|
if now - last_state_poll >= POLL_INTERVAL_S:
|
||||||
base_state = read_state()
|
# Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler
|
||||||
|
# schon ab (siehe _read_json/_read_conf/_service_active/local_address oben) - aber
|
||||||
|
# main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError)
|
||||||
|
# ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim
|
||||||
|
# Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese
|
||||||
|
# Endlosschleife sonst ganz beenden - und E-Paper behaelt sein zuletzt
|
||||||
|
# gezeichnetes Bild fuer immer (siehe Modul-Docstring): die Anzeige wuerde
|
||||||
|
# dann OHNE jede Fehlermeldung am Geraet einfach stehen bleiben, ununterscheidbar
|
||||||
|
# von einem echten, andauernden Zustand. read_state() bleibt deshalb hier
|
||||||
|
# abgesichert - ein einzelner Fehlschlag ueberspringt genau diese Erhebung
|
||||||
|
# (der vorherige Zustand bleibt stehen) statt den ganzen Dienst zu beenden;
|
||||||
|
# beim naechsten Takt in POLL_INTERVAL_S wird es erneut versucht.
|
||||||
|
base_state = _safe_read_state(read_state, base_state)
|
||||||
last_state_poll = now
|
last_state_poll = now
|
||||||
ui = read_ui()
|
ui = read_ui()
|
||||||
if ui:
|
if ui:
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ FONT_SEARCH_PATHS = (
|
|||||||
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
|
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
|
||||||
)
|
)
|
||||||
|
|
||||||
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
MODE_LABELS = {"recovery": "RECOVERY", "migration": "MIGRATION"}
|
||||||
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
|
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
|
||||||
# Zielnamen nach sich), deshalb hier nicht gelistet.
|
# Zielnamen nach sich), deshalb hier nicht gelistet.
|
||||||
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
||||||
@@ -186,8 +186,8 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
|||||||
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
|
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
|
||||||
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
|
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
|
||||||
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
|
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
|
||||||
ARMED_LABEL = "FREIGEGEBEN"
|
ARMED_LABEL = "ARMED"
|
||||||
ARMED_DETAIL_PREFIX = "Freigabe: "
|
ARMED_DETAIL_PREFIX = "Armed: "
|
||||||
|
|
||||||
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
|
# Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert
|
||||||
# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts,
|
# und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts,
|
||||||
@@ -201,16 +201,16 @@ ARMED_DETAIL_PREFIX = "Freigabe: "
|
|||||||
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe
|
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe
|
||||||
# test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
|
# test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
|
||||||
IMAGESET_LABELS = {
|
IMAGESET_LABELS = {
|
||||||
"ohne-wlan": "ohne WLAN-Daten (Onboarding-AP)",
|
"ohne-wlan": "no Wi-Fi data (onboarding AP)",
|
||||||
"mit-wlan": "MIT WLAN-Daten (eigenes Netz)",
|
"mit-wlan": "WITH Wi-Fi data (own network)",
|
||||||
}
|
}
|
||||||
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie
|
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie
|
||||||
# zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in
|
# zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in
|
||||||
# BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige
|
# BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige
|
||||||
# Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
|
# Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
|
||||||
IMAGESET_MISSING_LABELS = {
|
IMAGESET_MISSING_LABELS = {
|
||||||
"ohne-wlan": "SATZ FEHLT: ohne WLAN-Daten",
|
"ohne-wlan": "SET MISSING: no Wi-Fi data",
|
||||||
"mit-wlan": "SATZ FEHLT: MIT WLAN-Daten",
|
"mit-wlan": "SET MISSING: WITH Wi-Fi data",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
|
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
|
||||||
@@ -228,10 +228,10 @@ FINAL_LABELS = {
|
|||||||
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
|
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
|
||||||
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
|
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
|
||||||
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
|
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
|
||||||
"poweroff": ("AUSGESCHALTET", "Strom trennen erlaubt", []),
|
"poweroff": ("POWERED OFF", "Safe to unplug", []),
|
||||||
"reboot": ("NEUSTART", "Strom NICHT trennen!",
|
"reboot": ("REBOOTING", "Do NOT unplug!",
|
||||||
["Der Pi kommt in rund 2 min wieder.",
|
["The Pi is back in about 2 min.",
|
||||||
"Solange bleibt dieses Bild stehen."]),
|
"This screen stays until then."]),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
|
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
|
||||||
@@ -293,7 +293,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
|||||||
if label is None:
|
if label is None:
|
||||||
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
|
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
|
||||||
# zu sagen, wenn gleich wieder gebootet wird.
|
# zu sagen, wenn gleich wieder gebootet wird.
|
||||||
return [("Faehrt herunter", ROLE_BIG)]
|
return [("Shutting down", ROLE_BIG)]
|
||||||
title, hero, details = label
|
title, hero, details = label
|
||||||
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
|
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
|
||||||
+ [(d, ROLE_DETAIL) for d in details])
|
+ [(d, ROLE_DETAIL) for d in details])
|
||||||
@@ -308,14 +308,39 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
|||||||
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
|
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
|
||||||
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
|
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
|
||||||
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
|
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
|
||||||
out.append(("Station aus", ROLE_BIG))
|
out.append(("Station off", ROLE_BIG))
|
||||||
|
# Die Adresse, unter der der Pi gerade erreichbar ist - die einzige Auskunft, die
|
||||||
|
# man bei ausgeschalteter Station am Geraet wirklich braucht (per SSH draufgehen,
|
||||||
|
# ohne den Router zu befragen). display.py legt sie nur in DIESEN Zustand; fehlt
|
||||||
|
# das Feld (aeltere display.py-Fassung, von Hand gebauter Zustand im Test), bleibt
|
||||||
|
# die Zeile weg statt eine Adresse zu erfinden. Ist das Feld da, aber leer, sagt
|
||||||
|
# die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz"
|
||||||
|
# sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert.
|
||||||
|
#
|
||||||
|
# Beschriftet mit dem GERAET ("wlan0: 192.168.2.96"), nicht mit "LAN": derselbe
|
||||||
|
# Bediener las "LAN" als Falschauskunft, weil dieser Pi per WLAN im Netz haengt
|
||||||
|
# und gar kein Kabel hat (Rueckmeldung 2026-09-23). Der Geraetename grenzt
|
||||||
|
# ausserdem genauso eindeutig vom Zugangspunkt-Netz ab, das auf jedem ANDEREN
|
||||||
|
# Statusbild als SSID steht - und er sagt zusaetzlich, WORUEBER der Pi haengt.
|
||||||
|
# Ohne Netz "no home network" statt eines nackten "no network": letzteres wurde
|
||||||
|
# auf den Migrations-/Rettungs-Zugangspunkt bezogen (der im Aus-Zustand ja
|
||||||
|
# tatsaechlich aus ist). Gemeint ist das GEGENTEIL - das gewoehnliche Heimnetz.
|
||||||
|
ip = state.get("ip")
|
||||||
|
if isinstance(ip, str):
|
||||||
|
geraet = state.get("iface")
|
||||||
|
if not ip:
|
||||||
|
out.append(("no home network", ROLE_DETAIL))
|
||||||
|
elif isinstance(geraet, str) and geraet:
|
||||||
|
out.append((f"{geraet}: {ip}", ROLE_DETAIL))
|
||||||
|
else:
|
||||||
|
out.append((f"IP: {ip}", ROLE_DETAIL))
|
||||||
return out + footer
|
return out + footer
|
||||||
|
|
||||||
if mode not in ("recovery", "migration"):
|
if mode not in ("recovery", "migration"):
|
||||||
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
|
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
|
||||||
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
|
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
|
||||||
# unterstellen.
|
# unterstellen.
|
||||||
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG))
|
out.append((f"Status unknown ({mode})" if mode else "Status unknown", ROLE_BIG))
|
||||||
return out + footer
|
return out + footer
|
||||||
|
|
||||||
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
||||||
@@ -409,12 +434,23 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
|||||||
autooff_minutes = state.get("autooff_minutes")
|
autooff_minutes = state.get("autooff_minutes")
|
||||||
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
|
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
|
||||||
if autooff_minutes > 0:
|
if autooff_minutes > 0:
|
||||||
out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL))
|
out.append(("Auto-off in {} min".format(autooff_minutes), ROLE_DETAIL))
|
||||||
else:
|
else:
|
||||||
out.append(("Auto-Aus steht an", ROLE_DETAIL))
|
out.append(("Auto-off pending", ROLE_DETAIL))
|
||||||
|
|
||||||
served = state.get("served")
|
served = state.get("served")
|
||||||
out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL))
|
served_line = f"Served: {served if isinstance(served, int) else 0}"
|
||||||
|
# Lebenszeichen: ein kleiner Punkt, sobald IRGENDEINE Anfrage vor kurzem hereinkam
|
||||||
|
# (display.py:_recent_request() - auch eine erfolglose zaehlt). Angehaengt an eine
|
||||||
|
# bereits bestehende, immer vorhandene Zeile statt an einer eigenen Stelle im Bild
|
||||||
|
# gezeichnet: er braucht dadurch keine eigene Zeichenlogik in compose() und keinen
|
||||||
|
# eigenen Platz im knappen Zeilenbudget, und er faehrt ueber denselben
|
||||||
|
# Textvergleich (RefreshPlanner.decide() in display.py) mit, der ueber ein
|
||||||
|
# Neuzeichnen entscheidet - kein zusaetzlicher Takt, kein zusaetzliches Wachhalten
|
||||||
|
# des E-Papers, siehe display.py:ACTIVITY_WINDOW_S fuer die volle Begruendung.
|
||||||
|
if state.get("activity"):
|
||||||
|
served_line += " •"
|
||||||
|
out.append((served_line, ROLE_DETAIL))
|
||||||
|
|
||||||
recent = state.get("recent")
|
recent = state.get("recent")
|
||||||
if isinstance(recent, list) and recent:
|
if isinstance(recent, list) and recent:
|
||||||
@@ -467,7 +503,7 @@ def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
|
|||||||
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
|
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
|
||||||
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
|
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
|
||||||
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
|
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
|
||||||
out.append(("Bedienung", ROLE_BIG))
|
out.append(("Button menu", ROLE_BIG))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+201
-130
@@ -107,14 +107,14 @@ elif [ -e "$CONF" ]; then
|
|||||||
# Rechte aufzuweichen.
|
# Rechte aufzuweichen.
|
||||||
eval "$(sudo cat "$CONF")"
|
eval "$(sudo cat "$CONF")"
|
||||||
else
|
else
|
||||||
echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2
|
echo "Error: $CONF missing - run tools/station/setup_pi.sh first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
|
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
|
||||||
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
|
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
|
||||||
eval "test -n \"\${$_key-}\"" || {
|
eval "test -n \"\${$_key-}\"" || {
|
||||||
echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2
|
echo "Error: $CONF: key $_key missing or empty - run tools/station/setup_pi.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
@@ -125,13 +125,38 @@ need_profile() {
|
|||||||
# -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
|
# -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
|
||||||
# "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
|
# "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
|
||||||
nmcli -t --escape no -f NAME con show | grep -Fqx "$1" || {
|
nmcli -t --escape no -f NAME con show | grep -Fqx "$1" || {
|
||||||
echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
|
echo "Error: NetworkManager profile '$1' missing - run tools/station/setup_pi.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
images() {
|
# --- Modus lesen/schreiben (Fixrunde 1, Befund 3) -----------------------------------
|
||||||
find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort
|
#
|
||||||
|
# RUN_DIR/mode (write_mode() unten schreibt sie zusaetzlich) taugt NICHT als Quelle
|
||||||
|
# fuer "welcher Modus gilt gerade/als naechstes", solange die Station AUS ist: die Unit
|
||||||
|
# setzt RuntimeDirectoryPreserve=restart, das erhaelt /run/ninlight-station nur ueber
|
||||||
|
# einen "systemctl restart" (das, was "recovery on"/"migrate on" bei einem schon
|
||||||
|
# laufenden Dienst ausloesen), NICHT ueber einen echten "systemctl stop" - und genau
|
||||||
|
# den fuehrt "ninlight-station off" aus. Fehlt die Datei, gilt ueberall stillschweigend
|
||||||
|
# "recovery" (derselbe sichere Vorgabewert wie beim Bildsatz) - eine fruehere Fassung
|
||||||
|
# dieses Skripts UND von display.py hat das mit "die Datei bleibt bei jedem Zustand
|
||||||
|
# erhalten" verwechselt.
|
||||||
|
#
|
||||||
|
# LAST_MODE_FILE ist deshalb wie IMAGESET_FILE eine EIGENE, dauerhafte Ablage unter
|
||||||
|
# /var/lib - dieselbe Datei, die display.py:LAST_MODE_FILE liest (Panel und CLI muessen
|
||||||
|
# sich hier einig sein, sonst zeigt das eine "Rettung: MISSING" waehrend das andere
|
||||||
|
# "Migration: 3 image(s)" meldet).
|
||||||
|
LAST_MODE_FILE=${NINLIGHT_LAST_MODE_FILE:-/var/lib/ninlight-station/mode}
|
||||||
|
|
||||||
|
# Der zuletzt angeforderte Modus, oder "recovery" bei fehlender/unlesbarer/unbekannter
|
||||||
|
# Datei - dieselbe Vorgabe und dieselbe Begruendung wie imageset_get() unten. Scheitert
|
||||||
|
# nie: eine kaputte Datei darf weder "status" noch "bildsatz" aufhalten.
|
||||||
|
current_mode() {
|
||||||
|
_m=$(cat "$LAST_MODE_FILE" 2>/dev/null | tr -d ' \t\r\n') || true
|
||||||
|
case "$_m" in
|
||||||
|
recovery|migration) printf '%s' "$_m" ;;
|
||||||
|
*) printf '%s' "recovery" ;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Bildsatz lesen, schreiben, benennen --------------------------------------------
|
# --- Bildsatz lesen, schreiben, benennen --------------------------------------------
|
||||||
@@ -153,18 +178,33 @@ imageset_get() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Das Verzeichnis zum Namen des Bildsatzes ($1).
|
# Das Verzeichnis zum Namen des Bildsatzes ($1), fuer den Modus $2 (Vorgabe:
|
||||||
|
# current_mode()). Rettung und Migration liegen in getrennten Verzeichnispaaren
|
||||||
|
# (server.py:RESCUE_SET_DIRS/IMAGE_SET_DIRS begruendet das) - dieselbe Unterscheidung
|
||||||
|
# wie display.py:_read_imageset(). Fixrunde 1, Befund 1: vor dieser Aenderung pruefte
|
||||||
|
# dieses Skript hier IMMER die Migrationsverzeichnisse, auch im Rettungsbetrieb - ein
|
||||||
|
# falsches "MISSING" dort verleitet dazu, auf mit-wlan umzuschalten, und DANN liefert
|
||||||
|
# die Rettung tatsaechlich Abbilder mit dem WLAN-Passwort aus.
|
||||||
imageset_dir() {
|
imageset_dir() {
|
||||||
case "$1" in
|
_name=$1
|
||||||
mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;;
|
_mode=${2:-$(current_mode)}
|
||||||
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;;
|
if [ "$_mode" = "recovery" ]; then
|
||||||
esac
|
case "$_name" in
|
||||||
|
mit-wlan) printf '%s' "$IMAGE_DIR/rescue-wlan" ;;
|
||||||
|
*) printf '%s' "$IMAGE_DIR/rescue-nocreds" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
case "$_name" in
|
||||||
|
mit-wlan) printf '%s' "$IMAGE_DIR/migrate-wlan" ;;
|
||||||
|
*) printf '%s' "$IMAGE_DIR/migrate-nocreds" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
imageset_label() {
|
imageset_label() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
mit-wlan) printf 'mit-wlan (Lampe haengt sofort im eigenen WLAN)' ;;
|
mit-wlan) printf 'mit-wlan (lamp joins your Wi-Fi right away)' ;;
|
||||||
*) printf 'ohne-wlan (Lampe startet im Onboarding-Zugangspunkt)' ;;
|
*) printf 'ohne-wlan (lamp starts its onboarding access point)' ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +219,14 @@ imageset_set() {
|
|||||||
sudo mv -f "$_tmp" "$IMAGESET_FILE"
|
sudo mv -f "$_tmp" "$IMAGESET_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate_images() {
|
# Die Abbilder des eingestellten Bildsatzes fuer den Modus $1 (Vorgabe: current_mode()).
|
||||||
find "$(imageset_dir "$(imageset_get)")" -maxdepth 1 -name '*.img' 2>/dev/null | sort
|
# Ersetzt das fruehere migrate_images() (nur Migration) UND das fruehere images() (immer
|
||||||
|
# flach in $IMAGE_DIR, Fixrunde 1, Befund 2: nach dem Deploy-Umbau liegt dort nichts
|
||||||
|
# mehr - recovery_on() meldete seither bei JEDEM Start faelschlich "answers everything
|
||||||
|
# with 404", ununterscheidbar vom echten Fehlerfall).
|
||||||
|
imageset_images() {
|
||||||
|
_mode=${1:-$(current_mode)}
|
||||||
|
find "$(imageset_dir "$(imageset_get)" "$_mode")" -maxdepth 1 -name '*.img' 2>/dev/null | sort
|
||||||
}
|
}
|
||||||
|
|
||||||
# "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten.
|
# "bildsatz" ohne Argument: nur berichten. Mit Argument: umschalten.
|
||||||
@@ -194,49 +240,54 @@ bildsatz_cmd() {
|
|||||||
case "$_want" in
|
case "$_want" in
|
||||||
ohne-wlan|mit-wlan) ;;
|
ohne-wlan|mit-wlan) ;;
|
||||||
*)
|
*)
|
||||||
echo "Fehler: unbekannter Bildsatz '$_want' - erlaubt sind 'ohne-wlan' und 'mit-wlan'" >&2
|
echo "Error: unknown image set '$_want' - allowed are 'ohne-wlan' and 'mit-wlan'" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
imageset_set "$_want" || {
|
imageset_set "$_want" || {
|
||||||
echo "Fehler: $IMAGESET_FILE konnte nicht geschrieben werden" >&2
|
echo "Error: could not write $IMAGESET_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
echo "Bildsatz: $(imageset_label "$_want")"
|
echo "Image set: $(imageset_label "$_want")"
|
||||||
[ "$_now" = "$_want" ] && echo " (war schon eingestellt)"
|
[ "$_now" = "$_want" ] && echo " (was already set)"
|
||||||
|
# Der Umschalter gilt fuer BEIDE Betriebsarten - hier wird deshalb der Satz gemeldet,
|
||||||
|
# der fuer den Modus zaehlt, in dem die Station gerade steht bzw. als naechstes
|
||||||
|
# anlaufen wuerde (current_mode()), nicht pauschal die Migration.
|
||||||
_dir=$(imageset_dir "$_want")
|
_dir=$(imageset_dir "$_want")
|
||||||
if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then
|
if [ "$(find "$_dir" -maxdepth 1 -name '*.img' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then
|
||||||
# Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts
|
# Kein stiller Rueckfall auf den anderen Satz - server.py liefert dann gar nichts
|
||||||
# aus (Status "noimageset"), und das Panel sagt "FEHLT". Hier trotzdem sofort
|
# aus (Status "noimageset"), und das Panel sagt "MISSING". Hier trotzdem sofort
|
||||||
# warnen, damit niemand erst an der Lampe merkt, dass nichts kommt.
|
# warnen, damit niemand erst an der Lampe merkt, dass nichts kommt.
|
||||||
echo "Warnung: $_dir fehlt oder ist leer - in diesem Bildsatz wird NICHTS ausgeliefert. tools/station/deploy.sh erneut laufen lassen." >&2
|
echo "Warning: $_dir missing or empty - this image set serves NOTHING. Run tools/station/deploy.sh again." >&2
|
||||||
fi
|
fi
|
||||||
if [ "$_want" = "mit-wlan" ]; then
|
if [ "$_want" = "mit-wlan" ]; then
|
||||||
echo "Achtung: migrierte Lampen haengen damit sofort im WLAN aus config.local.cmake - auch fremde Lampen, die im Automatikmodus mitgenommen werden." >&2
|
echo "Caution: any lamp migrated OR rescued while this is set joins the Wi-Fi from config.local.cmake right away - including lamps that are not yours and got picked up in automatic mode." >&2
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben.
|
# Die zwei Zeilen, die "status" und "bildsatz" ohne Argument gemeinsam ausgeben - fuer
|
||||||
|
# den Modus, in dem die Station gerade steht bzw. als naechstes anlaufen wuerde.
|
||||||
imageset_status_lines() {
|
imageset_status_lines() {
|
||||||
|
_mode=$(current_mode)
|
||||||
_set=$(imageset_get)
|
_set=$(imageset_get)
|
||||||
_dir=$(imageset_dir "$_set")
|
_dir=$(imageset_dir "$_set" "$_mode")
|
||||||
# "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie
|
# "Vorhanden" heisst: mindestens ein Image darin - dieselbe Regel wie
|
||||||
# server.py:migrate_set_present() und display.py:_read_imageset(). Ein leeres
|
# server.py:migrate_set_present()/rescue_set_present() und display.py:_read_imageset().
|
||||||
# Verzeichnis liefert genauso nichts aus wie ein fehlendes.
|
# Ein leeres Verzeichnis liefert genauso nichts aus wie ein fehlendes.
|
||||||
_n=$(migrate_images | wc -l | tr -d ' ')
|
_n=$(imageset_images "$_mode" | wc -l | tr -d ' ')
|
||||||
if [ "$_n" -gt 0 ]; then
|
if [ "$_n" -gt 0 ]; then
|
||||||
_state="$_n Image(s)"
|
_state="$_n image(s)"
|
||||||
else
|
else
|
||||||
_state="FEHLT - es wird nichts ausgeliefert"
|
_state="MISSING - nothing will be served"
|
||||||
fi
|
fi
|
||||||
printf 'Bildsatz: %s\n' "$(imageset_label "$_set")"
|
printf 'Image set: %s\n' "$(imageset_label "$_set")"
|
||||||
printf ' %s (%s)\n' "$_dir" "$_state"
|
printf ' %s (%s)\n' "$_dir" "$_state"
|
||||||
# Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die
|
# Steht in der Datei etwas, das kein Bildsatzname ist, gilt stillschweigend die
|
||||||
# Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst
|
# Vorgabe - das ist richtig so (sicher), aber der Bediener muss es erfahren: sonst
|
||||||
# glaubt er, sein Eintrag von Hand haette gewirkt.
|
# glaubt er, sein Eintrag von Hand haette gewirkt.
|
||||||
_raw=$(imageset_raw)
|
_raw=$(imageset_raw)
|
||||||
if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then
|
if [ -n "$_raw" ] && [ "$_raw" != "$_set" ]; then
|
||||||
printf " (unbekannte Einstellung '%s' in %s - Vorgabe gilt)\n" \
|
printf " (unknown setting '%s' in %s - default applies)\n" \
|
||||||
"$_raw" "$IMAGESET_FILE"
|
"$_raw" "$IMAGESET_FILE"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -276,7 +327,7 @@ ap_switch() {
|
|||||||
# gehoert zum Wert).
|
# gehoert zum Wert).
|
||||||
_active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)
|
_active=$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)
|
||||||
if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
|
if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
|
||||||
echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2
|
echo "Error: after switching off, wlan0 still shows '$_active' active - check NetworkManager (access point stays up)" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
@@ -287,18 +338,28 @@ ap_switch() {
|
|||||||
if [ "$_active" != "$_target" ]; then
|
if [ "$_active" != "$_target" ]; then
|
||||||
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
|
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
|
||||||
sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true
|
sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true
|
||||||
echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2
|
echo "Error: after switching to '$_target', wlan0 unexpectedly shows '$_active' - both station profiles brought down again" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann
|
# Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann
|
||||||
# umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file.
|
# umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file. ZUSAETZLICH
|
||||||
|
# dieselbe Zeichenkette nach $LAST_MODE_FILE (dauerhaft, siehe dort) - RUN_DIR/mode
|
||||||
|
# ueberlebt nur einen "systemctl restart", nicht das "systemctl stop" von
|
||||||
|
# "ninlight-station off" (Fixrunde 1, Befund 3). Beide Schreibvorgaenge muessen
|
||||||
|
# gelingen: schlaegt der dauerhafte fehl, wuerden Panel und CLI nach einem "off" auf
|
||||||
|
# den falschen (naemlich den vorherigen) Modus zurueckfallen, ohne dass das hier
|
||||||
|
# auffiele.
|
||||||
write_mode() {
|
write_mode() {
|
||||||
sudo mkdir -p "$RUN_DIR"
|
sudo mkdir -p "$RUN_DIR"
|
||||||
_tmp="$RUN_DIR/.mode-$$"
|
_tmp="$RUN_DIR/.mode-$$"
|
||||||
printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
|
printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
|
||||||
sudo mv -f "$_tmp" "$RUN_DIR/mode"
|
sudo mv -f "$_tmp" "$RUN_DIR/mode" || return 1
|
||||||
|
sudo mkdir -p "$(dirname "$LAST_MODE_FILE")"
|
||||||
|
_tmp2="$(dirname "$LAST_MODE_FILE")/.mode-$$"
|
||||||
|
printf '%s' "$1" | sudo tee "$_tmp2" >/dev/null && sudo chmod 0644 "$_tmp2" || return 1
|
||||||
|
sudo mv -f "$_tmp2" "$LAST_MODE_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im
|
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im
|
||||||
@@ -326,16 +387,16 @@ write_control() {
|
|||||||
autooff_check_minutes() {
|
autooff_check_minutes() {
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
'' )
|
'' )
|
||||||
echo "Fehler: '--for' braucht eine Angabe in Minuten ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2
|
echo "Error: '--for' needs a value in minutes ($AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES)" >&2
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
*[!0-9]* )
|
*[!0-9]* )
|
||||||
echo "Fehler: '$1' ist keine Minutenangabe - erlaubt sind ganze Zahlen von $AUTOOFF_MIN_MINUTES bis $AUTOOFF_MAX_MINUTES" >&2
|
echo "Error: '$1' is not a number of minutes - allowed are whole numbers from $AUTOOFF_MIN_MINUTES to $AUTOOFF_MAX_MINUTES" >&2
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then
|
if [ "$1" -lt "$AUTOOFF_MIN_MINUTES" ] || [ "$1" -gt "$AUTOOFF_MAX_MINUTES" ]; then
|
||||||
echo "Fehler: $1 Minuten liegen ausserhalb von $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2
|
echo "Error: $1 minutes is outside $AUTOOFF_MIN_MINUTES-$AUTOOFF_MAX_MINUTES" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -424,11 +485,11 @@ autooff_arm() {
|
|||||||
set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB"
|
set -- "$@" "--setenv=NINLIGHT_STATION_LIB=$NINLIGHT_STATION_LIB"
|
||||||
fi
|
fi
|
||||||
if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then
|
if ! sudo systemd-run "$@" "$SELF" __autooff >/dev/null; then
|
||||||
echo "Fehler: Zeitschalter liess sich nicht anlegen (systemd-run '$AUTOOFF_UNIT') - die Station bleibt AN, 'ninlight-station off' nicht vergessen." >&2
|
echo "Error: could not create the timer (systemd-run '$AUTOOFF_UNIT') - the station stays ON, do not forget 'ninlight-station off'." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || {
|
autooff_write "$_req" "$(( $(date +%s) + _delay ))" "$_deferrals" || {
|
||||||
echo "Fehler: $AUTOOFF_FILE konnte nicht geschrieben werden - Zeitschalter wieder abgeraeumt" >&2
|
echo "Error: could not write $AUTOOFF_FILE - timer removed again" >&2
|
||||||
autooff_clear
|
autooff_clear
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -455,7 +516,7 @@ autooff_busy() {
|
|||||||
# "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur
|
# "ja, beschaeftigt", haette ein fehlendes ss den Zeitschalter bis zur
|
||||||
# Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der
|
# Obergrenze ausgehebelt - und der Zeitschalter ist angefordert worden, WEIL der
|
||||||
# Nutzer sonst nicht mehr an den Pi kommt.
|
# Nutzer sonst nicht mehr an den Pi kommt.
|
||||||
echo "Warnung: 'ss' nicht gefunden - laufende Auslieferungen konnten nicht geprueft werden" >&2
|
echo "Warning: 'ss' not found - could not check for downloads in progress" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
_conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true)
|
_conns=$("$_ss" -Htn state established "( sport = :80 )" dst "$AUTOOFF_STATION_NET" 2>/dev/null | grep -c . || true)
|
||||||
@@ -485,10 +546,10 @@ autooff_fire() {
|
|||||||
if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then
|
if [ "$_deferrals" -lt "$AUTOOFF_MAX_DEFERRALS" ]; then
|
||||||
_deferrals=$((_deferrals + 1))
|
_deferrals=$((_deferrals + 1))
|
||||||
autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals"
|
autooff_write "$_req" "$(( $(date +%s) + AUTOOFF_RETRY_S ))" "$_deferrals"
|
||||||
echo "Zeitschalter verschoben ($_deferrals/$AUTOOFF_MAX_DEFERRALS): eine Auslieferung laeuft noch"
|
echo "Timer deferred ($_deferrals/$AUTOOFF_MAX_DEFERRALS): a download is still running"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warnung: $AUTOOFF_MAX_DEFERRALS Verschiebungen erreicht - es steht immer noch eine Verbindung offen, sie haengt vermutlich. Station wird jetzt trotzdem abgeschaltet." >&2
|
echo "Warning: reached $AUTOOFF_MAX_DEFERRALS deferrals - a connection is still open and has probably stalled. Switching the station off anyway." >&2
|
||||||
fi
|
fi
|
||||||
full_off
|
full_off
|
||||||
}
|
}
|
||||||
@@ -529,7 +590,7 @@ start_and_verify() {
|
|||||||
# Lebendigkeitspruefung unten je erreicht wird, und der eben
|
# Lebendigkeitspruefung unten je erreicht wird, und der eben
|
||||||
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
|
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
|
||||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||||
echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
|
echo "Error: could not (re)start ninlight-station - access point switched off again. Details: journalctl -u ninlight-station" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess
|
# "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess
|
||||||
@@ -542,14 +603,14 @@ start_and_verify() {
|
|||||||
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
|
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
|
||||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||||
echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
|
echo "Error: ninlight-station is not active after starting - access point switched off again. Details: journalctl -u ninlight-station" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
_actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true)
|
_actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true)
|
||||||
if [ "$_actual_mode" != "$_expect_mode" ]; then
|
if [ "$_actual_mode" != "$_expect_mode" ]; then
|
||||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||||
echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2
|
echo "Error: after the restart $RUN_DIR/mode holds '$_actual_mode' instead of '$_expect_mode' - the RuntimeDirectory was probably wiped (check RuntimeDirectoryPreserve=restart in the unit). Access point switched off again." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -n "$_expect_phase" ]; then
|
if [ -n "$_expect_phase" ]; then
|
||||||
@@ -568,7 +629,7 @@ PY
|
|||||||
if [ "$_actual_phase" != "$_expect_phase" ]; then
|
if [ "$_actual_phase" != "$_expect_phase" ]; then
|
||||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||||
echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2
|
echo "Error: after the restart $RUN_DIR/control.json holds phase '$_actual_phase' instead of '$_expect_phase' - the RuntimeDirectory was probably wiped. Access point switched off again." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -606,26 +667,30 @@ parse_for_option() {
|
|||||||
autooff_arm_if_requested() {
|
autooff_arm_if_requested() {
|
||||||
[ -n "$_for_minutes" ] || return 0
|
[ -n "$_for_minutes" ] || return 0
|
||||||
autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1
|
autooff_arm "$_for_minutes" "$((_for_minutes * 60))" 0 || exit 1
|
||||||
echo "Zeitschalter: Auto-Aus in $_for_minutes min ('ninlight-station renew' verlaengert, 'hold' bestellt ab)"
|
echo "Timer: auto-off in $_for_minutes min ('ninlight-station renew' extends it, 'hold' cancels it)"
|
||||||
}
|
}
|
||||||
|
|
||||||
recovery_on() {
|
recovery_on() {
|
||||||
parse_for_option recovery "$@"
|
parse_for_option recovery "$@"
|
||||||
need_profile "$AP_CONNECTION"
|
need_profile "$AP_CONNECTION"
|
||||||
n=$(images | wc -l | tr -d ' ')
|
# Explizit "recovery" statt current_mode(): dieser Aufruf ENTSCHEIDET gerade, dass
|
||||||
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2
|
# der naechste Start Rettung ist - write_mode() darunter schreibt das erst gleich.
|
||||||
|
# current_mode() koennte hier noch "migration" von einem vorherigen Lauf zeigen.
|
||||||
|
_setdir=$(imageset_dir "$(imageset_get)" recovery)
|
||||||
|
n=$(imageset_images recovery | wc -l | tr -d ' ')
|
||||||
|
[ "$n" -gt 0 ] || echo "Warning: $_setdir holds no image - the station answers everything with 404" >&2
|
||||||
ap_switch "$AP_CONNECTION"
|
ap_switch "$AP_CONNECTION"
|
||||||
write_mode "recovery"
|
write_mode "recovery"
|
||||||
start_and_verify "$AP_CONNECTION" "recovery"
|
start_and_verify "$AP_CONNECTION" "recovery"
|
||||||
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
|
echo "Station on: SSID $SSID at 192.168.8.1, $n image(s)"
|
||||||
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
|
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
|
||||||
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
|
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
|
||||||
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
|
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
|
||||||
# ausschalten) steht unten.
|
# ausschalten) steht unten.
|
||||||
if [ "$PSK" = "ninlight-recovery" ]; then
|
if [ "$PSK" = "ninlight-recovery" ]; then
|
||||||
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
|
echo "Warning: PSK is still the public default 'ninlight-recovery' - set your own password in /etc/ninlight-station.conf." >&2
|
||||||
fi
|
fi
|
||||||
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
echo "Do not forget: 'ninlight-station off' when you are done."
|
||||||
autooff_arm_if_requested
|
autooff_arm_if_requested
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,25 +721,27 @@ migrate_on() {
|
|||||||
[ "$_for_seen" -eq 1 ] || _for_minutes=""
|
[ "$_for_seen" -eq 1 ] || _for_minutes=""
|
||||||
need_profile "$MIGRATE_AP_CONNECTION"
|
need_profile "$MIGRATE_AP_CONNECTION"
|
||||||
_set=$(imageset_get)
|
_set=$(imageset_get)
|
||||||
_setdir=$(imageset_dir "$_set")
|
# Explizit "migration", aus demselben Grund wie in recovery_on(): dieser Aufruf legt
|
||||||
n=$(migrate_images | wc -l | tr -d ' ')
|
# den naechsten Modus gerade erst fest.
|
||||||
[ "$n" -gt 0 ] || echo "Warnung: $_setdir enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2
|
_setdir=$(imageset_dir "$_set" migration)
|
||||||
|
n=$(imageset_images migration | wc -l | tr -d ' ')
|
||||||
|
[ "$n" -gt 0 ] || echo "Warning: $_setdir holds no migration images - the station answers everything with 404" >&2
|
||||||
# Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
|
# Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
|
||||||
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
|
# hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
|
||||||
ap_switch "$MIGRATE_AP_CONNECTION"
|
ap_switch "$MIGRATE_AP_CONNECTION"
|
||||||
write_mode "migration"
|
write_mode "migration"
|
||||||
write_control "$_phase" "" || {
|
write_control "$_phase" "" || {
|
||||||
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
|
echo "Error: could not write $RUN_DIR/control.json" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
|
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
|
||||||
echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)"
|
echo "Migration on: SSID $MIGRATE_SSID at 192.168.8.1, phase $_phase, $n migration image(s)"
|
||||||
# Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte
|
# Der Bildsatz gehoert in JEDE Einschaltmeldung: er entscheidet, ob die migrierte
|
||||||
# Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche
|
# Lampe das WLAN-Passwort des Bedieners mitbekommt, und er kann von vorletzter Woche
|
||||||
# stehen.
|
# stehen.
|
||||||
echo "Bildsatz: $(imageset_label "$_set")"
|
echo "Image set: $(imageset_label "$_set")"
|
||||||
echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2
|
echo "Caution: the password of this access point is publicly known from the old firmware - see README.md." >&2
|
||||||
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
echo "Do not forget: 'ninlight-station off' when you are done."
|
||||||
autooff_arm_if_requested
|
autooff_arm_if_requested
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,7 +789,7 @@ display_run() {
|
|||||||
display_service_resume() {
|
display_service_resume() {
|
||||||
[ "${_was_active:-0}" -eq 1 ] || return 0
|
[ "${_was_active:-0}" -eq 1 ] || return 0
|
||||||
sudo systemctl start ninlight-display 2>/dev/null \
|
sudo systemctl start ninlight-display 2>/dev/null \
|
||||||
|| echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2
|
|| echo "Warning: could not start the display service again - check 'systemctl status ninlight-display'" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
display_handover() {
|
display_handover() {
|
||||||
@@ -789,7 +856,7 @@ display_off() {
|
|||||||
sudo systemctl stop ninlight-display 2>/dev/null || true
|
sudo systemctl stop ninlight-display 2>/dev/null || true
|
||||||
# Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und
|
# Kein Abbruch, wenn das Panel fehlt: display.py beendet sich dann selbst mit 0, und
|
||||||
# ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten.
|
# ein Fehler an der Anzeige darf das Ausschalten der Station nie aufhalten.
|
||||||
display_run --clear || echo "Warnung: Panel konnte nicht geleert werden - Anzeige pruefen" >&2
|
display_run --clear || echo "Warning: could not clear the panel - check the display" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart ----------------------
|
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart ----------------------
|
||||||
@@ -820,13 +887,13 @@ display_final() {
|
|||||||
# Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der
|
# Bewusst KEIN "systemctl stop" von hier aus: das liefe mitten in der
|
||||||
# Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche
|
# Abschalt-Transaktion und koennte sich mit ihr verhaken. Lieber eine ehrliche
|
||||||
# Zeile im Journal als ein haengendes Herunterfahren.
|
# Zeile im Journal als ein haengendes Herunterfahren.
|
||||||
echo "Warnung: Anzeigedienst laeuft nach ${DISPLAY_RELEASE_WAIT_S}s noch - kein Abschlussbild (GPIO belegt)" >&2
|
echo "Warning: display service still running after ${DISPLAY_RELEASE_WAIT_S}s - no final image (GPIO busy)" >&2
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
_waited=$((_waited + 1))
|
_waited=$((_waited + 1))
|
||||||
done
|
done
|
||||||
display_run --final "$1" || echo "Warnung: Abschlussbild konnte nicht gezeichnet werden - Anzeige pruefen" >&2
|
display_run --final "$1" || echo "Warning: could not draw the final image - check the display" >&2
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,18 +913,18 @@ full_off() {
|
|||||||
ap_switch ""
|
ap_switch ""
|
||||||
if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
|
if nmcli -t --escape no -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
|
||||||
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
|
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
|
||||||
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
|
echo "Station off, wlan0 back in the home Wi-Fi ($HOME_CONNECTION)"
|
||||||
else
|
else
|
||||||
echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2
|
echo "Station off. Switching back to '$HOME_CONNECTION' failed - check the wlan0 status." >&2
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2
|
echo "Station off. Profile '$HOME_CONNECTION' missing, wlan0 stays unused - access over the cable is unaffected." >&2
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
listen_cmd() {
|
listen_cmd() {
|
||||||
write_control "listen" "" || {
|
write_control "listen" "" || {
|
||||||
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
|
echo "Error: could not write $RUN_DIR/control.json" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
echo "Phase: listen"
|
echo "Phase: listen"
|
||||||
@@ -882,7 +949,7 @@ if not t:
|
|||||||
print(t)
|
print(t)
|
||||||
PY
|
PY
|
||||||
); then
|
); then
|
||||||
echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm <target>" >&2
|
echo "Error: no recently detected model (last_seen) in $_status_file - name a target: ninlight-station arm <target>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -896,14 +963,14 @@ target = sys.argv[2]
|
|||||||
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
|
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
|
||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2
|
echo "Error: unknown target '$_target' - valid targets are listed in products.py" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
write_control "armed" "$_target" || {
|
write_control "armed" "$_target" || {
|
||||||
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
|
echo "Error: could not write $RUN_DIR/control.json" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
echo "Freigegeben: $_target"
|
echo "Armed: $_target"
|
||||||
}
|
}
|
||||||
|
|
||||||
# "renew [<minuten>]" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die
|
# "renew [<minuten>]" - die Uhr zurueckstellen. Ohne Argument auf dieselbe Dauer, die
|
||||||
@@ -915,7 +982,7 @@ PY
|
|||||||
renew_cmd() {
|
renew_cmd() {
|
||||||
_arg=${1:-}
|
_arg=${1:-}
|
||||||
if ! _info=$(autooff_read); then
|
if ! _info=$(autooff_read); then
|
||||||
echo "Fehler: kein Zeitschalter aktiv - 'renew' stellt nur einen laufenden zurueck. Einen neuen gibt es nur mit 'ninlight-station on --for <minuten>'." >&2
|
echo "Error: no timer active - 'renew' only resets a running one. A new one comes from 'ninlight-station on --for <minutes>'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt.
|
# Absichtliches Wortsplitting: autooff_read() liefert drei Zahlen, durch Leerzeichen getrennt.
|
||||||
@@ -927,17 +994,17 @@ renew_cmd() {
|
|||||||
_minutes=$_arg
|
_minutes=$_arg
|
||||||
fi
|
fi
|
||||||
autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1
|
autooff_arm "$_minutes" "$((_minutes * 60))" 0 || exit 1
|
||||||
echo "Zeitschalter zurueckgestellt: Auto-Aus in $_minutes min"
|
echo "Timer reset: auto-off in $_minutes min"
|
||||||
}
|
}
|
||||||
|
|
||||||
# "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt.
|
# "hold" - den Zeitschalter abbestellen. Die Station bleibt an, bis jemand "off" sagt.
|
||||||
hold_cmd() {
|
hold_cmd() {
|
||||||
if ! autooff_read >/dev/null; then
|
if ! autooff_read >/dev/null; then
|
||||||
echo "Fehler: kein Zeitschalter aktiv - es gibt nichts abzubestellen." >&2
|
echo "Error: no timer active - there is nothing to cancel." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
autooff_clear
|
autooff_clear
|
||||||
echo "Zeitschalter abbestellt - die Station bleibt an, bis 'ninlight-station off'."
|
echo "Timer cancelled - the station stays on until 'ninlight-station off'."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der
|
# Restzeit fuer "status", oder leer, wenn kein Zeitschalter laeuft (dann steht in der
|
||||||
@@ -949,9 +1016,9 @@ autooff_status_line() {
|
|||||||
set -- $_info
|
set -- $_info
|
||||||
_rest=$(( ($2 - $(date +%s) + 59) / 60 ))
|
_rest=$(( ($2 - $(date +%s) + 59) / 60 ))
|
||||||
if [ "$_rest" -gt 0 ]; then
|
if [ "$_rest" -gt 0 ]; then
|
||||||
printf 'Auto-Aus: in %s min (angefordert: %s min)\n' "$_rest" "$1"
|
printf 'Auto-off: in %s min (requested: %s min)\n' "$_rest" "$1"
|
||||||
else
|
else
|
||||||
printf 'Auto-Aus: steht an (angefordert: %s min)\n' "$1"
|
printf 'Auto-off: pending (requested: %s min)\n' "$1"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,8 +1061,8 @@ PY
|
|||||||
_modus=$(printf '%s\n' "$_info" | sed -n '1p')
|
_modus=$(printf '%s\n' "$_info" | sed -n '1p')
|
||||||
_erkannt=$(printf '%s\n' "$_info" | sed -n '2p')
|
_erkannt=$(printf '%s\n' "$_info" | sed -n '2p')
|
||||||
elif [ -e "$_status_file" ]; then
|
elif [ -e "$_status_file" ]; then
|
||||||
_modus="unbekannt (Datei nicht lesbar)"
|
_modus="unknown (file not readable)"
|
||||||
_erkannt="unbekannt (Datei nicht lesbar)"
|
_erkannt="unknown (file not readable)"
|
||||||
else
|
else
|
||||||
_modus="-"
|
_modus="-"
|
||||||
_erkannt="-"
|
_erkannt="-"
|
||||||
@@ -1044,16 +1111,20 @@ PY
|
|||||||
else
|
else
|
||||||
_phase="-"
|
_phase="-"
|
||||||
fi
|
fi
|
||||||
printf 'Modus : %s\n' "$_modus"
|
printf 'Mode : %s\n' "$_modus"
|
||||||
printf 'Phase : %s\n' "$_phase"
|
printf 'Phase : %s\n' "$_phase"
|
||||||
imageset_status_lines
|
imageset_status_lines
|
||||||
printf 'Erkannt: %s\n' "$_erkannt"
|
printf 'Detected: %s\n' "$_erkannt"
|
||||||
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
|
printf 'Service : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
|
||||||
autooff_status_line
|
autooff_status_line
|
||||||
printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)"
|
printf 'wlan0 : %s\n' "$(nmcli -t --escape no -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2-)"
|
||||||
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
|
printf 'Address : %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
|
||||||
echo "Images :"
|
# Die Rettungsabbilder des eingestellten Bildsatzes - unabhaengig von $_modus (der
|
||||||
images | while read -r f; do
|
# Migrationsbildsatz hat kein passendes Trailer-Format). Fixrunde 1, Befund 2:
|
||||||
|
# frueher stand hier "images" (flach in $IMAGE_DIR) - dort liegt seit dem
|
||||||
|
# Deploy-Umbau nichts mehr.
|
||||||
|
echo "Images :"
|
||||||
|
imageset_images recovery | while read -r f; do
|
||||||
printf ' %-34s %s\n' "$(basename "$f")" \
|
printf ' %-34s %s\n' "$(basename "$f")" \
|
||||||
"$(python3 - "$f" <<'PY'
|
"$(python3 - "$f" <<'PY'
|
||||||
import pathlib, sys
|
import pathlib, sys
|
||||||
@@ -1063,10 +1134,10 @@ PY
|
|||||||
)"
|
)"
|
||||||
done
|
done
|
||||||
if [ -r "$_status_file" ]; then
|
if [ -r "$_status_file" ]; then
|
||||||
echo "Zuletzt ausgeliefert:"
|
echo "Last served:"
|
||||||
sed 's/^/ /' "$_status_file"
|
sed 's/^/ /' "$_status_file"
|
||||||
elif [ -e "$_status_file" ]; then
|
elif [ -e "$_status_file" ]; then
|
||||||
echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2
|
echo "Error: $_status_file exists but is not readable (check permissions)" >&2
|
||||||
fi
|
fi
|
||||||
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
|
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
|
||||||
# ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier
|
# ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier
|
||||||
@@ -1082,7 +1153,7 @@ display_cmd() {
|
|||||||
final)
|
final)
|
||||||
case "${2:-}" in
|
case "${2:-}" in
|
||||||
poweroff|reboot) display_final "$2" ;;
|
poweroff|reboot) display_final "$2" ;;
|
||||||
*) echo "Fehler: 'display final' braucht 'poweroff' oder 'reboot'" >&2; exit 2 ;;
|
*) echo "Error: 'display final' needs 'poweroff' or 'reboot'" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
*) usage; exit 2 ;;
|
*) usage; exit 2 ;;
|
||||||
@@ -1090,48 +1161,48 @@ display_cmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Aufruf: ninlight-station on [--for <minuten>]|recovery {on [--for <minuten>]|off}|migrate {on [--listen] [--for <minuten>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minuten>]|hold|display {test|clear|off}|off|status" >&2
|
echo "Usage: ninlight-station on [--for <minutes>]|recovery {on [--for <minutes>]|off}|migrate {on [--listen] [--for <minutes>]|off}|arm [<target>]|listen|bildsatz [ohne-wlan|mit-wlan]|renew [<minutes>]|hold|display {test|clear|off}|off|status" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " bildsatz Umschalter: welcher Satz Migrationsimages ausgeliefert wird." >&2
|
echo " bildsatz Switch: which set of recovery/migration images is served." >&2
|
||||||
echo " Ohne Argument nur berichten. 'ohne-wlan' (Vorgabe) liefert die" >&2
|
echo " Without an argument it only reports. 'ohne-wlan' (default) serves" >&2
|
||||||
echo " Abzuege aus tools/build_nocreds.sh - die migrierte Lampe startet" >&2
|
echo " the builds from tools/build_nocreds.sh - the migrated lamp comes" >&2
|
||||||
echo " im eigenen Onboarding-Zugangspunkt. 'mit-wlan' liefert die" >&2
|
echo " up in its own onboarding access point. 'mit-wlan' serves the" >&2
|
||||||
echo " Abzuege aus build/ - die Lampe haengt sofort im WLAN aus" >&2
|
echo " builds from build/ - the lamp joins the Wi-Fi from" >&2
|
||||||
echo " config.local.cmake, auch wenn es eine fremde Lampe ist, die im" >&2
|
echo " config.local.cmake right away, including a lamp that is not" >&2
|
||||||
echo " Automatikmodus mitgenommen wurde. Der Zustand bleibt, bis ihn" >&2
|
echo " yours and got picked up in automatic mode. The setting sticks" >&2
|
||||||
echo " jemand aendert, und steht dauerhaft auf dem Panel." >&2
|
echo " until someone changes it, and is shown on the panel." >&2
|
||||||
echo " --for <minuten> Zeitschalter: die Station schaltet sich nach <minuten> selbst" >&2
|
echo " --for <minutes> Timer: the station switches itself off after <minutes> (back to" >&2
|
||||||
echo " wieder aus (zurueck ins Heim-WLAN). OPTIONAL - ohne '--for'" >&2
|
echo " the home Wi-Fi). OPTIONAL - without '--for' it stays on until" >&2
|
||||||
echo " bleibt sie an, bis jemand 'off' sagt. Nuetzlich, wenn du ueber" >&2
|
echo " someone says 'off'. Useful when you are logged in over Wi-Fi:" >&2
|
||||||
echo " WLAN angemeldet bist: 'on' macht wlan0 zum Zugangspunkt und" >&2
|
echo " 'on' turns wlan0 into an access point and drops your SSH" >&2
|
||||||
echo " reisst die SSH-Sitzung ab. Erlaubt: 1-240." >&2
|
echo " session. Allowed: 1-240." >&2
|
||||||
echo " renew [<min>] Uhr zurueckstellen (ohne Angabe auf die zuletzt angeforderte" >&2
|
echo " renew [<min>] Reset the timer (without an argument to the duration last" >&2
|
||||||
echo " Dauer). Laeuft keiner, ist das ein Fehler." >&2
|
echo " requested). If none is running, that is an error." >&2
|
||||||
echo " hold Zeitschalter abbestellen, Station bleibt an." >&2
|
echo " hold Cancel the timer, station stays on." >&2
|
||||||
echo " display test Testbild aufs Panel zeichnen. Das ist der erste Handgriff bei der" >&2
|
echo " display test Draw a test image on the panel. This is the first step of the" >&2
|
||||||
echo " Hardware-Abnahme und die EINZIGE Moeglichkeit, die Panel-Revision" >&2
|
echo " hardware acceptance and the ONLY way to determine the panel" >&2
|
||||||
echo " zu bestimmen: die steht am Geraet nirgends. Bleibt das Bild leer," >&2
|
echo " revision: it is printed nowhere on the device. If the image" >&2
|
||||||
echo " verzerrt oder verschoben, ist EPD_REVISION in" >&2
|
echo " stays blank, distorted or shifted, EPD_REVISION in" >&2
|
||||||
echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2
|
echo " /etc/ninlight-station.conf is wrong - switch it to the other" >&2
|
||||||
echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2
|
echo " revision (V2 instead of V3 or the other way round), power the" >&2
|
||||||
echo " stromlos machen und erneut 'display test'." >&2
|
echo " panel down briefly and run 'display test' again." >&2
|
||||||
echo " Ein laufender Anzeigedienst wird dafuer kurz gestoppt und" >&2
|
echo " A running display service is stopped for this and started" >&2
|
||||||
echo " danach wieder gestartet (er haelt sonst die GPIO-Leitungen)." >&2
|
echo " again afterwards (it holds the GPIO lines otherwise)." >&2
|
||||||
echo " display clear Panel leeren (ebenfalls mit kurzer Unterbrechung des Dienstes)." >&2
|
echo " display clear Clear the panel (also with a short interruption of the service)." >&2
|
||||||
echo " display final {poweroff|reboot}" >&2
|
echo " display final {poweroff|reboot}" >&2
|
||||||
echo " Abschlussbild aufs Panel. Kein Handgriff fuer Menschen: das" >&2
|
echo " Final image on the panel. Not a step for humans: this is what" >&2
|
||||||
echo " rufen ninlight-poweroff.service/ninlight-reboot.service beim" >&2
|
echo " ninlight-poweroff.service/ninlight-reboot.service call during" >&2
|
||||||
echo " Herunterfahren bzw. Neustart auf, nachdem der Anzeigedienst" >&2
|
echo " shutdown or reboot, once the display service has released the" >&2
|
||||||
echo " die GPIO-Leitungen freigegeben hat. E-Paper behaelt sein Bild" >&2
|
echo " GPIO lines. E-paper keeps its image forever - without this step" >&2
|
||||||
echo " dauerhaft - ohne diesen Schritt bliebe die letzte Rueckfrage" >&2
|
echo " the last confirmation prompt would stay on the panel. The two" >&2
|
||||||
echo " auf dem Panel stehen. Die Texte unterscheiden sich: beim" >&2
|
echo " texts differ: on a reboot you must NOT unplug." >&2
|
||||||
echo " Neustart darf man den Stecker gerade NICHT ziehen." >&2
|
echo " display off Stop the display service for good and clear the panel. The" >&2
|
||||||
echo " display off Anzeigedienst dauerhaft stoppen und das Panel leeren. 'on'/'off'" >&2
|
echo " station's 'on'/'off' do not need this - the display service" >&2
|
||||||
echo " der Station brauchen das nicht - der Anzeigedienst laeuft" >&2
|
echo " keeps running and draws the state by itself." >&2
|
||||||
echo " unabhaengig davon durch und zeichnet den Zustand selbst." >&2
|
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " Taster (GPIO23): kurzer Druck oeffnet am Geraet ein Menue ueber genau diese" >&2
|
echo " Button (GPIO23): a short press opens a menu on the device covering exactly" >&2
|
||||||
echo " Unterbefehle, 3 s gehalten brechen eine laufende Migration/Rettung ab." >&2
|
echo " these subcommands, holding 3 s aborts a running migration or" >&2
|
||||||
|
echo " recovery." >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
_cmd=${1:-}
|
_cmd=${1:-}
|
||||||
|
|||||||
+93
-8
@@ -87,17 +87,34 @@ def trailer_version(path: Path) -> str:
|
|||||||
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
||||||
DEFAULT_IMAGE_SET = "ohne-wlan"
|
DEFAULT_IMAGE_SET = "ohne-wlan"
|
||||||
|
|
||||||
|
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (nicht nur die Migration): der
|
||||||
|
# Eigentuemer hat das ausdruecklich verlangt, denn bisher bekam eine Rettung IMMER ein
|
||||||
|
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ stammten aus build/). Die
|
||||||
|
# Rettungsabzuege OHNE Zugangsdaten kommen aus tools/build_nocreds.sh, genau wie ihr
|
||||||
|
# Migrations-Gegenstueck - deshalb dasselbe Namensschema, nur mit "rescue-" statt
|
||||||
|
# "migrate-" (deploy.sh legt sie entsprechend ab, siehe dort).
|
||||||
|
#
|
||||||
|
# Zwei getrennte Verzeichnispaare (migrate-*/rescue-*) statt einem gemeinsamen: die
|
||||||
|
# Migrationsabzuege sind signiert und tragen ein eigenes manifest.json, die
|
||||||
|
# Rettungsabzuege sind die gewoehnlichen _ota.img mit ninlight-Trailer. Sie
|
||||||
|
# unterscheiden sich in Form und Herstellung; ein gemeinsames Verzeichnis wuerde beide
|
||||||
|
# Herstellungswege ununterscheidbar ineinander mischen (siehe auch: "migrate-wlan/ und
|
||||||
|
# migrate-nocreds/ werden bei der Rettung nie angefasst" im Auftrag).
|
||||||
|
RESCUE_SET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
|
||||||
|
|
||||||
|
|
||||||
def read_image_set(path: Path | None) -> str:
|
def read_image_set(path: Path | None) -> str:
|
||||||
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
|
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
|
||||||
ist oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei darf
|
ist (inklusive ungueltigem UTF-8 - Fixrunde 1, Kleinigkeit: ein reines "except
|
||||||
den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
|
OSError" liess eine Datei mit kaputten Bytes durchschlagen statt auf die Vorgabe zu
|
||||||
|
fallen) oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei
|
||||||
|
darf den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
|
||||||
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
|
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
|
||||||
if path is None:
|
if path is None:
|
||||||
return DEFAULT_IMAGE_SET
|
return DEFAULT_IMAGE_SET
|
||||||
try:
|
try:
|
||||||
value = path.read_text().strip()
|
value = path.read_text().strip()
|
||||||
except OSError:
|
except (OSError, UnicodeDecodeError):
|
||||||
return DEFAULT_IMAGE_SET
|
return DEFAULT_IMAGE_SET
|
||||||
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
|
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
|
||||||
|
|
||||||
@@ -118,6 +135,13 @@ class Station:
|
|||||||
self.since = now_iso()
|
self.since = now_iso()
|
||||||
self.served = 0
|
self.served = 0
|
||||||
self.recent: list[dict] = []
|
self.recent: list[dict] = []
|
||||||
|
# Zeitpunkt der letzten Anfrage JEDER Art (erfolgreich oder nicht) - das
|
||||||
|
# Lebenszeichen fuer die Anzeige (display.py:_recent_request()). Anders als
|
||||||
|
# "served" zaehlt hier auch eine erfolglose Anfrage: der Eigentuemer wollte
|
||||||
|
# gerade nicht erst am Servierzaehler raten muessen, ob ueberhaupt etwas
|
||||||
|
# anklopft (Bedienerwunsch 2026-09-23). None, solange seit dem Start der
|
||||||
|
# Station noch keine einzige Anfrage einging.
|
||||||
|
self.last_activity: str | None = None
|
||||||
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
|
# Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert
|
||||||
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
||||||
self.last_seen: dict | None = None
|
self.last_seen: dict | None = None
|
||||||
@@ -149,8 +173,38 @@ class Station:
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.write_state()
|
self.write_state()
|
||||||
|
|
||||||
|
def rescue_dir(self) -> Path:
|
||||||
|
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes fuer die RETTUNG. Dieselbe Regel
|
||||||
|
wie migrate_dir() (dort steht die ausfuehrliche Begruendung): der Umschalter
|
||||||
|
entscheidet, nicht eine Abfrage je Rettung, und es wird nie stillschweigend auf
|
||||||
|
den jeweils anderen Satz ausgewichen.
|
||||||
|
|
||||||
|
Bestandskompatibilitaet bewusst NICHT gegeben: vor diesem Umschalter lagen
|
||||||
|
Rettungsabzuege FLACH in image_dir (immer MIT Zugangsdaten, aus build/). Ein
|
||||||
|
Pi mit diesem alten Bestand wuerde, faende image_path() dort noch etwas, bei der
|
||||||
|
Vorgabe "ohne-wlan" weiterhin klaglos ein Abbild MIT Zugangsdaten ausliefern -
|
||||||
|
also genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend
|
||||||
|
fortsetzen, die dieser Umschalter gerade abstellen soll. Ein Pi mit altem
|
||||||
|
Bestand muss deshalb einmal per deploy.sh neu bestueckt werden (das raeumt den
|
||||||
|
flachen Altbestand auch auf); bis dahin liefert die Rettung in der Vorgabe
|
||||||
|
"ohne-wlan" sichtbar nichts aus (Status "notfound"), statt einen Fehlgriff zu
|
||||||
|
verstecken."""
|
||||||
|
return self.image_dir / RESCUE_SET_DIRS[self.image_set()]
|
||||||
|
|
||||||
|
def rescue_set_present(self) -> bool:
|
||||||
|
"""Liegt der eingestellte Rettungs-Bildsatz ueberhaupt auf dem Pi? Dieselbe Regel
|
||||||
|
wie migrate_set_present() (Verzeichnis vorhanden UND mindestens ein Image
|
||||||
|
darin), hier fuer die Startzeile im Journal (main()) verwendet. Panel
|
||||||
|
(display.py) und CLI (ninlight-station) sind eigene Prozesse und rufen diese
|
||||||
|
Methode nicht auf, urteilen aber nach DERSELBEN REGEL - eigene, gleich lautende
|
||||||
|
Pruefungen, kein gemeinsamer Code."""
|
||||||
|
try:
|
||||||
|
return any(p.name.endswith(".img") for p in self.rescue_dir().iterdir())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
def image_path(self, target: str) -> Path:
|
def image_path(self, target: str) -> Path:
|
||||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
return self.rescue_dir() / f"ninlight_{target}_ota.img"
|
||||||
|
|
||||||
def image_set(self) -> str:
|
def image_set(self) -> str:
|
||||||
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
|
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
|
||||||
@@ -327,7 +381,11 @@ class Station:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
if status == "ok":
|
if status == "ok":
|
||||||
self.served += 1
|
self.served += 1
|
||||||
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
ts = now_iso()
|
||||||
|
# Jede Anfrage zaehlt als Lebenszeichen, nicht nur eine erfolgreiche - siehe
|
||||||
|
# self.last_activity oben.
|
||||||
|
self.last_activity = ts
|
||||||
|
self.recent.append({"ts": ts, "ip": ip, "target": target,
|
||||||
"version": version, "bytes": nbytes, "status": status})
|
"version": version, "bytes": nbytes, "status": status})
|
||||||
del self.recent[:-RECENT_MAX]
|
del self.recent[:-RECENT_MAX]
|
||||||
self.write_state()
|
self.write_state()
|
||||||
@@ -348,6 +406,9 @@ class Station:
|
|||||||
"variant_source": variant_source,
|
"variant_source": variant_source,
|
||||||
"product_id": product_id, "lamp_version": lamp_version,
|
"product_id": product_id, "lamp_version": lamp_version,
|
||||||
"version": version, "bytes": nbytes, "status": status}
|
"version": version, "bytes": nbytes, "status": status}
|
||||||
|
# Jede Anfrage zaehlt als Lebenszeichen, auch "listen"/"unknown"/"notfound"/
|
||||||
|
# "noimageset" - siehe self.last_activity oben und record()'s Gegenstueck.
|
||||||
|
self.last_activity = entry["ts"]
|
||||||
self.recent.append(entry)
|
self.recent.append(entry)
|
||||||
del self.recent[:-RECENT_MAX]
|
del self.recent[:-RECENT_MAX]
|
||||||
if target is not None:
|
if target is not None:
|
||||||
@@ -360,7 +421,11 @@ class Station:
|
|||||||
def write_state(self) -> None:
|
def write_state(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
payload = {"since": self.since, "mode": self.mode, "served": self.served,
|
payload = {"since": self.since, "mode": self.mode, "served": self.served,
|
||||||
"recent": list(self.recent)}
|
"recent": list(self.recent),
|
||||||
|
# In BEIDEN Modi (nicht nur migration, siehe unten) - das
|
||||||
|
# Lebenszeichen auf dem Panel gilt fuer die Rettung genauso wie
|
||||||
|
# fuer die Migration (display.py:_recent_request()).
|
||||||
|
"last_request_ts": self.last_activity}
|
||||||
if self.mode == "migration":
|
if self.mode == "migration":
|
||||||
phase, armed_target = self.read_control()
|
phase, armed_target = self.read_control()
|
||||||
payload["phase"] = phase
|
payload["phase"] = phase
|
||||||
@@ -658,10 +723,30 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
||||||
httpd.daemon_threads = True
|
httpd.daemon_threads = True
|
||||||
httpd.station = station # type: ignore[attr-defined]
|
httpd.station = station # type: ignore[attr-defined]
|
||||||
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
|
# Fixrunde 1, Kleinigkeit: vor dem Bildsatz-Umschalter fuer die Rettung lagen deren
|
||||||
|
# Abbilder flach in a.image_dir, das Glob dort war also die richtige Auskunft. Seit
|
||||||
|
# image_path() aus rescue_dir() liest (dem eingestellten Satz), muss diese Zeile
|
||||||
|
# demselben Verzeichnis folgen - sonst zeigt das Journal "0 image(s)" fuer eine
|
||||||
|
# Rettung, die tatsaechlich welche hat (oder umgekehrt).
|
||||||
|
if mode == "recovery":
|
||||||
|
try:
|
||||||
|
images = sorted(p.name for p in station.rescue_dir().glob("ninlight_*_ota.img"))
|
||||||
|
except OSError:
|
||||||
|
images = []
|
||||||
|
else:
|
||||||
|
images = []
|
||||||
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
|
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
|
||||||
f"{', '.join(images) or 'none'}", flush=True)
|
f"{', '.join(images) or 'none'}", flush=True)
|
||||||
if mode == "migration":
|
if mode == "recovery":
|
||||||
|
# Bildsatz auch fuer die Rettung im Journal - dieselbe Begruendung wie unten fuer
|
||||||
|
# die Migration: "warum bekam die Lampe ein Abbild ohne/mit Zugangsdaten?" ist
|
||||||
|
# sonst nirgends nachschlagbar. rescue_set_present() (bislang toter Code) ist das
|
||||||
|
# hier tatsaechlich Verwendete.
|
||||||
|
selected = station.image_set()
|
||||||
|
present = "vorhanden" if station.rescue_set_present() else "FEHLT"
|
||||||
|
print(f"{now_iso()} image set: {selected} ({station.rescue_dir()}, {present})",
|
||||||
|
flush=True)
|
||||||
|
elif mode == "migration":
|
||||||
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
|
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
|
||||||
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
|
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
|
||||||
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
|
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
|
||||||
|
|||||||
@@ -167,43 +167,43 @@ def _labels(state):
|
|||||||
|
|
||||||
|
|
||||||
def test_menu_off_offers_starting_and_shutdown():
|
def test_menu_off_offers_starting_and_shutdown():
|
||||||
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen",
|
assert _labels(STATE_OFF) == ["Start recovery", "Migration: listen",
|
||||||
"Migration: AUTOMATIK", "Umschalten: mit WLAN",
|
"Migration: AUTOMATIC", "Switch to: with Wi-Fi",
|
||||||
"Herunterfahren"]
|
"Shut down"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_listening_migration_comes_before_the_automatic_one():
|
def test_the_listening_migration_comes_before_the_automatic_one():
|
||||||
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
|
"""Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und
|
||||||
irgendwo loslaesst, soll nicht im Automatikmodus landen."""
|
irgendwo loslaesst, soll nicht im Automatikmodus landen."""
|
||||||
labels = _labels(STATE_OFF)
|
labels = _labels(STATE_OFF)
|
||||||
assert labels.index("Migration: horchen") < labels.index("Migration: AUTOMATIK")
|
assert labels.index("Migration: listen") < labels.index("Migration: AUTOMATIC")
|
||||||
|
|
||||||
|
|
||||||
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
|
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
|
||||||
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN",
|
assert _labels(STATE_RECOVERY) == ["Station off", "Switch to: with Wi-Fi",
|
||||||
"Anzeige auffrischen", "Herunterfahren"]
|
"Refresh display", "Shut down"]
|
||||||
|
|
||||||
|
|
||||||
def test_menu_in_migration_offers_the_detected_target():
|
def test_menu_in_migration_offers_the_detected_target():
|
||||||
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus",
|
assert _labels(STATE_MIGRATION) == ["Arm: gu10_rgb", "Station off",
|
||||||
"Umschalten: mit WLAN", "Anzeige auffrischen",
|
"Switch to: with Wi-Fi", "Refresh display",
|
||||||
"Herunterfahren"]
|
"Shut down"]
|
||||||
|
|
||||||
|
|
||||||
def test_menu_in_migration_without_a_detected_target_offers_no_release():
|
def test_menu_in_migration_without_a_detected_target_offers_no_release():
|
||||||
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
|
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
|
||||||
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
|
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
|
||||||
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
|
assert "Armed: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
|
||||||
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN",
|
assert _labels(STATE_MIGRATION_BLIND) == ["Station off", "Switch to: with Wi-Fi",
|
||||||
"Anzeige auffrischen", "Herunterfahren"]
|
"Refresh display", "Shut down"]
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_refresh_entry_skips_the_confirmation():
|
def test_only_the_refresh_entry_skips_the_confirmation():
|
||||||
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Anzeige auffrischen"
|
"""Zusage: JEDE veraendernde Auswahl bekommt eine Rueckfrage. "Refresh display"
|
||||||
veraendert nichts und braucht keine."""
|
veraendert nichts und braucht keine."""
|
||||||
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
|
for state in (STATE_OFF, STATE_RECOVERY, STATE_MIGRATION):
|
||||||
for action in button.menu_actions(state):
|
for action in button.menu_actions(state):
|
||||||
assert action.confirm is (action.label != "Anzeige auffrischen"), action.label
|
assert action.confirm is (action.label != "Refresh display"), action.label
|
||||||
|
|
||||||
|
|
||||||
def test_actions_run_through_the_station_cli():
|
def test_actions_run_through_the_station_cli():
|
||||||
@@ -222,7 +222,7 @@ def test_a_short_press_opens_the_menu():
|
|||||||
rec = _drive([_short(1.0)], 4.0)
|
rec = _drive([_short(1.0)], 4.0)
|
||||||
assert rec.argvs() == []
|
assert rec.argvs() == []
|
||||||
t, view = rec.first_view("menu")
|
t, view = rec.first_view("menu")
|
||||||
assert view["hero"] == "> Rettung starten"
|
assert view["hero"] == "> Start recovery"
|
||||||
assert view["active"] is True
|
assert view["active"] is True
|
||||||
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
|
# Spuerbar, aber bedienbar: das Menue steht binnen eines Schleifentakts nach dem
|
||||||
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
|
# Loslassen (die Verzoegerung am Panel kommt aus dessen eigenem Takt, siehe
|
||||||
@@ -233,19 +233,19 @@ def test_a_short_press_opens_the_menu():
|
|||||||
def test_a_short_press_steps_to_the_next_entry():
|
def test_a_short_press_steps_to_the_next_entry():
|
||||||
rec = _drive([_short(1.0), _short(2.0)], 4.0)
|
rec = _drive([_short(1.0), _short(2.0)], 4.0)
|
||||||
assert rec.argvs() == []
|
assert rec.argvs() == []
|
||||||
assert rec.views()[-1][1]["hero"] == "> Migration: horchen"
|
assert rec.views()[-1][1]["hero"] == "> Migration: listen"
|
||||||
|
|
||||||
|
|
||||||
def test_a_short_press_wraps_around_the_last_entry():
|
def test_a_short_press_wraps_around_the_last_entry():
|
||||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
|
||||||
_short(6.0)], 8.0)
|
_short(6.0)], 8.0)
|
||||||
assert rec.views()[-1][1]["hero"] == "> Rettung starten"
|
assert rec.views()[-1][1]["hero"] == "> Start recovery"
|
||||||
|
|
||||||
|
|
||||||
def test_holding_one_and_a_half_seconds_selects_the_entry():
|
def test_holding_one_and_a_half_seconds_selects_the_entry():
|
||||||
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
|
rec = _drive([_short(1.0), (2.0, 4.0)], 6.0)
|
||||||
t, view = rec.first_view("confirm")
|
t, view = rec.first_view("confirm")
|
||||||
assert view["hero"] == "Rettung starten"
|
assert view["hero"] == "Start recovery"
|
||||||
assert 3.5 <= t <= 3.6
|
assert 3.5 <= t <= 3.6
|
||||||
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
|
# Die Auswahl allein fuehrt noch nichts aus - dafuer ist die Rueckfrage da.
|
||||||
assert rec.argvs() == []
|
assert rec.argvs() == []
|
||||||
@@ -271,7 +271,7 @@ def test_the_refresh_entry_runs_without_a_confirmation():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_listening_entry_calls_the_cli_with_listen():
|
def test_the_listening_entry_calls_the_cli_with_listen():
|
||||||
"""Der zweite Eintrag ("Migration: horchen") ruft das CLI MIT --listen: der
|
"""Der zweite Eintrag ("Migration: listen") ruft das CLI MIT --listen: der
|
||||||
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
|
Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe
|
||||||
freigibt."""
|
freigibt."""
|
||||||
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
|
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0)
|
||||||
@@ -280,7 +280,7 @@ def test_the_listening_entry_calls_the_cli_with_listen():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_automatic_entry_calls_the_cli_without_listen():
|
def test_the_automatic_entry_calls_the_cli_without_listen():
|
||||||
"""Der dritte Eintrag ("Migration: AUTOMATIK") ruft dasselbe CLI OHNE --listen - das
|
"""Der dritte Eintrag ("Migration: AUTOMATIC") ruft dasselbe CLI OHNE --listen - das
|
||||||
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
|
ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob
|
||||||
fremde Lampen ungefragt migriert werden."""
|
fremde Lampen ungefragt migriert werden."""
|
||||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0)
|
||||||
@@ -293,18 +293,18 @@ def test_the_automatic_entry_asks_a_louder_question_than_the_others():
|
|||||||
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege."""
|
zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege."""
|
||||||
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
|
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0)
|
||||||
_t, view = rec.first_view("confirm")
|
_t, view = rec.first_view("confirm")
|
||||||
assert view["title"] == "ACHTUNG"
|
assert view["title"] == "WARNING"
|
||||||
assert view["hero"] == "Migration: AUTOMATIK"
|
assert view["hero"] == "Migration: AUTOMATIC"
|
||||||
text = " ".join(view["details"]).lower()
|
text = " ".join(view["details"]).lower()
|
||||||
assert "jede" in text and "fremde" in text
|
assert "every" in text and "other people" in text
|
||||||
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
|
# Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt.
|
||||||
assert view["details"][-2:] == ["halten (1,5 s): ja", "kurz: nein"]
|
assert view["details"][-2:] == ["hold (1.5 s): yes", "tap: no"]
|
||||||
|
|
||||||
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
|
# Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung.
|
||||||
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
|
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0)
|
||||||
_t, plain = rec.first_view("confirm")
|
_t, plain = rec.first_view("confirm")
|
||||||
assert plain["title"] == "RUECKFRAGE"
|
assert plain["title"] == "CONFIRM"
|
||||||
assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"]
|
assert plain["details"] == ["hold (1.5 s): yes", "tap: no"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
|
def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
|
||||||
@@ -316,7 +316,7 @@ def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():
|
|||||||
assert 4.0 <= t <= 4.1
|
assert 4.0 <= t <= 4.1
|
||||||
# ... und danach laeuft am Panel der Countdown der zweiten Stufe.
|
# ... und danach laeuft am Panel der Countdown der zweiten Stufe.
|
||||||
later = [v for vt, v in rec.views() if vt > t]
|
later = [v for vt, v in rec.views() if vt > t]
|
||||||
assert any("Herunterfahren in" in v.get("hero", "") for v in later)
|
assert any("Shutting down in" in v.get("hero", "") for v in later)
|
||||||
|
|
||||||
|
|
||||||
def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
|
def test_releasing_between_three_and_ten_seconds_does_not_shut_down():
|
||||||
@@ -337,8 +337,8 @@ def test_the_shutdown_countdown_is_visible_before_it_happens():
|
|||||||
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
|
erreichbar - der Countdown muss also vorher auf dem Panel gestanden haben."""
|
||||||
rec = _drive([(1.0, 12.0)], 14.0)
|
rec = _drive([(1.0, 12.0)], 14.0)
|
||||||
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
|
heroes = [v.get("hero", "") for t, v in rec.views() if t < 11.0]
|
||||||
assert "Herunterfahren in 6 s" in heroes
|
assert "Shutting down in 6 s" in heroes
|
||||||
assert "Herunterfahren in 1 s" in heroes
|
assert "Shutting down in 1 s" in heroes
|
||||||
|
|
||||||
|
|
||||||
def test_a_blocking_action_does_not_break_the_hold_gesture():
|
def test_a_blocking_action_does_not_break_the_hold_gesture():
|
||||||
@@ -392,7 +392,7 @@ def test_an_expired_confirmation_leaves_nothing_pending():
|
|||||||
|
|
||||||
def test_a_failed_action_says_so_on_the_panel():
|
def test_a_failed_action_says_so_on_the_panel():
|
||||||
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
|
rec = _drive([_short(1.0), (2.0, 4.0), (5.0, 7.0)], 9.0, rc=1)
|
||||||
assert any(v.get("view") == "result" and v.get("title") == "FEHLER"
|
assert any(v.get("view") == "result" and v.get("title") == "ERROR"
|
||||||
for _t, v in rec.views())
|
for _t, v in rec.views())
|
||||||
|
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ def test_the_ui_file_is_written_atomically_with_0644(tmp_path):
|
|||||||
geschrieben."""
|
geschrieben."""
|
||||||
path = tmp_path / "run" / "ui.json"
|
path = tmp_path / "run" / "ui.json"
|
||||||
writer = button.UiWriter(path)
|
writer = button.UiWriter(path)
|
||||||
view = {"view": "menu", "title": "MENUE", "hero": "> Station aus", "details": [],
|
view = {"view": "menu", "title": "MENU", "hero": "> Station off", "details": [],
|
||||||
"active": True}
|
"active": True}
|
||||||
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
|
# Unter einer strengen umask geschrieben: die Rechte muessen ausdruecklich gesetzt
|
||||||
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
|
# werden, nicht zufaellig aus der umask des Dienstes fallen - sonst laege die Zusage
|
||||||
@@ -509,10 +509,10 @@ def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
|
|||||||
def test_the_image_set_entry_names_where_it_switches_to():
|
def test_the_image_set_entry_names_where_it_switches_to():
|
||||||
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
|
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
|
||||||
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
|
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
|
||||||
assert "Umschalten: mit WLAN" in _labels(STATE_OFF)
|
assert "Switch to: with Wi-Fi" in _labels(STATE_OFF)
|
||||||
ohne = dict(STATE_OFF, imageset="mit-wlan")
|
ohne = dict(STATE_OFF, imageset="mit-wlan")
|
||||||
assert "Umschalten: ohne WLAN" in _labels(ohne)
|
assert "Switch to: no Wi-Fi" in _labels(ohne)
|
||||||
assert "Umschalten: mit WLAN" not in _labels(ohne)
|
assert "Switch to: with Wi-Fi" not in _labels(ohne)
|
||||||
|
|
||||||
|
|
||||||
def test_the_image_set_entry_switches_through_the_cli():
|
def test_the_image_set_entry_switches_through_the_cli():
|
||||||
@@ -527,9 +527,9 @@ def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
|
|||||||
loud = button.action_imageset("ohne-wlan")
|
loud = button.action_imageset("ohne-wlan")
|
||||||
back = button.action_imageset("mit-wlan")
|
back = button.action_imageset("mit-wlan")
|
||||||
assert loud.confirm and back.confirm
|
assert loud.confirm and back.confirm
|
||||||
assert loud.confirm_title == "ACHTUNG"
|
assert loud.confirm_title == "WARNING"
|
||||||
assert back.confirm_title == "RUECKFRAGE"
|
assert back.confirm_title == "CONFIRM"
|
||||||
assert "WLAN-Passwort" in " ".join(loud.confirm_details)
|
assert "Wi-Fi password" in " ".join(loud.confirm_details)
|
||||||
assert back.confirm_details is None
|
assert back.confirm_details is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -263,12 +263,19 @@ setup_sandbox() {
|
|||||||
local sandbox=$1 unitfile=${2:-$REAL_UNIT}
|
local sandbox=$1 unitfile=${2:-$REAL_UNIT}
|
||||||
rm -rf "$sandbox"
|
rm -rf "$sandbox"
|
||||||
mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \
|
mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \
|
||||||
|
"$sandbox/images/rescue-nocreds" "$sandbox/images/rescue-wlan" \
|
||||||
"$sandbox/bin" "$sandbox/nmstate" "$sandbox/lib/display" "$sandbox/var"
|
"$sandbox/bin" "$sandbox/nmstate" "$sandbox/lib/display" "$sandbox/var"
|
||||||
# Je ein Image je Bildsatz - "vorhanden" heisst fuer CLI, Server und Anzeige
|
# Je ein Image je Bildsatz - "vorhanden" heisst fuer CLI, Server und Anzeige
|
||||||
# gleichermassen "mindestens ein Image darin".
|
# gleichermassen "mindestens ein Image darin". Getrennte Verzeichnispaare fuer
|
||||||
|
# Rettung (rescue-*) und Migration (migrate-*), seit der Bildsatz-Umschalter auch
|
||||||
|
# die Rettung betrifft (Fixrunde 1, Befund 1/2) - VORHER lag hier ein flaches
|
||||||
|
# "images/ninlight_foo_ota.img", das server.py/deploy.sh seit diesem Umschalter
|
||||||
|
# nicht mehr benutzen; eine Testvorrichtung, die weiter diese alte Welt aufbaut,
|
||||||
|
# haette genau die beiden Befunde verdeckt.
|
||||||
echo "ohne" >"$sandbox/images/migrate-nocreds/foo_dev.img"
|
echo "ohne" >"$sandbox/images/migrate-nocreds/foo_dev.img"
|
||||||
echo "mit" >"$sandbox/images/migrate-wlan/foo_dev.img"
|
echo "mit" >"$sandbox/images/migrate-wlan/foo_dev.img"
|
||||||
echo "dummy" >"$sandbox/images/ninlight_foo_ota.img"
|
echo "ohne" >"$sandbox/images/rescue-nocreds/ninlight_foo_ota.img"
|
||||||
|
echo "mit" >"$sandbox/images/rescue-wlan/ninlight_foo_ota.img"
|
||||||
|
|
||||||
# NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst.
|
# NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst.
|
||||||
# products.py wird gebraucht (arm prueft das Ziel dagegen) und deshalb echt kopiert;
|
# products.py wird gebraucht (arm prueft das Ziel dagegen) und deshalb echt kopiert;
|
||||||
@@ -342,6 +349,7 @@ run_cli() {
|
|||||||
NINLIGHT_RUN_DIR="$sandbox/run" \
|
NINLIGHT_RUN_DIR="$sandbox/run" \
|
||||||
NINLIGHT_STATION_LIB="$sandbox/lib" \
|
NINLIGHT_STATION_LIB="$sandbox/lib" \
|
||||||
NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \
|
NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \
|
||||||
|
NINLIGHT_LAST_MODE_FILE="$sandbox/var/last-mode" \
|
||||||
"$CLI" "$@"
|
"$CLI" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +372,7 @@ set -e
|
|||||||
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
|
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
|
||||||
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
|
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
|
||||||
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)"
|
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)"
|
||||||
echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
|
echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ==="
|
echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ==="
|
||||||
@@ -378,7 +386,7 @@ set -e
|
|||||||
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
|
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
|
||||||
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
|
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
|
||||||
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch"
|
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch"
|
||||||
echo "$OUT" | grep -q "Station an" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
|
echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
|
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
|
||||||
@@ -427,7 +435,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR"
|
||||||
echo "$ERR" | grep -q "Warnung" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR"
|
echo "$ERR" | grep -q "Warning" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ==="
|
echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ==="
|
||||||
@@ -469,7 +477,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
|
||||||
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
|
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
|
||||||
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")"
|
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -482,7 +490,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
|
||||||
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
|
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
|
||||||
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt"
|
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -513,7 +521,7 @@ ACTIVE=$(cat "$SBX/nmstate/active")
|
|||||||
echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv"
|
echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv"
|
||||||
echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv"
|
echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv"
|
||||||
echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE"
|
echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE"
|
||||||
echo "$OUT" | grep -q "Heim-WLAN" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT"
|
echo "$OUT" | grep -q "home Wi-Fi" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ==="
|
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ==="
|
||||||
@@ -531,9 +539,9 @@ OUT=$(run_cli "$SBX" status 2>&1)
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
|
||||||
echo "$OUT" | grep -q "Modus.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT"
|
echo "$OUT" | grep -q "Mode.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT"
|
||||||
echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT"
|
echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT"
|
||||||
echo "$OUT" | grep -q "Erkannt.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT"
|
echo "$OUT" | grep -q "Detected.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ==="
|
echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ==="
|
||||||
@@ -547,7 +555,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt
|
chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT"
|
||||||
echo "$OUT" | grep -q "existiert, ist aber nicht lesbar" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT"
|
echo "$OUT" | grep -q "not readable" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ==="
|
echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ==="
|
||||||
@@ -558,7 +566,7 @@ OUT=$(run_cli "$SBX" frobnicate 2>&1)
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
[ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2"
|
[ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2"
|
||||||
echo "$OUT" | grep -qi "Aufruf" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT"
|
echo "$OUT" | grep -qi "usage" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ==="
|
echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ==="
|
||||||
@@ -597,7 +605,7 @@ set -e
|
|||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT"
|
||||||
echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR"
|
echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR"
|
||||||
echo "$OUT" | grep -q "Migration an" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'"
|
echo "$OUT" | grep -q "Migration on" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'"
|
||||||
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
|
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
|
||||||
[ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER"
|
[ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER"
|
||||||
# Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei -
|
# Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei -
|
||||||
@@ -646,8 +654,8 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT"
|
||||||
echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung"
|
echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung"
|
||||||
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR"
|
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR"
|
||||||
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
|
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
|
||||||
[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER"
|
[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER"
|
||||||
|
|
||||||
@@ -679,7 +687,7 @@ ERR=$(cat "$SBX/stderr.log")
|
|||||||
[ "$RC" -ne 0 ] \
|
[ "$RC" -ne 0 ] \
|
||||||
&& pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \
|
&& pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \
|
||||||
|| fail "Exit-Code war 0, obwohl 'ninlight:recovery' weiterhin aktiv war: $OUT"
|
|| fail "Exit-Code war 0, obwohl 'ninlight:recovery' weiterhin aktiv war: $OUT"
|
||||||
echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" \
|
echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" \
|
||||||
|| pass "keine falsche 'Station aus'-Meldung"
|
|| pass "keine falsche 'Station aus'-Meldung"
|
||||||
echo "$ERR" | grep -q "ninlight:recovery" \
|
echo "$ERR" | grep -q "ninlight:recovery" \
|
||||||
&& pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \
|
&& pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \
|
||||||
@@ -695,7 +703,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT"
|
||||||
echo "$ERR" | grep -q "unerwartet" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR"
|
echo "$ERR" | grep -q "unexpectedly" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR"
|
||||||
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
|
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
|
||||||
[ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER"
|
[ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER"
|
||||||
|
|
||||||
@@ -714,7 +722,7 @@ cat >"$SBX/run/status.json" <<'JSON'
|
|||||||
{"mode": "recovery"}
|
{"mode": "recovery"}
|
||||||
JSON
|
JSON
|
||||||
OUT=$(run_cli "$SBX" status 2>&1)
|
OUT=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$OUT" | grep -qE "Modus[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT"
|
echo "$OUT" | grep -qE "Mode[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT"
|
||||||
echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus"
|
echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus"
|
||||||
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
|
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
|
||||||
|
|
||||||
@@ -728,7 +736,7 @@ run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1
|
|||||||
run_cli "$SBX" on >/dev/null 2>&1
|
run_cli "$SBX" on >/dev/null 2>&1
|
||||||
rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie
|
rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie
|
||||||
OUT=$(run_cli "$SBX" status 2>&1)
|
OUT=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$OUT" | grep -qE "Modus[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT"
|
echo "$OUT" | grep -qE "Mode[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT"
|
||||||
echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus"
|
echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus"
|
||||||
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
|
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
|
||||||
|
|
||||||
@@ -1024,9 +1032,9 @@ set -e
|
|||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
|
||||||
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zeitschalter-Zustandsdatei angelegt" || fail "ohne --for entstand trotzdem ein Zeitschalter: $(cat "$SBX/run/autooff.json")"
|
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zeitschalter-Zustandsdatei angelegt" || fail "ohne --for entstand trotzdem ein Zeitschalter: $(cat "$SBX/run/autooff.json")"
|
||||||
grep -q "systemd-run" "$SBX/cmd.log" && fail "ohne --for wurde trotzdem ein Timer angelegt: $(grep systemd-run "$SBX/cmd.log")" || pass "kein systemd-run-Aufruf"
|
grep -q "systemd-run" "$SBX/cmd.log" && fail "ohne --for wurde trotzdem ein Timer angelegt: $(grep systemd-run "$SBX/cmd.log")" || pass "kein systemd-run-Aufruf"
|
||||||
echo "$OUT" | grep -qi "Auto-Aus" && fail "Ausgabe verspricht einen Zeitschalter, den es nicht gibt: $OUT" || pass "Ausgabe erwaehnt keinen Zeitschalter"
|
echo "$OUT" | grep -qi "Auto-off" && fail "Ausgabe verspricht einen Zeitschalter, den es nicht gibt: $OUT" || pass "Ausgabe erwaehnt keinen Zeitschalter"
|
||||||
OUT=$(run_cli "$SBX" status 2>&1)
|
OUT=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$OUT" | grep -qi "Auto-Aus" && fail "'status' zeigt eine Zeitschalter-Zeile, obwohl keiner laeuft: $OUT" || pass "'status' schweigt ueber den Zeitschalter"
|
echo "$OUT" | grep -qi "Auto-off" && fail "'status' zeigt eine Zeitschalter-Zeile, obwohl keiner laeuft: $OUT" || pass "'status' schweigt ueber den Zeitschalter"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ==="
|
echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ==="
|
||||||
@@ -1046,7 +1054,7 @@ grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "Timer feuert nach 30 min"
|
|||||||
grep -q "__autooff" "$SBX/cmd.log" && pass "Timer ruft das CLI mit seiner Abschaltaktion" || fail "Timer ruft nichts Abschaltendes: $(grep systemd-run "$SBX/cmd.log")"
|
grep -q "__autooff" "$SBX/cmd.log" && pass "Timer ruft das CLI mit seiner Abschaltaktion" || fail "Timer ruft nichts Abschaltendes: $(grep systemd-run "$SBX/cmd.log")"
|
||||||
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch"
|
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch"
|
||||||
OUT=$(run_cli "$SBX" status 2>&1)
|
OUT=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$OUT" | grep -qE "Auto-Aus: in (29|30) min" && pass "'status' zeigt die Restzeit" || fail "'status' zeigt keine brauchbare Restzeit: $OUT"
|
echo "$OUT" | grep -qE "Auto-off: in (29|30) min" && pass "'status' zeigt die Restzeit" || fail "'status' zeigt keine brauchbare Restzeit: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ==="
|
echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ==="
|
||||||
@@ -1070,7 +1078,7 @@ for BAD in "0" "241" "abc" "-5" "1.5" ""; do
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT"
|
[ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT"
|
||||||
echo "$ERR" | grep -qi "fehler" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR"
|
echo "$ERR" | grep -qi "error" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR"
|
||||||
grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren"
|
grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren"
|
||||||
grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet"
|
grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet"
|
||||||
[ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")"
|
[ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")"
|
||||||
@@ -1115,7 +1123,7 @@ grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "die Station w
|
|||||||
grep -q "nmcli con down" "$SBX/cmd.log" && fail "der Zugangspunkt wurde mitten in der Auslieferung heruntergefahren" || pass "der Zugangspunkt bleibt oben"
|
grep -q "nmcli con down" "$SBX/cmd.log" && fail "der Zugangspunkt wurde mitten in der Auslieferung heruntergefahren" || pass "der Zugangspunkt bleibt oben"
|
||||||
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "1" ] && pass "Verschiebung gezaehlt (1)" || fail "deferrals nicht hochgezaehlt: $(cat "$SBX/run/autooff.json" 2>&1)"
|
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "1" ] && pass "Verschiebung gezaehlt (1)" || fail "deferrals nicht hochgezaehlt: $(cat "$SBX/run/autooff.json" 2>&1)"
|
||||||
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "die angeforderte Dauer bleibt fuer 'renew' erhalten" || fail "minutes wurde ueberschrieben: $(cat "$SBX/run/autooff.json")"
|
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "die angeforderte Dauer bleibt fuer 'renew' erhalten" || fail "minutes wurde ueberschrieben: $(cat "$SBX/run/autooff.json")"
|
||||||
echo "$OUT" | grep -qi "verschoben" && pass "die Verschiebung wird gemeldet" || fail "keine Meldung ueber die Verschiebung: $OUT"
|
echo "$OUT" | grep -qi "deferred" && pass "die Verschiebung wird gemeldet" || fail "keine Meldung ueber die Verschiebung: $OUT"
|
||||||
# Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab.
|
# Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab.
|
||||||
SBX="$TMPROOT/tZ5b"
|
SBX="$TMPROOT/tZ5b"
|
||||||
setup_sandbox "$SBX"
|
setup_sandbox "$SBX"
|
||||||
@@ -1149,7 +1157,7 @@ set -e
|
|||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR"
|
||||||
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "an der Obergrenze wird trotz offener Verbindung abgeschaltet" || fail "die haengende Verbindung hebelt den Zeitschalter dauerhaft aus: $(cat "$SBX/cmd.log")"
|
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "an der Obergrenze wird trotz offener Verbindung abgeschaltet" || fail "die haengende Verbindung hebelt den Zeitschalter dauerhaft aus: $(cat "$SBX/cmd.log")"
|
||||||
echo "$ERR" | grep -qi "warnung" && pass "das Abschalten trotz offener Verbindung wird gemeldet" || fail "keine Warnung: $ERR"
|
echo "$ERR" | grep -qi "warning" && pass "das Abschalten trotz offener Verbindung wird gemeldet" || fail "keine Warnung: $ERR"
|
||||||
[ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen"
|
[ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen"
|
||||||
# Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die
|
# Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die
|
||||||
# Grenzpruefung auch einfach immer abschalten).
|
# Grenzpruefung auch einfach immer abschalten).
|
||||||
@@ -1195,7 +1203,7 @@ for CMD in renew hold; do
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT"
|
[ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT"
|
||||||
echo "$ERR" | grep -qi "kein Zeitschalter" && pass "'$CMD': klare Meldung auf stderr" || fail "'$CMD': unklare Meldung: $ERR"
|
echo "$ERR" | grep -qi "no timer active" && pass "'$CMD': klare Meldung auf stderr" || fail "'$CMD': unklare Meldung: $ERR"
|
||||||
[ ! -e "$SBX/run/autooff.json" ] && pass "'$CMD' hat keinen Zeitschalter angelegt" || fail "'$CMD' hat stillschweigend einen angelegt: $(cat "$SBX/run/autooff.json")"
|
[ ! -e "$SBX/run/autooff.json" ] && pass "'$CMD' hat keinen Zeitschalter angelegt" || fail "'$CMD' hat stillschweigend einen angelegt: $(cat "$SBX/run/autooff.json")"
|
||||||
grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run"
|
grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run"
|
||||||
done
|
done
|
||||||
@@ -1215,7 +1223,7 @@ set -e
|
|||||||
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "dieselbe Dauer wie zuletzt angefordert (30)" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")"
|
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "dieselbe Dauer wie zuletzt angefordert (30)" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")"
|
||||||
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "Verschiebungszaehler zurueckgesetzt" || fail "deferrals nicht zurueckgesetzt: $(cat "$SBX/run/autooff.json")"
|
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "Verschiebungszaehler zurueckgesetzt" || fail "deferrals nicht zurueckgesetzt: $(cat "$SBX/run/autooff.json")"
|
||||||
STATUS=$(run_cli "$SBX" status 2>&1)
|
STATUS=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$STATUS" | grep -qE "Auto-Aus: in (29|30) min" && pass "die Restzeit steht wieder bei 30 min" || fail "Restzeit nicht zurueckgestellt: $STATUS"
|
echo "$STATUS" | grep -qE "Auto-off: in (29|30) min" && pass "die Restzeit steht wieder bei 30 min" || fail "Restzeit nicht zurueckgestellt: $STATUS"
|
||||||
grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "der Timer wurde neu gestellt" || fail "kein neuer Timer: $(cat "$SBX/cmd.log")"
|
grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "der Timer wurde neu gestellt" || fail "kein neuer Timer: $(cat "$SBX/cmd.log")"
|
||||||
# Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt.
|
# Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt.
|
||||||
set +e
|
set +e
|
||||||
@@ -1262,7 +1270,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
ERR=$(cat "$SBX/stderr.log")
|
ERR=$(cat "$SBX/stderr.log")
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT"
|
||||||
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
|
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
|
||||||
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")"
|
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")"
|
||||||
echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR"
|
echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR"
|
||||||
|
|
||||||
@@ -1278,7 +1286,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
||||||
echo "$OUT" | grep -q "ohne-wlan" && pass "ohne Datei gilt 'ohne-wlan'" || fail "Vorgabe ist nicht 'ohne-wlan': $OUT"
|
echo "$OUT" | grep -q "ohne-wlan" && pass "ohne Datei gilt 'ohne-wlan'" || fail "Vorgabe ist nicht 'ohne-wlan': $OUT"
|
||||||
echo "$OUT" | grep -q "migrate-nocreds" && pass "und das zugehoerige Verzeichnis wird genannt" || fail "Verzeichnis fehlt in der Ausgabe: $OUT"
|
echo "$OUT" | grep -q "rescue-nocreds" && pass "und das zugehoerige Verzeichnis wird genannt (Vorgabemodus: recovery)" || fail "Verzeichnis fehlt in der Ausgabe: $OUT"
|
||||||
[ ! -e "$SBX/var/imageset" ] && pass "das blosse Berichten legt keine Datei an" || fail "'bildsatz' ohne Argument hat geschrieben"
|
[ ! -e "$SBX/var/imageset" ] && pass "das blosse Berichten legt keine Datei an" || fail "'bildsatz' ohne Argument hat geschrieben"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -1294,7 +1302,7 @@ set -e
|
|||||||
MODE=$(file_mode "$SBX/var/imageset")
|
MODE=$(file_mode "$SBX/var/imageset")
|
||||||
[ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644"
|
[ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644"
|
||||||
[ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da"
|
[ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da"
|
||||||
echo "$OUT" | grep -qi "achtung" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT"
|
echo "$OUT" | grep -qi "caution" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT"
|
||||||
# ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn.
|
# ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn.
|
||||||
OUT2=$(run_cli "$SBX" bildsatz 2>&1)
|
OUT2=$(run_cli "$SBX" bildsatz 2>&1)
|
||||||
echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2"
|
echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2"
|
||||||
@@ -1313,7 +1321,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz"
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz"
|
||||||
[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")"
|
[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")"
|
||||||
echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
|
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ==="
|
echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ==="
|
||||||
@@ -1323,16 +1331,16 @@ setup_sandbox "$SBX"
|
|||||||
printf 'voellig-anderes\n' >"$SBX/var/imageset"
|
printf 'voellig-anderes\n' >"$SBX/var/imageset"
|
||||||
OUT=$(run_cli "$SBX" bildsatz 2>&1)
|
OUT=$(run_cli "$SBX" bildsatz 2>&1)
|
||||||
echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fail "kaputter Inhalt fuehrte nicht auf die Vorgabe: $OUT"
|
echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fail "kaputter Inhalt fuehrte nicht auf die Vorgabe: $OUT"
|
||||||
echo "$OUT" | grep -q "migrate-nocreds" && pass "und das Verzeichnis des sicheren Satzes" || fail "falsches Verzeichnis: $OUT"
|
echo "$OUT" | grep -q "rescue-nocreds" && pass "und das Verzeichnis des sicheren Satzes (Vorgabemodus: recovery)" || fail "falsches Verzeichnis: $OUT"
|
||||||
# Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die
|
# Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die
|
||||||
# Station fuer umgeschaltet.
|
# Station fuer umgeschaltet.
|
||||||
echo "$OUT" | grep -q "unbekannte Einstellung 'voellig-anderes'" \
|
echo "$OUT" | grep -q "unknown setting 'voellig-anderes'" \
|
||||||
&& pass "die unbekannte Einstellung wird beim Namen genannt" \
|
&& pass "die unbekannte Einstellung wird beim Namen genannt" \
|
||||||
|| fail "die unbekannte Einstellung wird verschwiegen: $OUT"
|
|| fail "die unbekannte Einstellung wird verschwiegen: $OUT"
|
||||||
# Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen.
|
# Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen.
|
||||||
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
|
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
|
||||||
OUT=$(run_cli "$SBX" bildsatz 2>&1)
|
OUT=$(run_cli "$SBX" bildsatz 2>&1)
|
||||||
echo "$OUT" | grep -q "unbekannte Einstellung" \
|
echo "$OUT" | grep -q "unknown setting" \
|
||||||
&& fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \
|
&& fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \
|
||||||
|| pass "kein Fehlalarm bei gueltigem Eintrag"
|
|| pass "kein Fehlalarm bei gueltigem Eintrag"
|
||||||
|
|
||||||
@@ -1340,23 +1348,23 @@ echo
|
|||||||
echo "=== Bildsatz E: ein gewaehlter, aber fehlender Satz wird beim Umschalten UND im Status gemeldet ==="
|
echo "=== Bildsatz E: ein gewaehlter, aber fehlender Satz wird beim Umschalten UND im Status gemeldet ==="
|
||||||
SBX="$TMPROOT/bs5"
|
SBX="$TMPROOT/bs5"
|
||||||
setup_sandbox "$SBX"
|
setup_sandbox "$SBX"
|
||||||
rm -rf "$SBX/images/migrate-wlan"
|
rm -rf "$SBX/images/rescue-wlan" # Vorgabemodus (ohne vorheriges "on") ist recovery
|
||||||
set +e
|
set +e
|
||||||
ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null)
|
ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null)
|
||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
[ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC"
|
[ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC"
|
||||||
echo "$ERR" | grep -qi "warnung" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR"
|
echo "$ERR" | grep -qi "warning" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR"
|
||||||
OUT=$(run_cli "$SBX" status 2>&1)
|
OUT=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$OUT" | grep -q "FEHLT" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT"
|
echo "$OUT" | grep -q "MISSING" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ==="
|
echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ==="
|
||||||
SBX="$TMPROOT/bs6"
|
SBX="$TMPROOT/bs6"
|
||||||
setup_sandbox "$SBX"
|
setup_sandbox "$SBX"
|
||||||
rm -f "$SBX/images/migrate-nocreds"/*.img
|
rm -f "$SBX/images/rescue-nocreds"/*.img # Vorgabemodus (ohne vorheriges "on") ist recovery
|
||||||
OUT=$(run_cli "$SBX" status 2>&1)
|
OUT=$(run_cli "$SBX" status 2>&1)
|
||||||
echo "$OUT" | grep -q "FEHLT" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT"
|
echo "$OUT" | grep -q "MISSING" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ==="
|
echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ==="
|
||||||
@@ -1369,7 +1377,7 @@ OUT=$(run_cli "$SBX" migrate on --listen 2>&1)
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
||||||
echo "$OUT" | grep -q "Bildsatz: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT"
|
echo "$OUT" | grep -q "Image set: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ==="
|
echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ==="
|
||||||
@@ -1379,12 +1387,12 @@ rm -f "$SBX/images/migrate-wlan"/*.img # nur der andere Satz ist leer
|
|||||||
set +e
|
set +e
|
||||||
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
|
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
|
||||||
set -e
|
set -e
|
||||||
echo "$ERR" | grep -q "keine Migrationsimages" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz"
|
echo "$ERR" | grep -q "no migration images" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz"
|
||||||
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
|
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
|
||||||
set +e
|
set +e
|
||||||
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
|
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
|
||||||
set -e
|
set -e
|
||||||
echo "$ERR" | grep -q "keine Migrationsimages" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR"
|
echo "$ERR" | grep -q "no migration images" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ==="
|
echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ==="
|
||||||
@@ -1466,7 +1474,7 @@ set -e
|
|||||||
unset FAKE_DISPLAY_ACTIVE_FOR
|
unset FAKE_DISPLAY_ACTIVE_FOR
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR"
|
||||||
grep -q "STUB display.py" "$SBX/cmd.log" && fail "es wurde auf belegte Leitungen gezeichnet - 'GPIO busy'" || pass "nicht gezeichnet, solange der Dienst haelt"
|
grep -q "STUB display.py" "$SBX/cmd.log" && fail "es wurde auf belegte Leitungen gezeichnet - 'GPIO busy'" || pass "nicht gezeichnet, solange der Dienst haelt"
|
||||||
echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
|
echo "$ERR" | grep -qi "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
|
||||||
# Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in
|
# Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in
|
||||||
# der Abschalt-Transaktion.
|
# der Abschalt-Transaktion.
|
||||||
grep -q "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'systemctl stop' mitten im Herunterfahren" || pass "kein 'systemctl stop' aus dem Abschaltschritt heraus"
|
grep -q "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'systemctl stop' mitten im Herunterfahren" || pass "kein 'systemctl stop' aus dem Abschaltschritt heraus"
|
||||||
@@ -1482,7 +1490,7 @@ RC=$?
|
|||||||
set -e
|
set -e
|
||||||
unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT
|
unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT
|
||||||
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - ein Anzeigefehler wuerde das Herunterfahren rot machen: $ERR"
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - ein Anzeigefehler wuerde das Herunterfahren rot machen: $ERR"
|
||||||
echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
|
echo "$ERR" | grep -qi "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
|
|||||||
@@ -217,18 +217,63 @@ if with_dev:
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
# Erzeugt eine gueltige rohe _gz.img-Attrappe: >= 1024 Byte, durch 4 teilbar, ohne
|
# Baut eine Attrappe mit dem AUFBAU EINES ECHTEN ABBILDS: 56 Byte Kopf, dann ein
|
||||||
# NINL-Trailer - genau das, was nl_sign.py als Eingabe akzeptiert.
|
# gzip-Strom mit dem Rumpf, dann ggf. 512 Byte Trailer. Genau diesen Aufbau setzt die
|
||||||
make_gz_file() {
|
# Zugangsdatenpruefung in deploy.sh voraus - eine unkomprimierte Attrappe wuerde die
|
||||||
local path=$1 fill=${2:-A}
|
# Pruefung an einem Abbild vorfuehren, das es so nie gibt, und damit nichts belegen.
|
||||||
python3 -c "
|
# $1 = Pfad, $2 = Fuellzeichen (auch die 56 Kopfbytes, damit Szenarien den Quellbaum
|
||||||
|
# am Dateianfang wiedererkennen), $3 = "creds", wenn die Zugangsdaten in den
|
||||||
|
# KOMPRIMIERTEN Rumpf eingebaut werden sollen, $4 = "ota" fuer den NINL-Trailer.
|
||||||
|
# Ergebnis ohne Trailer ist eine gueltige Eingabe fuer nl_sign.py: >= 1024 Byte, durch 4
|
||||||
|
# teilbar, kein NINL im Signaturblock.
|
||||||
|
make_image_file() {
|
||||||
|
local path=$1 fill=${2:-A} with_creds=${3:-} kind=${4:-gz}
|
||||||
|
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
|
||||||
|
python3 - "$path" "$fill" "$with_creds" "$kind" <<'PY'
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
path, fill = sys.argv[1], sys.argv[2].encode()
|
|
||||||
data = bytearray((fill * 2048)[:2048])
|
path, fill, with_creds, kind = sys.argv[1], sys.argv[2].encode(), sys.argv[3], sys.argv[4]
|
||||||
data[-512:-508] = b'\\x00\\x00\\x00\\x00' # nie zufaellig wie 'NINL' aussehen
|
|
||||||
with open(path, 'wb') as fh:
|
body = bytearray((fill * 4096)[:4096])
|
||||||
|
if with_creds == "creds":
|
||||||
|
creds = (os.environ["SSID"] + "\0" + os.environ["PASS"] + "\0"
|
||||||
|
+ os.environ["TOKEN"] + "\0").encode()
|
||||||
|
body[64:64 + len(creds)] = creds
|
||||||
|
|
||||||
|
# mtime=0: sonst haengt die Dateigroesse an der Uhrzeit und Szenarien, die Groessen
|
||||||
|
# vergleichen, waeren launisch.
|
||||||
|
packed = gzip.compress(bytes(body), mtime=0)
|
||||||
|
data = bytearray((fill * 56)[:56] + packed)
|
||||||
|
# Auffuellen auf die Mindestlaenge und auf ein Vielfaches von 4 - der Rumpf eines echten
|
||||||
|
# Abbilds ist deutlich groesser als der komprimierte Testrumpf. Die Nullen hinter dem
|
||||||
|
# gzip-Strom stoeren das Entpacken nicht.
|
||||||
|
while len(data) < 1024 or len(data) % 4 != 0:
|
||||||
|
data.append(0)
|
||||||
|
if kind == "ota":
|
||||||
|
trailer = bytearray(b"\xff" * 512)
|
||||||
|
trailer[0:4] = b"NINL"
|
||||||
|
trailer[4] = 1
|
||||||
|
data += trailer
|
||||||
|
with open(path, "wb") as fh:
|
||||||
fh.write(bytes(data))
|
fh.write(bytes(data))
|
||||||
" "$path" "$fill"
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# Erzeugt eine gueltige rohe _gz.img-Attrappe (ohne Trailer, ohne Zugangsdaten).
|
||||||
|
make_gz_file() {
|
||||||
|
make_image_file "$1" "${2:-A}" "" gz
|
||||||
|
}
|
||||||
|
|
||||||
|
# Erzeugt eine _ota.img-Attrappe im echten Aufbau, ohne Zugangsdaten.
|
||||||
|
make_ota_image() {
|
||||||
|
make_image_file "$1" "${2:-A}" "" ota
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dasselbe, aber mit den Zugangsdaten im komprimierten Rumpf.
|
||||||
|
make_ota_image_with_creds() {
|
||||||
|
make_image_file "$1" "${2:-A}" creds ota
|
||||||
}
|
}
|
||||||
|
|
||||||
# Erzeugt eine _ota.img-Attrappe mit ANDEREM Inhalt als das zugehoerige _gz.img und
|
# Erzeugt eine _ota.img-Attrappe mit ANDEREM Inhalt als das zugehoerige _gz.img und
|
||||||
@@ -270,33 +315,36 @@ EOF
|
|||||||
# test_release.sh: nur weil er die Werte tatsaechlich einbaut, sagt ein bestandener
|
# test_release.sh: nur weil er die Werte tatsaechlich einbaut, sagt ein bestandener
|
||||||
# Pruefdurchlauf ueberhaupt etwas.
|
# Pruefdurchlauf ueberhaupt etwas.
|
||||||
make_gz_file_with_creds() {
|
make_gz_file_with_creds() {
|
||||||
local path=$1 fill=${2:-A}
|
make_image_file "$1" "${2:-A}" creds gz
|
||||||
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 -c "
|
|
||||||
import os, sys
|
|
||||||
path, fill = sys.argv[1], sys.argv[2].encode()
|
|
||||||
creds = (os.environ['SSID'] + '\\0' + os.environ['PASS'] + '\\0'
|
|
||||||
+ os.environ['TOKEN'] + '\\0').encode()
|
|
||||||
data = bytearray((fill * 2048)[:2048])
|
|
||||||
data[64:64 + len(creds)] = creds
|
|
||||||
data[-512:-508] = b'\\x00\\x00\\x00\\x00' # nie zufaellig wie 'NINL' aussehen
|
|
||||||
with open(path, 'wb') as fh:
|
|
||||||
fh.write(bytes(data))
|
|
||||||
" "$path" "$fill"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sucht eine Zeichenkette BINAER in jeder Datei unterhalb eines Verzeichnisses.
|
# Sucht eine Zeichenkette in jeder Datei unterhalb eines Verzeichnisses - roh UND im
|
||||||
# Rueckgabe 0 = gefunden. Wie contains_secrets() in test_release.sh.
|
# ENTPACKTEN Rumpf jedes .img (56 Byte Kopf, gzip, 512 Byte Trailer/Signatur).
|
||||||
|
# Rueckgabe 0 = gefunden. Wie contains_secrets() in test_release.sh, nur dass die
|
||||||
|
# Gegenproben seit den echt aufgebauten Attrappen sonst nichts mehr faenden: im
|
||||||
|
# komprimierten Strom steht der Klartext nicht. Genau das war der Fehler in deploy.sh.
|
||||||
tree_contains() {
|
tree_contains() {
|
||||||
local dir=$1 needle=$2
|
local dir=$1 needle=$2
|
||||||
NEEDLE="$needle" python3 - "$dir" <<'PY'
|
NEEDLE="$needle" python3 - "$dir" <<'PY'
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
needle = os.environ["NEEDLE"].encode()
|
needle = os.environ["NEEDLE"].encode()
|
||||||
for root, _dirs, files in os.walk(sys.argv[1]):
|
for root, _dirs, files in os.walk(sys.argv[1]):
|
||||||
for fn in files:
|
for fn in files:
|
||||||
with open(os.path.join(root, fn), "rb") as fh:
|
with open(os.path.join(root, fn), "rb") as fh:
|
||||||
if needle in fh.read():
|
raw = fh.read()
|
||||||
|
if needle in raw:
|
||||||
|
sys.exit(0)
|
||||||
|
if not fn.endswith(".img") or len(raw) <= 56 + 512:
|
||||||
|
continue
|
||||||
|
for body in (raw[56:-512], raw[56:]):
|
||||||
|
try:
|
||||||
|
clear = zlib.decompressobj(zlib.MAX_WBITS | 16).decompress(body)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if needle in clear:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
PY
|
PY
|
||||||
@@ -355,10 +403,12 @@ setup_stubbin "$STUBBIN" "$LOG"
|
|||||||
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar"
|
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar"
|
||||||
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img"
|
echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img"
|
||||||
# Zielverzeichnis simuliert einen vorherigen Voll-Deploy: Images fuer foo UND bar liegen schon da.
|
# Zielverzeichnis simuliert einen vorherigen Voll-Deploy MIT dieser Fassung von
|
||||||
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
|
# deploy.sh: Rettungsabbilder fuer foo UND bar liegen schon in images/rescue-wlan/ (dem
|
||||||
echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img"
|
# "mit-wlan"-Bildsatz aus build/, siehe server.py:RESCUE_SET_DIRS).
|
||||||
echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img"
|
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan"
|
||||||
|
echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img"
|
||||||
|
echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_bar_ota.img"
|
||||||
: >"$LOG"
|
: >"$LOG"
|
||||||
set +e
|
set +e
|
||||||
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
@@ -370,13 +420,13 @@ if echo "$OUT" | grep -q "andere Geraetetypen im Zielverzeichnis bleiben erhalte
|
|||||||
else
|
else
|
||||||
fail "Ausgabe erwaehnt den gezielten Deploy nicht"
|
fail "Ausgabe erwaehnt den gezielten Deploy nicht"
|
||||||
fi
|
fi
|
||||||
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_bar_ota.img" ]; then
|
||||||
pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten"
|
pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten"
|
||||||
else
|
else
|
||||||
fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben"
|
fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben"
|
||||||
fi
|
fi
|
||||||
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ] \
|
||||||
&& [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img")" = "foo-image-neu" ]; then
|
&& [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img")" = "foo-image-neu" ]; then
|
||||||
pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert"
|
pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert"
|
||||||
else
|
else
|
||||||
fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert"
|
fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert"
|
||||||
@@ -646,7 +696,7 @@ OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||||
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; then
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
|
||||||
pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert"
|
pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert"
|
||||||
else
|
else
|
||||||
fail "Rettungsimage fehlt: $OUT"
|
fail "Rettungsimage fehlt: $OUT"
|
||||||
@@ -901,7 +951,7 @@ OUT=$(TEST_SIGN_DIR="$SIGN_EMPTY" run_deploy "$SANDBOX" pi-test 2>&1)
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||||
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ]; then
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
|
||||||
pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert"
|
pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert"
|
||||||
else
|
else
|
||||||
fail "Rettungsimage fehlt: $OUT"
|
fail "Rettungsimage fehlt: $OUT"
|
||||||
@@ -1419,6 +1469,298 @@ else
|
|||||||
fail "der Satz 'ohne-wlan' auf dem Pi enthaelt Zugangsdaten"
|
fail "der Satz 'ohne-wlan' auf dem Pi enthaelt Zugangsdaten"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 35: der Bildsatz-Umschalter gilt jetzt auch fuer die Rettung - beide Rettungssaetze werden ausgeliefert ==="
|
||||||
|
# Analog zu Szenario 28 (Migration), aber fuer die Rettungsabbilder: rescue-wlan/ kommt
|
||||||
|
# aus build/ (dasselbe _ota.img, das ohnehin schon fuer die Trailer-Pruefung gebraucht
|
||||||
|
# wird), rescue-nocreds/ aus build-nocreds/ - ein eigener, unsignierter Fund.
|
||||||
|
SANDBOX="$TMPROOT/s35"; STUBBIN="$TMPROOT/bin35"; LOG="$TMPROOT/s35.log"; SANDBOX_STATE="$TMPROOT/s35-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
|
||||||
|
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
body = b'N' * 2048
|
||||||
|
trailer = bytearray(b'\xff' * 512)
|
||||||
|
trailer[0:4] = b'NINL'
|
||||||
|
with open(path, 'wb') as fh:
|
||||||
|
fh.write(body + bytes(trailer))
|
||||||
|
" "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
||||||
|
RESCUE_WLAN="$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img"
|
||||||
|
RESCUE_NOCREDS="$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img"
|
||||||
|
if [ -f "$RESCUE_WLAN" ] && [ -f "$RESCUE_NOCREDS" ]; then
|
||||||
|
pass "beide Rettungssaetze liegen getrennt auf dem Pi"
|
||||||
|
else
|
||||||
|
fail "mindestens ein Rettungssatz fehlt: $OUT"
|
||||||
|
fi
|
||||||
|
if head -c 1 "$RESCUE_WLAN" | grep -q "B"; then
|
||||||
|
pass "rescue-wlan/ kommt wirklich aus build/"
|
||||||
|
else
|
||||||
|
fail "rescue-wlan/ hat nicht den erwarteten Inhalt aus build/"
|
||||||
|
fi
|
||||||
|
if head -c 1 "$RESCUE_NOCREDS" | grep -q "N"; then
|
||||||
|
pass "rescue-nocreds/ kommt wirklich aus build-nocreds/"
|
||||||
|
else
|
||||||
|
fail "rescue-nocreds/ hat nicht den erwarteten Inhalt aus build-nocreds/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 36: ohne build-nocreds/ bleibt der Rettungssatz 'ohne-wlan' auf dem Pi unangetastet ==="
|
||||||
|
SANDBOX="$TMPROOT/s36"; STUBBIN="$TMPROOT/bin36"; LOG="$TMPROOT/s36.log"; SANDBOX_STATE="$TMPROOT/s36-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/foo"
|
||||||
|
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
|
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds"
|
||||||
|
echo "alter-rescue-nocreds-abzug" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlendem build-nocreds/" || fail "Exit-Code $RC: $OUT"
|
||||||
|
if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img" 2>/dev/null)" \
|
||||||
|
= "alter-rescue-nocreds-abzug" ]; then
|
||||||
|
pass "vorhandener Rettungssatz 'ohne-wlan' blieb unangetastet"
|
||||||
|
else
|
||||||
|
fail "der Rettungssatz 'ohne-wlan' wurde geleert oder ueberschrieben, obwohl build-nocreds/ fehlte"
|
||||||
|
fi
|
||||||
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
|
||||||
|
pass "der Rettungssatz 'mit-wlan' wurde trotzdem ausgeliefert"
|
||||||
|
else
|
||||||
|
fail "der Rettungssatz 'mit-wlan' fehlt: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -q "build-nocreds"; then
|
||||||
|
pass "der Hinweis nennt, womit der fehlende Rettungssatz entsteht"
|
||||||
|
else
|
||||||
|
fail "kein Hinweis auf build-nocreds/ in der Ausgabe: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 37: gezielter Deploy ohne build-nocreds/<ziel> bricht nicht ab, sondern laesst 'ohne-wlan' fuer dieses Ziel unangetastet ==="
|
||||||
|
SANDBOX="$TMPROOT/s37"; STUBBIN="$TMPROOT/bin37"; LOG="$TMPROOT/s37.log"; SANDBOX_STATE="$TMPROOT/s37-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/foo"
|
||||||
|
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlendem build-nocreds/foo" || fail "Exit-Code $RC: $OUT"
|
||||||
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
|
||||||
|
pass "der Rettungssatz 'mit-wlan' fuer foo wurde trotzdem ausgeliefert"
|
||||||
|
else
|
||||||
|
fail "der Rettungssatz 'mit-wlan' fuer foo fehlt: $OUT"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img" ]; then
|
||||||
|
pass "kein Rettungsimage 'ohne-wlan' fuer foo entstanden - build-nocreds/foo fehlte"
|
||||||
|
else
|
||||||
|
fail "es entstand ein Rettungsimage 'ohne-wlan' fuer foo, obwohl build-nocreds/foo fehlte"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -q "bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert"; then
|
||||||
|
pass "Hinweis auf das fehlende build-nocreds/foo vorhanden"
|
||||||
|
else
|
||||||
|
fail "kein Hinweis auf das fehlende build-nocreds/foo: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 38: Altbestand - vor diesem Umschalter flach in images/ liegende Rettungsabbilder werden auch bei einem gezielten Deploy aufgeraeumt ==="
|
||||||
|
# Anders als bei den Migrationsabbildern (die ein eigenes, benanntes Verzeichnis erst
|
||||||
|
# seit einem frueheren Umschalter haben) gab es fuer die Rettung nie eine Uebergangsphase
|
||||||
|
# mit --delete: der flache Bestand wird von server.py seit diesem Umschalter nicht mehr
|
||||||
|
# gelesen (siehe rescue_dir() dort) und ist deshalb toter Ballast, sobald IRGENDEIN
|
||||||
|
# Deploy mit dieser Fassung laeuft - auch ein gezielter, der images/ sonst nicht mit
|
||||||
|
# --delete abgleicht.
|
||||||
|
SANDBOX="$TMPROOT/s38"; STUBBIN="$TMPROOT/bin38"; LOG="$TMPROOT/s38.log"; SANDBOX_STATE="$TMPROOT/s38-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
mkdir -p "$SANDBOX/build/foo"
|
||||||
|
make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||||
|
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
|
||||||
|
echo "alter-flacher-bestand" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img"
|
||||||
|
echo "alter-flacher-bestand-bar" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
||||||
|
if [ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \
|
||||||
|
&& [ ! -e "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then
|
||||||
|
pass "der flache Altbestand wurde auch bei einem gezielten Deploy entfernt"
|
||||||
|
else
|
||||||
|
fail "flacher Altbestand liegt noch da - er wuerde von niemandem mehr gelesen, aber am Pi einen gueltigen Satz vortaeuschen"
|
||||||
|
fi
|
||||||
|
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ]; then
|
||||||
|
pass "das neue Rettungsimage liegt an der richtigen Stelle (images/rescue-wlan/)"
|
||||||
|
else
|
||||||
|
fail "das neue Rettungsimage fehlt: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 39: Zugangsdaten im RETTUNGSSATZ 'ohne-wlan' brechen den Deploy ab ==="
|
||||||
|
# Der Rettungssatz ist der gefaehrlichere der beiden Saetze "ohne-wlan": er geht ohne
|
||||||
|
# Signatur an die Lampe. Trotzdem hing die Zugangsdatenpruefung frueher allein an
|
||||||
|
# migrate-nocreds/ - rescue-nocreds/ lief ungeprueft durch. Hier ohne Schluessel, damit
|
||||||
|
# es NUR um den Rettungssatz geht: es entsteht gar kein migrate-nocreds/.
|
||||||
|
SANDBOX="$TMPROOT/s39"; STUBBIN="$TMPROOT/bin39"; LOG="$TMPROOT/s39.log"; SANDBOX_STATE="$TMPROOT/s39-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
write_local_cmake "$SANDBOX"
|
||||||
|
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
|
||||||
|
make_ota_image "$SANDBOX/build/foo/ninlight_foo_ota.img" W
|
||||||
|
# Der undichte Rettungsabzug: die Zugangsdaten stecken im KOMPRIMIERTEN Rumpf.
|
||||||
|
make_ota_image_with_creds "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" N
|
||||||
|
# GEGENPROBE: ohne sie waere dieses Szenario wertlos - der "Compiler" dieser Suite muss
|
||||||
|
# die Zugangsdaten wirklich einbauen.
|
||||||
|
if tree_contains "$SANDBOX/build-nocreds" "$SECRET_PASS"; then
|
||||||
|
pass "Gegenprobe: der Rettungsabzug enthaelt das WLAN-Passwort wirklich (im gzip-Rumpf)"
|
||||||
|
else
|
||||||
|
fail "Gegenprobe fehlgeschlagen - der Rettungsabzug enthaelt gar keine Zugangsdaten, Szenario 39 waere wertlos"
|
||||||
|
fi
|
||||||
|
# Und die Gegenprobe zur Gegenprobe: ROH ist nichts davon zu finden. Genau deshalb hat
|
||||||
|
# die alte Pruefung nie etwas gemeldet.
|
||||||
|
if grep -q "$SECRET_PASS" "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" 2>/dev/null; then
|
||||||
|
fail "das Passwort steht im Klartext in der Datei - die Attrappe ist nicht komprimiert und belegt nichts"
|
||||||
|
else
|
||||||
|
pass "Gegenprobe: roh ist im Abbild nichts zu finden - nur entpackt"
|
||||||
|
fi
|
||||||
|
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds"
|
||||||
|
echo "alter-sauberer-abzug" >"$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0 - der undichte Rettungssatz waere ausgeliefert worden: $OUT"
|
||||||
|
echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD" \
|
||||||
|
&& pass "Fehlermeldung nennt den betroffenen Schluessel" \
|
||||||
|
|| fail "Fehlermeldung nennt den Schluessel nicht: $OUT"
|
||||||
|
echo "$OUT" | grep -q "ninlight_foo_ota.img" \
|
||||||
|
&& pass "Fehlermeldung nennt die betroffene Datei" \
|
||||||
|
|| fail "Fehlermeldung nennt die Datei nicht: $OUT"
|
||||||
|
if echo "$OUT" | grep -q "$SECRET_PASS"; then
|
||||||
|
fail "die Meldung gibt den WERT aus - sie darf nur den Namen nennen"
|
||||||
|
else
|
||||||
|
pass "die Meldung nennt nur den Namen, nie den Wert"
|
||||||
|
fi
|
||||||
|
if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then
|
||||||
|
pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen"
|
||||||
|
else
|
||||||
|
fail "es wurde trotz gefundener Zugangsdaten uebertragen: $(cat "$LOG")"
|
||||||
|
fi
|
||||||
|
[ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img")" = "alter-sauberer-abzug" ] \
|
||||||
|
&& pass "der Bestand auf dem Pi blieb unangetastet" \
|
||||||
|
|| fail "der Bestand auf dem Pi wurde veraendert"
|
||||||
|
if ! tree_contains "$SANDBOX_STATE" "$SECRET_PASS"; then
|
||||||
|
pass "kein Zugangsdatum hat es in die Sandbox-Zielablage geschafft"
|
||||||
|
else
|
||||||
|
fail "Zugangsdaten sind trotz Abbruch beim Ziel gelandet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 40 (Abgrenzung zu 39): rescue-wlan DARF die Zugangsdaten tragen ==="
|
||||||
|
# Dieselbe Abgrenzung wie Szenario 34 fuer die Migration: der Satz 'mit-wlan' SOLL die
|
||||||
|
# Zugangsdaten enthalten. Eine Pruefung, die auch ihn abweist, waere unbenutzbar.
|
||||||
|
SANDBOX="$TMPROOT/s40"; STUBBIN="$TMPROOT/bin40"; LOG="$TMPROOT/s40.log"; SANDBOX_STATE="$TMPROOT/s40-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
write_local_cmake "$SANDBOX"
|
||||||
|
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
|
||||||
|
make_ota_image_with_creds "$SANDBOX/build/foo/ninlight_foo_ota.img" W
|
||||||
|
make_ota_image "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" N
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - die Pruefung weist einen sauberen Rettungssatz ab: $OUT"
|
||||||
|
[ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan/ninlight_foo_ota.img" ] \
|
||||||
|
&& pass "rescue-wlan kam trotz einkompilierter Zugangsdaten an" \
|
||||||
|
|| fail "rescue-wlan wurde faelschlich mit abgewiesen: $OUT"
|
||||||
|
[ -f "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds/ninlight_foo_ota.img" ] \
|
||||||
|
&& pass "der saubere Rettungssatz 'ohne-wlan' kam an" \
|
||||||
|
|| fail "der saubere Rettungssatz 'ohne-wlan' fehlt: $OUT"
|
||||||
|
if tree_contains "$SANDBOX_STATE/opt/ninlight-station/images/rescue-wlan" "$SECRET_PASS"; then
|
||||||
|
pass "Gegenprobe: rescue-wlan traegt die Zugangsdaten wirklich - die Pruefung haette anschlagen KOENNEN"
|
||||||
|
else
|
||||||
|
fail "Gegenprobe fehlgeschlagen - rescue-wlan traegt gar keine Zugangsdaten, die Abgrenzung waere wertlos"
|
||||||
|
fi
|
||||||
|
if ! tree_contains "$SANDBOX_STATE/opt/ninlight-station/images/rescue-nocreds" "$SECRET_PASS"; then
|
||||||
|
pass "und der Rettungssatz 'ohne-wlan' auf dem Pi ist frei davon"
|
||||||
|
else
|
||||||
|
fail "der Rettungssatz 'ohne-wlan' auf dem Pi enthaelt Zugangsdaten"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 41: ein Abbild, das sich nicht entpacken laesst, bricht den Deploy ab ==="
|
||||||
|
# Nachsicht an dieser Stelle ("dann eben ungeprueft durchlassen") ist der Weg, auf dem
|
||||||
|
# das Passwort am Ende doch hinausgeht: ein Abbild in einem anderen Format ist
|
||||||
|
# genau das, was niemand geprueft hat.
|
||||||
|
SANDBOX="$TMPROOT/s41"; STUBBIN="$TMPROOT/bin41"; LOG="$TMPROOT/s41.log"; SANDBOX_STATE="$TMPROOT/s41-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
write_local_cmake "$SANDBOX"
|
||||||
|
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
|
||||||
|
make_ota_image "$SANDBOX/build/foo/ninlight_foo_ota.img" W
|
||||||
|
# Kein gzip-Rumpf: aufgebaut wie ein Abbild (56 Byte Kopf, 512 Byte Trailer), aber der
|
||||||
|
# Rumpf dazwischen ist unkomprimiert - die alte, unkomprimierte Attrappe dieser Suite.
|
||||||
|
make_ota_file "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img"
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0 - ein ungepruefter Rettungssatz waere ausgeliefert worden: $OUT"
|
||||||
|
echo "$OUT" | grep -q "nicht entpacken" \
|
||||||
|
&& pass "Fehlermeldung nennt den Grund" \
|
||||||
|
|| fail "keine Fehlermeldung zum Entpacken: $OUT"
|
||||||
|
echo "$OUT" | grep -q "rescue-nocreds/ninlight_foo_ota.img" \
|
||||||
|
&& pass "Fehlermeldung nennt die betroffene Datei" \
|
||||||
|
|| fail "Fehlermeldung nennt die Datei nicht: $OUT"
|
||||||
|
if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then
|
||||||
|
pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen"
|
||||||
|
else
|
||||||
|
fail "es wurde trotz ungepruefbarem Abbild uebertragen: $(cat "$LOG")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Szenario 42: die Meldung sagt, wie viele Abbilder in wie vielen Saetzen geprueft wurden ==="
|
||||||
|
# Die alte Meldung "sauber (2 geprueft)" zaehlte die SUCHBEGRIFFE und las sich wie zwei
|
||||||
|
# Dateien - sie haette genauso gelautet, wenn kein einziges Abbild angefasst worden waere.
|
||||||
|
SANDBOX="$TMPROOT/s42"; STUBBIN="$TMPROOT/bin42"; LOG="$TMPROOT/s42.log"; SANDBOX_STATE="$TMPROOT/s42-state"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
|
write_local_cmake "$SANDBOX"
|
||||||
|
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build-nocreds/foo"
|
||||||
|
make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" W
|
||||||
|
make_ota_image "$SANDBOX/build/foo/ninlight_foo_ota.img" W
|
||||||
|
make_gz_file "$SANDBOX/build-nocreds/foo/ninlight_foo_gz.img" N
|
||||||
|
make_ota_image "$SANDBOX/build-nocreds/foo/ninlight_foo_ota.img" N
|
||||||
|
: >"$LOG"
|
||||||
|
set +e
|
||||||
|
OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
|
||||||
|
# migrate-nocreds/: foo_prod.img + foo_dev.img, rescue-nocreds/: ninlight_foo_ota.img.
|
||||||
|
echo "$OUT" | grep -q "sauber (3 Abbilder in 2 Saetzen geprueft" \
|
||||||
|
&& pass "die Meldung nennt Zahl der Abbilder UND der Saetze" \
|
||||||
|
|| fail "die Meldung sagt nicht, was geprueft wurde: $OUT"
|
||||||
|
echo "$OUT" | grep -q "migrate-nocreds, rescue-nocreds" \
|
||||||
|
&& pass "die Meldung nennt beide geprueften Saetze beim Namen" \
|
||||||
|
|| fail "die Meldung nennt die Saetze nicht: $OUT"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
echo "Alle Tests bestanden."
|
echo "Alle Tests bestanden."
|
||||||
|
|||||||
@@ -14,10 +14,60 @@ import render # noqa: E402
|
|||||||
|
|
||||||
def test_off_mode_says_so_and_shows_nothing_else():
|
def test_off_mode_says_so_and_shows_nothing_else():
|
||||||
lines = render.lines({"mode": "off"})
|
lines = render.lines({"mode": "off"})
|
||||||
assert any("Station aus" in l for l in lines)
|
assert any("Station off" in l for l in lines)
|
||||||
assert not any("SSID" in l for l in lines)
|
assert not any("SSID" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_mode_shows_the_address_the_pi_is_reachable_at():
|
||||||
|
"""Bei ausgeschalteter Station ist der Pi ein gewoehnlicher Rechner im Heimnetz - die
|
||||||
|
Adresse am Panel erspart die Suche im Router, bevor man per SSH drangeht."""
|
||||||
|
lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"})
|
||||||
|
assert any("wlan0: 192.168.2.96" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_mode_names_the_interface_the_address_belongs_to():
|
||||||
|
"""Das Geraet gehoert dazu: Michael betreibt den Pi ohne Kabel, "LAN" las sich
|
||||||
|
deshalb als Falschauskunft (Rueckmeldung 2026-09-23). Am Kabel steht dort eth0."""
|
||||||
|
lines = render.lines({"mode": "off", "ip": "192.168.1.92", "iface": "eth0"})
|
||||||
|
assert any("eth0: 192.168.1.92" in l for l in lines)
|
||||||
|
assert not any("LAN" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_mode_shows_the_address_even_without_an_interface_name():
|
||||||
|
"""Notnagel: Adresse ohne Geraetenamen ist immer noch die Auskunft, die gebraucht
|
||||||
|
wird - dann ohne Beschriftung raten, sondern schlicht "IP"."""
|
||||||
|
lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": ""})
|
||||||
|
assert any("IP: 192.168.2.96" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_mode_says_when_there_is_no_network():
|
||||||
|
"""Leeres Feld heisst "kein Netz" und wird ausdruecklich gesagt: das erklaert, warum
|
||||||
|
SSH gerade scheitert. Eine fehlende Zeile wuerde die Frage offen lassen.
|
||||||
|
|
||||||
|
"no home network" statt eines nackten "no network": ein Bediener las die fruehere
|
||||||
|
Formulierung so, als sei der Migrations-/Rettungs-Zugangspunkt gemeint - der ist im
|
||||||
|
Aus-Zustand ja tatsaechlich aus. Gemeint ist aber das GEGENTEIL, das Heimnetz, in dem
|
||||||
|
der Pi als gewoehnlicher Rechner haengt (siehe render.py)."""
|
||||||
|
lines = render.lines({"mode": "off", "ip": "", "iface": ""})
|
||||||
|
assert any("no home network" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_mode_without_an_address_field_claims_nothing():
|
||||||
|
"""Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand): ohne das Feld
|
||||||
|
bleibt die Zeile weg, statt eine Adresse zu erfinden."""
|
||||||
|
lines = render.lines({"mode": "off"})
|
||||||
|
assert not any("home network" in l or ": 192." in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_address_line_belongs_to_the_off_screen_only():
|
||||||
|
"""Im Betrieb traegt wlan0 die Zugangspunkt-Adresse, die ohnehin auf jedem Statusbild
|
||||||
|
steht - eine zweite Adresse waere irrefuehrend und kostet die knappe Zeile."""
|
||||||
|
for mode in ("migration", "recovery"):
|
||||||
|
lines = render.lines({"mode": mode, "phase": "auto", "ip": "192.168.2.96",
|
||||||
|
"iface": "wlan0"})
|
||||||
|
assert not any("192.168.2.96" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
def test_migration_shows_detected_lamp():
|
def test_migration_shows_detected_lamp():
|
||||||
lines = render.lines({
|
lines = render.lines({
|
||||||
"mode": "migration", "phase": "auto", "served": 3,
|
"mode": "migration", "phase": "auto", "served": 3,
|
||||||
@@ -57,7 +107,7 @@ def test_recovery_mode_without_traffic():
|
|||||||
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
|
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
|
||||||
"served": 0, "recent": []})
|
"served": 0, "recent": []})
|
||||||
text = "\n".join(lines)
|
text = "\n".join(lines)
|
||||||
assert "RETTUNG" in text
|
assert "RECOVERY" in text
|
||||||
assert "ninlight-recovery" in text
|
assert "ninlight-recovery" in text
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +201,7 @@ def _migration_state():
|
|||||||
|
|
||||||
|
|
||||||
def test_off_screen_is_drawn_big_and_centered():
|
def test_off_screen_is_drawn_big_and_centered():
|
||||||
""""Station aus" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
|
""""Station off" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
|
||||||
|
|
||||||
Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links
|
Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links
|
||||||
wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind
|
wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind
|
||||||
@@ -519,7 +569,7 @@ def test_font_cache_keeps_different_modules_and_search_paths_apart(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch):
|
def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch):
|
||||||
""""Station aus" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
|
""""Station off" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
|
||||||
nicht erst gebaut werden (faules Laden, Befund M4)."""
|
nicht erst gebaut werden (faules Laden, Befund M4)."""
|
||||||
real_load_font = render._load_font
|
real_load_font = render._load_font
|
||||||
requested = []
|
requested = []
|
||||||
@@ -663,7 +713,7 @@ def test_missing_fields_do_not_raise():
|
|||||||
|
|
||||||
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
|
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
|
||||||
#
|
#
|
||||||
# Frueher stand "FREIGEGEBEN: <ziel>" als Phase im Kopfbalken. Neben dem Modus bleiben
|
# Frueher stand "ARMED: <target>" als Phase im Kopfbalken. Neben dem Modus bleiben
|
||||||
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein
|
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein
|
||||||
# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
|
# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
|
||||||
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war
|
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war
|
||||||
@@ -760,11 +810,11 @@ def test_armed_phase_label_is_visible():
|
|||||||
|
|
||||||
def test_autooff_line_appears_only_with_a_running_timer():
|
def test_autooff_line_appears_only_with_a_running_timer():
|
||||||
without = render.lines(_migration_state())
|
without = render.lines(_migration_state())
|
||||||
assert not any("Auto-Aus" in line for line in without), without
|
assert not any("Auto-off" in line for line in without), without
|
||||||
|
|
||||||
state = dict(_migration_state(), autooff_minutes=7)
|
state = dict(_migration_state(), autooff_minutes=7)
|
||||||
with_timer = render.lines(state)
|
with_timer = render.lines(state)
|
||||||
assert "Auto-Aus in 7 min" in with_timer, with_timer
|
assert "Auto-off in 7 min" in with_timer, with_timer
|
||||||
|
|
||||||
|
|
||||||
def test_autooff_line_also_appears_in_recovery_mode():
|
def test_autooff_line_also_appears_in_recovery_mode():
|
||||||
@@ -772,7 +822,7 @@ def test_autooff_line_also_appears_in_recovery_mode():
|
|||||||
die SSH-Sitzung genauso ab."""
|
die SSH-Sitzung genauso ab."""
|
||||||
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0,
|
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0,
|
||||||
"recent": [], "autooff_minutes": 42})
|
"recent": [], "autooff_minutes": 42})
|
||||||
assert "Auto-Aus in 42 min" in lines, lines
|
assert "Auto-off in 42 min" in lines, lines
|
||||||
|
|
||||||
|
|
||||||
def test_autooff_line_is_actually_drawn_on_the_panel():
|
def test_autooff_line_is_actually_drawn_on_the_panel():
|
||||||
@@ -781,7 +831,7 @@ def test_autooff_line_is_actually_drawn_on_the_panel():
|
|||||||
unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete
|
unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete
|
||||||
Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten."""
|
Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten."""
|
||||||
state = dict(_armed_state(), autooff_minutes=7)
|
state = dict(_armed_state(), autooff_minutes=7)
|
||||||
expected = "Auto-Aus in 7 min"
|
expected = "Auto-off in 7 min"
|
||||||
# Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile
|
# Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile
|
||||||
# sind eigene Rollen und belegen keine Beiwerkzeile.)
|
# sind eigene Rollen und belegen keine Beiwerkzeile.)
|
||||||
details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL]
|
details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL]
|
||||||
@@ -800,34 +850,36 @@ def test_autooff_line_is_actually_drawn_on_the_panel():
|
|||||||
def test_autooff_line_says_so_when_the_deadline_has_arrived():
|
def test_autooff_line_says_so_when_the_deadline_has_arrived():
|
||||||
lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [],
|
lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [],
|
||||||
"autooff_minutes": 0})
|
"autooff_minutes": 0})
|
||||||
assert "Auto-Aus steht an" in lines, lines
|
assert "Auto-off pending" in lines, lines
|
||||||
|
|
||||||
|
|
||||||
def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
|
def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
(tmp_path / "mode").write_text("recovery")
|
(tmp_path / "last-mode").write_text("recovery")
|
||||||
conf_path = tmp_path / "station.conf"
|
conf_path = tmp_path / "station.conf"
|
||||||
conf_path.write_text("SSID='ninlight-recovery'\n")
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
||||||
# Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet.
|
# Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet.
|
||||||
(tmp_path / "autooff.json").write_text(json.dumps(
|
(tmp_path / "autooff.json").write_text(json.dumps(
|
||||||
{"minutes": 30, "deadline": time.time() + 361, "deferrals": 0}))
|
{"minutes": 30, "deadline": time.time() + 361, "deferrals": 0}))
|
||||||
|
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
|
||||||
assert state["autooff_minutes"] == 7
|
assert state["autooff_minutes"] == 7
|
||||||
assert "Auto-Aus in 7 min" in render.lines(state)
|
assert "Auto-off in 7 min" in render.lines(state)
|
||||||
|
|
||||||
|
|
||||||
def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch):
|
def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
(tmp_path / "mode").write_text("recovery")
|
(tmp_path / "last-mode").write_text("recovery")
|
||||||
conf_path = tmp_path / "station.conf"
|
conf_path = tmp_path / "station.conf"
|
||||||
conf_path.write_text("SSID='ninlight-recovery'\n")
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
||||||
|
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
|
||||||
assert "autooff_minutes" not in state
|
assert "autooff_minutes" not in state
|
||||||
assert not any("Auto-Aus" in line for line in render.lines(state))
|
assert not any("Auto-off" in line for line in render.lines(state))
|
||||||
|
|
||||||
|
|
||||||
def test_broken_autooff_file_is_treated_as_no_timer(tmp_path):
|
def test_broken_autooff_file_is_treated_as_no_timer(tmp_path):
|
||||||
@@ -855,7 +907,7 @@ def test_autooff_remaining_time_rounds_up(tmp_path):
|
|||||||
|
|
||||||
def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
||||||
""""off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz
|
""""off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz
|
||||||
liegen bliebe, darf neben "Station aus" keine Restzeit stehen."""
|
liegen bliebe, darf neben "Station off" keine Restzeit stehen."""
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
||||||
(tmp_path / "autooff.json").write_text(json.dumps(
|
(tmp_path / "autooff.json").write_text(json.dumps(
|
||||||
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
|
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
|
||||||
@@ -866,7 +918,7 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
assert state["mode"] == "off"
|
assert state["mode"] == "off"
|
||||||
assert "autooff_minutes" not in state
|
assert "autooff_minutes" not in state
|
||||||
assert not any("Auto-Aus" in line for line in render.lines(state))
|
assert not any("Auto-off" in line for line in render.lines(state))
|
||||||
|
|
||||||
|
|
||||||
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
|
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
|
||||||
@@ -874,51 +926,180 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
|||||||
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
|
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
|
||||||
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
|
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
|
||||||
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
|
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
|
||||||
# Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen.
|
# Rettungsmodus nicht als "ARMED" durchschlagen.
|
||||||
|
|
||||||
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
|
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
(tmp_path / "mode").write_text("recovery")
|
(tmp_path / "last-mode").write_text("recovery")
|
||||||
(tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"}))
|
(tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"}))
|
||||||
conf_path = tmp_path / "station.conf"
|
conf_path = tmp_path / "station.conf"
|
||||||
conf_path.write_text("SSID='ninlight-recovery'\n")
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
||||||
|
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
|
||||||
assert state["mode"] == "recovery"
|
assert state["mode"] == "recovery"
|
||||||
assert "phase" not in state
|
assert "phase" not in state
|
||||||
assert "armed_target" not in state
|
assert "armed_target" not in state
|
||||||
assert "FREIGEGEBEN" not in "\n".join(render.lines(state))
|
assert "ARMED" not in "\n".join(render.lines(state))
|
||||||
|
|
||||||
|
|
||||||
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
|
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
||||||
# Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern -
|
# Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern -
|
||||||
# "ninlight-station off" laesst status.json bewusst unangetastet liegen.
|
# "ninlight-station off" laesst status.json bewusst unangetastet liegen.
|
||||||
(tmp_path / "mode").write_text("migration")
|
(tmp_path / "last-mode").write_text("migration")
|
||||||
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
|
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
|
||||||
|
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||||
service_name="x",
|
service_name="x",
|
||||||
imageset_path=tmp_path / "imageset")
|
imageset_path=tmp_path / "imageset",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
|
||||||
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
|
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
|
||||||
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
|
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
|
||||||
assert state["mode"] == "off"
|
assert state["mode"] == "off"
|
||||||
assert set(state) == {"mode", "imageset", "imageset_ok"}
|
assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface"}
|
||||||
|
assert isinstance(state["ip"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_off_carries_the_local_address(tmp_path, monkeypatch):
|
||||||
|
"""Adresse und Geraet kommen aus local_address() und landen nur im Off-Zustand."""
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
||||||
|
monkeypatch.setattr(display, "local_address", lambda: ("wlan0", "192.168.2.96"))
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||||
|
service_name="x", imageset_path=tmp_path / "imageset")
|
||||||
|
assert state["ip"] == "192.168.2.96"
|
||||||
|
assert state["iface"] == "wlan0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_running_carries_no_address(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
(tmp_path / "last-mode").write_text("migration")
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||||
|
service_name="x", imageset_path=tmp_path / "imageset",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
assert "ip" not in state
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_address_returns_strings_and_never_raises():
|
||||||
|
"""Ohne Netz gibt es keine Route - dann ("", "") statt einer Ausnahme, die das Panel
|
||||||
|
aufhielte."""
|
||||||
|
iface, ip = display.local_address()
|
||||||
|
assert isinstance(iface, str) and isinstance(ip, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_address_works_without_af_inet(monkeypatch):
|
||||||
|
"""Der Anzeigedienst darf laut Unit NUR AF_UNIX und AF_NETLINK (siehe
|
||||||
|
ninlight-display.service:RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort
|
||||||
|
mit EAFNOSUPPORT - genau daran ist die Adresszeile am echten Panel gescheitert,
|
||||||
|
waehrend derselbe Aufruf von Hand (ohne Sandbox) die Adresse lieferte. Dieser Test
|
||||||
|
sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem."""
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
def kein_af_inet(family, *a, **kw):
|
||||||
|
if family == _socket.AF_INET:
|
||||||
|
raise OSError(97, "Address family not supported by protocol")
|
||||||
|
raise AssertionError("unerwartete Socketfamilie: %r" % (family,))
|
||||||
|
|
||||||
|
monkeypatch.setattr(_socket, "socket", kein_af_inet)
|
||||||
|
monkeypatch.setattr(display, "_route_lookup",
|
||||||
|
lambda: "192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000")
|
||||||
|
assert display.local_address() == ("wlan0", "192.168.2.96")
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_address_reads_interface_and_source_from_the_route(monkeypatch):
|
||||||
|
"""Das Geraet gehoert mit auf das Panel: Michael hat kein Kabel, "LAN" las sich
|
||||||
|
deshalb falsch (Rueckmeldung 2026-09-23). Beide Angaben stehen in derselben
|
||||||
|
Auskunft."""
|
||||||
|
monkeypatch.setattr(display, "_route_lookup",
|
||||||
|
lambda: "192.0.2.1 dev eth0 src 192.168.1.92 uid 1000 \n cache")
|
||||||
|
assert display.local_address() == ("eth0", "192.168.1.92")
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_address_without_a_route(monkeypatch):
|
||||||
|
"""Kein Netz: "ip route get" meldet einen Fehler und liefert nichts Brauchbares."""
|
||||||
|
monkeypatch.setattr(display, "_route_lookup", lambda: "")
|
||||||
|
assert display.local_address() == ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lebenszeichen: ein kleiner Punkt, sobald eine Anfrage jeder Art hereinkommt -------
|
||||||
|
#
|
||||||
|
# Bedienerwunsch (2026-09-23): "so ist es immer raetselraten und warten das der zaehler
|
||||||
|
# hochgeht um zu wissen ob es geklappt hat" - der Punkt soll VOR dem Erfolg schon zeigen,
|
||||||
|
# dass ueberhaupt jemand anklopft.
|
||||||
|
|
||||||
|
def test_recent_request_is_true_right_after_a_request():
|
||||||
|
status = {"last_request_ts": "2026-09-23T12:00:00Z"}
|
||||||
|
now = display.datetime(2026, 9, 23, 12, 0, 5, tzinfo=display.timezone.utc).timestamp()
|
||||||
|
assert display._recent_request(status, now=now) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_request_expires_after_the_window():
|
||||||
|
status = {"last_request_ts": "2026-09-23T12:00:00Z"}
|
||||||
|
now = (display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp()
|
||||||
|
+ display.ACTIVITY_WINDOW_S + 1)
|
||||||
|
assert display._recent_request(status, now=now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_request_without_a_timestamp_is_false():
|
||||||
|
"""Aeltere server.py-Fassung, oder noch nie eine Anfrage seit dem Start - kein
|
||||||
|
erfundener Punkt."""
|
||||||
|
assert display._recent_request({}) is False
|
||||||
|
assert display._recent_request({"last_request_ts": None}) is False
|
||||||
|
assert display._recent_request({"last_request_ts": "kaputt"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_request_from_the_future_is_false():
|
||||||
|
"""Eine verstellte Uhr darf den Punkt nicht auf ewig scharf halten."""
|
||||||
|
status = {"last_request_ts": "2026-09-23T12:00:10Z"}
|
||||||
|
now = display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp()
|
||||||
|
assert display._recent_request(status, now=now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_served_line_shows_a_dot_while_activity_is_recent():
|
||||||
|
lines_active = render.lines({"mode": "recovery", "served": 3, "recent": [],
|
||||||
|
"activity": True})
|
||||||
|
lines_idle = render.lines({"mode": "recovery", "served": 3, "recent": [],
|
||||||
|
"activity": False})
|
||||||
|
active_line = next(l for l in lines_active if l.startswith("Served:"))
|
||||||
|
idle_line = next(l for l in lines_idle if l.startswith("Served:"))
|
||||||
|
assert active_line != idle_line
|
||||||
|
assert "3" in active_line and "3" in idle_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_served_line_without_an_activity_field_claims_nothing():
|
||||||
|
"""Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand im Test): ohne das
|
||||||
|
Feld bleibt die Zeile wie zuvor, kein erfundener Punkt."""
|
||||||
|
lines = render.lines({"mode": "recovery", "served": 3, "recent": []})
|
||||||
|
served_line = next(l for l in lines if l.startswith("Served:"))
|
||||||
|
assert served_line == "Served: 3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_carries_activity_in_recovery_and_migration(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
(tmp_path / "last-mode").write_text("recovery")
|
||||||
|
(tmp_path / "status.json").write_text(json.dumps({"last_request_ts": None}))
|
||||||
|
conf = tmp_path / "station.conf"
|
||||||
|
conf.write_text("")
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
|
imageset_path=tmp_path / "imageset",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
assert state["activity"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
|
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
|
||||||
"""Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld -
|
"""Der Modus-Vorrang liegt bei der dauerhaften Modusdatei (last_mode_path), nicht
|
||||||
status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst
|
bei status.json's "mode"-Feld - status.json kann veraltet sein (Fixrunde 1,
|
||||||
umgekehrt)."""
|
Kleinigkeit: der Vorrang war zuerst umgekehrt)."""
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
(tmp_path / "mode").write_text("recovery")
|
(tmp_path / "last-mode").write_text("recovery")
|
||||||
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1}))
|
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1}))
|
||||||
conf_path = tmp_path / "station.conf"
|
conf_path = tmp_path / "station.conf"
|
||||||
conf_path.write_text("SSID='ninlight-recovery'\n")
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
||||||
|
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x",
|
||||||
|
last_mode_path=tmp_path / "last-mode")
|
||||||
|
|
||||||
assert state["mode"] == "recovery"
|
assert state["mode"] == "recovery"
|
||||||
assert state["served"] == 1
|
assert state["served"] == 1
|
||||||
@@ -927,10 +1108,36 @@ def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeyp
|
|||||||
def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch):
|
def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
||||||
service_name="x")
|
service_name="x", last_mode_path=tmp_path / "last-mode")
|
||||||
assert state["mode"] == "recovery"
|
assert state["mode"] == "recovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_reads_the_durable_mode_file_even_after_a_real_stop(tmp_path, monkeypatch):
|
||||||
|
"""Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart", NICHT
|
||||||
|
das "systemctl stop", das "ninlight-station off" ausloest (die Unit setzt
|
||||||
|
RuntimeDirectoryPreserve=restart, nicht "yes") - ein frueherer Docstring behauptete
|
||||||
|
das Gegenteil. Dieser Test bildet GENAU diesen Ablauf nach: "migrate on" schreibt
|
||||||
|
den Modus, RUN_DIR wird dann (wie bei einem echten Stop) GELEERT, und build_state()
|
||||||
|
muss den Modus trotzdem noch aus der dauerhaften Datei kennen - nicht auf "recovery"
|
||||||
|
zurueckfallen, waehrend tatsaechlich eine Migration ansteht."""
|
||||||
|
last_mode = tmp_path / "var" / "last-mode"
|
||||||
|
run_dir = tmp_path / "run"
|
||||||
|
run_dir.mkdir()
|
||||||
|
(run_dir / "mode").write_text("migration")
|
||||||
|
last_mode.parent.mkdir(parents=True)
|
||||||
|
last_mode.write_text("migration")
|
||||||
|
|
||||||
|
# "systemctl stop" leert das RuntimeDirectory - hier nachgestellt, ohne RUN_DIR
|
||||||
|
# selbst zu entfernen (das legt server.py/das CLI bei Bedarf neu an).
|
||||||
|
(run_dir / "mode").unlink()
|
||||||
|
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
state = display.build_state(run_dir=run_dir, conf_path=tmp_path / "station.conf",
|
||||||
|
service_name="x", imageset_path=tmp_path / "imageset",
|
||||||
|
last_mode_path=last_mode)
|
||||||
|
assert state["mode"] == "migration"
|
||||||
|
|
||||||
|
|
||||||
def test_read_conf_accepts_export_prefix(tmp_path):
|
def test_read_conf_accepts_export_prefix(tmp_path):
|
||||||
"""setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein
|
"""setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein
|
||||||
optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene
|
optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene
|
||||||
@@ -1457,6 +1664,68 @@ def _changing_every(period_s, mode="recovery"):
|
|||||||
return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []}
|
return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lebenszeichen im Aus-Zustand: die IP muss auftauchen, sobald sie da ist -----------
|
||||||
|
#
|
||||||
|
# Rueckmeldung eines Bedieners (2026-09-23): am laufenden Pi blieb "no network" stehen,
|
||||||
|
# obwohl der Pi laengst im Heimnetz haengt. local_address() liefert die Adresse dabei
|
||||||
|
# nachweislich korrekt - der Verdacht war also die Anzeigeschleife: wird der Aus-Zustand
|
||||||
|
# ueberhaupt regelmaessig neu erhoben, und fliesst die IP in den Vergleich ein, der ueber
|
||||||
|
# ein Neuzeichnen entscheidet? Dieser Test treibt run_daemon() GENAU durch diesen Fall:
|
||||||
|
# beim ersten Erheben keine Adresse, beim zweiten eine.
|
||||||
|
|
||||||
|
def test_off_mode_redraws_once_the_address_appears(monkeypatch):
|
||||||
|
"""Haelt fest: run_daemon() erhebt den Zustand auch im Aus-Zustand unbedingt alle
|
||||||
|
POLL_INTERVAL_S Sekunden neu (kein Ueberspringen mangels Moduswechsel), und die IP
|
||||||
|
ist Teil des Vergleichs, der ueber ein Neuzeichnen entscheidet (render.lines() traegt
|
||||||
|
sie, siehe render.py). Schlaegt dieser Test fehl, bleibt "no home network" auf dem
|
||||||
|
Panel stehen, obwohl der Pi laengst erreichbar ist."""
|
||||||
|
seq = [{"mode": "off", "ip": "", "iface": ""},
|
||||||
|
{"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}]
|
||||||
|
|
||||||
|
def state_at(t):
|
||||||
|
# Erste POLL_INTERVAL_S-Erhebung (bei t=0) sieht noch keine Adresse, danach schon -
|
||||||
|
# weit genug auseinander, dass zwei unterschiedliche Polls sicher dazwischenliegen.
|
||||||
|
return seq[0] if t < 2 * display.POLL_INTERVAL_S else seq[1]
|
||||||
|
|
||||||
|
panel = _drive_daemon(state_at, duration_s=4 * display.POLL_INTERVAL_S)
|
||||||
|
# Erster Aufbau zeigt "keine Adresse" (Vollaufbau beim Start). Danach MUSS
|
||||||
|
# spaetestens einer der Aufrufe die neue Adresse tatsaechlich zeichnen - das
|
||||||
|
# Panel-Doppel protokolliert keinen Bildinhalt, deshalb wird direkt geprueft, dass
|
||||||
|
# render.lines() zum Zeitpunkt des naechsten Aufbaus schon die neue Adresse traegt
|
||||||
|
# (dieselbe Quelle, die run_daemon() intern fuer den Vergleich benutzt).
|
||||||
|
assert len(panel.calls) >= 2, panel.calls
|
||||||
|
draws_after_first = [name for _, name in panel.calls[1:]]
|
||||||
|
assert any(name in ("full", "partial", "wake-full") for name in draws_after_first), (
|
||||||
|
"kein weiterer Aufbau nach dem ersten - die neue Adresse waere nie gezeichnet worden",
|
||||||
|
panel.calls,
|
||||||
|
)
|
||||||
|
assert any("wlan0: 192.168.2.96" in line for line in render.lines(state_at(4 * display.POLL_INTERVAL_S)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_daemon_survives_a_single_failed_state_read(monkeypatch):
|
||||||
|
"""Ein einzelner unerwarteter Fehler beim Erheben (ausserhalb dessen, was
|
||||||
|
build_state()s eigene Bausteine schon abfangen) darf die Endlosschleife nicht
|
||||||
|
beenden - sonst friert das Panel beim naechsten Fehlschlag fuer immer auf dem
|
||||||
|
zuletzt gezeichneten Bild ein (E-Paper vergisst nichts von selbst), und zwar OHNE
|
||||||
|
jede Fehlermeldung am Geraet. Erhebung 1 wirft, Erhebung 2 liefert einen echten
|
||||||
|
Zustand - danach muss dieser auch tatsaechlich gezeichnet sein."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
good_state = {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}
|
||||||
|
|
||||||
|
def flaky_state_at(t):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
raise RuntimeError("simulierter Lesefehler (z.B. RUN_DIR mitten im Neustart)")
|
||||||
|
return good_state
|
||||||
|
|
||||||
|
panel = _drive_daemon(flaky_state_at, duration_s=3 * display.POLL_INTERVAL_S)
|
||||||
|
# Kein Absturz (sonst haette _drive_daemon die Ausnahme durchgereicht statt sie
|
||||||
|
# abzufangen) und die Schleife lief tatsaechlich weiter: mindestens der initiale
|
||||||
|
# Vollaufbau (mit dem neutralen Platzhalter) und ein weiterer, sobald der zweite
|
||||||
|
# Versuch den echten Zustand liefert.
|
||||||
|
assert len(panel.calls) >= 2, panel.calls
|
||||||
|
|
||||||
|
|
||||||
def test_static_content_never_triggers_another_full_refresh():
|
def test_static_content_never_triggers_another_full_refresh():
|
||||||
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
|
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
|
||||||
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen.
|
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen.
|
||||||
@@ -1599,7 +1868,7 @@ def test_planner_counts_an_upgraded_partial_as_a_full_refresh():
|
|||||||
|
|
||||||
def test_planner_draws_fully_on_mode_change():
|
def test_planner_draws_fully_on_mode_change():
|
||||||
planner = display.RefreshPlanner(0.0)
|
planner = display.RefreshPlanner(0.0)
|
||||||
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True)
|
planner.record_draw(0.0, ["RECOVERY"], "recovery", was_full=True)
|
||||||
|
|
||||||
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"
|
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"
|
||||||
|
|
||||||
@@ -1612,14 +1881,14 @@ def test_planner_draws_fully_on_mode_change():
|
|||||||
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
|
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
|
||||||
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
|
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
|
||||||
|
|
||||||
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus",
|
_MENU_UI = {"view": "menu", "title": "MENU", "hero": "> Station off",
|
||||||
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True}
|
"details": ["Refresh display", "Shut down"], "active": True}
|
||||||
|
|
||||||
|
|
||||||
def test_the_ui_file_lives_outside_the_stations_runtime_directory():
|
def test_the_ui_file_lives_outside_the_stations_runtime_directory():
|
||||||
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
|
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
|
||||||
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem
|
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station off", in dem
|
||||||
das Menue "Rettung starten"/"Migration starten" anbietet."""
|
das Menue "Start recovery"/"Migration starten" anbietet."""
|
||||||
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
|
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
|
||||||
assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
|
assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
|
||||||
|
|
||||||
@@ -1627,7 +1896,7 @@ def test_the_ui_file_lives_outside_the_stations_runtime_directory():
|
|||||||
def test_a_running_operation_replaces_the_status_screen():
|
def test_a_running_operation_replaces_the_status_screen():
|
||||||
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
|
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
|
||||||
"recent": [], "ui": _MENU_UI})
|
"recent": [], "ui": _MENU_UI})
|
||||||
assert lines == ["MENUE", "> Station aus", "Anzeige auffrischen", "Herunterfahren"]
|
assert lines == ["MENU", "> Station off", "Refresh display", "Shut down"]
|
||||||
assert not any("ninlight-recovery" in l for l in lines)
|
assert not any("ninlight-recovery" in l for l in lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -1652,7 +1921,7 @@ def test_a_broken_ui_file_does_not_break_the_display():
|
|||||||
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
|
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
|
||||||
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
|
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
|
||||||
"hero": None, "details": "nope"}})
|
"hero": None, "details": "nope"}})
|
||||||
assert lines == ["Bedienung"]
|
assert lines == ["Button menu"]
|
||||||
render.compose({"mode": "recovery", "ui": {"view": "menu"}})
|
render.compose({"mode": "recovery", "ui": {"view": "menu"}})
|
||||||
|
|
||||||
|
|
||||||
@@ -1668,7 +1937,7 @@ def test_read_ui_ignores_a_missing_broken_or_idle_file(tmp_path):
|
|||||||
path.write_text(json.dumps({"view": "menu", "active": False}))
|
path.write_text(json.dumps({"view": "menu", "active": False}))
|
||||||
assert display.read_ui(path) == {}
|
assert display.read_ui(path) == {}
|
||||||
path.write_text(json.dumps(_MENU_UI))
|
path.write_text(json.dumps(_MENU_UI))
|
||||||
assert display.read_ui(path)["hero"] == "> Station aus"
|
assert display.read_ui(path)["hero"] == "> Station off"
|
||||||
|
|
||||||
|
|
||||||
def test_the_daemon_shows_an_operation_within_half_a_second():
|
def test_the_daemon_shows_an_operation_within_half_a_second():
|
||||||
@@ -1748,13 +2017,13 @@ def test_the_two_image_sets_read_differently():
|
|||||||
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
|
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
|
||||||
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
|
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
|
||||||
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
|
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
|
||||||
assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"]
|
assert "Wi-Fi" in render.IMAGESET_LABELS["mit-wlan"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_missing_image_set_says_so_on_the_panel():
|
def test_a_missing_image_set_says_so_on_the_panel():
|
||||||
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
|
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
|
||||||
line = _imageset_line(state)
|
line = _imageset_line(state)
|
||||||
assert line is not None and "FEHLT" in line
|
assert line is not None and "MISSING" in line
|
||||||
|
|
||||||
|
|
||||||
def test_without_an_image_set_field_nothing_is_claimed():
|
def test_without_an_image_set_field_nothing_is_claimed():
|
||||||
@@ -1806,7 +2075,8 @@ def test_the_image_set_line_survives_the_fullest_state():
|
|||||||
def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
|
def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
|
||||||
"""Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg."""
|
"""Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg."""
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
(tmp_path / "mode").write_text("migration")
|
last_mode = tmp_path / "last-mode"
|
||||||
|
last_mode.write_text("migration")
|
||||||
images = tmp_path / "images"
|
images = tmp_path / "images"
|
||||||
(images / "migrate-nocreds").mkdir(parents=True)
|
(images / "migrate-nocreds").mkdir(parents=True)
|
||||||
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
@@ -1818,7 +2088,7 @@ def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def state():
|
def state():
|
||||||
return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
imageset_path=switch)
|
imageset_path=switch, last_mode_path=last_mode)
|
||||||
|
|
||||||
# Ohne Datei: die sichere Vorgabe.
|
# Ohne Datei: die sichere Vorgabe.
|
||||||
assert state()["imageset"] == "ohne-wlan"
|
assert state()["imageset"] == "ohne-wlan"
|
||||||
@@ -1831,8 +2101,16 @@ def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
|
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
|
||||||
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
|
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
|
||||||
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts."""
|
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts.
|
||||||
|
|
||||||
|
Ausdruecklich im Migrationsmodus (last_mode_path explizit auf "migration" gesetzt,
|
||||||
|
nicht dem Vorgabewert ueberlassen - Fixrunde 1, Befund 3: eine fehlende Datei faellt
|
||||||
|
auf "recovery", und dieser Test soll die MIGRATION pruefen) - das ist es, was die
|
||||||
|
migrate-*-Verzeichnisse betrifft. Der Rettungsmodus prueft ein eigenes Paar
|
||||||
|
(rescue-*), siehe test_build_state_reports_a_missing_rescue_image_set unten."""
|
||||||
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
last_mode = tmp_path / "last-mode"
|
||||||
|
last_mode.write_text("migration")
|
||||||
images = tmp_path / "images"
|
images = tmp_path / "images"
|
||||||
(images / "migrate-nocreds").mkdir(parents=True)
|
(images / "migrate-nocreds").mkdir(parents=True)
|
||||||
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||||
@@ -1842,17 +2120,58 @@ def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
switch.write_text("ohne-wlan")
|
switch.write_text("ohne-wlan")
|
||||||
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
imageset_path=switch)["imageset_ok"] is True
|
imageset_path=switch,
|
||||||
|
last_mode_path=last_mode)["imageset_ok"] is True
|
||||||
switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht
|
switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht
|
||||||
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
imageset_path=switch)
|
imageset_path=switch, last_mode_path=last_mode)
|
||||||
assert state["imageset_ok"] is False
|
assert state["imageset_ok"] is False
|
||||||
assert "FEHLT" in _imageset_line(state)
|
assert "MISSING" in _imageset_line(state)
|
||||||
|
|
||||||
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
|
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
|
||||||
(images / "migrate-wlan").mkdir()
|
(images / "migrate-wlan").mkdir()
|
||||||
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
imageset_path=switch)["imageset_ok"] is False
|
imageset_path=switch,
|
||||||
|
last_mode_path=last_mode)["imageset_ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_reports_a_missing_rescue_image_set(tmp_path, monkeypatch):
|
||||||
|
"""Derselbe Umschalter gilt jetzt auch fuer die Rettung, mit einem EIGENEN
|
||||||
|
Verzeichnispaar (rescue-*, nicht migrate-*) - server.py begruendet die Trennung.
|
||||||
|
|
||||||
|
last_mode_path wird ausdruecklich auf "recovery" gesetzt, statt den Vorgabewert zu
|
||||||
|
nutzen (Fixrunde 1, Befund 3 - ein vorheriger Zustand dieses Tests liess die Datei
|
||||||
|
ganz weg und pruefte damit nur den ZUFAELLIG gleich lautenden Rueckfallwert, nicht
|
||||||
|
die Wirklichkeit: mit einer fehlenden Datei haette dieser Test nicht bemerkt, wenn
|
||||||
|
"recovery" faelschlich auch fuer eine echte Migration zurueckkaeme)."""
|
||||||
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
||||||
|
last_mode = tmp_path / "last-mode"
|
||||||
|
last_mode.write_text("recovery")
|
||||||
|
images = tmp_path / "images"
|
||||||
|
(images / "rescue-nocreds").mkdir(parents=True)
|
||||||
|
(images / "rescue-nocreds" / "ninlight_g95_cw_ota.img").write_bytes(b"IMG")
|
||||||
|
# Ein voll bestueckter Migrations-Bildsatz daneben - er darf das Ergebnis nicht
|
||||||
|
# beeinflussen (falsche Zuordnung waere der Fehler, den dieser Test faengt).
|
||||||
|
(images / "migrate-wlan").mkdir(parents=True)
|
||||||
|
conf = tmp_path / "station.conf"
|
||||||
|
conf.write_text(f"IMAGE_DIR='{images}'\n")
|
||||||
|
switch = tmp_path / "imageset"
|
||||||
|
|
||||||
|
switch.write_text("ohne-wlan")
|
||||||
|
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
|
imageset_path=switch,
|
||||||
|
last_mode_path=last_mode)["imageset_ok"] is True
|
||||||
|
switch.write_text("mit-wlan") # rescue-wlan/ gibt es nicht
|
||||||
|
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
|
imageset_path=switch, last_mode_path=last_mode)
|
||||||
|
assert state["imageset_ok"] is False
|
||||||
|
assert "MISSING" in _imageset_line(state)
|
||||||
|
|
||||||
|
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
|
||||||
|
(images / "rescue-wlan").mkdir()
|
||||||
|
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
||||||
|
imageset_path=switch,
|
||||||
|
last_mode_path=last_mode)["imageset_ok"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_the_display_and_the_server_agree_on_the_set_names():
|
def test_the_display_and_the_server_agree_on_the_set_names():
|
||||||
@@ -1865,6 +2184,7 @@ def test_the_display_and_the_server_agree_on_the_set_names():
|
|||||||
spec.loader.exec_module(server)
|
spec.loader.exec_module(server)
|
||||||
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
|
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
|
||||||
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
|
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
|
||||||
|
assert display.RESCUE_IMAGESET_DIRS == server.RESCUE_SET_DIRS
|
||||||
|
|
||||||
|
|
||||||
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
|
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
|
||||||
@@ -1884,14 +2204,14 @@ def test_the_shutdown_and_the_reboot_screen_differ():
|
|||||||
gekostet)."""
|
gekostet)."""
|
||||||
off, reboot = _final_text("poweroff"), _final_text("reboot")
|
off, reboot = _final_text("poweroff"), _final_text("reboot")
|
||||||
assert off != reboot
|
assert off != reboot
|
||||||
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot
|
assert "POWERED OFF" in off and "REBOOTING" in reboot
|
||||||
# Ausschalten: erlaubt das Trennen ausdruecklich. Geprueft wird die ZUSAGE, nicht
|
# Ausschalten: erlaubt das Trennen ausdruecklich. Geprueft wird die ZUSAGE, nicht
|
||||||
# der genaue Satz - der Wortlaut musste am 2026-09-15 kuerzer werden, weil die alte
|
# der genaue Satz - der Wortlaut musste am 2026-09-15 kuerzer werden, weil die alte
|
||||||
# Fassung ("Strom kann getrennt werden", 262,1 px) auf dem Panel abgeschnitten war.
|
# Fassung ("Strom kann getrennt werden", 262,1 px) auf dem Panel abgeschnitten war.
|
||||||
assert "trennen erlaubt" in off
|
assert "Safe to unplug" in off
|
||||||
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
|
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
|
||||||
assert "NICHT trennen" in reboot
|
assert "Do NOT unplug" in reboot
|
||||||
assert "trennen erlaubt" not in reboot
|
assert "Safe to unplug" not in reboot
|
||||||
|
|
||||||
|
|
||||||
def test_the_reboot_screen_says_how_long_it_stays():
|
def test_the_reboot_screen_says_how_long_it_stays():
|
||||||
@@ -1907,14 +2227,14 @@ def test_the_final_screen_carries_nothing_else():
|
|||||||
"imageset_ok": True, "served": 7}
|
"imageset_ok": True, "served": 7}
|
||||||
blocks = render.blocks(state)
|
blocks = render.blocks(state)
|
||||||
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
|
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
|
||||||
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks)
|
assert not any("Bildsatz" in text or "Served" in text for text, _ in blocks)
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_final_kind_claims_neither():
|
def test_an_unknown_final_kind_claims_neither():
|
||||||
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
|
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
|
||||||
gebootet wird."""
|
gebootet wird."""
|
||||||
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
|
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
|
||||||
assert "AUSGESCHALTET" not in text
|
assert "POWERED OFF" not in text
|
||||||
assert "getrennt" not in text
|
assert "getrennt" not in text
|
||||||
|
|
||||||
|
|
||||||
@@ -1965,7 +2285,7 @@ def test_display_main_passes_the_final_kind_through(monkeypatch):
|
|||||||
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
|
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
|
||||||
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
|
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
|
||||||
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
|
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
|
||||||
"Strom kann getrennt werden"."""
|
"Safe to unplug"."""
|
||||||
drawn = []
|
drawn = []
|
||||||
|
|
||||||
class _Panel:
|
class _Panel:
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ class Station:
|
|||||||
def __init__(self, tmp_path, *extra):
|
def __init__(self, tmp_path, *extra):
|
||||||
self.image_dir = tmp_path / "images"
|
self.image_dir = tmp_path / "images"
|
||||||
self.image_dir.mkdir()
|
self.image_dir.mkdir()
|
||||||
|
# image_path() folgt jetzt dem Bildsatz-Umschalter, genauso wie migrate_dir() es
|
||||||
|
# tut - ohne --imageset-file gilt die Vorgabe "ohne-wlan" (server.py,
|
||||||
|
# DEFAULT_IMAGE_SET), also landen Rettungsabbilder in image_dir/rescue-nocreds.
|
||||||
|
# Die meisten Tests hier pruefen nur die Ausliefermechanik, nicht den
|
||||||
|
# Bildsatz-Umschalter selbst - die Bildsatz-spezifischen Tests weiter unten legen
|
||||||
|
# ihre Abbilder gezielt in andere Verzeichnisse.
|
||||||
|
self.rescue_dir = self.image_dir / "rescue-nocreds"
|
||||||
|
self.rescue_dir.mkdir()
|
||||||
self.state = tmp_path / "status.json"
|
self.state = tmp_path / "status.json"
|
||||||
self.port = free_port()
|
self.port = free_port()
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
@@ -140,7 +148,7 @@ def station(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_serves_a_known_image(station):
|
def test_serves_a_known_image(station):
|
||||||
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
data = make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
r = c.getresponse()
|
r = c.getresponse()
|
||||||
@@ -157,7 +165,7 @@ def test_missing_image_is_404(station):
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_target_is_404(station):
|
def test_unknown_target_is_404(station):
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
||||||
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", path)
|
c.request("GET", path)
|
||||||
@@ -216,7 +224,7 @@ def test_log_error_is_not_silenced_by_log_message(capsys):
|
|||||||
|
|
||||||
|
|
||||||
def test_range_request_returns_206_and_the_right_slice(station):
|
def test_range_request_returns_206_and_the_right_slice(station):
|
||||||
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
data = make_image(station.rescue_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
||||||
r = c.getresponse()
|
r = c.getresponse()
|
||||||
@@ -227,7 +235,7 @@ def test_range_request_returns_206_and_the_right_slice(station):
|
|||||||
|
|
||||||
|
|
||||||
def test_state_file_records_a_delivery(station):
|
def test_state_file_records_a_delivery(station):
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
c.getresponse().read()
|
c.getresponse().read()
|
||||||
@@ -241,6 +249,21 @@ def test_state_file_records_a_delivery(station):
|
|||||||
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_file_carries_a_last_request_timestamp_for_the_display(station):
|
||||||
|
"""display.py zeigt einen kleinen Lebenszeichen-Punkt, sobald eine Anfrage jeder Art
|
||||||
|
hereinkommt (auch ein 404) - dafuer muss status.json den Zeitpunkt tragen, nicht nur
|
||||||
|
den Erfolgszaehler. Vor der ersten Anfrage ist das Feld da, aber None."""
|
||||||
|
initial = station.read_state()
|
||||||
|
assert "last_request_ts" in initial
|
||||||
|
assert initial["last_request_ts"] is None
|
||||||
|
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_g95_cw_ota.img") # existiert nicht - zaehlt trotzdem
|
||||||
|
c.getresponse().read()
|
||||||
|
state = station.read_state(lambda s: s["last_request_ts"] is not None)
|
||||||
|
assert isinstance(state["last_request_ts"], str) and state["last_request_ts"]
|
||||||
|
|
||||||
|
|
||||||
def test_state_file_records_a_missing_image(station):
|
def test_state_file_records_a_missing_image(station):
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_g95_cw_ota.img")
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||||
@@ -266,7 +289,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
|||||||
finally:
|
finally:
|
||||||
os.umask(old_umask)
|
os.umask(old_umask)
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
||||||
|
|
||||||
def world_readable() -> bool:
|
def world_readable() -> bool:
|
||||||
mode = stat.S_IMODE(s.state.stat().st_mode)
|
mode = stat.S_IMODE(s.state.stat().st_mode)
|
||||||
@@ -289,7 +312,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_recent_is_capped_at_eight(station):
|
def test_recent_is_capped_at_eight(station):
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
@@ -302,7 +325,7 @@ def test_recent_is_capped_at_eight(station):
|
|||||||
def test_aborted_transfer_is_recorded(tmp_path):
|
def test_aborted_transfer_is_recorded(tmp_path):
|
||||||
s = Station(tmp_path, "--stall-after", "256")
|
s = Station(tmp_path, "--stall-after", "256")
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
||||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
sock.recv(4096)
|
sock.recv(4096)
|
||||||
@@ -326,7 +349,7 @@ def test_aborted_transfer_is_recorded(tmp_path):
|
|||||||
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||||
s = Station(tmp_path, "--stall-after", "512")
|
s = Station(tmp_path, "--stall-after", "512")
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
got = b""
|
got = b""
|
||||||
@@ -369,7 +392,7 @@ def test_stall_after_budget_survives_a_resumed_request(tmp_path):
|
|||||||
"""
|
"""
|
||||||
s = Station(tmp_path, "--stall-after", "1000")
|
s = Station(tmp_path, "--stall-after", "1000")
|
||||||
try:
|
try:
|
||||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||||
|
|
||||||
# First request: burns through the whole --stall-after budget, then the server
|
# First request: burns through the whole --stall-after budget, then the server
|
||||||
# holds the connection open and sends nothing further.
|
# holds the connection open and sends nothing further.
|
||||||
@@ -419,7 +442,7 @@ def test_concurrent_downloads_are_all_recorded(station):
|
|||||||
concurrent requests can lose increments (two threads read the same old value) or have
|
concurrent requests can lose increments (two threads read the same old value) or have
|
||||||
one thread's status.json write clobber another's.
|
one thread's status.json write clobber another's.
|
||||||
"""
|
"""
|
||||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
||||||
expected_len = 5000 + TRAILER_SIZE
|
expected_len = 5000 + TRAILER_SIZE
|
||||||
n_workers = 20
|
n_workers = 20
|
||||||
errors = []
|
errors = []
|
||||||
@@ -1092,6 +1115,75 @@ def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_serve
|
|||||||
)["recent"][-1]["version"] == "0.4.0-wlan"
|
)["recent"][-1]["version"] == "0.4.0-wlan"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Derselbe Umschalter gilt jetzt auch fuer die RETTUNG ---------------------------
|
||||||
|
#
|
||||||
|
# Der Eigentuemer hat das ausdruecklich verlangt: bisher bekam eine Rettung IMMER ein
|
||||||
|
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ kamen aus build/). image_path()
|
||||||
|
# folgt jetzt demselben Umschalter wie migrate_dir() - dieselben Tests wie oben fuer die
|
||||||
|
# Migration, nur gegen den Rettungspfad "/ninlight_<target>_ota.img".
|
||||||
|
|
||||||
|
def _both_rescue_sets(imgdir):
|
||||||
|
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
|
||||||
|
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
|
||||||
|
b"OHNE" * 100)
|
||||||
|
(imgdir / "rescue-wlan").mkdir(exist_ok=True)
|
||||||
|
make_image(imgdir / "rescue-wlan" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
|
||||||
|
b"MITW" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_default_image_set_is_the_one_without_wifi_credentials(server):
|
||||||
|
"""Wie bei der Migration: ohne Umschalterdatei gilt die sichere Vorgabe - ein
|
||||||
|
Fehlgriff darf niemandem das eigene WLAN-Passwort in ein fremdes Geraet schreiben.
|
||||||
|
Vor diesem Umschalter bekam eine Rettung IMMER das Abbild MIT Zugangsdaten; das war
|
||||||
|
genau die Nebenwirkung, die der Eigentuemer sichtbar gemacht haben wollte."""
|
||||||
|
srv, imgdir = server
|
||||||
|
_both_rescue_sets(imgdir)
|
||||||
|
r = srv.get("/ninlight_g95_cw_ota.img")
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.body.startswith(b"OHNE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_switch_decides_which_file_is_actually_delivered(server):
|
||||||
|
srv, imgdir = server
|
||||||
|
_both_rescue_sets(imgdir)
|
||||||
|
srv.set_image_set("mit-wlan")
|
||||||
|
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"MITW")
|
||||||
|
# ... und wieder zurueck, ohne Dienstneustart.
|
||||||
|
srv.set_image_set("ohne-wlan")
|
||||||
|
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"OHNE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_missing_selected_set_delivers_nothing(server):
|
||||||
|
"""Der gewaehlte Rettungs-Bildsatz liegt nicht auf dem Pi: NICHTS geht raus -
|
||||||
|
insbesondere nicht das gleichnamige Abbild des anderen, danebenliegenden Satzes.
|
||||||
|
Auf den anderen Satz auszuweichen waere genau der Fehlgriff, gegen den der
|
||||||
|
Umschalter da ist (siehe rescue_dir() in server.py)."""
|
||||||
|
srv, imgdir = server
|
||||||
|
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
|
||||||
|
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
|
||||||
|
b"OHNE" * 100)
|
||||||
|
srv.set_image_set("mit-wlan") # rescue-wlan/ existiert nicht
|
||||||
|
|
||||||
|
r = srv.get("/ninlight_g95_cw_ota.img")
|
||||||
|
assert r.status == 404
|
||||||
|
assert b"OHNE" not in r.body
|
||||||
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||||
|
assert st["served"] == 0
|
||||||
|
assert st["recent"][-1]["status"] == "notfound"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescue_old_flat_layout_is_no_longer_served(server):
|
||||||
|
"""Rueckwaertskompatibilitaet bewusst NICHT gegeben (server.py, rescue_dir()): ein Pi
|
||||||
|
mit dem alten, flachen Bestand (vor diesem Umschalter, immer MIT Zugangsdaten aus
|
||||||
|
build/) muss einmal per deploy.sh neu bestueckt werden. Wuerde image_path() den
|
||||||
|
flachen Altbestand noch lesen, wuerde die Vorgabe "ohne-wlan" NICHTS daran aendern -
|
||||||
|
genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend fortgesetzt."""
|
||||||
|
srv, imgdir = server
|
||||||
|
make_image(imgdir / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", b"ALTBESTAND" * 50)
|
||||||
|
r = srv.get("/ninlight_g95_cw_ota.img")
|
||||||
|
assert r.status == 404
|
||||||
|
|
||||||
|
|
||||||
def test_read_image_set_defaults_without_a_file(tmp_path):
|
def test_read_image_set_defaults_without_a_file(tmp_path):
|
||||||
module = _load_server_module()
|
module = _load_server_module()
|
||||||
assert module.read_image_set(None) == "ohne-wlan"
|
assert module.read_image_set(None) == "ohne-wlan"
|
||||||
|
|||||||
+182
-15
@@ -11,6 +11,13 @@
|
|||||||
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
|
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
|
||||||
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug.
|
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug.
|
||||||
#
|
#
|
||||||
|
# Und zwar an der Stelle, an der sie beim echten Bau auch landen: IM KOMPRIMIERTEN
|
||||||
|
# RUMPF. Ein Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann - beim _ota.img - 512
|
||||||
|
# Byte Trailer. Frueher schrieb der Stub reinen Klartext in eine Datei, die er nur
|
||||||
|
# "..._ota.img" nannte; damit fand die rohe Bytesuche in release.sh die Werte, und Fall
|
||||||
|
# 5 war gruen, obwohl der Pruefkoerper an echten Abbildern nie irgendetwas gefunden
|
||||||
|
# haette. Ein Testartefakt, das dem Pruefling recht gibt, ist schlimmer als kein Test.
|
||||||
|
#
|
||||||
# Aufruf: tools/tests/test_release.sh
|
# Aufruf: tools/tests/test_release.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -75,6 +82,9 @@ EOF
|
|||||||
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
|
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
|
||||||
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
|
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
|
||||||
# config.local.cmake beiseite liegt (prueft den doppelten Boden)
|
# config.local.cmake beiseite liegt (prueft den doppelten Boden)
|
||||||
|
# STUB_TRUNC_TARGET schneidet beim _ota.img dieses Ziels den gzip-Strom ab - das
|
||||||
|
# Abbild laesst sich dann nicht entpacken. Das _gz.img bleibt heil,
|
||||||
|
# damit das Signieren davor nicht schon abbricht.
|
||||||
setup_stub_docker() {
|
setup_stub_docker() {
|
||||||
local stub=$1 sandbox=$2
|
local stub=$1 sandbox=$2
|
||||||
cat >"$stub" <<'STUB'
|
cat >"$stub" <<'STUB'
|
||||||
@@ -115,22 +125,56 @@ fi
|
|||||||
if [ "${STUB_LEAK:-}" = "1" ]; then
|
if [ "${STUB_LEAK:-}" = "1" ]; then
|
||||||
creds="$STUB_LEAK_VALUE"
|
creds="$STUB_LEAK_VALUE"
|
||||||
fi
|
fi
|
||||||
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" python3 - <<'PY'
|
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" TRUNC="${STUB_TRUNC_TARGET:-}" python3 - <<'PY'
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
|
|
||||||
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
|
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
|
||||||
body = bytearray(("firmware-%s-" % target).encode() * 200)
|
|
||||||
body = body[:4096] + b"\0" * (4096 - len(body[:4096]))
|
# Der Rumpf ist ~100 kB gleichfoermiger Text - so wie echte Firmware gross genug, dass
|
||||||
|
# deflate ihn WIRKLICH komprimiert. Zwei Klippen liegen hier dicht nebeneinander:
|
||||||
|
# zu kleiner Rumpf -> gzip-Strom unter 1024 Byte, und nl_sign.py lehnt ab;
|
||||||
|
# unkomprimierbarer Rumpf (etwa Zufallsbytes) -> deflate legt ihn als "stored block"
|
||||||
|
# UNVERAENDERT ab, der Klartext stuende wieder roh in der Datei und der Test prueft
|
||||||
|
# erneut eine Welt, die es nicht gibt.
|
||||||
|
body = bytearray(
|
||||||
|
b"".join(b"ninlight-%s-block-%06d\n" % (target.encode(), i) for i in range(4000)))
|
||||||
|
tag = ("firmware-%s-" % target).encode()
|
||||||
|
body[0:len(tag)] = tag
|
||||||
blob = creds.encode()
|
blob = creds.encode()
|
||||||
if blob:
|
if blob:
|
||||||
|
# Die Zugangsdaten liegen IM Rumpf - also nach dem Packen im komprimierten Strom
|
||||||
|
# und nicht im Klartext in der Datei. Genau wie beim echten Compiler.
|
||||||
body[100:100 + len(blob)] = blob
|
body[100:100 + len(blob)] = blob
|
||||||
gz = bytes(body)
|
|
||||||
|
# Aufbau wie beim echten Bau: 56 Byte Kopf (wm_tool), dann der gzip-Strom.
|
||||||
|
header = bytearray(b"\x9f\xff\xff\xa0" + b"\x00" * 52)
|
||||||
|
header[4:8] = b"STUB"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
# mtime=0: sonst haengt der Dateiinhalt an der Uhrzeit und Vergleiche werden wacklig.
|
||||||
|
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gf:
|
||||||
|
gf.write(bytes(body))
|
||||||
|
stream = buf.getvalue()
|
||||||
|
raw = bytes(header) + stream
|
||||||
|
# nl_sign.py verlangt eine durch 4 teilbare Laenge von mindestens 1024 Byte. Die
|
||||||
|
# Fuellbytes stehen hinter dem gzip-Strom und stoeren das Entpacken nicht.
|
||||||
|
raw += b"\x00" * (-len(raw) % 4)
|
||||||
|
assert len(raw) >= 1024, "Stub-Abbild zu kurz fuer nl_sign.py"
|
||||||
|
# Selbstkontrolle gegen genau den Fehler, der diese Suite jahrelang wertlos machte.
|
||||||
|
assert not blob or blob not in raw, \
|
||||||
|
"Zugangsdaten stehen roh in der Datei - der Rumpf wurde nicht wirklich komprimiert"
|
||||||
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
|
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
|
||||||
fh.write(gz) # roh, ohne NINL-Trailer - das signiert nl_sign.py
|
fh.write(raw) # roh, ohne NINL-Trailer - das signiert nl_sign.py
|
||||||
trailer = bytearray(b"\xff" * 512)
|
trailer = bytearray(b"\xff" * 512)
|
||||||
trailer[0:4] = b"NINL"
|
trailer[0:4] = b"NINL"
|
||||||
|
ota_body = raw
|
||||||
|
if os.environ.get("TRUNC") == target:
|
||||||
|
# Abgeschnittener gzip-Strom: decompressobj liefert dafuer stillschweigend nur den
|
||||||
|
# lesbaren Teil, wenn niemand dec.eof abfragt.
|
||||||
|
ota_body = bytes(header) + stream[:len(stream) // 2]
|
||||||
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
|
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
|
||||||
fh.write(gz + bytes(trailer))
|
fh.write(ota_body + bytes(trailer))
|
||||||
PY
|
PY
|
||||||
STUB
|
STUB
|
||||||
chmod +x "$stub"
|
chmod +x "$stub"
|
||||||
@@ -171,29 +215,50 @@ run_release() {
|
|||||||
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
|
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
|
||||||
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
|
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
|
||||||
STUB_LEAK="${STUB_LEAK:-}" \
|
STUB_LEAK="${STUB_LEAK:-}" \
|
||||||
|
STUB_TRUNC_TARGET="${STUB_TRUNC_TARGET:-}" \
|
||||||
STUB_LEAK_VALUE="$SECRET_SSID" \
|
STUB_LEAK_VALUE="$SECRET_SSID" \
|
||||||
"$sandbox/tools/release.sh" "$version"
|
"$sandbox/tools/release.sh" "$version"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sucht die Zugangsdaten BINAER in jeder Datei unterhalb eines Verzeichnisses.
|
# Sucht die Zugangsdaten in jeder Datei unterhalb eines Verzeichnisses - in den
|
||||||
|
# Rohbytes UND im entpackten Rumpf jedes Abbilds. Nur roh zu suchen hiesse, die
|
||||||
|
# Zusage "keine Zugangsdaten im Archiv" gar nicht zu pruefen: im gzip-Strom steht
|
||||||
|
# kein Klartext. Ein Abbild, das sich nicht entpacken laesst, wird hier gemeldet und
|
||||||
|
# nicht verschwiegen - sonst haette die Gegenprobe ein stilles Loch.
|
||||||
|
# $2 (optional) = "roh": nur die Rohbytes durchsuchen, nichts entpacken.
|
||||||
grep_secrets() {
|
grep_secrets() {
|
||||||
local dir=$1
|
local dir=$1 mode=${2:-entpackt}
|
||||||
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 - "$dir" <<'PY'
|
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
|
||||||
|
python3 - "$dir" "$mode" <<'PY'
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
root = sys.argv[1]
|
HEADER_LEN = 56
|
||||||
|
|
||||||
|
root, mode = sys.argv[1], sys.argv[2]
|
||||||
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
|
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
|
||||||
for dirpath, _dirs, files in os.walk(root):
|
for dirpath, _dirs, files in os.walk(root):
|
||||||
for fn in sorted(files):
|
for fn in sorted(files):
|
||||||
p = os.path.join(dirpath, fn)
|
p = os.path.join(dirpath, fn)
|
||||||
|
rel = os.path.relpath(p, root)
|
||||||
if os.path.islink(p):
|
if os.path.islink(p):
|
||||||
continue
|
continue
|
||||||
with open(p, "rb") as fh:
|
with open(p, "rb") as fh:
|
||||||
data = fh.read()
|
data = fh.read()
|
||||||
|
views = [data]
|
||||||
|
if mode != "roh" and fn.endswith(".img"):
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
try:
|
||||||
|
plain = dec.decompress(data[HEADER_LEN:])
|
||||||
|
if not dec.eof:
|
||||||
|
raise ValueError("gzip-Strom unvollstaendig")
|
||||||
|
views.append(plain)
|
||||||
|
except Exception as exc:
|
||||||
|
print("NICHT ENTPACKBAR %s (%s)" % (rel, exc))
|
||||||
for name, needle in needles.items():
|
for name, needle in needles.items():
|
||||||
if needle in data:
|
if any(needle in view for view in views):
|
||||||
print("%s in %s" % (name, os.path.relpath(p, root)))
|
print("%s in %s" % (name, rel))
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +348,12 @@ else
|
|||||||
fail "Pruefsummenpruefung fehlgeschlagen"
|
fail "Pruefsummenpruefung fehlgeschlagen"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "--- Fall 5: keine Zugangsdaten im Archiv (binaer gesucht) ---"
|
echo "--- Fall 5: keine Zugangsdaten im Archiv (roh UND im entpackten Rumpf gesucht) ---"
|
||||||
HITS=$(grep_secrets "$A")
|
HITS=$(grep_secrets "$A")
|
||||||
if [ -z "$HITS" ]; then
|
if [ -z "$HITS" ]; then
|
||||||
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
|
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
|
||||||
else
|
else
|
||||||
fail "Zugangsdaten im Archiv gefunden:
|
fail "Zugangsdaten im Archiv gefunden (oder ein Abbild liess sich nicht entpacken):
|
||||||
$HITS"
|
$HITS"
|
||||||
fi
|
fi
|
||||||
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
|
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
|
||||||
@@ -312,6 +377,51 @@ if grep_secrets "$TMPROOT/probe" | grep -q SSID; then
|
|||||||
else
|
else
|
||||||
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
|
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
|
||||||
fi
|
fi
|
||||||
|
# Und zwar realistisch: IM komprimierten Rumpf, nicht als Klartext in der Datei. Faende
|
||||||
|
# die rohe Suche sie, prueften alle Faelle oben eine Welt, die es nicht gibt - an echten
|
||||||
|
# Abbildern findet eine rohe Suche nie etwas.
|
||||||
|
if grep_secrets "$TMPROOT/probe" roh | grep -q .; then
|
||||||
|
fail "Gegenprobe: der Stub legt die Zugangsdaten im Klartext ab - unrealistisches Abbild"
|
||||||
|
else
|
||||||
|
pass "Gegenprobe: im Rohbyteinhalt des Stub-Abbilds steht kein Klartext (nur im gzip-Rumpf)"
|
||||||
|
fi
|
||||||
|
# Und das Abbild hat wirklich den Aufbau des echten: 56 Byte Kopf, gzip, 512 Byte
|
||||||
|
# Trailer mit NINL am Anfang.
|
||||||
|
if python3 - "$TMPROOT/probe/evk" <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
d = sys.argv[1]
|
||||||
|
ota = open(os.path.join(d, "ninlight_evk_ota.img"), "rb").read()
|
||||||
|
gz = open(os.path.join(d, "ninlight_evk_gz.img"), "rb").read()
|
||||||
|
assert ota[-512:][:4] == b"NINL", "kein NINL am Trailer-Anfang"
|
||||||
|
assert ota[:-512] == gz, "_ota.img ist nicht _gz.img + 512 Byte Trailer"
|
||||||
|
assert gz[56:58] == b"\x1f\x8b", "kein gzip-Strom ab Byte 56"
|
||||||
|
for raw in (ota, gz):
|
||||||
|
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||||
|
dec.decompress(raw[56:])
|
||||||
|
assert dec.eof, "gzip-Strom unvollstaendig"
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
pass "Gegenprobe: Stub-Abbild hat den echten Aufbau (56 Byte Kopf, gzip, NINL-Trailer)"
|
||||||
|
else
|
||||||
|
fail "Gegenprobe: Stub-Abbild hat nicht den Aufbau eines echten Abbilds"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Meldung der Zugangsdatenpruefung nennt Dateien und entpackte Abbilder ---"
|
||||||
|
# 9 ota + 9 flash + 18 migrate + bruno/ninlight.yml + README.md + onboard.sh = 39,
|
||||||
|
# davon 36 Abbilder. SHA256SUMS entsteht erst nach der Pruefung.
|
||||||
|
#
|
||||||
|
# Die Zahlen haengen an der Laenge von TARGETS: mit ledshield_rgb kamen vier
|
||||||
|
# Dateien dazu (je ein _ota, ein _gz und zwei signierte Migrationsabzuege), alle
|
||||||
|
# vier sind Abbilder. Wer ein Ziel ergaenzt, passt sie hier mit an - der Merge
|
||||||
|
# des Shield-Zweigs lief konfliktfrei durch und liess trotzdem 35/32 stehen.
|
||||||
|
if echo "$OUT" | grep -qF "sauber (39 Dateien geprueft, davon 36 Abbilder entpackt)"; then
|
||||||
|
pass "Meldung nennt Dateizahl und Zahl der entpackten Abbilder"
|
||||||
|
else
|
||||||
|
fail "Meldung der Zugangsdatenpruefung unerwartet: $(echo "$OUT" | grep -i sauber || echo '(keine sauber-Zeile)')"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---"
|
echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---"
|
||||||
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
|
||||||
@@ -413,7 +523,10 @@ else
|
|||||||
fail "andere Version schlaegt fehl: $OUT"
|
fail "andere Version schlaegt fehl: $OUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im Abzug brechen den Lauf ab ==="
|
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im KOMPRIMIERTEN Rumpf brechen den Lauf ab ==="
|
||||||
|
# Das ist der Kern: der Stub legt die SSID im gzip-Rumpf jedes Abbilds ab, wie es der
|
||||||
|
# echte Compiler taete. Ein Pruefkoerper, der nur die Rohbytes durchsucht, laesst diesen
|
||||||
|
# Lauf anstandslos durch und packt Michaels WLAN-Daten ins Archiv.
|
||||||
SANDBOX="$TMPROOT/s6"
|
SANDBOX="$TMPROOT/s6"
|
||||||
setup_sandbox "$SANDBOX"
|
setup_sandbox "$SANDBOX"
|
||||||
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
@@ -433,6 +546,60 @@ else
|
|||||||
pass "die Meldung druckt den Wert nicht, nur den Namen"
|
pass "die Meldung druckt den Wert nicht, nur den Namen"
|
||||||
fi
|
fi
|
||||||
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
|
||||||
|
if echo "$OUT" | grep -q "NINLIGHT_WIFI_SSID in ota/ninlight_evk_ota.img"; then
|
||||||
|
pass "Meldung nennt den Namen des Wertes und die Datei"
|
||||||
|
else
|
||||||
|
fail "Meldung nennt Name und Datei nicht: $OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Szenario 6b: Klartext in einer Nicht-Abbild-Datei bricht den Lauf ab ==="
|
||||||
|
# Nicht jede Datei im Archiv ist ein Abbild: README, onboard.sh und die Bruno-Collection
|
||||||
|
# sind Text. Fuer sie bleibt die rohe Bytesuche die einzige Pruefung - sie darf durch das
|
||||||
|
# Entpacken nicht verlorengehen.
|
||||||
|
SANDBOX="$TMPROOT/s6b"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
printf '\n# versehentlich notiert: %s\n' "$SECRET_PASS" >>"$SANDBOX/bruno/ninlight.yml"
|
||||||
|
set +e
|
||||||
|
OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Klartext in bruno/ninlight.yml erfolgreich"; fi
|
||||||
|
if echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD in bruno/ninlight.yml"; then
|
||||||
|
pass "Meldung nennt NINLIGHT_WIFI_PASSWORD und die Datei"
|
||||||
|
else
|
||||||
|
fail "Meldung nennt den Fund in bruno/ninlight.yml nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -qF "$SECRET_PASS"; then
|
||||||
|
fail "die Meldung druckt das Passwort selbst"
|
||||||
|
else
|
||||||
|
pass "die Meldung druckt das Passwort nicht, nur den Namen"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
|
||||||
|
|
||||||
|
echo "=== Szenario 6c: ein nicht entpackbares Abbild bricht den Lauf ab ==="
|
||||||
|
# Ein abgeschnittener gzip-Strom ist der heimtueckische Fall: decompressobj liefert
|
||||||
|
# stillschweigend nur den lesbaren Teil. Was dahinter steht, bliebe ungeprueft - und
|
||||||
|
# genau dort koennte das Passwort liegen. Ungeprueft durchlassen ist keine Option.
|
||||||
|
SANDBOX="$TMPROOT/s6c"
|
||||||
|
setup_sandbox "$SANDBOX"
|
||||||
|
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
|
||||||
|
set +e
|
||||||
|
OUT=$(STUB_TRUNC_TARGET=e14_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf mit unentpackbarem Abbild war erfolgreich"; fi
|
||||||
|
if echo "$OUT" | grep -q "ota/ninlight_e14_cw_ota.img"; then
|
||||||
|
pass "Meldung nennt die betroffene Datei"
|
||||||
|
else
|
||||||
|
fail "Meldung nennt die betroffene Datei nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if echo "$OUT" | grep -q "laesst sich nicht entpacken"; then
|
||||||
|
pass "Meldung sagt, dass das Abbild nicht entpackbar ist"
|
||||||
|
else
|
||||||
|
fail "Meldung erklaert den Grund nicht: $OUT"
|
||||||
|
fi
|
||||||
|
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz unentpackbarem Abbild gepackt"; fi
|
||||||
|
|
||||||
echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
|
echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
|
||||||
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"
|
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"
|
||||||
|
|||||||
Reference in New Issue
Block a user