diff --git a/README.md b/README.md index 34e2f07..7e56f1d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Firmware flashen (Standard: App-Image): tools/flash.sh -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 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 full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet). 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 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). -- 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: `, 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: `, Default-Token `aabbccddeeff0011223344`). - 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`. - 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`. +### 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 `.local`. + +Einschalten: + + curl -X PUT http://:3333/api/v1/system/settings \ + -H 'Authorization: ' \ + -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-` 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-`. + +`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-`. 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 Der Build erzeugt pro Gerät zwei Images: diff --git a/docs/hardware-checklist.md b/docs/hardware-checklist.md index d905123..24a7039 100644 --- a/docs/hardware-checklist.md +++ b/docs/hardware-checklist.md @@ -1,6 +1,6 @@ # Hardware-Checkliste (EVK) -Konsole: `tools/monitor.sh /dev/cu.` (115200 8N1). Flashen: `tools/flash.sh evk `. +Konsole: `tools/monitor.sh /dev/cu.` (115200 8N1). Flashen: `tools/flash.sh evk ` — 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) - [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 - [ ] Farbtemperaturbereiche aller Varianten — *die Bereiche stammen seit 2026-09-13 von Michael aus den Produktdaten und stehen so in `nl_board.c`: G95 Vintage **2000–5000 K**, alle übrigen **2700–6500 K**. Vorher standen die vier Weiß-Varianten flach auf 2500–5000 K, was für die E27/E14/GU10 CW den oberen Bereich abgeschnitten und für die G95 den unteren verschenkt hat. Am Gerät gegengeprüft ist bisher nur gu10_rgb; die G95 wurde mit den alten Grenzen getestet und gehört an den neuen wiederholt.* +## Standalone-AP-Modus (Abnahme am EVK, MAC 28:6d:cd:5c:68:1f) + +- [x] `type: standalone` per `PUT system/settings` einschalten → 202 `{"status":"standalone"}`, `system/info` meldet `wifi.standalone: true` — 2026-10-02 +- [x] Neustart → Lampe startet direkt im eigenen AP: Konsole `wifi: standalone AP, hostname ninlight-681f`, keine Onboarding-Zeile, kein Onboarding-Pulsen (den gibt es nur im Zustand `AP_ONBOARDING`, aus dem Code ableitbar; der Standalone-Startpuls ist davon getrennt und unten offen) — 2026-10-02 +- [x] Geänderte AP-Daten → `wifi: AP data changed, rebuilding`, danach `wifi: AP ninlight-681f (ch 6) up` — 2026-10-02 +- [x] Standalone mit exakt den Vorgabedaten auf einem bereits stehenden Onboarding-AP (kein Neuaufbau) löst die mDNS-Bekanntmachung ebenfalls aus — 2026-10-02 +- [x] mDNS im eigenen AP: Konsole `mdns: ninlight-681f.local _ninlight._tcp up on en1 192.168.4.1`, `dns-sd -G v4 ninlight-681f.local` löst auf `192.168.4.1` auf, `ping` 0 % Verlust — 2026-10-02 +- [x] REST über den AP antwortet auf `192.168.4.1:3333` — 2026-10-02 +- [x] Startpuls als Sichtprüfung: Michael hat zugesehen — der Puls lief nach dem Start, hörte nach rund 5 s auf, danach stand die voreingestellte Farbe wieder. Das Einschaltverhalten greift vor dem Netz-Task (`app_main.c:224` vs. `:250`), die Lampe zeigt deshalb erst ihre Farbe und der Puls legt sich darüber — 2026-10-02 +- [x] REST über den AP verlangt das Token: im Standalone-AP `GET system/settings` ohne Token → 401 `unauthorized`, mit Werkstoken → 200; `GET system/info` ohne Token → 200. Gegenprobe nach dem Werksreset im unkonfigurierten Onboarding-AP: derselbe Aufruf ohne Token → **200** — 2026-10-02 +- [x] `{"enabled":false}` **ohne** gespeicherte WLAN-Daten: 202, danach heißt der AP wieder `ninlight-681f` (Beitritt mit dem Vorgabepasswort gelang, Name **und** Passwort sind also umgestellt), `system/info` meldet `wifi.ssid: ninlight-681f` und `standalone: false`, `GET system/settings` ohne Token → 200. Der gespeicherte Wunschname bleibt in `system/settings` als `standalone.ssid: Buehne` stehen — 2026-10-02 +- [x] Standalone-Daten ändern, während der Aufrufer **über den AP** verbunden ist: die 202 kommt an. Direkter Vorher/Nachher-Beleg — derselbe Aufruf lieferte auf dem Stand **vor** der I6-Pause `HTTP 000` (Verbindung riss ab, bevor die Antwort raus war), mit der Pause `{"status":"standalone"} HTTP 202` — 2026-10-02 +- [x] WLAN eingeben, falsches Passwort → Gerät bleibt im eigenen AP mit eigenen Daten: 202 `connecting`, nach dem Fehlschlag `wifi.error: auth`, `standalone: true`, AP weiterhin `Buehne` mit dem Nutzerpasswort, `GET system/settings` ohne Token weiterhin **401** — 2026-10-02 +- [x] WLAN eingeben, richtiges Passwort → Gerät im Heimnetz (192.168.2.58, `standalone: false`), nach einem Neustart `mode: sta` und **nicht** wieder im AP — 2026-10-02 +- [x] Im Standalone-Betrieb meldet `GET system/info` unter `wifi.ssid` den AP-Namen: an einer Lampe mit gespeichertem Heimnetz (`settings.wifi.ssid: ninIOT, source: stored`) meldete die **tokenfreie** Antwort `wifi.ssid: Buehne`, nicht `ninIOT`. Vor dem Wechsel in den Standalone-Modus stand dort korrekt die Stations-SSID — 2026-10-02 +- [x] Werksreset über 5× ein/aus → wieder unkonfigurierter Onboarding-AP: alte Heimnetz-IP antwortet nicht mehr, AP `ninlight-681f` mit Vorgabepasswort, `settings` ohne Token → 200, alle Werte auf Vorgabe (`name: ''`, `standalone.enabled: false`) — 2026-10-02 +- [x] sACN über den Standalone-AP, sichtbar an der Lampe: Michael hat grün (unicast an 192.168.4.1) und blau (multicast 239.255.0.1, Sender mit `IP_MULTICAST_IF` auf 192.168.4.2) gesehen, `sacn.active` jeweils true — 2026-10-02 + ## Offene Beobachtung aus der EVK-Session vom 2026-09-12 Beim Test mit dem verfälschten Image meldete lwIP einmalig diff --git a/src/app_main.c b/src/app_main.c index 121cbbc..fa6ba1a 100644 --- a/src/app_main.c +++ b/src/app_main.c @@ -111,6 +111,19 @@ int nl_app_set_wifi_credentials(const char *ssid, const char *pass) 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) { printf("factory reset\r\n"); @@ -228,10 +241,20 @@ static void app_task(void *arg) boot = s_settings; nl_app_unlock(); nl_mdns_set_name(boot.name); - if (boot.wifi_ssid[0]) - nl_wifi_start(boot.wifi_ssid, boot.wifi_pass, NINLIGHT_AP_PASSWORD); + if (boot.standalone) + /* 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 - 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()); diff --git a/src/config/nl_settings.c b/src/config/nl_settings.c index 6e70e97..1d5e1ec 100644 --- a/src/config/nl_settings.c +++ b/src/config/nl_settings.c @@ -62,6 +62,12 @@ typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 2 * Vorgabe. */ typedef char nl_settings_layout_offset_check[offsetof(nl_settings_t, sacn_layout) == 216 ? 1 : -1]; +/* Dasselbe fuer die Standalone-Felder: standalone muss bei 220 beginnen, dem + * sizeof(nl_settings_t) der Firmware davor. Wer hier anstoesst, hat ein Feld zu frueh + * eingefuegt - dann kaeme standalone_pulse aus einem aelteren Satz als Fuellbyte (0) + * zurueck statt als Vorgabe (1), und der Startpuls waere still aus. */ +typedef char nl_settings_standalone_check[offsetof(nl_settings_t, standalone) == 220 ? 1 : -1]; + static uint32_t s_addr[2]; 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_hold = 0; s->sacn_layout = 1; + s->standalone_pulse = 1; /* standalone, ap_channel, ap_ssid, ap_pass bleiben auf 0/leer */ } void nl_settings_init(uint32_t addr_a, uint32_t addr_b) diff --git a/src/config/nl_settings.h b/src/config/nl_settings.h index 8ec3aa5..d406ec6 100644 --- a/src/config/nl_settings.h +++ b/src/config/nl_settings.h @@ -41,6 +41,17 @@ typedef struct { * dem vorherigen sizeof (216), sonst ueberschreibt das Fuellbyte * eines aelteren Satzes sie. */ uint8_t _padding2[3]; /* haelt sizeof auf einem Vielfachen von 4 */ + uint8_t standalone; /* Offset 220 - 1 = eigener AP statt Station. Dieselbe Regel + * wie oben: neue Felder beginnen bei oder hinter dem + * vorherigen sizeof (220), sonst ueberschreibt das + * Fuellbyte eines aelteren Satzes sie. */ + uint8_t ap_channel; /* 0 = Vorgabe (6), sonst 1..13 */ + uint8_t standalone_pulse; /* 1 = beim Start kurz pulsen (Vorgabe) */ + uint8_t _padding3; + char ap_ssid[NL_SSID_LEN]; /* leer = "ninlight-" */ + 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; void nl_settings_defaults(nl_settings_t *s); diff --git a/src/net/nl_api.c b/src/net/nl_api.c index b68cddc..2555e9c 100644 --- a/src/net/nl_api.c +++ b/src/net/nl_api.c @@ -10,6 +10,9 @@ #define P_SETTINGS "/api/v1/system/settings" #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 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); else 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, "free_heap", in.free_heap); 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_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); } @@ -345,6 +362,67 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t cJSON_Delete(j); 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) { if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1) err = "invalid name"; diff --git a/src/net/nl_api.h b/src/net/nl_api.h index 3d37c4e..d11b742 100644 --- a/src/net/nl_api.h +++ b/src/net/nl_api.h @@ -27,18 +27,24 @@ typedef struct { bool sacn_active; /* true, solange die Lampe ferngesteuert wird */ bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */ bool sacn_running; /* Empfangs-Task lebt und haelt einen Socket */ + bool wifi_standalone; /* eigener Dauer-AP, nicht der offene Onboarding-AP */ } nl_sys_info_t; /* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */ typedef struct { const nl_board_t *board; const char *fw_version; + const char *hostname; /* "ninlight-", Vorgabe fuer den AP-Namen */ const char *build_date; void (*get_state)(nl_light_state_t *out); nl_ctrl_err_t (*apply)(const nl_light_change_t *c); void (*get_settings)(nl_settings_t *out); 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 */ + /* 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 */ void (*get_info)(nl_sys_info_t *out); void (*factory_reset)(void); /* request only */ diff --git a/src/net/nl_mdns.c b/src/net/nl_mdns.c index 73ae544..5ee8f09 100644 --- a/src/net/nl_mdns.c +++ b/src/net/nl_mdns.c @@ -3,6 +3,7 @@ #include "wm_include.h" #include "lwip/apps/mdns.h" +#include "lwip/netif.h" #include "lwip/tcpip.h" #include "config/nl_settings.h" @@ -16,8 +17,13 @@ #define NL_MDNS_TTL 120 static bool s_inited; +static struct netif *s_nif; /* Netif, an dem Antwortdienst und Dienst haengen */ 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_mac[18]; @@ -45,15 +51,53 @@ static void txt_cb(struct mdns_service *service, void *data) 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 */ static void announce(void *arg) { - struct netif *nif = tls_get_netif(); + struct netif *nif = pick_netif(); bool attempted = false; (void)arg; - if (!nif) + if (!nif) { + printf("mdns: no usable netif yet\r\n"); + s_announce_pending = false; 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) { mdns_resp_init(); @@ -69,10 +113,19 @@ static void announce(void *arg) NL_MDNS_TTL, txt_cb, NULL) == ERR_OK; } - if (attempted) - printf("mdns: %s.local _ninlight._tcp %s\r\n", nl_wifi_hostname(), s_service_added ? "up" : "failed"); - else + if (attempted) { + /* Welches Netif benutzt wurde, steht in der Zeile selbst: Namenskuerzel, Nummer, IPv4. + * 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 */ + s_announce_pending = false; } void nl_mdns_on_ip_up(void) @@ -80,9 +133,15 @@ void nl_mdns_on_ip_up(void) uint8_t mac[6]; nl_wifi_mac(mac); nl_names_mac_str(mac, s_mac); + s_announce_pending = true; 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) { u32 sr = tls_os_set_critical(); diff --git a/src/net/nl_mdns.h b/src/net/nl_mdns.h index 7380eac..08b22f2 100644 --- a/src/net/nl_mdns.h +++ b/src/net/nl_mdns.h @@ -1,8 +1,14 @@ #ifndef NL_MDNS_H #define NL_MDNS_H +#include + /* Called by the network task once the station got an IP (again). */ 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. */ void nl_mdns_set_name(const char *name); diff --git a/src/net/nl_wifi.h b/src/net/nl_wifi.h index 7704815..c25f4f9 100644 --- a/src/net/nl_wifi.h +++ b/src/net/nl_wifi.h @@ -19,8 +19,12 @@ typedef struct { * sure the network task is still alive, not just the light task. */ extern volatile uint32_t nl_net_beat; -/* 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); +/* Starts the network task. An empty ssid starts the onboarding AP. Strings are copied. + * 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 * 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 @@ -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); /* Starts an onboarding test with new credentials (from the HTTP task). 0 = queued. */ 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). */ bool nl_wifi_open_access(void); void nl_wifi_status(nl_wifi_status_t *out); diff --git a/src/net/nl_wifi_sm.c b/src/net/nl_wifi_sm.c index 373d80a..ff653be 100644 --- a/src/net/nl_wifi_sm.c +++ b/src/net/nl_wifi_sm.c @@ -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) { 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->since = now; 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(); memset(sm, 0, sizeof *sm); 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); a.connect = 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: if (ev == NL_WIFI_EV_CONNECTED) { a.save_test_creds = true; + if (sm->standalone) { + sm->standalone = false; + a.clear_standalone = true; + } sm->have_creds = true; sm->error = NL_WIFI_ERR_NONE; 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) a.disconnect = true; /* I1: stray success of a timed-out/failed attempt */ break; + case NL_WIFI_ST_STANDALONE: + if (ev == NL_WIFI_EV_CONNECTED) + a.disconnect = true; /* stray success of an old attempt */ + break; default: 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(); + 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)) { sm->ap_stop_armed = 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->retry_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.connect = true; a.use_test_creds = true; 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) { - 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) { - 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 sm->ap_up ? "apsta" : "sta"; } diff --git a/src/net/nl_wifi_sm.h b/src/net/nl_wifi_sm.h index 5db82f1..17c7639 100644 --- a/src/net/nl_wifi_sm.h +++ b/src/net/nl_wifi_sm.h @@ -11,13 +11,15 @@ #define NL_WIFI_BACKOFF_MIN_MS 1000u #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_STANDALONE_PULSE_MS 5000u /* short sign-of-life pulse, then normal light */ typedef enum { NL_WIFI_ST_AP_ONBOARDING = 0, NL_WIFI_ST_STA_CONNECTING, NL_WIFI_ST_STA_CONNECTED, NL_WIFI_ST_APSTA_FALLBACK, - NL_WIFI_ST_ONBOARD_TEST + NL_WIFI_ST_ONBOARD_TEST, + NL_WIFI_ST_STANDALONE } 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; @@ -33,6 +35,7 @@ typedef struct { bool disconnect; /* drop a stray/failed station attempt (before any connect in the same set) */ bool pulse_on; bool pulse_off; + bool clear_standalone; /* Onboarding gelungen: den Standalone-Modus dauerhaft loeschen */ } nl_wifi_actions_t; typedef struct { @@ -44,18 +47,26 @@ typedef struct { bool retry_armed; bool ap_stop_armed; 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 retry_at; uint32_t backoff; uint32_t deadline; /* onboarding test */ uint32_t ap_stop_at; uint32_t connect_at; /* when the pending connect was issued */ + uint32_t pulse_stop_at; } 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_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); +/* 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); const char *nl_wifi_sm_mode(const nl_wifi_sm_t *sm); const char *nl_wifi_error_name(nl_wifi_error_t e); diff --git a/src/net/nl_wifi_w600.c b/src/net/nl_wifi_w600.c index acf0493..2d22ab0 100644 --- a/src/net/nl_wifi_w600.c +++ b/src/net/nl_wifi_w600.c @@ -16,8 +16,12 @@ #define NL_NET_PRIO 35 #define NL_NET_QUEUE 8 -#define MSG_NETIF 0x100u /* | netif status */ -#define MSG_ONBOARD 0x200u +#define MSG_NETIF 0x100u /* | netif status */ +#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 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_test_ssid[NL_SSID_LEN], s_test_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 uint32_t s_ap_create_retry_at; @@ -88,18 +120,77 @@ void nl_wifi_disable_sdk_auto_connect(void) tls_wifi_disconnect(); } +/* Setzt NUR Name und Kanal des naechsten AP auf die Vorgaben zurueck: "ninlight-" 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) { struct tls_softap_info_t ap; 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); 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(&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.channel = 6; + ap.channel = s_ap_channel; ap.keyinfo.format = 1; /* ascii */ ap.keyinfo.index = 1; 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; memcpy(ip.dnsname, "local", 6); 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. */ s_ap_create_pending = r != WM_SUCCESS; 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) @@ -149,6 +275,24 @@ static void execute(nl_wifi_actions_t a) printf("wifi: onboarding ok, saving credentials for %s\r\n", ssid); 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) pulse(true); if (a.pulse_off) @@ -157,9 +301,7 @@ static void execute(nl_wifi_actions_t a) start_ap(); if (a.stop_ap) { printf("wifi: AP down\r\n"); - tls_wifi_softap_destroy(); - s_ap_create_pending = false; - clear_softap_param(); /* C2: every softap teardown clears the persisted SOFTAP bit */ + destroy_ap(); } if (a.disconnect) { printf("wifi: dropping stray/failed station attempt\r\n"); @@ -185,7 +327,9 @@ static void net_task(void *arg) void *msg = NULL; nl_wifi_actions_t ev, tick; 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); 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); } else if (m == MSG_ONBOARD) { 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-" 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); /* 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 * (which only guards s_sm and the credential buffers) is held. */ 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(); if (unexpected_softap) { printf("wifi: unexpected SDK softap while AP should be down, destroying it\r\n"); - tls_wifi_softap_destroy(); - clear_softap_param(); + destroy_ap(); } if (m & MSG_NETIF) 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-". + * 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); + /* 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); if (got_ip_event && connected_now) 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. */ 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; 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); copy(s_ssid, ssid, sizeof s_ssid); 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. */ 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 * 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"); - tls_wifi_softap_destroy(); - clear_softap_param(); + destroy_ap(); } nif = tls_get_netif(); 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); 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(); 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); 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; } +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 r; @@ -316,7 +598,16 @@ void nl_wifi_status(nl_wifi_status_t *out) lock(); out->mode = nl_wifi_sm_mode(&s_sm); 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; unlock(); } diff --git a/src/sys/nl_app.h b/src/sys/nl_app.h index df355e7..ee119ad 100644 --- a/src/sys/nl_app.h +++ b/src/sys/nl_app.h @@ -28,6 +28,11 @@ void nl_app_request_factory_reset(void); /* Stores new station credentials (from a successful onboarding test) and persists them. */ 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. * 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 diff --git a/src/sys/nl_app_api.c b/src/sys/nl_app_api.c index be04c4e..1f66782 100644 --- a/src/sys/nl_app_api.c +++ b/src/sys/nl_app_api.c @@ -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 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) { 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->free_heap = tls_mem_get_avail_heapsize(); 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(); } @@ -131,12 +140,17 @@ const nl_api_ctx_t *nl_app_api_ctx(void) cJSON_InitHooks(&hooks); ctx.board = nl_board_get(); 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.get_state = get_state; ctx.apply = apply; ctx.get_settings = get_settings; ctx.save_settings = save_settings; ctx.wifi_configure = wifi_configure; + ctx.standalone_configure = standalone_configure; ctx.open_access = open_access; ctx.get_info = get_info; ctx.factory_reset = factory_reset; diff --git a/test/fake_api_ctx.c b/test/fake_api_ctx.c index 2e6d5d6..c9ddc1c 100644 --- a/test/fake_api_ctx.c +++ b/test/fake_api_ctx.c @@ -1,5 +1,6 @@ #include "fake_api_ctx.h" +#include #include #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_wifi_calls, fake_wifi_result; 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; char fake_fw_url[256]; bool fake_open; @@ -49,6 +54,18 @@ static int wifi_configure(const char *ssid, const char *pass) 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 void get_info(nl_sys_info_t *out) { *out = fake_info; } 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_wifi_calls = fake_wifi_result = 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_fw_result = -1; 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.save_settings = save_settings; 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.get_info = get_info; fake_ctx.factory_reset = factory_reset; diff --git a/test/fake_api_ctx.h b/test/fake_api_ctx.h index 9abfebd..0aabcc9 100644 --- a/test/fake_api_ctx.h +++ b/test/fake_api_ctx.h @@ -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_wifi_calls, fake_wifi_result; 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 char fake_fw_url[256]; extern bool fake_open; diff --git a/test/test_api.c b/test/test_api.c index 6abb14d..8c9a832 100644 --- a/test/test_api.c +++ b/test/test_api.c @@ -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); } +/* --- 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) { UNITY_BEGIN(); @@ -627,5 +881,21 @@ int main(void) RUN_TEST(test_info_exposes_sacn_state); RUN_TEST(test_info_reports_a_dead_sacn_task); 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(); } diff --git a/test/test_settings.c b/test/test_settings.c index d013bf2..4ef38df 100644 --- a/test/test_settings.c +++ b/test/test_settings.c @@ -275,6 +275,55 @@ static void test_record_without_layout_gets_the_default(void) TEST_ASSERT_EQUAL_UINT8(1, out.sacn_layout); } +static void test_standalone_defaults(void) +{ + nl_settings_t s; + TEST_ASSERT_FALSE(nl_settings_load(&s)); + TEST_ASSERT_EQUAL_UINT8(0, s.standalone); + TEST_ASSERT_EQUAL_UINT8(0, s.ap_channel); /* 0 heisst "Vorgabe 6" */ + TEST_ASSERT_EQUAL_UINT8(1, s.standalone_pulse); + TEST_ASSERT_EQUAL_STRING("", s.ap_ssid); + TEST_ASSERT_EQUAL_STRING("", s.ap_pass); +} + +static void test_standalone_roundtrip(void) +{ + nl_settings_t in = sample("ap"), out; + in.standalone = 1; + in.ap_channel = 11; + in.standalone_pulse = 0; + strcpy(in.ap_ssid, "Wohnzimmerlampe"); + strcpy(in.ap_pass, "geheim123"); + TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in)); + TEST_ASSERT_TRUE(nl_settings_load(&out)); + TEST_ASSERT_EQUAL_UINT8(1, out.standalone); + TEST_ASSERT_EQUAL_UINT8(11, out.ap_channel); + TEST_ASSERT_EQUAL_UINT8(0, out.standalone_pulse); + TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", out.ap_ssid); + TEST_ASSERT_EQUAL_STRING("geheim123", out.ap_pass); +} + +/* Ein Datensatz aus einer Firmware VOR dem Standalone-Modus: die neuen Felder muessen + * als echte Vorgaben zurueckkommen, nicht als Null. Sonst stuende standalone_pulse nach + * dem Update auf jeder Lampe auf 0 - der Startpuls waere still abgeschaltet. */ +static void test_record_without_standalone_gets_defaults(void) +{ + nl_settings_t in = sample("alt"), out; + uint32_t crc; + nl_settings_save(&in); + { + uint16_t shorter = 220u; /* sizeof(nl_settings_t) vor diesem Zweig */ + memset(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + 217], 0, 3); /* Fuellbytes */ + memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2); + crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter); + memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4); + } + TEST_ASSERT_TRUE(nl_settings_load(&out)); + TEST_ASSERT_EQUAL_STRING("alt", out.name); + TEST_ASSERT_EQUAL_UINT8(0, out.standalone); + TEST_ASSERT_EQUAL_UINT8(1, out.standalone_pulse); +} + static void test_record_size_stays_aligned(void) { TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u); @@ -301,5 +350,8 @@ int main(void) RUN_TEST(test_sacn_layout_roundtrip); RUN_TEST(test_record_without_layout_gets_the_default); RUN_TEST(test_record_size_stays_aligned); + RUN_TEST(test_standalone_defaults); + RUN_TEST(test_standalone_roundtrip); + RUN_TEST(test_record_without_standalone_gets_defaults); return UNITY_END(); } diff --git a/test/test_wifi_sm.c b/test/test_wifi_sm.c index 57a48d4..009f675 100644 --- a/test/test_wifi_sm.c +++ b/test/test_wifi_sm.c @@ -10,7 +10,7 @@ void tearDown(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.pulse_on); 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) { - 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_FALSE(a.use_test_creds); 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) { - 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); TEST_ASSERT_FALSE(nl_wifi_sm_tick(&sm, 1999).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) { - 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); a = nl_wifi_sm_tick(&sm, NL_WIFI_FALLBACK_MS - 1); 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) { - 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_DISCONNECTED, NL_WIFI_ERR_NONE, 100000); 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) { - nl_wifi_sm_start(&sm, false, 0); + nl_wifi_sm_start(&sm, false, false, false, 0); a = nl_wifi_sm_onboard(&sm, 5000); TEST_ASSERT_FALSE(a.start_ap); /* already up */ TEST_ASSERT_TRUE(a.connect); @@ -106,7 +106,7 @@ static void test_onboarding_success(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); a = nl_wifi_sm_event(&sm, NL_WIFI_EV_JOIN_FAILED, NL_WIFI_ERR_AUTH, 8000); TEST_ASSERT_FALSE(a.pulse_off); @@ -121,7 +121,7 @@ static void test_onboarding_failure_without_credentials(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); TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state); 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 * 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_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); @@ -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) { - 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); a = nl_wifi_sm_onboard(&sm, 2000); 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 * 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); TEST_ASSERT_EQUAL(NL_WIFI_ST_ONBOARD_TEST, sm.state); 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) { - 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, 30000).connect); /* retry scheduled at 31000 */ 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); } +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) { UNITY_BEGIN(); @@ -218,5 +469,26 @@ int main(void) RUN_TEST(test_onboard_while_test_running_disconnects_stale_attempt); RUN_TEST(test_error_names); 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(); }