/ firmware / src / networking / wifi.cpp
wifi.cpp
  1  #include <networking/wifi.h>
  2  #include "wifi_internal.h"
  3  #include <identity.h>
  4  #include "../services/preferences.h"
  5  
  6  #include <Arduino.h>
  7  #include <Preferences.h>
  8  #include <WiFi.h>
  9  bool networking::wifi::internal::mdns_started = false;
 10  bool networking::wifi::internal::ap_active = false;
 11  
 12  const char networking::wifi::internal::wifi_ssid_slot[33] __attribute__((used, aligned(4))) =
 13    "@@WIFI_SSID@@";
 14  const char networking::wifi::internal::wifi_pass_slot[65] __attribute__((used, aligned(4))) =
 15    "@@WIFI_PASS@@";
 16  
 17  bool networking::wifi::internal::openPreferences(bool readonly, Preferences *prefs) {
 18    return services::preferences::open(config::wifi::NVS_NAMESPACE, readonly, prefs);
 19  }
 20  
 21  bool networking::wifi::accessSnapshot(NetworkStatusSnapshot *snapshot) {
 22    if (!snapshot) return false;
 23    memset(snapshot, 0, sizeof(*snapshot));
 24  
 25    snapshot->connected = WiFi.isConnected();
 26    snapshot->rssi = snapshot->connected ? WiFi.RSSI() : 0;
 27    snapshot->channel = snapshot->connected ? WiFi.channel() : 0;
 28  
 29    strncpy(snapshot->ssid, WiFi.SSID().c_str(), sizeof(snapshot->ssid) - 1);
 30    strncpy(snapshot->bssid, WiFi.BSSIDstr().c_str(), sizeof(snapshot->bssid) - 1);
 31    strncpy(snapshot->ip, WiFi.localIP().toString().c_str(), sizeof(snapshot->ip) - 1);
 32    strncpy(snapshot->gateway, WiFi.gatewayIP().toString().c_str(), sizeof(snapshot->gateway) - 1);
 33    strncpy(snapshot->subnet, WiFi.subnetMask().toString().c_str(), sizeof(snapshot->subnet) - 1);
 34    strncpy(snapshot->dns, WiFi.dnsIP().toString().c_str(), sizeof(snapshot->dns) - 1);
 35    strncpy(snapshot->mac, WiFi.macAddress().c_str(), sizeof(snapshot->mac) - 1);
 36    strncpy(snapshot->hostname, services::identity::access_hostname(), sizeof(snapshot->hostname) - 1);
 37  
 38    snapshot->ap.active = networking::wifi::ap::isActive();
 39    APConfig ap_config = {};
 40    networking::wifi::ap::accessConfig(&ap_config);
 41    strncpy(snapshot->ap.ssid, ap_config.ssid, sizeof(snapshot->ap.ssid) - 1);
 42    strncpy(snapshot->ap.password, ap_config.password, sizeof(snapshot->ap.password) - 1);
 43    strncpy(snapshot->ap.ip, WiFi.softAPIP().toString().c_str(), sizeof(snapshot->ap.ip) - 1);
 44    snapshot->ap.clients = WiFi.softAPgetStationNum();
 45    strncpy(snapshot->ap.hostname, services::identity::access_hostname(), sizeof(snapshot->ap.hostname) - 1);
 46    strncpy(snapshot->ap.mac, WiFi.softAPmacAddress().c_str(), sizeof(snapshot->ap.mac) - 1);
 47    return true;
 48  }
 49  
 50  bool networking::wifi::accessConfig(WifiSavedConfig *config) {
 51    if (!config) return false;
 52    memset(config, 0, sizeof(*config));
 53  
 54    Preferences prefs;
 55    if (!networking::wifi::internal::openPreferences(true, &prefs)) return false;
 56  
 57    bool has_ssid = prefs.getString("sta_ssid", config->ssid, sizeof(config->ssid)) > 0;
 58    bool has_password = prefs.getString("sta_pass", config->password, sizeof(config->password)) >= 0;
 59    prefs.end();
 60    config->valid = has_ssid;
 61    return has_ssid && has_password;
 62  }
 63  
 64  bool networking::wifi::storeConfig(WifiSavedConfig *config) {
 65    if (!config) return false;
 66    Preferences prefs;
 67    if (!networking::wifi::internal::openPreferences(false, &prefs)) return false;
 68  
 69    prefs.putString("sta_ssid", config->ssid);
 70    prefs.putString("sta_pass", config->password);
 71    prefs.end();
 72    config->valid = config->ssid[0] != '\0';
 73    return config->valid;
 74  }
 75  
 76  bool networking::wifi::connect(WifiConnectCommand *command) {
 77    if (!command) return false;
 78    command->result = {};
 79    command->result.connected = false;
 80    command->result.status_code = WL_DISCONNECTED;
 81    command->result.ap_enabled_for_fallback = false;
 82  
 83    WiFi.setAutoReconnect(false);
 84    WiFi.disconnect(false);
 85    WiFi.mode(networking::wifi::ap::isActive() ? WIFI_AP_STA : WIFI_MODE_STA);
 86    networking::wifi::configure_hostname(services::identity::access_hostname());
 87  
 88    if (command->request.ssid && command->request.ssid[0] != '\0') {
 89      WiFi.begin(command->request.ssid,
 90                 command->request.password ? command->request.password : "");
 91    } else {
 92      WifiSavedConfig saved_config = {};
 93      if (networking::wifi::accessConfig(&saved_config) && saved_config.valid) {
 94        Serial.printf("[wifi] credentials from NVS: %s\n", saved_config.ssid);
 95        WiFi.begin(saved_config.ssid, saved_config.password);
 96      } else
 97  #if defined(CONFIG_WIFI_SSID) && defined(CONFIG_WIFI_PASS)
 98      if (strlen(CONFIG_WIFI_SSID) > 0) {
 99        Serial.printf("[wifi] credentials from build flags: %s\n", CONFIG_WIFI_SSID);
100        WiFi.begin(CONFIG_WIFI_SSID, CONFIG_WIFI_PASS);
101      } else
102  #endif
103      if (networking::wifi::internal::wifi_ssid_slot[0] != '@' && networking::wifi::internal::wifi_ssid_slot[0] != '\0') {
104        Serial.printf("[wifi] credentials from embedded: %s\n", networking::wifi::internal::wifi_ssid_slot);
105        WiFi.begin(networking::wifi::internal::wifi_ssid_slot, networking::wifi::internal::wifi_pass_slot);
106      } else {
107        WiFi.begin();
108      }
109    }
110  
111    command->result.status_code = WiFi.waitForConnectResult(config::wifi::CONNECT_TIMEOUT_MS);
112    command->result.connected = (command->result.status_code == WL_CONNECTED);
113  
114    if (!command->result.connected && command->request.enable_ap_fallback
115        && !networking::wifi::ap::isActive()) {
116      networking::wifi::ap::enable();
117      command->result.ap_enabled_for_fallback = true;
118    }
119  
120    return command->result.connected;
121  }
122  
123  bool networking::wifi::scan(WifiScanCommand *command) {
124    if (!command || !command->results || command->max_results == 0) return false;
125    command->result_count = -1;
126  
127    WiFi.scanDelete();
128    int16_t count = WiFi.scanNetworks();
129    if (count < 0) return false;
130  
131    int16_t limit = (count < (int16_t)command->max_results) ? count : (int16_t)command->max_results;
132    for (int16_t index = 0; index < limit; index++) {
133      memset(&command->results[index], 0, sizeof(command->results[index]));
134      strlcpy(command->results[index].ssid, WiFi.SSID(index).c_str(), sizeof(command->results[index].ssid));
135      strlcpy(command->results[index].bssid, WiFi.BSSIDstr(index).c_str(), sizeof(command->results[index].bssid));
136      command->results[index].rssi = WiFi.RSSI(index);
137      command->results[index].channel = WiFi.channel(index);
138  
139      const char *encryption = "unknown";
140      switch (WiFi.encryptionType(index)) {
141        case WIFI_AUTH_OPEN:            encryption = "open"; break;
142        case WIFI_AUTH_WEP:             encryption = "wep"; break;
143        case WIFI_AUTH_WPA_PSK:         encryption = "wpa"; break;
144        case WIFI_AUTH_WPA2_PSK:        encryption = "wpa2"; break;
145        case WIFI_AUTH_WPA_WPA2_PSK:    encryption = "wpa_wpa2"; break;
146        case WIFI_AUTH_WPA2_ENTERPRISE: encryption = "wpa2_enterprise"; break;
147        case WIFI_AUTH_WPA3_PSK:        encryption = "wpa3"; break;
148        case WIFI_AUTH_WPA2_WPA3_PSK:   encryption = "wpa2_wpa3"; break;
149        default:                        break;
150      }
151      strlcpy(command->results[index].encryption, encryption, sizeof(command->results[index].encryption));
152      command->results[index].open = (WiFi.encryptionType(index) == WIFI_AUTH_OPEN);
153    }
154  
155    WiFi.scanDelete();
156    command->result_count = count;
157    return true;
158  }
159  
160  
161  // ─────────────────────────────────────────────────────────────────────────────
162  //  Tests
163  // ─────────────────────────────────────────────────────────────────────────────
164  #ifdef PIO_UNIT_TESTING
165  
166  
167  #include <networking/wifi.h>
168  #include <testing/utils.h>
169  
170  
171  namespace networking::wifi { void test(void); }
172  
173  #include <Arduino.h>
174  #include <WiFi.h>
175  #include <esp_wifi.h>
176  
177  static WifiNvsSnapshot saved;
178  static void save_nvs(void) { wifi_nvs_save(&saved); }
179  static void restore_nvs(void) { wifi_nvs_restore(&saved); }
180  
181  static void test_wifi_persistent_credentials(void) {
182    WHEN("WiFi.begin(ssid, pass) is called");
183  
184    WiFi.begin("test_ssid_persist", "test_pass_persist");
185    delay(100);
186    WiFi.disconnect(true);
187  
188    wifi_config_t conf;
189    esp_err_t err = esp_wifi_get_config(WIFI_IF_STA, &conf);
190    TEST_ASSERT_EQUAL_MESSAGE(ESP_OK, err,
191      "device: esp_wifi_get_config failed");
192    TEST_ASSERT_EQUAL_STRING_MESSAGE("test_ssid_persist", (const char *)conf.sta.ssid,
193      "device: SSID not persisted by WiFi.begin()");
194  
195  }
196  
197  static void test_wifi_connect_fails_without_ssid(void) {
198    GIVEN("no WiFi credentials are stored");
199  
200    save_nvs();
201  
202    Preferences preferences;
203    preferences.begin(config::wifi::NVS_NAMESPACE, false);
204    preferences.remove("sta_ssid");
205    preferences.remove("sta_pass");
206    preferences.end();
207  
208    WiFi.disconnect(true, true);
209    WiFi.eraseAP();
210    delay(100);
211  
212  #if defined(CONFIG_WIFI_SSID) && defined(CONFIG_WIFI_PASS)
213    if (strlen(CONFIG_WIFI_SSID) > 0) {
214      restore_nvs();
215      TEST_IGNORE_MESSAGE("build-flag WiFi credentials configured — skipping no-SSID failure assertion");
216    }
217  #endif
218  
219    if (networking::wifi::internal::wifi_ssid_slot[0] != '@'
220        && networking::wifi::internal::wifi_ssid_slot[0] != '\0') {
221      restore_nvs();
222      TEST_IGNORE_MESSAGE("embedded WiFi credentials configured — skipping no-SSID failure assertion");
223    }
224  
225    WHEN("wifi_connect is called");
226    networking::wifi::sta::initialize();
227    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::sta::connect(),
228      "device: wifi_connect should return false when no SSID stored");
229  
230    restore_nvs();
231  }
232  
233  static void test_wifi_saved_sta_config_roundtrip(void) {
234    WHEN("STA config is stored and read back");
235    save_nvs();
236  
237    WifiSavedConfig written = {};
238    strlcpy(written.ssid, "test-sta-ssid", sizeof(written.ssid));
239    strlcpy(written.password, "test-sta-pass", sizeof(written.password));
240  
241    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::storeConfig(&written),
242      "device: storing WiFi STA config should succeed");
243    TEST_ASSERT_TRUE_MESSAGE(written.valid,
244      "device: stored WiFi STA config should be marked valid");
245  
246    WifiSavedConfig read_back = {};
247    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::accessConfig(&read_back),
248      "device: reading WiFi STA config should succeed after store");
249    TEST_ASSERT_TRUE_MESSAGE(read_back.valid,
250      "device: read-back WiFi STA config should be marked valid");
251    TEST_ASSERT_EQUAL_STRING_MESSAGE("test-sta-ssid", read_back.ssid,
252      "device: STA SSID mismatch after config roundtrip");
253    TEST_ASSERT_EQUAL_STRING_MESSAGE("test-sta-pass", read_back.password,
254      "device: STA password mismatch after config roundtrip");
255  
256    restore_nvs();
257  }
258  
259  static void test_wifi_connect_prefers_explicit_credentials(void) {
260    GIVEN("a saved STA config exists");
261    save_nvs();
262  
263    WifiSavedConfig saved_config = {};
264    strlcpy(saved_config.ssid, "saved-ssid", sizeof(saved_config.ssid));
265    strlcpy(saved_config.password, "saved-password", sizeof(saved_config.password));
266    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::storeConfig(&saved_config),
267      "device: storing saved STA config should succeed");
268  
269    WHEN("connect is called with explicit credentials");
270    WifiConnectCommand command = {
271      .request = {
272        .ssid = "request-ssid",
273        .password = "request-password",
274        .enable_ap_fallback = false,
275      },
276      .result = {},
277    };
278  
279    networking::wifi::connect(&command);
280    delay(100);
281  
282    THEN("the explicit credentials override saved config");
283    wifi_config_t station_config = {};
284    esp_err_t err = esp_wifi_get_config(WIFI_IF_STA, &station_config);
285    TEST_ASSERT_EQUAL_MESSAGE(ESP_OK, err,
286      "device: esp_wifi_get_config should succeed after explicit connect request");
287    TEST_ASSERT_EQUAL_STRING_MESSAGE("request-ssid", (const char *)station_config.sta.ssid,
288      "device: explicit connect request should override saved SSID");
289    TEST_ASSERT_EQUAL_STRING_MESSAGE("request-password", (const char *)station_config.sta.password,
290      "device: explicit connect request should override saved password");
291  
292    WiFi.disconnect(true);
293    restore_nvs();
294  }
295  
296  static void test_wifi_connect_uses_saved_config(void) {
297    GIVEN("a saved STA config exists and no request SSID is provided");
298    save_nvs();
299  
300    WifiSavedConfig saved_config = {};
301    strlcpy(saved_config.ssid, "saved-only-ssid", sizeof(saved_config.ssid));
302    strlcpy(saved_config.password, "saved-only-password", sizeof(saved_config.password));
303    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::storeConfig(&saved_config),
304      "device: storing fallback STA config should succeed");
305  
306    WHEN("connect is called with empty request SSID");
307    WifiConnectCommand command = {
308      .request = {
309        .ssid = "",
310        .password = "",
311        .enable_ap_fallback = false,
312      },
313      .result = {},
314    };
315  
316    networking::wifi::connect(&command);
317    delay(100);
318  
319    THEN("the saved config is used");
320    wifi_config_t station_config = {};
321    esp_err_t err = esp_wifi_get_config(WIFI_IF_STA, &station_config);
322    TEST_ASSERT_EQUAL_MESSAGE(ESP_OK, err,
323      "device: esp_wifi_get_config should succeed after saved-config connect request");
324    TEST_ASSERT_EQUAL_STRING_MESSAGE("saved-only-ssid", (const char *)station_config.sta.ssid,
325      "device: saved STA config should be used when request SSID is empty");
326    TEST_ASSERT_EQUAL_STRING_MESSAGE("saved-only-password", (const char *)station_config.sta.password,
327      "device: saved STA password should be used when request SSID is empty");
328  
329    WiFi.disconnect(true);
330    restore_nvs();
331  }
332  
333  static void test_wifi_ap_fallback_on_failed_connect(void) {
334    GIVEN("AP is disabled and WiFi is disconnected");
335    save_nvs();
336  
337    networking::wifi::ap::disable();
338    WiFi.disconnect(true, true);
339    delay(100);
340  
341    WifiConnectCommand command = {
342      .request = {
343        .ssid = "ssid-that-should-not-exist",
344        .password = "definitely-not-the-right-password",
345        .enable_ap_fallback = true,
346      },
347      .result = {},
348    };
349  
350    WHEN("connect fails with fallback enabled");
351    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::connect(&command),
352      "device: WiFi connect should fail with intentionally invalid credentials");
353    TEST_ASSERT_FALSE_MESSAGE(command.result.connected,
354      "device: connect result should report disconnected after failed connect");
355  
356    THEN("AP fallback is activated");
357    TEST_ASSERT_TRUE_MESSAGE(command.result.ap_enabled_for_fallback,
358      "device: AP fallback should be enabled after failed connect when requested");
359    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::ap::isActive(),
360      "device: AP should be active after fallback-enabled failed connect");
361  
362    networking::wifi::ap::disable();
363    WiFi.disconnect(true, true);
364    restore_nvs();
365  }
366  
367  static void test_wifi_no_ap_fallback_without_request(void) {
368    GIVEN("AP is disabled and WiFi is disconnected");
369    save_nvs();
370  
371    networking::wifi::ap::disable();
372    WiFi.disconnect(true, true);
373    delay(100);
374  
375    WifiConnectCommand command = {
376      .request = {
377        .ssid = "ssid-that-should-not-exist",
378        .password = "definitely-not-the-right-password",
379        .enable_ap_fallback = false,
380      },
381      .result = {},
382    };
383  
384    WHEN("connect fails without fallback requested");
385    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::connect(&command),
386      "device: WiFi connect should fail with intentionally invalid credentials");
387    TEST_ASSERT_FALSE_MESSAGE(command.result.connected,
388      "device: connect result should report disconnected after failed connect");
389  
390    THEN("AP remains inactive");
391    TEST_ASSERT_FALSE_MESSAGE(command.result.ap_enabled_for_fallback,
392      "device: AP fallback should stay disabled when not requested");
393    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::ap::isActive(),
394      "device: AP should remain inactive after failed connect without fallback request");
395  
396    WiFi.disconnect(true, true);
397    restore_nvs();
398  }
399  
400  static void test_wifi_snapshot_ap_fallback_state(void) {
401    GIVEN("a failed connect with AP fallback enabled");
402    save_nvs();
403  
404    networking::wifi::ap::disable();
405    WiFi.disconnect(true, true);
406    delay(100);
407  
408    WifiConnectCommand command = {
409      .request = {
410        .ssid = "ssid-that-should-not-exist",
411        .password = "definitely-not-the-right-password",
412        .enable_ap_fallback = true,
413      },
414      .result = {},
415    };
416  
417    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::connect(&command),
418      "device: WiFi connect should fail before snapshot fallback verification");
419    TEST_ASSERT_TRUE_MESSAGE(command.result.ap_enabled_for_fallback,
420      "device: AP fallback should be active before snapshot verification");
421  
422    THEN("the snapshot reflects AP fallback state");
423    NetworkStatusSnapshot snapshot = {};
424    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::accessSnapshot(&snapshot),
425      "device: accessSnapshot should succeed after AP fallback activation");
426  
427    TEST_ASSERT_FALSE_MESSAGE(snapshot.connected,
428      "device: station snapshot should report disconnected after failed connect");
429    TEST_ASSERT_TRUE_MESSAGE(snapshot.ap.active,
430      "device: AP snapshot should report active after fallback activation");
431    TEST_ASSERT_EQUAL_STRING_MESSAGE(services::identity::access_hostname(), snapshot.hostname,
432      "device: station snapshot hostname should match identity hostname");
433    TEST_ASSERT_EQUAL_STRING_MESSAGE(services::identity::access_hostname(), snapshot.ap.hostname,
434      "device: AP snapshot hostname should match identity hostname");
435  
436    APConfig ap_config = {};
437    networking::wifi::ap::accessConfig(&ap_config);
438    TEST_ASSERT_EQUAL_STRING_MESSAGE(ap_config.ssid, snapshot.ap.ssid,
439      "device: AP snapshot SSID should match AP config");
440    TEST_ASSERT_EQUAL_STRING_MESSAGE(ap_config.password, snapshot.ap.password,
441      "device: AP snapshot password should match AP config");
442    TEST_ASSERT_TRUE_MESSAGE(snapshot.ap.ip[0] != '\0',
443      "device: AP snapshot IP should be populated when AP is active");
444    TEST_ASSERT_TRUE_MESSAGE(snapshot.ap.mac[0] != '\0',
445      "device: AP snapshot MAC should be populated when AP is active");
446  
447    networking::wifi::ap::disable();
448    WiFi.disconnect(true, true);
449    restore_nvs();
450  }
451  
452  static void test_wifi_snapshot_rejects_null(void) {
453    THEN("null snapshot buffer is rejected");
454    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::accessSnapshot(nullptr),
455      "device: accessSnapshot should reject null output buffer");
456  }
457  
458  static void test_wifi_config_rejects_null(void) {
459    THEN("null config buffer is rejected");
460    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::accessConfig(nullptr),
461      "device: accessConfig should reject null output buffer");
462  }
463  
464  static void test_wifi_store_config_rejects_null(void) {
465    THEN("null config input is rejected");
466    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::storeConfig(nullptr),
467      "device: storeConfig should reject null config buffer");
468  }
469  
470  static void test_wifi_connect_rejects_null(void) {
471    THEN("null connect command is rejected");
472    TEST_ASSERT_FALSE_MESSAGE(networking::wifi::connect(nullptr),
473      "device: connect should reject null command buffer");
474  }
475  
476  static void test_wifi_snapshot_ap_inactive(void) {
477    GIVEN("the AP is disabled");
478    save_nvs();
479  
480    networking::wifi::ap::disable();
481    WiFi.disconnect(true, true);
482    delay(100);
483  
484    THEN("the snapshot reports AP inactive");
485    NetworkStatusSnapshot snapshot = {};
486    TEST_ASSERT_TRUE_MESSAGE(networking::wifi::accessSnapshot(&snapshot),
487      "device: accessSnapshot should succeed when AP is disabled");
488    TEST_ASSERT_FALSE_MESSAGE(snapshot.ap.active,
489      "device: AP snapshot should report inactive when AP is disabled");
490    TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, snapshot.ap.clients,
491      "device: AP snapshot should report zero clients when AP is disabled");
492  
493    restore_nvs();
494  }
495  
496  static void test_wifi_ap_config_roundtrip(void) {
497    WHEN("AP config is stored and read back");
498    save_nvs();
499  
500    APConfigureCommand command = {
501      .config = {},
502      .snapshot = {},
503    };
504    strlcpy(command.config.ssid, "my-custom-ap", sizeof(command.config.ssid));
505    strlcpy(command.config.password, "secret123", sizeof(command.config.password));
506    networking::wifi::ap::applyConfig(&command);
507  
508    APConfig cfg = {};
509    networking::wifi::ap::accessConfig(&cfg);
510  
511    TEST_ASSERT_EQUAL_STRING_MESSAGE("my-custom-ap", cfg.ssid,
512      "device: AP SSID mismatch after roundtrip");
513    TEST_ASSERT_EQUAL_STRING_MESSAGE("secret123", cfg.password,
514      "device: AP password mismatch after roundtrip");
515  
516    restore_nvs();
517  }
518  
519  static void test_wifi_ap_default_ssid(void) {
520    GIVEN("the AP SSID key is removed from NVS");
521    save_nvs();
522  
523    Preferences preferences;
524    preferences.begin(config::wifi::NVS_NAMESPACE, false);
525    preferences.remove("ap_ssid");
526    preferences.end();
527  
528    THEN("the AP SSID defaults to the configured value");
529    APConfig cfg = {};
530    networking::wifi::ap::accessConfig(&cfg);
531  
532    TEST_ASSERT_EQUAL_STRING_MESSAGE(config::wifi::ap::SSID, cfg.ssid,
533      "device: AP SSID should default to config::wifi::ap::SSID");
534  
535    restore_nvs();
536  }
537  
538  static void test_wifi_ap_enabled_default_true(void) {
539    GIVEN("the ap_on key is removed from NVS");
540    save_nvs();
541  
542    Preferences preferences;
543    preferences.begin(config::wifi::NVS_NAMESPACE, false);
544    preferences.remove("ap_on");
545    preferences.end();
546  
547      THEN("AP is enabled by default");
548      Preferences prefs;
549      bool opened = networking::wifi::internal::openPreferences(true, &prefs);
550      bool enabled = opened ? prefs.getBool("ap_on", true) : true;
551      if (opened) prefs.end();
552      TEST_ASSERT_TRUE_MESSAGE(enabled,
553        "device: AP should be enabled by default");
554  
555    restore_nvs();
556  }
557  
558  static void test_wifi_ap_enabled_toggle(void) {
559    WHEN("the AP enabled flag is toggled");
560    save_nvs();
561  
562    Preferences preferences;
563    preferences.begin(config::wifi::NVS_NAMESPACE, false);
564    preferences.putBool("ap_on", false);
565    preferences.end();
566  
567      {
568        Preferences prefs;
569        TEST_ASSERT_TRUE_MESSAGE(networking::wifi::internal::openPreferences(true, &prefs),
570          "device: wifi NVS namespace must be readable");
571        TEST_ASSERT_FALSE_MESSAGE(prefs.getBool("ap_on", true),
572          "device: AP should be disabled after setting false");
573        prefs.end();
574      }
575  
576    preferences.begin(config::wifi::NVS_NAMESPACE, false);
577    preferences.putBool("ap_on", true);
578    preferences.end();
579  
580      {
581        Preferences prefs;
582        TEST_ASSERT_TRUE_MESSAGE(networking::wifi::internal::openPreferences(true, &prefs),
583          "device: wifi NVS namespace must be readable");
584        TEST_ASSERT_TRUE_MESSAGE(prefs.getBool("ap_on", true),
585          "device: AP should be enabled after setting true");
586        prefs.end();
587      }
588  
589    restore_nvs();
590  }
591  
592  void networking::wifi::test(void) {
593    MODULE("WiFi");
594    RUN_TEST(test_wifi_snapshot_rejects_null);
595    RUN_TEST(test_wifi_config_rejects_null);
596    RUN_TEST(test_wifi_store_config_rejects_null);
597    RUN_TEST(test_wifi_connect_rejects_null);
598    RUN_TEST(test_wifi_saved_sta_config_roundtrip);
599    RUN_TEST(test_wifi_connect_prefers_explicit_credentials);
600    RUN_TEST(test_wifi_connect_uses_saved_config);
601    RUN_TEST(test_wifi_ap_fallback_on_failed_connect);
602    RUN_TEST(test_wifi_no_ap_fallback_without_request);
603    RUN_TEST(test_wifi_snapshot_ap_fallback_state);
604    RUN_TEST(test_wifi_snapshot_ap_inactive);
605    RUN_TEST(test_wifi_connect_fails_without_ssid);
606    RUN_TEST(test_wifi_ap_config_roundtrip);
607    RUN_TEST(test_wifi_ap_default_ssid);
608    RUN_TEST(test_wifi_ap_enabled_default_true);
609    RUN_TEST(test_wifi_ap_enabled_toggle);
610  }
611  
612  #endif