24 Commits
Author SHA1 Message Date
Michael Sauer e0379445e8 docs(bruno): Standalone-Anfragen und die neuen Felder in system/info 2026-10-02 23:50:15 +02:00
Michael Sauer 58cdc73afc feat(station): ausgelieferte Firmware-Version auf dem Panel
Welche Version die Station gerade ausliefert, war am Geraet nur per SSH zu
beantworten. Jetzt sagt es die Anzeige selbst, je nach Bild an der Stelle, wo
Platz ist:

- Aus-Zustand: rechtsbuendig neben der Adresse (neue Rolle ROLE_DETAIL_RIGHT,
  nach dem Muster der Phase neben dem Modus)
- Rettung: rechtsbuendig in der Kopfzeile, dort steht keine Phase im Weg
- Migration: als beschriftete Zeile "liefert: <version>" direkt hinter "FW:",
  NICHT in der Kopfzeile - daneben bleiben nur rund 112 px, daran ist schon
  "FREIGEGEBEN: <ziel>" gescheitert, und zwei nackte Versionsnummern
  nebeneinander waeren eine Verwechslungsfalle (FW: ist die der Lampe)

Quelle ist der GEWAEHLTE Bildsatz: im Migrationsmodus manifest.json (die
Migrationsabzuege tragen keinen ninlight-Anhaenger), im Rettungsmodus der
Anhaenger der _ota.img. Weichen die Eintraege voneinander ab, steht da
"gemischt" - ein zusammengefuehrter Deploy kann Alt-Eintraege mit anderer
Version enthalten, und eine davon herauszugreifen waere geraten. Fehlt die
Quelle, bleibt die Angabe weg statt eine Version zu erfinden.
2026-10-02 23:44:37 +02:00
Michael Sauer c53a23daba docs: Standalone als Alternative beim Onboarding, Tokenschutz am Geraet belegt 2026-10-02 22:06:29 +02:00
Michael Sauer 6fe5efd2c0 merge: Standalone-AP-Modus (eigener Dauer-AP statt WLAN-Einwahl)
Die Lampe kann dauerhaft einen eigenen Zugangspunkt aufspannen, mit Passwort,
optional eigenem Namen und Funkkanal. Nach einem Neustart geht sie direkt
dorthin, ohne Onboarding-Pulsen. Steuerung weiter per REST und sACN ueber
diesen AP, mDNS inklusive. Am EVK vollstaendig abgenommen - siehe
docs/hardware-checklist.md.

* standalone-ap:
  docs: Standalone-AP am EVK abgenommen, alle neun Punkte belegt
  Doku: AP-Name in system/settings (gewuenscht) und system/info (laufend) unterschieden, Abnahmepunkt fuer wifi.ssid im AP-Betrieb
  fix(wifi): kurze Pause vor dem Standalone-Wechsel, Abnahmepunkt dazu
  fix(mdns): im eigenen AP bis zum Erfolg wiederholen, netif-Zeiger raus
  fix(api): system/info meldet im AP-Betrieb den AP-Namen
  fix(wifi): Zugriffsschutz und AP-Daten beim Verlassen des Standalone-Modus
  docs: Standalone-AP, ssid/pulse-Angaben korrigiert, Begriffe geschaerft
  docs: Standalone-AP-Modus in Abnahmeliste und README
  fix(mdns): im Standalone-Modus am AP-Netif anmelden, genutztes Netif protokollieren
  fix(mdns): Bekanntmachung am Zustand statt am Erzeugungsereignis
  fix(mdns): Bekanntmachung auch im eigenen AP
  fix(wifi): AP-Daten nach dem Onboarding zuruecksetzen, Netz trotz abgelehntem AP-Aufbau
  feat(wifi): Standalone-AP im Treiber und im Startpfad
  fix(api): standalone rollt bei 503 zurueck, Tests fuer Grenzen und behaltene Felder
  feat(api): Settings-Typ standalone und wifi.standalone in system/info
  fix(wifi): Standalone beendet laufenden Puls, schaerfere Tests fuer den Fehlschlagzweig
  feat(wifi): Wechsel in und aus dem Standalone-Modus
  feat(wifi): Standalone-Zustand mit begrenztem Startpuls
  feat(settings): Felder fuer den Standalone-AP-Modus
2026-10-02 21:30:35 +02:00
Michael Sauer b198fead77 docs: Standalone-AP am EVK abgenommen, alle neun Punkte belegt 2026-10-02 21:17:35 +02:00
Michael Sauer 71cede19a4 Doku: AP-Name in system/settings (gewuenscht) und system/info (laufend) unterschieden, Abnahmepunkt fuer wifi.ssid im AP-Betrieb 2026-10-02 19:45:39 +02:00
Michael Sauer ba8f2a10a0 fix(wifi): kurze Pause vor dem Standalone-Wechsel, Abnahmepunkt dazu
I6: Der HTTP-Task schreibt die 202 erst nach nl_api_handle(); die
Warteschlangennachricht geht aber schon darin raus, und net_task hat die
hoehere Prioritaet (35 gegen 42, im WM-SDK ist die kleinere Zahl hoeher). Er
verdraengt also sofort und reisst das Netz ab, bevor send_all() laeuft. Beim
Wechsel AUS dem WLAN kam die 202 trotzdem an, weil tls_wifi_disconnect()
asynchron arbeitet - ueber den eigenen AP ist tls_wifi_softap_destroy() der
haertere Abriss, und dieser Fall ist ungemessen.

Deshalb beides: rund 250 ms Pause im MSG_STANDALONE-Zweig vor dem Wechsel als
billige Versicherung, und der Fall als offener Punkt in der Abnahmeliste - die
Messung am Geraet bleibt noetig.
2026-10-02 19:32:56 +02:00
Michael Sauer bc3eff1a41 fix(mdns): im eigenen AP bis zum Erfolg wiederholen, netif-Zeiger raus
I5: s_ap_mdns_done wurde VOR nl_mdns_on_ip_up() gesetzt und merkte damit den
Versuch, nicht den Erfolg. Die Arbeit laeuft asynchron im tcpip-Thread; liefert
pick_netif() dort NULL, weil das AP-Netif noch keine Adresse hat, protokolliert
announce() nur "no usable netif yet" - und niemand versucht es je wieder,
solange der AP steht. mDNS ist laut Spec Pflichtteil, die Lampe waere im
eigenen AP dauerhaft nicht auffindbar.

Neu meldet nl_mdns_service_up() den tatsaechlichen Zustand aus s_service_added,
und der Netz-Task stoesst alle 5 s erneut an, solange die Lampe standalone ist,
der AP steht und der Dienst noch nicht gemeldet ist. s_announce_pending deckt
das Fenster zwischen dem Anstossen und dem Lauf von announce() ab: ohne das
haette die Abfrage beim Wechsel Station -> eigener AP noch den Dienst des alten
Netifs als Erfolg gewertet.

Dazu der netif-Zeiger (%p) aus der mdns-Protokollzeile: waehrend der
Fehlersuche war er wertvoll, in einer Release-Firmware ist er Rauschen.
Namenskuerzel, Nummer und IPv4 unterscheiden Station und eigenen AP bereits
eindeutig, eine Speicheradresse in einem Protokoll, das Nutzer weiterreichen,
hilft niemandem - und die Abnahmeliste zitiert die Zeile ohnehin ohne ihn.
2026-10-02 19:32:47 +02:00
Michael Sauer 08ea01f944 fix(api): system/info meldet im AP-Betrieb den AP-Namen
nl_wifi_status() fuellte out->ssid immer aus der Stations-SSID. Eine
Standalone-Lampe mit noch gespeicherten Heimnetzdaten meldete damit
mode: "ap", standalone: true UND den Namen des Heim-WLANs: App und HA zeigten
den falschen Netznamen, und der Heimnetzname stand in einer tokenfrei lesbaren
Antwort. Die Spec verlangt dort den AP-Namen.

Gemeldet wird jetzt im Modus "ap" (eigener AP oder Onboarding-AP) der
WIRKSAME AP-Name: die Daten des laufenden AP, sonst der gewuenschte, sonst der
Hostname. "apsta" bleibt bei der Station - dort ist sie die Hauptsache.
Erwuenschter Nebeneffekt: ein Rueckfall auf andere AP-Daten wird ueber die API
sichtbar, ohne ein neues Feld.

Beruehrt den API-Vertrag, deshalb ein eigener Commit. test/test_api.c ist
nicht betroffen: die Tests fahren ueber den Fake nl_sys_info_t, nl_wifi_status()
selbst gibt es im Hostbau nicht.
2026-10-02 19:32:33 +02:00
Michael Sauer ef321b37ef fix(wifi): Zugriffsschutz und AP-Daten beim Verlassen des Standalone-Modus
C1 (Merge-Blocker): Schaltet jemand den Standalone-Modus ab, waehrend keine
WLAN-Daten gespeichert sind, geht die Zustandsmaschine in den Onboarding-AP -
nl_wifi_sm_open_access() liefert dann true, obwohl der AP noch mit Name und
Passwort des Nutzers steht. Wer das AP-Passwort kennt, koennte system/settings
und system/command ohne Token benutzen: Token setzen, OTA anstossen,
Werksreset. Der Netz-Task reisst den AP in diesem Fall jetzt ausdruecklich ab
und baut ihn mit den Vorgabedaten neu auf - vor execute(ev) und nicht ueber die
Aktionsstruktur, weil execute() start_ap vor stop_ap ausfuehrt. Damit stimmt
auch die README-Zusage wieder, der AP heisse danach "ninlight-<mac4>".

I1: Der zweite Rueckfall nach drei abgelehnten tls_wifi_softap_create() setzte
ueber reset_ap_defaults() auch das Passwort auf das einkompilierte, das in der
README oeffentlich dokumentiert ist - eine Standalone-Lampe stuende dann mit
einem allgemein bekannten Passwort im Funkfeld, und sACN ist unauthentifiziert.
reset_ap_defaults() ist dafuer in reset_ap_identity() (Name und Kanal) und die
vollstaendige Rueckstellung aufgeteilt; der Rueckfall nimmt nur noch die
Identitaet. Kanal und SSID sind ohnehin die plausiblen Gruende, die
Passwortlaenge prueft die API selbst.

I2: destroy_ap() liess s_ap_live_ssid/-pass/-channel stehen. Nach dem Verlassen
des Standalone-Modus trugen diese Puffer weiter die Daten des Nutzers, und der
erste Rueckfallzweig holte sie Minuten spaeter zurueck - die Lampe pendelte
dann im 15-s-Takt zwischen Nutzerdaten und Vorgaben. Neu: forget_ap_live() in
beiden Abschaltwegen (clear_standalone und enabled:false), nicht in
destroy_ap() selbst, weil der Rueckfall auf zuletzt tragende Daten sonst auch
im Neuaufbau-Fall verlorenginge, fuer den er gedacht ist.

I3: s_ap_create_fails wurde nur bei Erfolg genullt und summierte damit ueber
verschiedene Datensaetze - nach einer Korrektur von Kanal 13 auf 6 loeste schon
ein einziger Fehlschlag den Rueckfall aus. Jetzt auch in destroy_ap() und bei
jedem neuen Wunsch aus MSG_STANDALONE.

Kleinigkeiten: nl_app_clear_standalone() wird auf seinen Rueckgabewert geprueft
und ein Schreibfehler protokolliert (sonst steht die Lampe im WLAN und nach dem
Neustart wieder im eigenen AP, ohne jede Spur). enabled:false auf einer Lampe,
die gar nicht standalone war, erzwingt keine Neuverbindung mehr, sondern tut
nichts und antwortet weiter mit 202.

Abnahmeliste: der Gerätebeleg für C1 als offener Punkt.
2026-10-02 19:32:19 +02:00
Michael Sauer 813d861cd1 docs: Standalone-AP, ssid/pulse-Angaben korrigiert, Begriffe geschaerft 2026-10-02 18:54:26 +02:00
Michael Sauer a022d8ae3e docs: Standalone-AP-Modus in Abnahmeliste und README 2026-10-02 18:51:50 +02:00
Michael Sauer 20c26d16d7 fix(mdns): im Standalone-Modus am AP-Netif anmelden, genutztes Netif protokollieren 2026-10-02 15:03:07 +02:00
Michael Sauer fda2552e50 fix(mdns): Bekanntmachung am Zustand statt am Erzeugungsereignis 2026-10-02 14:17:09 +02:00
Michael Sauer ded09a3516 fix(mdns): Bekanntmachung auch im eigenen AP 2026-10-02 10:47:10 +02:00
Michael Sauer 24c4e38e4e fix(wifi): AP-Daten nach dem Onboarding zuruecksetzen, Netz trotz abgelehntem AP-Aufbau 2026-10-02 01:17:00 +02:00
Michael Sauer 18eb7b5c01 feat(wifi): Standalone-AP im Treiber und im Startpfad 2026-10-02 00:52:52 +02:00
Michael Sauer e36add5030 fix(api): standalone rollt bei 503 zurueck, Tests fuer Grenzen und behaltene Felder 2026-10-02 00:21:40 +02:00
Michael Sauer 416307083f feat(api): Settings-Typ standalone und wifi.standalone in system/info 2026-10-02 00:10:38 +02:00
Michael Sauer 8469b92338 fix(wifi): Standalone beendet laufenden Puls, schaerfere Tests fuer den Fehlschlagzweig 2026-10-01 23:52:01 +02:00
Michael Sauer 906a6d55f2 feat(wifi): Wechsel in und aus dem Standalone-Modus 2026-10-01 23:41:10 +02:00
Michael Sauer 1c29735eaf feat(wifi): Standalone-Zustand mit begrenztem Startpuls
open_access() fordert zusaetzlich !standalone, damit der Tokenschutz auch
waehrend eines Onboarding-Tests aus dem Standalone-Modus erhalten bleibt.
2026-10-01 23:18:43 +02:00
Michael Sauer d4b683de8f feat(settings): Felder fuer den Standalone-AP-Modus 2026-10-01 23:01:02 +02:00
Michael Sauer 543123a60d feat(api): system/info sagt, ob der sACN-Empfaenger laeuft
Ein toter Empfangs-Task war von aussen nicht von "es sendet gerade niemand" zu
unterscheiden: beides meldete nur active=false. "multicast" half nicht weiter -
es zeigt den gemerkten Erfolg des LETZTEN Beitritts und bleibt true, auch wenn
der Socket laengst zu ist.

Genau dieser Fall trat am Montana-Black-Shield auf (2026-09-26, direkt nach
Migration und Onboarding) und hat eine halbe Stunde Suche gekostet, an deren
Ende die Ursache immer noch offen war. nl_sacn_running() gab es da schon, es
wurde nur nirgends ausgeliefert.

Zwei Tests: der bestehende prueft running=true mit, ein neuer haelt den toten
Task fest.
2026-09-27 02:48:48 +02:00
24 changed files with 1673 additions and 56 deletions
+61 -2
View File
@@ -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,7 +65,16 @@ 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` (kennt u.a. den Typ `sacn`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
**Alternative beim Einrichten:** Statt ein WLAN anzugeben, kann man die Lampe im selben Schritt
dauerhaft auf ihren eigenen Zugangspunkt festlegen — dann braucht sie überhaupt keinen Router.
Im unkonfigurierten AP geht das wie das WLAN-Onboarding ohne Token:
curl -X PUT http://192.168.4.1:3333/api/v1/system/settings \
-d '{"type":"standalone","enabled":true,"password":"geheim123"}'
Einzelheiten unter [Standalone-AP](#standalone-ap).
- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. die Typen `sacn` und `standalone`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: <token>`, Default-Token `aabbccddeeff0011223344`).
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'` - Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`. - Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen. - Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
@@ -107,6 +116,56 @@ 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`. Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`.
### Standalone-AP
Statt sich in ein WLAN einzuwählen, kann die Lampe dauerhaft einen eigenen Zugangspunkt aufspannen
(z. B. für Bühne oder Gartenhaus ohne Router). Der Modus überlebt Neustarts: die Lampe startet
direkt im eigenen AP, ohne Onboarding und ohne Onboarding-Pulsen. Die Lampe ist dann unter
`192.168.4.1` erreichbar, per mDNS als `<hostname>.local`.
Einschalten:
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
-H 'Authorization: <token>' \
-d '{"type":"standalone","enabled":true,"ssid":"Buehne","password":"geheim123","channel":6}'
Antwort `202 {"status":"standalone"}`. Der Aufruf braucht den Token, sobald die Lampe konfiguriert
ist; im unkonfigurierten Onboarding-AP geht er ohne. Anders als dieser ist der Standalone-AP
anschließend **nicht** token-frei: der Tokenschutz gilt dort wie im Normalbetrieb. Am Gerät
gemessen (2026-10-02, EVK und eine E27 RGB): `GET system/settings` ohne Token → 401 im
Standalone-AP, 200 im unkonfigurierten Onboarding-AP. Felder:
| Feld | Pflicht | Bedeutung |
|---|---|---|
| `enabled` | ja | `true` schaltet den Modus ein, `false` aus |
| `password` | beim Einschalten, solange noch keines gespeichert ist | AP-Passwort, 8..63 Zeichen |
| `ssid` | nein | AP-Name, 1..32 Zeichen; ein leerer String wird mit 400 `invalid ssid` abgelehnt. Weggelassen bleibt der gespeicherte Name erhalten; `ninlight-<mac4>` gilt nur, solange nie ein eigener gesetzt wurde — über die API führt kein Weg zurück zum Vorgabenamen |
| `channel` | nein | Funkkanal 1..13, Vorgabe 6 |
| `pulse` | nein | Startpuls beim Hochfahren der Lampe an/aus, Vorgabe an (läuft rund 5 s, danach gilt das normale Einschaltverhalten) |
Ausschalten braucht kein Passwort: `{"type":"standalone","enabled":false}`. Hat die Lampe gespeicherte
WLAN-Daten, wählt sie sich damit wieder ins WLAN ein; ohne solche öffnet sie den unkonfigurierten
Onboarding-AP `ninlight-<mac4>`.
`GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **gewünschten** AP-Namen
(`ssid`: der gespeicherte Name, ohne eigenen Namen der Hostname), `channel` und `pulse` — das
Passwort steht nie darin. `GET system/info` meldet `wifi.standalone` und im AP-Betrieb (`mode: "ap"`,
also eigener AP oder Onboarding-AP) unter `wifi.ssid` den **laufenden AP-Namen** statt der
gespeicherten Stations-SSID — der Name des Heimnetzes steht damit nicht in einer tokenfrei lesbaren
Antwort.
Die beiden Namen können auseinanderlaufen, und das ist Absicht. Scheitert der AP-Aufbau mehrfach,
fällt der Treiber auf die zuletzt funktionierenden Daten zurück, und gibt es die nicht, auf Name und
Kanal der Vorgaben (das Nutzerpasswort bleibt); die gespeicherte Einstellung behält dabei den
Wunschnamen. Nach `{"enabled":false}` behält sie `ap_ssid` ebenfalls, der laufende AP heißt dann
wieder `ninlight-<mac4>`. Genau diese Abweichung zwischen `system/settings` (gewünscht) und
`system/info` (läuft) ist das Erkennungsmerkmal für einen Rückfall.
Der Startpuls übermalt als Überlagerung die sACN-Ausgabe: solange er läuft, ist von sACN nichts
zu sehen. Beim Ausprobieren von sACN über den eigenen AP den Puls mit `pulse: false` abschalten oder
abwarten. Außerdem muss der Sender bei zwei Default-Routen (LAN und AP) das Interface für Multicast
festlegen (`IP_MULTICAST_IF`), sonst sendet er ins eigene LAN statt in den AP.
## Firmware-Update ## Firmware-Update
Der Build erzeugt pro Gerät zwei Images: Der Build erzeugt pro Gerät zwei Images:
+84 -1
View File
@@ -234,6 +234,16 @@ items:
Enthält `last_ota_error`: `null`, solange kein Update fehlgeschlagen ist, sonst einer von Enthält `last_ota_error`: `null`, solange kein Update fehlgeschlagen ist, sonst einer von
`download`, `crc`, `trailer`, `device_mismatch`, `hash`, `busy` (`busy` heißt: es läuft bereits `download`, `crc`, `trailer`, `device_mismatch`, `hash`, `busy` (`busy` heißt: es läuft bereits
ein Update). Kein `reset_reason` mehr im Feld-Satz. ein Update). Kein `reset_reason` mehr im Feld-Satz.
`wifi.standalone` unterscheidet den dauerhaften eigenen Zugangspunkt vom
Onboarding-AP - `wifi.mode` ist bei beiden `"ap"`. Im AP-Betrieb steht unter
`wifi.ssid` der **laufende** AP-Name, nicht die gespeicherte Stations-SSID; der
Name des Heimnetzes steht damit nicht in dieser tokenfrei lesbaren Antwort.
`sacn.running` sagt, ob der Empfangs-Task lebt und einen Socket haelt - zusammen
mit `sacn.active` (es kommt gerade ein gueltiger Strom an) und `sacn.multicast`
(der letzte Gruppenbeitritt hat geklappt) ist damit unterscheidbar, ob niemand
sendet oder ob der Empfang tot ist.
- info: - info:
name: Get System Settings name: Get System Settings
type: http type: http
@@ -251,6 +261,12 @@ items:
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
eine verbundene SSID zeigen. eine verbundene SSID zeigen.
Der `standalone`-Block zeigt `enabled`, den **gewuenschten** AP-Namen (`ssid`: der
gespeicherte, ohne eigenen der Hostname), `channel` und `pulse`. Das AP-Passwort
steht nie darin. Der gewuenschte Name kann vom laufenden abweichen, den
`GET system/info` meldet - genau das ist das Erkennungsmerkmal fuer einen Rueckfall
auf die Vorgaben nach mehrfach gescheitertem AP-Aufbau.
- info: - info:
name: Set WiFi name: Set WiFi
type: http type: http
@@ -350,10 +366,77 @@ items:
timeout: 0 timeout: 0
followRedirects: true followRedirects: true
maxRedirects: 5 maxRedirects: 5
- info:
name: Standalone einschalten
type: http
seq: 8
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "standalone",
"enabled": true,
"password": "geheim123",
"ssid": "Buehne",
"channel": 6,
"pulse": true
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Die Lampe spannt dauerhaft einen eigenen Zugangspunkt auf, statt sich in ein WLAN
einzuwaehlen - danach ist sie unter `192.168.4.1` erreichbar, per mDNS als
`<hostname>.local`. Antwort **202** `{"status":"standalone"}`, nicht 200: die
Antwort muss das Geraet verlassen haben, bevor das Netz unter der Verbindung
weggezogen wird.
`password` ist beim Einschalten Pflicht, solange keines gespeichert ist (8..63
Zeichen). `ssid` (1..32), `channel` (1..13, Vorgabe 6) und `pulse` sind optional
und behalten sonst ihren gespeicherten Wert; ein leerer `ssid`-String wird mit 400
`invalid ssid` abgelehnt.
**Achtung beim Ausprobieren:** Aenderst du Name oder Passwort, waehrend du ueber
genau diesen AP verbunden bist, baut die Lampe ihn neu auf und wirft dich hinaus -
die 202 kommt noch an, danach musst du dem neuen Netz beitreten.
Anders als der unkonfigurierte Onboarding-AP ist der Standalone-AP **nicht**
token-frei: ab da gilt der Tokenschutz wie im Normalbetrieb.
- info:
name: Standalone ausschalten
type: http
seq: 9
http:
method: PUT
url: '{{device_address}}:{{device_port}}/api/v1/system/settings'
body:
type: json
data: |-
{
"type": "standalone",
"enabled": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Braucht kein Passwort. Hat die Lampe gespeicherte WLAN-Daten, waehlt sie sich damit
wieder ein; ohne solche oeffnet sie den unkonfigurierten Onboarding-AP
`ninlight-<mac4>` - der ist dann wieder token-frei.
Der gespeicherte AP-Name bleibt dabei in den Einstellungen stehen und greift beim
naechsten Einschalten des Modus erneut.
- info: - info:
name: Commands name: Commands
type: folder type: folder
seq: 8 seq: 10
items: items:
- info: - info:
name: Ping name: Ping
+19 -1
View File
@@ -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
+26 -3
View File
@@ -111,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");
@@ -228,10 +241,20 @@ 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());
+7
View File
@@ -62,6 +62,12 @@ typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 2
* Vorgabe. */ * Vorgabe. */
typedef char nl_settings_layout_offset_check[offsetof(nl_settings_t, sacn_layout) == 216 ? 1 : -1]; 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)
@@ -78,6 +84,7 @@ void nl_settings_defaults(nl_settings_t *s)
s->sacn_start_slot = 1; s->sacn_start_slot = 1;
s->sacn_hold = 0; s->sacn_hold = 0;
s->sacn_layout = 1; 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)
+11
View File
@@ -41,6 +41,17 @@ typedef struct {
* dem vorherigen sizeof (216), sonst ueberschreibt das Fuellbyte * dem vorherigen sizeof (216), sonst ueberschreibt das Fuellbyte
* eines aelteren Satzes sie. */ * eines aelteren Satzes sie. */
uint8_t _padding2[3]; /* haelt sizeof auf einem Vielfachen von 4 */ 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);
+84
View File
@@ -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,6 +262,10 @@ 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)
@@ -275,6 +282,12 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
* der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die * der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die
* Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */ * Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */
cJSON_AddBoolToObject(sa, "multicast", in.sacn_joined); 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);
} }
@@ -308,6 +321,16 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer"); cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer");
cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board, s.sacn_layout != 0)); 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);
} }
@@ -339,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";
+7
View File
@@ -26,18 +26,25 @@ typedef struct {
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_active; /* true, solange die Lampe ferngesteuert wird */
bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */ 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 */
+65 -6
View File
@@ -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();
+6
View File
@@ -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);
+13 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
} }
+5
View File
@@ -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
+15
View File
@@ -80,6 +80,14 @@ static int save_settings(const nl_settings_t *s)
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;
@@ -97,6 +105,7 @@ static void get_info(nl_sys_info_t *out)
nl_app_unlock(); nl_app_unlock();
/* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */ /* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */
out->sacn_joined = nl_sacn_joined(); 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);
@@ -107,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(); }
@@ -130,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;
+23
View File
@@ -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;
+4
View File
@@ -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;
+286
View File
@@ -466,12 +466,27 @@ static void test_info_exposes_sacn_state(void)
{ {
fake_info.sacn_active = true; fake_info.sacn_active = true;
fake_info.sacn_joined = true; fake_info.sacn_joined = true;
fake_info.sacn_running = true;
fake_settings.sacn_universe = 5; fake_settings.sacn_universe = 5;
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL); call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status); TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active"))); TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active")));
TEST_ASSERT_EQUAL_INT(5, (int)cJSON_GetNumberValue(sacn_field("universe"))); 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("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 --- */
@@ -574,6 +589,260 @@ static void test_put_sacn_layout_and_start_slot_together(void)
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot); 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();
@@ -610,6 +879,23 @@ int main(void)
RUN_TEST(test_put_sacn_start_slot_must_leave_room_for_all_slots); 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_put_sacn_start_slot_limit_follows_the_board);
RUN_TEST(test_info_exposes_sacn_state); 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();
} }
+52
View File
@@ -275,6 +275,55 @@ static void test_record_without_layout_gets_the_default(void)
TEST_ASSERT_EQUAL_UINT8(1, out.sacn_layout); 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) static void test_record_size_stays_aligned(void)
{ {
TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u); TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u);
@@ -301,5 +350,8 @@ int main(void)
RUN_TEST(test_sacn_layout_roundtrip); RUN_TEST(test_sacn_layout_roundtrip);
RUN_TEST(test_record_without_layout_gets_the_default); RUN_TEST(test_record_without_layout_gets_the_default);
RUN_TEST(test_record_size_stays_aligned); 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
View File
@@ -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();
} }
+77 -2
View File
@@ -319,6 +319,77 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tupl
return name, False return name, False
def _trailer_version(path: Path) -> str:
"""Firmware-Version aus dem NINL-Anhaenger eines ninlight-Abzugs, sonst "".
Nur die letzten 512 Byte werden gelesen: die Rettungsabzuege liegen als
_ota.img vor und tragen ihre Version im Anhaenger (Geraetetyp bei Offset 5,
Version bei 21) - das Entpacken des gzip-Rumpfs waere dafuer unnoetige Arbeit auf
einem Pi, der alle paar Sekunden neu zeichnet.
"""
try:
with path.open("rb") as f:
f.seek(-512, 2)
anhaenger = f.read(512)
except OSError:
return ""
if len(anhaenger) != 512 or anhaenger[0:4] != b"NINL":
return ""
return anhaenger[21:37].split(b"\x00", 1)[0].decode("ascii", errors="replace")
def delivered_version(conf: dict, imageset: str, mode: str) -> str:
"""Die Firmware-Version, die die Station gerade ausliefern wuerde, sonst "".
Quelle ist der GEWAEHLTE Bildsatz, nicht irgendein Verzeichnis: wer am Taster
zwischen "mit WLAN" und "ohne WLAN" umschaltet, bekommt sonst eine Zahl angezeigt,
die zum anderen Satz gehoert.
Im Migrationsmodus steht die Version in `manifest.json` - die Migrationsabzuege
selbst tragen keinen ninlight-Anhaenger (siehe server.py:image_version). Weichen
die Eintraege voneinander ab, ist das Ergebnis "gemischt": ein zusammengefuehrter
Deploy kann Alt-Eintraege mit anderer Version enthalten, und eine davon
herauszugreifen waere geraten. Im Rettungsmodus liegen gewoehnliche _ota.img, deren
Anhaenger die Version tragen.
Fehlt die Quelle oder ist sie unlesbar, bleibt das Ergebnis leer - dann zeigt die
Anzeige gar nichts, statt eine Version zu erfinden.
"""
image_dir = conf.get("IMAGE_DIR")
if not image_dir:
return ""
if mode == "recovery":
verzeichnis = Path(image_dir) / RESCUE_IMAGESET_DIRS.get(imageset, "")
try:
abzuege = sorted(p for p in verzeichnis.iterdir() if p.name.endswith(".img"))
except OSError:
return ""
versionen = {v for v in (_trailer_version(p) for p in abzuege) if v}
else:
pfad = Path(image_dir) / IMAGESET_DIRS.get(imageset, "") / "manifest.json"
try:
daten = json.loads(pfad.read_text())
except (OSError, ValueError):
return ""
if not isinstance(daten, dict):
return ""
versionen = set()
eintraege = daten.get("images")
if isinstance(eintraege, list):
versionen = {
e["ninlight_version"]
for e in eintraege
if isinstance(e, dict) and isinstance(e.get("ninlight_version"), str)
and e["ninlight_version"]
}
if not versionen:
oben = daten.get("ninlight_version")
versionen = {oben} if isinstance(oben, str) and oben else set()
if not versionen:
return ""
return versionen.pop() if len(versionen) == 1 else "gemischt"
def _route_lookup() -> str: def _route_lookup() -> str:
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt. """Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt.
@@ -416,13 +487,17 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und # (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen. # wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
geraet, adresse = local_address() geraet, adresse = local_address()
# Auch im Aus-Zustand: welche Version die Station ausliefern WUERDE, ist genau
# dann interessant, wenn man sie noch nicht eingeschaltet hat.
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok, return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
"ip": adresse, "iface": geraet} "ip": adresse, "iface": geraet,
"image_version": delivered_version(conf, imageset, "migration")}
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)} "activity": _recent_request(status),
"image_version": delivered_version(conf, imageset, mode)}
if mode == "recovery": if mode == "recovery":
state["ssid"] = conf.get("SSID") state["ssid"] = conf.get("SSID")
+53
View File
@@ -76,6 +76,11 @@ TEXT_MARGIN_PX = 2
# Migration. # Migration.
ROLE_HEADER = "header" ROLE_HEADER = "header"
ROLE_HEADER_PHASE = "header_phase" ROLE_HEADER_PHASE = "header_phase"
# Rechtsbuendig in DERSELBEN Zeile wie die zuletzt gezeichnete Detailzeile.
# Genau ein Block benutzt sie: die ausgelieferte Firmware-Version neben der
# Adresse im Aus-Zustand. Eigene Rolle statt einer zweiten Zeile, weil der
# Platz auf 250x122 knapp ist und beide Angaben zusammengehoeren.
ROLE_DETAIL_RIGHT = "detail_right"
ROLE_HERO = "hero" ROLE_HERO = "hero"
ROLE_DETAIL = "detail" ROLE_DETAIL = "detail"
ROLE_BIG = "big" ROLE_BIG = "big"
@@ -187,6 +192,9 @@ PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
# 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 = "ARMED" ARMED_LABEL = "ARMED"
# Praefix der Zeile mit der ausgelieferten Version - grenzt sie von "FW:" ab, das die
# Version der erkannten Lampe traegt.
DELIVERS_PREFIX = "liefert: "
ARMED_DETAIL_PREFIX = "Armed: " 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
@@ -334,6 +342,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
out.append((f"{geraet}: {ip}", ROLE_DETAIL)) out.append((f"{geraet}: {ip}", ROLE_DETAIL))
else: else:
out.append((f"IP: {ip}", ROLE_DETAIL)) out.append((f"IP: {ip}", ROLE_DETAIL))
out += _delivered_version_blocks(state, ROLE_DETAIL_RIGHT)
return out + footer return out + footer
if mode not in ("recovery", "migration"): if mode not in ("recovery", "migration"):
@@ -348,6 +357,7 @@ def blocks(state: dict) -> list[tuple[str, str]]:
ssid = state.get("ssid") ssid = state.get("ssid")
if mode == "recovery": if mode == "recovery":
out += _delivered_version_blocks(state, ROLE_HEADER_PHASE)
# Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die # Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
# Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin # Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
# wie vor der zweiten Hardware-Abnahme, siehe dort). # wie vor der zweiten Hardware-Abnahme, siehe dort).
@@ -409,6 +419,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
lamp_version = last_seen.get("lamp_version") lamp_version = last_seen.get("lamp_version")
if lamp_version: if lamp_version:
out.append((f"FW: {lamp_version}", ROLE_DETAIL)) out.append((f"FW: {lamp_version}", ROLE_DETAIL))
# Beschriftet und unmittelbar hinter "FW:", weil beides Versionsnummern sind
# und nebeneinander sonst niemand weiss, welche wem gehoert: "FW:" ist die
# der erkannten LAMPE, "liefert:" die des Bildsatzes. Nicht in die Kopfzeile:
# dort steht im Migrationsmodus die Phase, und neben dem Modus bleiben nur
# rund 112 px - daran ist schon "FREIGEGEBEN: <ziel>" gescheitert.
for text, _ in _delivered_version_blocks(state, ROLE_DETAIL):
out.append((f"{DELIVERS_PREFIX}{text}", ROLE_DETAIL))
if ssid and armed_target is None: if ssid and armed_target is None:
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die # Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
# SSID zweimal auf dem Panel. # SSID zweimal auf dem Panel.
@@ -462,6 +479,19 @@ def blocks(state: dict) -> list[tuple[str, str]]:
return out + footer return out + footer
def _delivered_version_blocks(state: dict, role: str) -> list[tuple[str, str]]:
"""Die Version, die die Station ausliefert - oder [], wenn sie unbekannt ist.
Bewusst keine Ersatzanzeige: eine erfundene oder veraltete Versionsnummer ist
schlimmer als keine, denn sie beantwortet genau die Frage falsch, fuer die sie da
ist. display.py liefert "" , wenn manifest.json bzw. die Abzuege fehlen.
"""
version = state.get("image_version")
if not isinstance(version, str) or not version:
return []
return [(version, role)]
def _imageset_blocks(state: dict) -> list[tuple[str, str]]: def _imageset_blocks(state: dict) -> list[tuple[str, str]]:
"""Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt """Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt
(aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test). (aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test).
@@ -636,6 +666,7 @@ def compose(state: dict):
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True), ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
ROLE_HERO: (HERO_FONT_PX, True), ROLE_HERO: (HERO_FONT_PX, True),
ROLE_DETAIL: (DETAIL_FONT_PX, False), ROLE_DETAIL: (DETAIL_FONT_PX, False),
ROLE_DETAIL_RIGHT: (DETAIL_FONT_PX, False),
ROLE_BIG: (BIG_FONT_PX, True), ROLE_BIG: (BIG_FONT_PX, True),
ROLE_FOOTER: (FOOTER_FONT_PX, True), ROLE_FOOTER: (FOOTER_FONT_PX, True),
} }
@@ -700,6 +731,10 @@ def compose(state: dict):
fill=0, align="center") fill=0, align="center")
y = TEXT_MARGIN_PX y = TEXT_MARGIN_PX
# Oberkante und rechter Rand der zuletzt gezeichneten Flusszeile - ROLE_DETAIL_RIGHT
# setzt seinen Text daneben.
letzte_zeile_oben: int | None = None
letzte_zeile_rechts = 0.0
for raw_text, role in block_list: for raw_text, role in block_list:
font = font_for(role) font = font_for(role)
@@ -740,6 +775,22 @@ def compose(state: dict):
# Ueberlappen mit dem Modus ist nicht erlaubt. # Ueberlappen mit dem Modus ist nicht erlaubt.
continue continue
if role == ROLE_DETAIL_RIGHT:
# Rechtsbuendig neben der zuletzt gezeichneten Zeile, gemessen ab deren
# tatsaechlichem rechten Rand - wie die Phase neben dem Modus. Ohne
# vorangehende Zeile (von Hand zusammengesetzter Zustand im Test) oder bei
# zu wenig Platz entfaellt die Angabe: ein Ueberlappen waere schlimmer als
# eine fehlende Zusatzauskunft.
if letzte_zeile_oben is None:
continue
frei_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - letzte_zeile_rechts
- HEADER_PHASE_GAP_PX)
if frei_px >= HEADER_PHASE_MIN_WIDTH_PX:
text = fit(raw_text, font, width_px=frei_px)
_draw_text(draw, text, font, top=letzte_zeile_oben,
height=DETAIL_LINE_HEIGHT_PX, fill=0, align="right")
continue
text = fit(raw_text, font) text = fit(raw_text, font)
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
if y + line_height > flow_limit_px: if y + line_height > flow_limit_px:
@@ -748,6 +799,8 @@ def compose(state: dict):
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert. # Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
break break
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0) draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
letzte_zeile_oben = y
letzte_zeile_rechts = TEXT_MARGIN_PX + draw.textlength(text, font=font)
y += line_height y += line_height
return image return image
+60 -2
View File
@@ -957,9 +957,11 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
last_mode_path=tmp_path / "last-mode") 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), die Adresse
# fuer SSH und die Version, die die Station ausliefern WUERDE.
assert state["mode"] == "off" assert state["mode"] == "off"
assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface"} assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface",
"image_version"}
assert isinstance(state["ip"], str) assert isinstance(state["ip"], str)
@@ -2462,3 +2464,59 @@ def test_fixed_labels_fit_the_panel_with_room_for_a_wider_font(source, text, rol
"{}: {!r} misst {:.1f} px; mit {:.0f} % Reserve fuer eine breitere Schrift " "{}: {!r} misst {:.1f} px; mit {:.0f} % Reserve fuer eine breitere Schrift "
"bleiben nur {:.1f} px Platz".format( "bleiben nur {:.1f} px Platz".format(
source, text, width_px, (FOOTER_WIDTH_SAFETY - 1) * 100, max_width_px)) source, text, width_px, (FOOTER_WIDTH_SAFETY - 1) * 100, max_width_px))
# --- Ausgelieferte Firmware-Version ---------------------------------------------------
#
# "Welche Version liefert die Station gerade aus?" war am Geraet nur zu beantworten,
# indem man sich per SSH die Abzuege ansah. Die Anzeige sagt es jetzt selbst.
def test_off_screen_shows_delivered_version_next_to_the_address():
bloecke = render.blocks({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0",
"image_version": "0.4.0"})
assert ("wlan0: 192.168.2.96", render.ROLE_DETAIL) in bloecke
assert ("0.4.0", render.ROLE_DETAIL_RIGHT) in bloecke
# Rechts NEBEN der Adresse, also unmittelbar dahinter - sonst landete sie neben
# einer anderen Zeile.
assert bloecke.index(("0.4.0", render.ROLE_DETAIL_RIGHT)) == (
bloecke.index(("wlan0: 192.168.2.96", render.ROLE_DETAIL)) + 1
)
def test_rescue_shows_delivered_version_in_the_header():
bloecke = render.blocks({"mode": "recovery", "ssid": "ninlight-rescue",
"image_version": "0.4.0"})
assert ("0.4.0", render.ROLE_HEADER_PHASE) in bloecke
def test_migration_labels_the_delivered_version():
"""Im Migrationsmodus beschriftet, weil daneben schon die Lampenversion steht."""
bloecke = render.blocks({
"mode": "migration", "phase": "auto", "image_version": "0.4.0",
"last_seen": {"product_name": "Klyqa E27 RGB", "lamp_version": "1.1.1.2358"},
})
texte = [text for text, _ in bloecke]
assert "FW: 1.1.1.2358" in texte
assert "liefert: 0.4.0" in texte
# NICHT in die Kopfzeile: dort steht die Phase.
assert ("0.4.0", render.ROLE_HEADER_PHASE) not in bloecke
assert ("AUTO", render.ROLE_HEADER_PHASE) in bloecke
def test_unknown_version_shows_nothing():
"""Lieber keine Angabe als eine erfundene - sie beantwortet sonst genau die Frage
falsch, fuer die sie da ist."""
for zustand in ({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"},
{"mode": "off", "ip": "192.168.2.96", "iface": "wlan0",
"image_version": ""},
{"mode": "recovery", "ssid": "x", "image_version": None}):
bloecke = render.blocks(zustand)
assert not [b for b in bloecke if b[1] == render.ROLE_DETAIL_RIGHT]
assert not [b for b in bloecke if b[1] == render.ROLE_HEADER_PHASE]
def test_mixed_sets_are_called_out():
bloecke = render.blocks({"mode": "off", "ip": "10.0.0.5", "iface": "eth0",
"image_version": "gemischt"})
assert ("gemischt", render.ROLE_DETAIL_RIGHT) in bloecke