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
This commit is contained in:
Michael Sauer
2026-10-02 21:30:35 +02:00
20 changed files with 1364 additions and 51 deletions
+50 -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,7 @@ 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`). - 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 +107,54 @@ 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"}`. Anders als der unkonfigurierte Onboarding-AP ist der
Standalone-AP nicht token-frei: der Tokenschutz gilt dort wie im Normalbetrieb (am Gerät noch nicht
nachgemessen, siehe Abnahmeliste). Felder:
| Feld | Pflicht | Bedeutung |
|---|---|---|
| `enabled` | ja | `true` schaltet den Modus ein, `false` aus |
| `password` | beim Einschalten, solange noch keines gespeichert ist | AP-Passwort, 8..63 Zeichen |
| `ssid` | nein | AP-Name, 1..32 Zeichen; ein leerer String wird mit 400 `invalid ssid` abgelehnt. Weggelassen bleibt der gespeicherte Name erhalten; `ninlight-<mac4>` gilt nur, solange nie ein eigener gesetzt wurde — über die API führt kein Weg zurück zum Vorgabenamen |
| `channel` | nein | Funkkanal 1..13, Vorgabe 6 |
| `pulse` | nein | Startpuls beim Hochfahren der Lampe an/aus, Vorgabe an (läuft rund 5 s, danach gilt das normale Einschaltverhalten) |
Ausschalten braucht kein Passwort: `{"type":"standalone","enabled":false}`. Hat die Lampe gespeicherte
WLAN-Daten, wählt sie sich damit wieder ins WLAN ein; ohne solche öffnet sie den unkonfigurierten
Onboarding-AP `ninlight-<mac4>`.
`GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **gewünschten** AP-Namen
(`ssid`: der gespeicherte Name, ohne eigenen Namen der Hostname), `channel` und `pulse` — das
Passwort steht nie darin. `GET system/info` meldet `wifi.standalone` und im AP-Betrieb (`mode: "ap"`,
also eigener AP oder Onboarding-AP) unter `wifi.ssid` den **laufenden AP-Namen** statt der
gespeicherten Stations-SSID — der Name des Heimnetzes steht damit nicht in einer tokenfrei lesbaren
Antwort.
Die beiden Namen können auseinanderlaufen, und das ist Absicht. Scheitert der AP-Aufbau mehrfach,
fällt der Treiber auf die zuletzt funktionierenden Daten zurück, und gibt es die nicht, auf Name und
Kanal der Vorgaben (das Nutzerpasswort bleibt); die gespeicherte Einstellung behält dabei den
Wunschnamen. Nach `{"enabled":false}` behält sie `ap_ssid` ebenfalls, der laufende AP heißt dann
wieder `ninlight-<mac4>`. Genau diese Abweichung zwischen `system/settings` (gewünscht) und
`system/info` (läuft) ist das Erkennungsmerkmal für einen Rückfall.
Der Startpuls übermalt als Überlagerung die sACN-Ausgabe: solange er läuft, ist von sACN nichts
zu sehen. Beim Ausprobieren von sACN über den eigenen AP den Puls mit `pulse: false` abschalten oder
abwarten. Außerdem muss der Sender bei zwei Default-Routen (LAN und AP) das Interface für Multicast
festlegen (`IP_MULTICAST_IF`), sonst sendet er ins eigene LAN statt in den AP.
## Firmware-Update ## Firmware-Update
Der Build erzeugt pro Gerät zwei Images: Der Build erzeugt pro Gerät zwei Images:
+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);
+78
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)
@@ -314,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);
} }
@@ -345,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";
+6
View File
@@ -27,18 +27,24 @@ typedef struct {
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 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
+14
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;
@@ -108,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(); }
@@ -131,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;
+270
View File
@@ -589,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();
@@ -627,5 +881,21 @@ int main(void)
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_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();
} }