From c5f3595173d13ea7404f4062d5a9fe71f6edaa06 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sun, 23 Aug 2026 14:57:53 +0200 Subject: [PATCH] Add native Wi-Fi management and persistence --- README.md | 64 +- sdkconfig.defaults | 5 + src/CMakeLists.txt | 8 + src/main.c | 54 +- src/wifi_config.c | 439 ++++++++++++++ src/wifi_config.h | 113 ++++ src/wifi_console.c | 670 +++++++++++++++++++++ src/wifi_console.h | 8 + src/wifi_manager.c | 1407 ++++++++++++++++++++++++++++++++++++++++++++ src/wifi_manager.h | 102 ++++ wiring.md | 1 + 11 files changed, 2869 insertions(+), 2 deletions(-) create mode 100644 src/wifi_config.c create mode 100644 src/wifi_config.h create mode 100644 src/wifi_console.c create mode 100644 src/wifi_console.h create mode 100644 src/wifi_manager.c create mode 100644 src/wifi_manager.h diff --git a/README.md b/README.md index 466f740..3e7409d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Universal wireless serial adaptor firmware for the ESP32-S3. - 8 MB octal PSRAM - Adafruit MAX3243 full-pinout RS-232 breakout, product 5988 -The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, and the **Phase 2 transport-neutral session broker**. The current phase adds the first real broker transport: native USB CDC-ACM on the ESP32-S3 USB port. The MAX3243 diagnostics and persistent serial configuration remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a host opens native USB CDC. +The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, the **Phase 2 transport-neutral session broker**, and the first real broker transport through native USB CDC-ACM. The current phase adds persistent multi-profile Wi-Fi station configuration and managed AP fallback/AP+STA operation. The MAX3243 diagnostics and recovery consoles remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a host opens native USB CDC. ## Hardware wiring @@ -188,6 +188,68 @@ Close the terminal and check `usb status` and `broker clients`; DTR-aware applic Power down and remove the DE-9 pin 3-to-2 jumper before connecting an external serial peer. +### Wi-Fi foundation + +Wi-Fi is managed independently of the serial-session broker in this phase. It provides network connectivity and recovery access-point policy for the later HTTPS/WebSocket transport, but it does not yet run HTTP, HTTPS, DNS interception, a captive portal, NAPT, or any TCP serial listener. + +Configuration uses four fixed station-profile slots. Lower numeric priority values are tried first, with slot number breaking ties. Profiles support WPA2/WPA3 mixed operation or require WPA3-SAE. ESP-IDF's station threshold can express “WPA2 or stronger” but not a strict WPA2-only maximum, so the configuration does not pretend to offer a distinct WPA2-only mode. Each profile attempt has a 12-second association/DHCP deadline. After all enabled profiles fail, the manager uses exponential retry delays from 2 to 60 seconds. + +AP policy is independent of the station profiles: + +| Policy | Behavior | +|---|---| +| `off` | Station only; never start the fallback AP | +| `fallback` | Start the AP immediately when no profiles exist, or after one failed profile cycle; disable it after station connectivity has remained stable for 30 seconds | +| `always` | Keep AP and station active concurrently | + +Fresh defaults enable Wi-Fi with `fallback` policy, AP channel 6, a MAC-suffixed SSID such as `ESP32-SAK-A1B2C3`, and a randomly generated 16-character password. The initial random credential is saved to NVS automatically when possible so it remains stable across reboot. Retrieve it deliberately from the physical UART0 administration console with `wifi ap show-secret`. + +The `wifi` command provides: + +```text +wifi status +wifi profiles +wifi start|stop|reconnect +wifi profile set +wifi profile secret +wifi profile enable|disable|delete +wifi ap policy +wifi ap ssid +wifi ap channel <1..11> +wifi ap secret|show-secret +wifi save|load|defaults|reset +wifi counters|clear-counters +``` + +Ordinary status and profile output never displays passwords. `wifi profile secret` and `wifi ap secret` read through a dedicated no-echo UART0 prompt, keeping credentials out of the command line and its history. SSIDs containing spaces can be quoted. Profile and AP edits apply to the working RAM configuration and restart Wi-Fi asynchronously if it is running; use `wifi save` explicitly to persist them. `wifi start` and `wifi stop` also change the working `enabled-at-boot` setting, which becomes persistent only after `wifi save`. + +A typical station setup is: + +```text +wifi profile set 0 10 mixed "your SSID" +wifi profile secret 0 +wifi profile enable 0 +wifi save +wifi reconnect +wifi status +``` + +The fallback AP uses Espressif's default `192.168.4.1/24` network for now. AP clients receive addresses through its DHCP server but are not routed to the station network. ESP32-S3 has one 2.4 GHz radio, so in AP+STA mode the AP follows the connected station's channel. Station connection attempts and scans can temporarily increase AP latency, and clients can briefly reconnect when the channel moves. + +Wi-Fi credentials currently reside as plaintext in the application-owned `wifi_app/config` NVS blob. Selecting `WIFI_STORAGE_RAM` prevents the ESP-IDF driver from creating a second persistent credential copy, but it does not encrypt the application's blob. The reserved `nvs_key` partition alone does not enable encryption. NVS encryption, secure boot, flash encryption, and core-dump credential exposure require a deliberate later security phase. + +#### Wi-Fi validation + +1. Boot with no station profiles. `wifi status` should report `ap-only`, and the generated SSID should be visible from another device. +2. Use `wifi ap show-secret`, join the AP, confirm a `192.168.4.x` lease, and ping `192.168.4.1`. No web page is expected yet. +3. Configure and enable a WPA2/WPA3 station profile using the example above. `wifi status` should progress through `connecting`, `waiting-ip`, and `online` and display the acquired address, channel, RSSI, and negotiated authentication. +4. Reboot and verify profile and AP credential persistence. +5. Configure two profiles with different priorities, make the first unavailable, and verify failover to the second after its timeout. +6. Make all profiles unavailable and verify fallback AP startup plus increasing retry delays in `wifi status`/`wifi counters`. +7. Test `wifi ap policy always` while online and confirm both interfaces remain available; expect the AP channel to follow the station. +8. Test `wifi stop`, `wifi start`, and `wifi reconnect` while confirming UART0 and native USB serial operation remain unaffected. +9. If available, test a WPA3-only profile and a wrong password, then inspect the disconnect reason and counters. + ### Phase 0 diagnostics The retained hardware-characterization commands are: diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f06f9ba..ccf5413 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -13,5 +13,10 @@ CONFIG_TINYUSB_CDC_RX_BUFSIZE=1024 CONFIG_TINYUSB_CDC_TX_BUFSIZE=1024 CONFIG_TINYUSB_CDC_EP_BUFSIZE=512 +# Support WPA3-SAE for station profiles and the WPA2/WPA3 fallback AP. +CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y +CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y +CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT=y + # Keep diagnostic and interactive-console logging concise but useful. CONFIG_LOG_DEFAULT_LEVEL_INFO=y diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a6ddee8..5dd4e4d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,15 +11,23 @@ idf_component_register( "session_console.c" "usb_cdc_transport.c" "usb_console.c" + "wifi_config.c" + "wifi_manager.c" + "wifi_console.c" INCLUDE_DIRS "." REQUIRES + bootloader_support console esp_driver_gpio esp_driver_uart + esp_event + esp_netif esp_psram esp_timer esp_tinyusb + esp_wifi freertos led_strip + mbedtls nvs_flash ) diff --git a/src/main.c b/src/main.c index 2c746ab..172895e 100644 --- a/src/main.c +++ b/src/main.c @@ -13,6 +13,9 @@ #include "status_led.h" #include "usb_cdc_transport.h" #include "usb_console.h" +#include "wifi_config.h" +#include "wifi_console.h" +#include "wifi_manager.h" #define CONSOLE_BAUD_RATE 115200 #define CONSOLE_TX_GPIO 43 @@ -22,7 +25,7 @@ static const char *TAG = "firmware"; void app_main(void) { - ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife native USB CDC phase started"); + ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife Wi-Fi foundation phase started"); if (esp_psram_is_initialized()) { ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size()); @@ -30,6 +33,9 @@ void app_main(void) ESP_LOGW(TAG, "PSRAM is not initialized"); } + /* Seed credential generation before any future RF or ADC initialization. */ + esp_err_t wifi_entropy_error = wifi_config_entropy_init(); + /* Blue means the firmware is initialized and waiting for a console command. */ ESP_ERROR_CHECK(status_led_init()); ESP_ERROR_CHECK(rs232_port_owner_init()); @@ -49,6 +55,51 @@ void app_main(void) ESP_ERROR_CHECK(session_broker_init()); /* Native USB owns GPIO19/20; UART0 logging stays on the USB-to-UART bridge. */ ESP_ERROR_CHECK(usb_cdc_transport_init()); + + wifi_app_config_t wifi_config; + wifi_config_load_source_t wifi_config_source; + esp_err_t wifi_config_error = wifi_entropy_error; + if (wifi_config_error == ESP_OK) { + wifi_config_error = wifi_config_load(&wifi_config, &wifi_config_source); + } + if (wifi_config_error != ESP_OK) { + ESP_LOGW(TAG, + "NVS Wi-Fi configuration unavailable (%s); trying generated RAM defaults", + esp_err_to_name(wifi_config_error)); + wifi_config_error = wifi_config_defaults(&wifi_config); + } else if (wifi_config_source == WIFI_CONFIG_LOAD_GENERATED_MISSING) { + /* Persist the random per-device fallback-AP credential on true first boot. */ + esp_err_t save_error = wifi_config_save(&wifi_config); + if (save_error != ESP_OK) { + ESP_LOGW(TAG, "Could not persist initial Wi-Fi defaults: %s", + esp_err_to_name(save_error)); + } + } else if (wifi_config_source == WIFI_CONFIG_LOAD_GENERATED_INVALID) { + ESP_LOGW(TAG, + "Stored Wi-Fi configuration is incompatible; using RAM defaults without overwriting it"); + } + + if (wifi_config_error == ESP_OK) { + esp_err_t wifi_error = wifi_manager_init(&wifi_config); + if (wifi_error == ESP_OK && wifi_config.enabled_at_boot != 0U) { + wifi_error = wifi_manager_start(); + } + if (wifi_error != ESP_OK) { + /* UART0 and native USB remain recovery paths if networking is unavailable. */ + ESP_LOGE(TAG, "Wi-Fi manager unavailable: %s", esp_err_to_name(wifi_error)); + } else { + ESP_LOGI(TAG, "Using %s Wi-Fi configuration; AP policy=%s", + wifi_config_source == WIFI_CONFIG_LOAD_STORED + ? "stored" + : "generated default", + wifi_config_ap_policy_to_string(wifi_config.ap_policy)); + } + } else { + ESP_LOGE(TAG, "Could not create a valid Wi-Fi configuration: %s", + esp_err_to_name(wifi_config_error)); + } + wifi_config_secure_wipe(&wifi_config, sizeof(wifi_config)); + ESP_LOGI( TAG, "Using %s serial configuration; UART service starts on 'serial start' or native USB open", @@ -77,6 +128,7 @@ void app_main(void) ESP_ERROR_CHECK(serial_console_register_commands()); ESP_ERROR_CHECK(session_console_register_commands()); ESP_ERROR_CHECK(usb_console_register_commands()); + ESP_ERROR_CHECK(wifi_console_register_commands()); ESP_ERROR_CHECK(esp_console_start_repl(repl)); ESP_LOGI(TAG, "Interactive test console ready at %d baud", CONSOLE_BAUD_RATE); diff --git a/src/wifi_config.c b/src/wifi_config.c new file mode 100644 index 0000000..6ac1de2 --- /dev/null +++ b/src/wifi_config.c @@ -0,0 +1,439 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Native ESP-IDF Wi-Fi configuration and non-destructive NVS persistence. */ + +#include "wifi_config.h" + +#include +#include +#include + +#include "bootloader_random.h" +#include "esp_mac.h" +#include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "mbedtls/ctr_drbg.h" +#include "nvs.h" +#include "nvs_flash.h" + +_Static_assert(sizeof(wifi_config_sta_profile_t) == WIFI_CONFIG_STA_PROFILE_BLOB_SIZE, + "Wi-Fi STA profile schema size changed"); +_Static_assert(offsetof(wifi_app_config_t, profiles) == 112U, + "Wi-Fi config schema offsets changed"); +_Static_assert(sizeof(wifi_app_config_t) == WIFI_CONFIG_BLOB_SIZE, + "Wi-Fi config schema size changed"); + +static SemaphoreHandle_t s_drbg_mutex; +static mbedtls_ctr_drbg_context s_drbg; +static bool s_drbg_ready; + +static int early_entropy_source(void *context, unsigned char *output, size_t length) +{ + (void)context; + /* This callback is used only during the explicit pre-radio initialization. */ + bootloader_random_enable(); + esp_fill_random(output, length); + bootloader_random_disable(); + return 0; +} + +esp_err_t wifi_config_entropy_init(void) +{ + static const unsigned char personalization[] = "esp32-serial-sak-wifi"; + + if (s_drbg_ready) { + return ESP_OK; + } + if (s_drbg_mutex == NULL) { + s_drbg_mutex = xSemaphoreCreateMutex(); + if (s_drbg_mutex == NULL) { + return ESP_ERR_NO_MEM; + } + } + + xSemaphoreTake(s_drbg_mutex, portMAX_DELAY); + esp_err_t error = ESP_OK; + if (!s_drbg_ready) { + mbedtls_ctr_drbg_init(&s_drbg); + int result = mbedtls_ctr_drbg_seed(&s_drbg, + early_entropy_source, + NULL, + personalization, + sizeof(personalization) - 1U); + if (result == 0) { + /* Credential generation is rare; never invoke early entropy after RF starts. */ + mbedtls_ctr_drbg_set_reseed_interval(&s_drbg, INT_MAX); + s_drbg_ready = true; + } else { + mbedtls_ctr_drbg_free(&s_drbg); + error = ESP_FAIL; + } + } + xSemaphoreGive(s_drbg_mutex); + return error; +} + +static esp_err_t fill_credential_random(uint8_t *output, size_t length) +{ + if (!s_drbg_ready || output == NULL) { + return ESP_ERR_INVALID_STATE; + } + + xSemaphoreTake(s_drbg_mutex, portMAX_DELAY); + int result = mbedtls_ctr_drbg_random(&s_drbg, output, length); + xSemaphoreGive(s_drbg_mutex); + return result == 0 ? ESP_OK : ESP_FAIL; +} + +static bool bytes_are_zero(const uint8_t *data, size_t size) +{ + for (size_t i = 0; i < size; ++i) { + if (data[i] != 0U) { + return false; + } + } + return true; +} + +static bool unused_bytes_are_zero(const uint8_t *data, size_t used, size_t capacity) +{ + return used <= capacity && bytes_are_zero(data + used, capacity - used); +} + +static bool psk_is_valid(const uint8_t *psk, uint8_t length) +{ + if (length < WIFI_CONFIG_PSK_MIN_LEN || length > WIFI_CONFIG_PSK_MAX_LEN) { + return false; + } + + /* ESP-IDF accepts WPA passphrases as 8..63 printable ASCII characters. */ + for (uint8_t i = 0; i < length; ++i) { + if (psk[i] < 0x20U || psk[i] > 0x7eU) { + return false; + } + } + return unused_bytes_are_zero(psk, length, WIFI_CONFIG_PSK_MAX_LEN); +} + +static bool ap_policy_is_valid(wifi_config_ap_policy_t policy) +{ + return policy == WIFI_CONFIG_AP_POLICY_OFF || + policy == WIFI_CONFIG_AP_POLICY_FALLBACK || + policy == WIFI_CONFIG_AP_POLICY_ALWAYS; +} + +static bool security_is_valid(wifi_config_security_t security) +{ + return security == WIFI_CONFIG_SECURITY_MIXED || + security == WIFI_CONFIG_SECURITY_WPA3; +} + +void wifi_config_secure_wipe(void *data, size_t size) +{ + volatile uint8_t *byte = (volatile uint8_t *)data; + + if (byte == NULL) { + return; + } + while (size-- > 0U) { + *byte++ = 0U; + } +} + +esp_err_t wifi_config_defaults(wifi_app_config_t *config) +{ + static const uint8_t ap_prefix[] = "ESP32-SAK-"; + static const char password_alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + static const char hex[] = "0123456789ABCDEF"; + uint8_t mac[6] = {0}; + uint8_t random_bytes[WIFI_CONFIG_DEFAULT_AP_PSK_LEN] = {0}; + + if (config == NULL) { + return ESP_ERR_INVALID_ARG; + } + + /* A zero baseline makes all reserved and unused bytes canonical. */ + memset(config, 0, sizeof(*config)); + config->schema_version = WIFI_CONFIG_SCHEMA_VERSION; + config->blob_size = WIFI_CONFIG_BLOB_SIZE; + config->enabled_at_boot = 1U; + config->ap_policy = WIFI_CONFIG_AP_POLICY_FALLBACK; + /* Channel 6 is a conventional world-safe default until country settings exist. */ + config->ap_channel = 6U; + + esp_err_t err = esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP); + if (err != ESP_OK) { + return err; + } + + memcpy(config->ap_ssid, ap_prefix, sizeof(ap_prefix) - 1U); + size_t offset = sizeof(ap_prefix) - 1U; + for (size_t i = 3U; i < sizeof(mac); ++i) { + config->ap_ssid[offset++] = (uint8_t)hex[mac[i] >> 4U]; + config->ap_ssid[offset++] = (uint8_t)hex[mac[i] & 0x0fU]; + } + config->ap_ssid_len = (uint8_t)offset; + + /* + * A 64-character alphabet maps six random bits without modulo bias. The + * password is intentionally independent of the public MAC-derived suffix. + */ + err = fill_credential_random(random_bytes, sizeof(random_bytes)); + if (err != ESP_OK) { + wifi_config_secure_wipe(random_bytes, sizeof(random_bytes)); + return err; + } + for (size_t i = 0; i < sizeof(random_bytes); ++i) { + config->ap_psk[i] = (uint8_t)password_alphabet[random_bytes[i] & 0x3fU]; + } + config->ap_psk_len = WIFI_CONFIG_DEFAULT_AP_PSK_LEN; + wifi_config_secure_wipe(random_bytes, sizeof(random_bytes)); + + return wifi_config_validate(config); +} + +esp_err_t wifi_config_validate(const wifi_app_config_t *config) +{ + if (config == NULL || + config->schema_version != WIFI_CONFIG_SCHEMA_VERSION || + config->blob_size != WIFI_CONFIG_BLOB_SIZE || + config->enabled_at_boot > 1U || + !ap_policy_is_valid(config->ap_policy) || + config->ap_ssid_len == 0U || + config->ap_ssid_len > WIFI_CONFIG_SSID_MAX_LEN || + !unused_bytes_are_zero(config->ap_ssid, config->ap_ssid_len, + WIFI_CONFIG_SSID_MAX_LEN) || + !psk_is_valid(config->ap_psk, config->ap_psk_len) || + config->ap_channel < WIFI_CONFIG_AP_CHANNEL_MIN || + config->ap_channel > WIFI_CONFIG_AP_CHANNEL_MAX || + !bytes_are_zero(config->reserved, sizeof(config->reserved)) || + !bytes_are_zero(config->reserved_tail, sizeof(config->reserved_tail))) { + return ESP_ERR_INVALID_ARG; + } + + for (size_t i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) { + const wifi_config_sta_profile_t *profile = &config->profiles[i]; + if (profile->enabled > 1U || + !security_is_valid(profile->security) || + !bytes_are_zero(profile->reserved, sizeof(profile->reserved)) || + !bytes_are_zero(profile->reserved_tail, sizeof(profile->reserved_tail))) { + return ESP_ERR_INVALID_ARG; + } + + bool empty = profile->ssid_len == 0U && profile->psk_len == 0U; + if (empty) { + if (profile->enabled != 0U || + !bytes_are_zero(profile->ssid, sizeof(profile->ssid)) || + !bytes_are_zero(profile->psk, sizeof(profile->psk))) { + return ESP_ERR_INVALID_ARG; + } + continue; + } + + /* A disabled SSID may be staged before its no-echo secret is entered. */ + if (profile->ssid_len == 0U || + profile->ssid_len > WIFI_CONFIG_SSID_MAX_LEN || + !unused_bytes_are_zero(profile->ssid, profile->ssid_len, + WIFI_CONFIG_SSID_MAX_LEN)) { + return ESP_ERR_INVALID_ARG; + } + if (profile->psk_len == 0U) { + if (profile->enabled != 0U || + !bytes_are_zero(profile->psk, sizeof(profile->psk))) { + return ESP_ERR_INVALID_ARG; + } + } else if (!psk_is_valid(profile->psk, profile->psk_len)) { + return ESP_ERR_INVALID_ARG; + } + } + + return ESP_OK; +} + +bool wifi_config_parse_ap_policy(const char *text, wifi_config_ap_policy_t *value) +{ + if (text == NULL || value == NULL) { + return false; + } + if (strcmp(text, "off") == 0) { + *value = WIFI_CONFIG_AP_POLICY_OFF; + return true; + } + if (strcmp(text, "fallback") == 0) { + *value = WIFI_CONFIG_AP_POLICY_FALLBACK; + return true; + } + if (strcmp(text, "always") == 0) { + *value = WIFI_CONFIG_AP_POLICY_ALWAYS; + return true; + } + return false; +} + +const char *wifi_config_ap_policy_to_string(wifi_config_ap_policy_t value) +{ + switch (value) { + case WIFI_CONFIG_AP_POLICY_OFF: + return "off"; + case WIFI_CONFIG_AP_POLICY_FALLBACK: + return "fallback"; + case WIFI_CONFIG_AP_POLICY_ALWAYS: + return "always"; + default: + return NULL; + } +} + +bool wifi_config_parse_security(const char *text, wifi_config_security_t *value) +{ + if (text == NULL || value == NULL) { + return false; + } + if (strcmp(text, "mixed") == 0) { + *value = WIFI_CONFIG_SECURITY_MIXED; + return true; + } + if (strcmp(text, "wpa3") == 0) { + *value = WIFI_CONFIG_SECURITY_WPA3; + return true; + } + return false; +} + +const char *wifi_config_security_to_string(wifi_config_security_t value) +{ + switch (value) { + case WIFI_CONFIG_SECURITY_MIXED: + return "mixed"; + case WIFI_CONFIG_SECURITY_WPA3: + return "wpa3"; + default: + return NULL; + } +} + +esp_err_t wifi_config_storage_init(void) +{ + /* Never erase shared NVS to recover an error; report it to the caller. */ + return nvs_flash_init(); +} + +esp_err_t wifi_config_load(wifi_app_config_t *config, + wifi_config_load_source_t *source) +{ + if (config == NULL || source == NULL) { + return ESP_ERR_INVALID_ARG; + } + + *source = WIFI_CONFIG_LOAD_GENERATED_MISSING; + + /* Defaults are established before any NVS access, including failure paths. */ + esp_err_t err = wifi_config_defaults(config); + if (err != ESP_OK) { + return err; + } + + err = wifi_config_storage_init(); + if (err != ESP_OK) { + return err; + } + + nvs_handle_t handle; + err = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return ESP_OK; + } + if (err != ESP_OK) { + return err; + } + + size_t stored_size = 0U; + err = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, NULL, &stored_size); + if (err == ESP_ERR_NVS_NOT_FOUND) { + nvs_close(handle); + return ESP_OK; + } + if (err == ESP_ERR_NVS_TYPE_MISMATCH) { + *source = WIFI_CONFIG_LOAD_GENERATED_INVALID; + nvs_close(handle); + return ESP_OK; + } + if (err != ESP_OK) { + nvs_close(handle); + return err; + } + + /* A different size is a different schema, even if its prefix looks valid. */ + if (stored_size != WIFI_CONFIG_BLOB_SIZE) { + *source = WIFI_CONFIG_LOAD_GENERATED_INVALID; + nvs_close(handle); + return ESP_OK; + } + + wifi_app_config_t stored_config; + memset(&stored_config, 0, sizeof(stored_config)); + err = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, &stored_config, &stored_size); + nvs_close(handle); + if (err == ESP_ERR_NVS_INVALID_LENGTH) { + *source = WIFI_CONFIG_LOAD_GENERATED_INVALID; + wifi_config_secure_wipe(&stored_config, sizeof(stored_config)); + return ESP_OK; + } + if (err != ESP_OK) { + wifi_config_secure_wipe(&stored_config, sizeof(stored_config)); + return err; + } + + if (stored_size == WIFI_CONFIG_BLOB_SIZE && + wifi_config_validate(&stored_config) == ESP_OK) { + *config = stored_config; + *source = WIFI_CONFIG_LOAD_STORED; + } else { + *source = WIFI_CONFIG_LOAD_GENERATED_INVALID; + } + wifi_config_secure_wipe(&stored_config, sizeof(stored_config)); + return ESP_OK; +} + +esp_err_t wifi_config_save(const wifi_app_config_t *config) +{ + esp_err_t err = wifi_config_validate(config); + if (err != ESP_OK) { + return err; + } + + err = wifi_config_storage_init(); + if (err != ESP_OK) { + return err; + } + + nvs_handle_t handle; + err = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + return err; + } + + err = nvs_set_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, config, + WIFI_CONFIG_BLOB_SIZE); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + return err; +} + +esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults) +{ + if (defaults != NULL) { + return wifi_config_save(defaults); + } + + wifi_app_config_t generated_defaults; + esp_err_t err = wifi_config_defaults(&generated_defaults); + if (err == ESP_OK) { + err = wifi_config_save(&generated_defaults); + } + wifi_config_secure_wipe(&generated_defaults, sizeof(generated_defaults)); + return err; +} diff --git a/src/wifi_config.h b/src/wifi_config.h new file mode 100644 index 0000000..819cd35 --- /dev/null +++ b/src/wifi_config.h @@ -0,0 +1,113 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* + * Native ESP-IDF Wi-Fi configuration and persistence. + * + * The public structures below are also the version-1 NVS wire format. Keep + * every field fixed-width and introduce a new schema version for layout + * changes; do not silently reinterpret an existing blob. + */ + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#define WIFI_CONFIG_SCHEMA_VERSION 1U +#define WIFI_CONFIG_BLOB_SIZE 528U +#define WIFI_CONFIG_STA_PROFILE_BLOB_SIZE 104U + +#define WIFI_CONFIG_NVS_NAMESPACE "wifi_app" +#define WIFI_CONFIG_NVS_BLOB_KEY "config" + +#define WIFI_CONFIG_STA_PROFILE_COUNT 4U +#define WIFI_CONFIG_SSID_MAX_LEN 32U +#define WIFI_CONFIG_PSK_MIN_LEN 8U +#define WIFI_CONFIG_PSK_MAX_LEN 63U +#define WIFI_CONFIG_DEFAULT_AP_PSK_LEN 16U +#define WIFI_CONFIG_AP_CHANNEL_MIN 1U +#define WIFI_CONFIG_AP_CHANNEL_MAX 11U + +/* uint8_t aliases keep the persisted representation independent of enum size. */ +typedef uint8_t wifi_config_ap_policy_t; +enum { + WIFI_CONFIG_AP_POLICY_OFF = 0U, + WIFI_CONFIG_AP_POLICY_FALLBACK = 1U, + WIFI_CONFIG_AP_POLICY_ALWAYS = 2U, +}; + +typedef uint8_t wifi_config_security_t; +enum { + /* ESP-IDF thresholds can express WPA2-or-stronger, not WPA2-only. */ + WIFI_CONFIG_SECURITY_MIXED = 0U, + WIFI_CONFIG_SECURITY_WPA3 = 1U, +}; + +typedef uint8_t wifi_config_load_source_t; +enum { + WIFI_CONFIG_LOAD_GENERATED_MISSING = 0U, + WIFI_CONFIG_LOAD_STORED = 1U, + WIFI_CONFIG_LOAD_GENERATED_INVALID = 2U, +}; + +typedef struct { + /* Persisted booleans use 0/1 bytes so the blob layout remains fixed. */ + uint8_t enabled; + uint8_t priority; + wifi_config_security_t security; + uint8_t ssid_len; + uint8_t psk_len; + uint8_t reserved[3]; + + /* Lengths are authoritative; these fixed-width byte arrays need no NUL. */ + uint8_t ssid[WIFI_CONFIG_SSID_MAX_LEN]; + uint8_t psk[WIFI_CONFIG_PSK_MAX_LEN]; + uint8_t reserved_tail[1]; +} wifi_config_sta_profile_t; + +typedef struct { + uint32_t schema_version; + uint16_t blob_size; + uint8_t enabled_at_boot; + wifi_config_ap_policy_t ap_policy; + uint8_t ap_ssid_len; + uint8_t ap_psk_len; + uint8_t ap_channel; + uint8_t reserved[5]; + + uint8_t ap_ssid[WIFI_CONFIG_SSID_MAX_LEN]; + uint8_t ap_psk[WIFI_CONFIG_PSK_MAX_LEN]; + uint8_t reserved_tail[1]; + + wifi_config_sta_profile_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT]; +} wifi_app_config_t; + +/* Seed the credential DRBG before Wi-Fi, Bluetooth, or ADC use. */ +esp_err_t wifi_config_entropy_init(void); + +/* Generates a unique AP SSID and a fresh random AP password. */ +esp_err_t wifi_config_defaults(wifi_app_config_t *config); +esp_err_t wifi_config_validate(const wifi_app_config_t *config); + +bool wifi_config_parse_ap_policy(const char *text, wifi_config_ap_policy_t *value); +const char *wifi_config_ap_policy_to_string(wifi_config_ap_policy_t value); +bool wifi_config_parse_security(const char *text, wifi_config_security_t *value); +const char *wifi_config_security_to_string(wifi_config_security_t value); + +/* + * Storage uses only wifi_app/config. Invalid, stale, or absent blobs are left + * untouched. source distinguishes first boot from incompatible stored data so + * callers never overwrite a potentially migratable schema automatically. + */ +esp_err_t wifi_config_storage_init(void); +esp_err_t wifi_config_load(wifi_app_config_t *config, + wifi_config_load_source_t *source); +esp_err_t wifi_config_save(const wifi_app_config_t *config); + +/* Pass NULL to generate fresh defaults, or supply validated defaults to save. */ +esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults); + +/* Volatile writes prevent ordinary optimization from eliding secret cleanup. */ +void wifi_config_secure_wipe(void *data, size_t size); diff --git a/src/wifi_console.c b/src/wifi_console.c new file mode 100644 index 0000000..b954bc7 --- /dev/null +++ b/src/wifi_console.c @@ -0,0 +1,670 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Physical-admin UART0 console for Wi-Fi configuration and diagnostics. */ + +#include "wifi_console.h" + +#include +#include +#include +#include +#include + +#include "driver/uart.h" +#include "esp_console.h" +#include "esp_err.h" +#include "esp_netif_ip_addr.h" +#include "esp_wifi_types.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "wifi_config.h" +#include "wifi_manager.h" + +#define WIFI_CONSOLE_UART UART_NUM_0 +#define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN + +static void print_usage(void) +{ + printf("Usage:\n"); + printf(" wifi status|profiles|counters|clear-counters\n"); + printf(" wifi start|stop|reconnect\n"); + printf(" wifi profile set \n"); + printf(" wifi profile secret \n"); + printf(" wifi profile enable|disable|delete \n"); + printf(" wifi ap policy \n"); + printf(" wifi ap ssid \n"); + printf(" wifi ap channel <1..11>\n"); + printf(" wifi ap secret|show-secret\n"); + printf(" wifi save|load|defaults|reset\n"); +} + +static bool parse_u32(const char *text, uint32_t maximum, uint32_t *value) +{ + if (text == NULL || *text == '\0') { + return false; + } + for (const char *character = text; *character != '\0'; ++character) { + if (*character < '0' || *character > '9') { + return false; + } + } + + char *end = NULL; + errno = 0; + unsigned long parsed = strtoul(text, &end, 10); + if (errno != 0 || end == text || *end != '\0' || parsed > maximum) { + return false; + } + *value = (uint32_t)parsed; + return true; +} + +static bool parse_slot(const char *text, uint8_t *slot) +{ + uint32_t parsed; + if (!parse_u32(text, WIFI_CONFIG_STA_PROFILE_COUNT - 1U, &parsed)) { + return false; + } + *slot = (uint8_t)parsed; + return true; +} + +static void print_bytes_escaped(const uint8_t *data, size_t length) +{ + putchar('"'); + for (size_t index = 0; index < length; ++index) { + uint8_t byte = data[index]; + if (byte == '\\' || byte == '"') { + printf("\\%c", (char)byte); + } else if (byte >= 0x20U && byte <= 0x7eU) { + putchar((char)byte); + } else { + printf("\\x%02x", (unsigned int)byte); + } + } + putchar('"'); +} + +static const char *auth_mode_name(wifi_auth_mode_t auth) +{ + switch (auth) { + case WIFI_AUTH_OPEN: + return "open"; + case WIFI_AUTH_WEP: + return "wep"; + case WIFI_AUTH_WPA_PSK: + return "wpa"; + case WIFI_AUTH_WPA2_PSK: + return "wpa2"; + case WIFI_AUTH_WPA_WPA2_PSK: + return "wpa/wpa2"; + case WIFI_AUTH_WPA2_ENTERPRISE: + return "wpa2-enterprise"; + case WIFI_AUTH_WPA3_PSK: + return "wpa3"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "wpa2/wpa3"; + case WIFI_AUTH_WPA3_ENT_192: + return "wpa3-enterprise-192"; + default: + return "other"; + } +} + +static void print_ipv4(uint32_t address) +{ + esp_ip4_addr_t ip = {.addr = address}; + printf(IPSTR, IP2STR(&ip)); +} + +static int show_status(void) +{ + wifi_manager_snapshot_t snapshot; + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_snapshot(&snapshot); + if (error == ESP_OK) { + error = wifi_manager_get_working_config(&config); + } + if (error != ESP_OK) { + printf("Wi-Fi manager unavailable: %s\n", esp_err_to_name(error)); + return 1; + } + + printf("Wi-Fi: initialized=%s enabled-at-boot=%s started=%s state=%s generation=%" PRIu32 "\n", + snapshot.initialized ? "yes" : "no", + config.enabled_at_boot ? "yes" : "no", + snapshot.started ? "yes" : "no", + wifi_manager_state_to_string(snapshot.state), + snapshot.config_generation); + + if (snapshot.active_profile >= 0) { + printf("STA: profile=%d SSID=", snapshot.active_profile); + print_bytes_escaped((const uint8_t *)snapshot.sta_ssid, snapshot.sta_ssid_len); + printf(" channel=%u RSSI=%d auth=%s\n", + (unsigned int)snapshot.sta_channel, + (int)snapshot.sta_rssi, + auth_mode_name(snapshot.sta_auth)); + } else { + printf("STA: no active profile\n"); + } + + if (snapshot.ip != 0U) { + printf("IPv4: address="); + print_ipv4(snapshot.ip); + printf(" netmask="); + print_ipv4(snapshot.netmask); + printf(" gateway="); + print_ipv4(snapshot.gateway); + putchar('\n'); + } + + printf("AP: policy=%s running=%s clients=%u channel=%u SSID=", + wifi_config_ap_policy_to_string(snapshot.ap_policy), + snapshot.ap_running ? "yes" : "no", + (unsigned int)snapshot.ap_client_count, + (unsigned int)snapshot.ap_channel); + print_bytes_escaped(config.ap_ssid, config.ap_ssid_len); + printf(" secret=set\n"); + + printf("Last: disconnect-reason=%u error=%s retry=%" PRIu32 "s\n", + (unsigned int)snapshot.last_disconnect_reason, + esp_err_to_name(snapshot.last_error), + snapshot.retry_seconds); + wifi_config_secure_wipe(&config, sizeof(config)); + return 0; +} + +static int show_profiles(void) +{ + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read Wi-Fi profiles: %s\n", esp_err_to_name(error)); + return 1; + } + + printf("slot enabled priority security secret SSID\n"); + for (size_t slot = 0U; slot < WIFI_CONFIG_STA_PROFILE_COUNT; ++slot) { + const wifi_config_sta_profile_t *profile = &config.profiles[slot]; + printf("%u %-7s %-8u %-8s %-6s ", + (unsigned int)slot, + profile->enabled ? "yes" : "no", + (unsigned int)profile->priority, + wifi_config_security_to_string(profile->security), + profile->psk_len > 0U ? "set" : "unset"); + if (profile->ssid_len > 0U) { + print_bytes_escaped(profile->ssid, profile->ssid_len); + } else { + printf(""); + } + putchar('\n'); + } + + wifi_config_secure_wipe(&config, sizeof(config)); + return 0; +} + +static int show_counters(void) +{ + wifi_manager_snapshot_t snapshot; + esp_err_t error = wifi_manager_get_snapshot(&snapshot); + if (error != ESP_OK) { + printf("Could not read Wi-Fi counters: %s\n", esp_err_to_name(error)); + return 1; + } + + const wifi_manager_counters_t *counter = &snapshot.counters; + printf("Lifecycle: starts=%" PRIu64 " stops=%" PRIu64 " applies=%" PRIu64 "\n", + counter->starts, counter->stops, counter->applies); + printf("STA: attempts=%" PRIu64 " associations=%" PRIu64 + " got-IP=%" PRIu64 " disconnects=%" PRIu64 "\n", + counter->connect_attempts, counter->associations, + counter->got_ip, counter->disconnects); + printf("Policy: exhausted-cycles=%" PRIu64 " retries=%" PRIu64 "\n", + counter->profile_cycles, counter->retries); + printf("AP: starts=%" PRIu64 " stops=%" PRIu64 + " joins=%" PRIu64 " leaves=%" PRIu64 "\n", + counter->ap_starts, counter->ap_stops, + counter->client_joins, counter->client_leaves); + printf("Manager queue drops=%" PRIu64 "\n", counter->queue_drops); + return 0; +} + +static esp_err_t apply_candidate(wifi_app_config_t *candidate) +{ + esp_err_t error = wifi_manager_apply_working_config(candidate); + wifi_config_secure_wipe(candidate, sizeof(*candidate)); + return error; +} + +static esp_err_t read_secret_no_echo(uint8_t *secret, uint8_t *secret_len) +{ + uint8_t buffer[WIFI_CONSOLE_SECRET_CAPACITY]; + size_t length = 0U; + memset(buffer, 0, sizeof(buffer)); + + /* + * esp_console may execute on CR while the terminal's trailing LF is still + * arriving. Let that line ending settle, then discard only pre-prompt RX so + * it cannot be mistaken for an immediately submitted empty secret. + */ + vTaskDelay(1U); + esp_err_t flush_error = uart_flush_input(WIFI_CONSOLE_UART); + if (flush_error != ESP_OK) { + wifi_config_secure_wipe(buffer, sizeof(buffer)); + printf("Could not prepare secret input: %s\n", esp_err_to_name(flush_error)); + return flush_error; + } + + printf("Enter 8..63 printable ASCII characters (input hidden, Ctrl-C cancels): "); + fflush(stdout); + + for (;;) { + uint8_t byte = 0U; + int received = uart_read_bytes(WIFI_CONSOLE_UART, &byte, 1U, portMAX_DELAY); + if (received != 1) { + wifi_config_secure_wipe(buffer, sizeof(buffer)); + printf("\nSecret input failed.\n"); + return ESP_FAIL; + } + if (byte == 0x03U) { + wifi_config_secure_wipe(buffer, sizeof(buffer)); + printf("\nCancelled.\n"); + return ESP_ERR_INVALID_STATE; + } + if (byte == '\r' || byte == '\n') { + break; + } + if (byte == 0x08U || byte == 0x7fU) { + if (length > 0U) { + buffer[--length] = 0U; + } + continue; + } + if (byte < 0x20U || byte > 0x7eU || length >= sizeof(buffer)) { + putchar('\a'); + fflush(stdout); + continue; + } + buffer[length++] = byte; + } + putchar('\n'); + + if (length < WIFI_CONFIG_PSK_MIN_LEN || length > WIFI_CONFIG_PSK_MAX_LEN) { + wifi_config_secure_wipe(buffer, sizeof(buffer)); + printf("Secret length must be 8..63 characters.\n"); + return ESP_ERR_INVALID_ARG; + } + + memset(secret, 0, WIFI_CONFIG_PSK_MAX_LEN); + memcpy(secret, buffer, length); + *secret_len = (uint8_t)length; + wifi_config_secure_wipe(buffer, sizeof(buffer)); + return ESP_OK; +} + +static int set_profile(char **argv) +{ + uint8_t slot; + uint32_t priority; + wifi_config_security_t security; + size_t ssid_len = strlen(argv[6]); + if (!parse_slot(argv[3], &slot) || + !parse_u32(argv[4], UINT8_MAX, &priority) || + !wifi_config_parse_security(argv[5], &security) || + ssid_len == 0U || ssid_len > WIFI_CONFIG_SSID_MAX_LEN) { + printf("Profile requires slot 0..%u, priority 0..255, valid security, and a 1..32-byte SSID.\n", + WIFI_CONFIG_STA_PROFILE_COUNT - 1U); + return 1; + } + + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read working configuration: %s\n", esp_err_to_name(error)); + return 1; + } + + wifi_config_sta_profile_t *profile = &config.profiles[slot]; + memset(profile->ssid, 0, sizeof(profile->ssid)); + memcpy(profile->ssid, argv[6], ssid_len); + profile->ssid_len = (uint8_t)ssid_len; + profile->priority = (uint8_t)priority; + profile->security = security; + error = apply_candidate(&config); + if (error != ESP_OK) { + printf("Could not apply profile: %s\n", esp_err_to_name(error)); + return 1; + } + + printf("Profile %u updated in RAM; its existing secret and enabled state were preserved.\n", + (unsigned int)slot); + return 0; +} + +static int set_profile_secret(const char *slot_text) +{ + uint8_t slot; + if (!parse_slot(slot_text, &slot)) { + printf("Profile slot must be 0..%u.\n", WIFI_CONFIG_STA_PROFILE_COUNT - 1U); + return 1; + } + + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read working configuration: %s\n", esp_err_to_name(error)); + return 1; + } + if (config.profiles[slot].ssid_len == 0U) { + wifi_config_secure_wipe(&config, sizeof(config)); + printf("Set the profile SSID before its secret.\n"); + return 1; + } + + error = read_secret_no_echo(config.profiles[slot].psk, + &config.profiles[slot].psk_len); + if (error == ESP_OK) { + error = apply_candidate(&config); + } else { + wifi_config_secure_wipe(&config, sizeof(config)); + } + if (error != ESP_OK) { + if (error != ESP_ERR_INVALID_STATE) { + printf("Could not set profile secret: %s\n", esp_err_to_name(error)); + } + return 1; + } + + printf("Profile %u secret updated in RAM; use 'wifi save' to persist it.\n", + (unsigned int)slot); + return 0; +} + +static int change_profile_state(const char *operation, const char *slot_text) +{ + uint8_t slot; + if (!parse_slot(slot_text, &slot)) { + printf("Profile slot must be 0..%u.\n", WIFI_CONFIG_STA_PROFILE_COUNT - 1U); + return 1; + } + + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read working configuration: %s\n", esp_err_to_name(error)); + return 1; + } + + wifi_config_sta_profile_t *profile = &config.profiles[slot]; + if (strcmp(operation, "delete") == 0) { + wifi_config_secure_wipe(profile, sizeof(*profile)); + } else { + profile->enabled = strcmp(operation, "enable") == 0 ? 1U : 0U; + } + + error = apply_candidate(&config); + if (error != ESP_OK) { + printf("Could not %s profile %u: %s\n", operation, + (unsigned int)slot, esp_err_to_name(error)); + return 1; + } + printf("Profile %u %sd in RAM.\n", (unsigned int)slot, + strcmp(operation, "delete") == 0 ? "delete" : operation); + return 0; +} + +static int set_ap_parameter(const char *parameter, const char *value) +{ + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read working configuration: %s\n", esp_err_to_name(error)); + return 1; + } + + if (strcmp(parameter, "policy") == 0) { + if (!wifi_config_parse_ap_policy(value, &config.ap_policy)) { + wifi_config_secure_wipe(&config, sizeof(config)); + printf("AP policy must be off, fallback, or always.\n"); + return 1; + } + } else if (strcmp(parameter, "ssid") == 0) { + size_t length = strlen(value); + if (length == 0U || length > WIFI_CONFIG_SSID_MAX_LEN) { + wifi_config_secure_wipe(&config, sizeof(config)); + printf("AP SSID must be 1..32 bytes.\n"); + return 1; + } + memset(config.ap_ssid, 0, sizeof(config.ap_ssid)); + memcpy(config.ap_ssid, value, length); + config.ap_ssid_len = (uint8_t)length; + } else { + uint32_t channel; + if (!parse_u32(value, WIFI_CONFIG_AP_CHANNEL_MAX, &channel) || + channel < WIFI_CONFIG_AP_CHANNEL_MIN) { + wifi_config_secure_wipe(&config, sizeof(config)); + printf("AP channel must be %u..%u.\n", + WIFI_CONFIG_AP_CHANNEL_MIN, WIFI_CONFIG_AP_CHANNEL_MAX); + return 1; + } + config.ap_channel = (uint8_t)channel; + } + + error = apply_candidate(&config); + if (error != ESP_OK) { + printf("Could not apply AP configuration: %s\n", esp_err_to_name(error)); + return 1; + } + printf("AP %s updated in RAM; use 'wifi save' to persist it.\n", parameter); + return 0; +} + +static int set_ap_secret(void) +{ + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read working configuration: %s\n", esp_err_to_name(error)); + return 1; + } + + error = read_secret_no_echo(config.ap_psk, &config.ap_psk_len); + if (error == ESP_OK) { + error = apply_candidate(&config); + } else { + wifi_config_secure_wipe(&config, sizeof(config)); + } + if (error != ESP_OK) { + if (error != ESP_ERR_INVALID_STATE) { + printf("Could not set AP secret: %s\n", esp_err_to_name(error)); + } + return 1; + } + printf("AP secret updated in RAM; use 'wifi save' to persist it.\n"); + return 0; +} + +static int show_ap_secret(void) +{ + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error != ESP_OK) { + printf("Could not read AP secret: %s\n", esp_err_to_name(error)); + return 1; + } + + printf("AP secret: "); + fwrite(config.ap_psk, 1U, config.ap_psk_len, stdout); + putchar('\n'); + printf("This credential is stored as plaintext in NVS until NVS encryption is enabled.\n"); + wifi_config_secure_wipe(&config, sizeof(config)); + return 0; +} + +static int save_config(void) +{ + wifi_app_config_t config; + esp_err_t error = wifi_manager_get_working_config(&config); + if (error == ESP_OK) { + error = wifi_config_save(&config); + } + wifi_config_secure_wipe(&config, sizeof(config)); + if (error != ESP_OK) { + printf("Could not save Wi-Fi configuration: %s\n", esp_err_to_name(error)); + return 1; + } + printf("Wi-Fi configuration saved to NVS. Credentials are not yet encrypted at rest.\n"); + return 0; +} + +static int load_config(void) +{ + wifi_app_config_t config; + wifi_config_load_source_t source; + esp_err_t error = wifi_config_load(&config, &source); + if (error == ESP_OK) { + error = wifi_manager_apply_working_config(&config); + } + wifi_config_secure_wipe(&config, sizeof(config)); + if (error != ESP_OK) { + printf("Could not load Wi-Fi configuration: %s\n", esp_err_to_name(error)); + return 1; + } + const char *description = source == WIFI_CONFIG_LOAD_STORED + ? "stored" + : (source == WIFI_CONFIG_LOAD_GENERATED_MISSING + ? "new default (no stored blob)" + : "new default (stored blob is incompatible)"); + printf("Loaded %s Wi-Fi configuration into RAM.\n", description); + return 0; +} + +static int apply_defaults(bool persist) +{ + wifi_app_config_t previous; + wifi_app_config_t defaults; + esp_err_t error = wifi_manager_get_working_config(&previous); + if (error == ESP_OK) { + error = wifi_config_defaults(&defaults); + } + if (error == ESP_OK) { + error = wifi_manager_apply_working_config(&defaults); + } + if (error == ESP_OK && persist) { + error = wifi_config_reset_storage(&defaults); + if (error != ESP_OK) { + /* Restore RAM behavior if persistence failed. */ + (void)wifi_manager_apply_working_config(&previous); + } + } + + wifi_config_secure_wipe(&previous, sizeof(previous)); + wifi_config_secure_wipe(&defaults, sizeof(defaults)); + if (error != ESP_OK) { + printf("Could not apply Wi-Fi defaults: %s\n", esp_err_to_name(error)); + return 1; + } + printf("Fresh defaults applied%s; use 'wifi ap show-secret' for the new AP credential.\n", + persist ? " and saved" : " in RAM"); + return 0; +} + +static int queue_lifecycle(const char *operation) +{ + esp_err_t error; + if (strcmp(operation, "start") == 0) { + error = wifi_manager_start(); + } else if (strcmp(operation, "stop") == 0) { + error = wifi_manager_stop(); + } else { + error = wifi_manager_reconnect(); + } + if (error != ESP_OK) { + printf("Could not queue Wi-Fi %s: %s\n", operation, esp_err_to_name(error)); + return 1; + } + printf("Wi-Fi %s queued.\n", operation); + return 0; +} + +static int command_wifi(int argc, char **argv) +{ + if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) { + return show_status(); + } + if (argc == 2 && strcmp(argv[1], "profiles") == 0) { + return show_profiles(); + } + if (argc == 2 && strcmp(argv[1], "counters") == 0) { + return show_counters(); + } + if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) { + esp_err_t error = wifi_manager_clear_counters(); + if (error != ESP_OK) { + printf("Could not clear Wi-Fi counters: %s\n", esp_err_to_name(error)); + return 1; + } + printf("Wi-Fi counters cleared.\n"); + return 0; + } + if (argc == 2 && (strcmp(argv[1], "start") == 0 || + strcmp(argv[1], "stop") == 0 || + strcmp(argv[1], "reconnect") == 0)) { + return queue_lifecycle(argv[1]); + } + if (argc == 7 && strcmp(argv[1], "profile") == 0 && + strcmp(argv[2], "set") == 0) { + return set_profile(argv); + } + if (argc == 4 && strcmp(argv[1], "profile") == 0 && + strcmp(argv[2], "secret") == 0) { + return set_profile_secret(argv[3]); + } + if (argc == 4 && strcmp(argv[1], "profile") == 0 && + (strcmp(argv[2], "enable") == 0 || + strcmp(argv[2], "disable") == 0 || + strcmp(argv[2], "delete") == 0)) { + return change_profile_state(argv[2], argv[3]); + } + if (argc == 4 && strcmp(argv[1], "ap") == 0 && + (strcmp(argv[2], "policy") == 0 || + strcmp(argv[2], "ssid") == 0 || + strcmp(argv[2], "channel") == 0)) { + return set_ap_parameter(argv[2], argv[3]); + } + if (argc == 3 && strcmp(argv[1], "ap") == 0 && + strcmp(argv[2], "secret") == 0) { + return set_ap_secret(); + } + if (argc == 3 && strcmp(argv[1], "ap") == 0 && + strcmp(argv[2], "show-secret") == 0) { + return show_ap_secret(); + } + if (argc == 2 && strcmp(argv[1], "save") == 0) { + return save_config(); + } + if (argc == 2 && strcmp(argv[1], "load") == 0) { + return load_config(); + } + if (argc == 2 && strcmp(argv[1], "defaults") == 0) { + return apply_defaults(false); + } + if (argc == 2 && strcmp(argv[1], "reset") == 0) { + return apply_defaults(true); + } + + print_usage(); + return 1; +} + +esp_err_t wifi_console_register_commands(void) +{ + const esp_console_cmd_t command = { + .command = "wifi", + .help = "Configure multi-profile Wi-Fi and AP fallback; use 'wifi' for usage/status", + .hint = NULL, + .func = &command_wifi, + .argtable = NULL, + }; + return esp_console_cmd_register(&command); +} diff --git a/src/wifi_console.h b/src/wifi_console.h new file mode 100644 index 0000000..cef6730 --- /dev/null +++ b/src/wifi_console.h @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ + +#pragma once + +#include "esp_err.h" + +/* Register Wi-Fi configuration, lifecycle, and diagnostic commands on UART0. */ +esp_err_t wifi_console_register_commands(void); diff --git a/src/wifi_manager.c b/src/wifi_manager.c new file mode 100644 index 0000000..8896c90 --- /dev/null +++ b/src/wifi_manager.c @@ -0,0 +1,1407 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Native ESP-IDF Wi-Fi lifecycle and connection policy manager. */ + +#include "wifi_manager.h" + +#include +#include +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "esp_wifi_default.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#define WIFI_MANAGER_QUEUE_LENGTH 16U +#define WIFI_MANAGER_TASK_STACK_SIZE 6144U +#define WIFI_MANAGER_TASK_PRIORITY 5U +#define WIFI_MANAGER_ATTEMPT_US (12LL * 1000LL * 1000LL) +#define WIFI_MANAGER_STABLE_US (30LL * 1000LL * 1000LL) +#define WIFI_MANAGER_DISCONNECT_SETTLE_US (1LL * 1000LL * 1000LL) +#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2U +#define WIFI_MANAGER_MAX_BACKOFF_SECONDS 60U + +static const char *const TAG = "wifi_manager"; + +typedef enum { + MESSAGE_COMMAND_START = 0, + MESSAGE_COMMAND_STOP, + MESSAGE_COMMAND_APPLY, + MESSAGE_COMMAND_RECONNECT, + MESSAGE_STA_CONNECTED, + MESSAGE_STA_DISCONNECTED, + MESSAGE_STA_GOT_IP, + MESSAGE_STA_LOST_IP, + MESSAGE_STA_STOPPED, + MESSAGE_AP_STOPPED, + MESSAGE_AP_CLIENT_JOINED, + MESSAGE_AP_CLIENT_LEFT, +} manager_message_type_t; + +typedef struct { + manager_message_type_t type; + union { + struct { + uint8_t channel; + wifi_auth_mode_t auth; + uint8_t ssid_len; + uint8_t ssid[WIFI_CONFIG_SSID_MAX_LEN]; + } connected; + struct { + uint16_t reason; + } disconnected; + struct { + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + } got_ip; + } data; +} manager_message_t; + +typedef struct { + wifi_app_config_t config; + wifi_manager_snapshot_t snapshot; +} manager_shared_t; + +typedef struct { + bool radio_started; + bool ap_enabled; + bool associated; + bool online; + uint8_t profile_order[WIFI_CONFIG_STA_PROFILE_COUNT]; + uint8_t profile_count; + uint8_t next_profile; + uint32_t next_backoff_seconds; + uint32_t intentional_disconnects; + bool advance_after_disconnect; + bool stop_pending; + bool restart_pending; + bool restart_waits_for_ap_stop; + int64_t attempt_deadline; + int64_t disconnect_deadline; + int64_t restart_deadline; + int64_t backoff_deadline; + int64_t stable_deadline; +} manager_runtime_t; + +static SemaphoreHandle_t s_mutex; +static QueueHandle_t s_queue; +static TaskHandle_t s_task; +static esp_netif_t *s_sta_netif; +static esp_netif_t *s_ap_netif; +static esp_event_handler_instance_t s_wifi_handler; +static esp_event_handler_instance_t s_ip_handler; +static bool s_event_loop_owned; +static manager_shared_t s_shared; + +/* Event callbacks cannot take the manager mutex; this counter has its own lock. */ +static portMUX_TYPE s_drop_mux = portMUX_INITIALIZER_UNLOCKED; +static uint64_t s_queue_drops; + +static void manager_task(void *context); +static void start_profile_cycle(manager_runtime_t *runtime); +static void start_next_profile(manager_runtime_t *runtime); + +static void lock_shared(void) +{ + (void)xSemaphoreTake(s_mutex, portMAX_DELAY); +} + +static void unlock_shared(void) +{ + (void)xSemaphoreGive(s_mutex); +} + +static bool manager_is_started(void) +{ + bool started; + + lock_shared(); + started = s_shared.snapshot.started; + unlock_shared(); + return started; +} + +static void count_queue_drop(void) +{ + portENTER_CRITICAL(&s_drop_mux); + ++s_queue_drops; + portEXIT_CRITICAL(&s_drop_mux); +} + +static bool enqueue_message(const manager_message_t *message) +{ + if (xQueueSend(s_queue, message, 0) == pdTRUE) { + return true; + } + count_queue_drop(); + return false; +} + +static void set_state(wifi_manager_state_t state) +{ + lock_shared(); + s_shared.snapshot.state = state; + unlock_shared(); +} + +static void set_last_error(esp_err_t error) +{ + lock_shared(); + s_shared.snapshot.last_error = error; + unlock_shared(); +} + +static void clear_station_network_snapshot(void) +{ + lock_shared(); + s_shared.snapshot.ip = 0U; + s_shared.snapshot.netmask = 0U; + s_shared.snapshot.gateway = 0U; + s_shared.snapshot.sta_channel = 0U; + s_shared.snapshot.sta_rssi = 0; + s_shared.snapshot.sta_auth = WIFI_AUTH_OPEN; + unlock_shared(); +} + +static void set_active_profile(int8_t slot, const wifi_config_sta_profile_t *profile) +{ + lock_shared(); + s_shared.snapshot.active_profile = slot; + memset(s_shared.snapshot.sta_ssid, 0, sizeof(s_shared.snapshot.sta_ssid)); + s_shared.snapshot.sta_ssid_len = 0U; + if (profile != NULL) { + size_t length = profile->ssid_len; + if (length > WIFI_CONFIG_SSID_MAX_LEN) { + length = WIFI_CONFIG_SSID_MAX_LEN; + } + memcpy(s_shared.snapshot.sta_ssid, profile->ssid, length); + s_shared.snapshot.sta_ssid[length] = '\0'; + s_shared.snapshot.sta_ssid_len = (uint8_t)length; + } + unlock_shared(); +} + +static void copy_working_config(wifi_app_config_t *config) +{ + lock_shared(); + *config = s_shared.config; + unlock_shared(); +} + +static bool connected_event_matches_active_profile(const manager_message_t *message) +{ + bool matches = false; + lock_shared(); + int8_t slot = s_shared.snapshot.active_profile; + if (slot >= 0 && slot < (int8_t)WIFI_CONFIG_STA_PROFILE_COUNT) { + const wifi_config_sta_profile_t *profile = &s_shared.config.profiles[slot]; + matches = profile->ssid_len == message->data.connected.ssid_len && + memcmp(profile->ssid, + message->data.connected.ssid, + profile->ssid_len) == 0; + } + unlock_shared(); + return matches; +} + +static uint8_t build_profile_order(uint8_t *order, const wifi_app_config_t *config) +{ + uint8_t count = 0U; + + /* Insertion sort preserves ascending slot order when priorities are equal. */ + for (uint8_t slot = 0U; slot < WIFI_CONFIG_STA_PROFILE_COUNT; ++slot) { + if (config->profiles[slot].enabled == 0U) { + continue; + } + uint8_t position = count; + while (position > 0U && + config->profiles[order[position - 1U]].priority > + config->profiles[slot].priority) { + order[position] = order[position - 1U]; + --position; + } + order[position] = slot; + ++count; + } + return count; +} + +static void note_ap_running(bool running, uint8_t channel) +{ + lock_shared(); + bool changed = s_shared.snapshot.ap_running != running; + s_shared.snapshot.ap_running = running; + s_shared.snapshot.ap_channel = channel; + if (!running) { + s_shared.snapshot.ap_client_count = 0U; + } + if (changed) { + if (running) { + ++s_shared.snapshot.counters.ap_starts; + } else { + ++s_shared.snapshot.counters.ap_stops; + } + } + unlock_shared(); +} + +static esp_err_t configure_ap(const wifi_app_config_t *config) +{ + wifi_config_t wifi_config; + memset(&wifi_config, 0, sizeof(wifi_config)); + + memcpy(wifi_config.ap.ssid, config->ap_ssid, config->ap_ssid_len); + wifi_config.ap.ssid_len = config->ap_ssid_len; + memcpy(wifi_config.ap.password, config->ap_psk, config->ap_psk_len); + wifi_config.ap.channel = config->ap_channel; + wifi_config.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK; + wifi_config.ap.ssid_hidden = 0U; + wifi_config.ap.max_connection = 4U; + wifi_config.ap.pmf_cfg.capable = true; + wifi_config.ap.pmf_cfg.required = false; + wifi_config.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH; + + esp_err_t error = esp_wifi_set_config(WIFI_IF_AP, &wifi_config); + wifi_config_secure_wipe(&wifi_config, sizeof(wifi_config)); + return error; +} + +static esp_err_t configure_station(const wifi_config_sta_profile_t *profile) +{ + wifi_config_t wifi_config; + memset(&wifi_config, 0, sizeof(wifi_config)); + + memcpy(wifi_config.sta.ssid, profile->ssid, profile->ssid_len); + memcpy(wifi_config.sta.password, profile->psk, profile->psk_len); + wifi_config.sta.sae_pwe_h2e = WPA3_SAE_PWE_BOTH; + wifi_config.sta.pmf_cfg.capable = true; + + switch (profile->security) { + case WIFI_CONFIG_SECURITY_MIXED: + /* WPA2 is the minimum; SAE options also permit WPA3 and transition APs. */ + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi_config.sta.pmf_cfg.required = false; + break; + case WIFI_CONFIG_SECURITY_WPA3: + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA3_PSK; + wifi_config.sta.pmf_cfg.required = true; + break; + default: + wifi_config_secure_wipe(&wifi_config, sizeof(wifi_config)); + return ESP_ERR_INVALID_ARG; + } + + esp_err_t error = esp_wifi_set_config(WIFI_IF_STA, &wifi_config); + wifi_config_secure_wipe(&wifi_config, sizeof(wifi_config)); + return error; +} + +static esp_err_t set_runtime_ap_enabled(manager_runtime_t *runtime, bool enabled) +{ + if (!runtime->radio_started) { + runtime->ap_enabled = enabled; + return ESP_OK; + } + if (runtime->ap_enabled == enabled) { + return ESP_OK; + } + + wifi_app_config_t config; + copy_working_config(&config); + esp_err_t error; + + if (enabled) { + /* A live STA-to-APSTA transition starts the already-created AP netif. */ + error = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (error == ESP_OK) { + error = configure_ap(&config); + } + if (error == ESP_OK) { + runtime->ap_enabled = true; + note_ap_running(true, config.ap_channel); + } else { + /* Do not leave an untracked default/stale AP active after failure. */ + esp_err_t rollback_error = esp_wifi_set_mode(WIFI_MODE_STA); + if (rollback_error != ESP_OK) { + ESP_LOGW(TAG, "fallback AP rollback failed: %s", + esp_err_to_name(rollback_error)); + } + } + } else { + error = esp_wifi_set_mode(WIFI_MODE_STA); + if (error == ESP_OK) { + runtime->ap_enabled = false; + note_ap_running(false, config.ap_channel); + } + } + + wifi_config_secure_wipe(&config, sizeof(config)); + return error; +} + +static void mark_intentional_disconnect(manager_runtime_t *runtime) +{ + esp_err_t error = esp_wifi_disconnect(); + if (error == ESP_OK) { + /* The corresponding event is consumed without invoking retry policy. */ + ++runtime->intentional_disconnects; + } else if (error != ESP_ERR_WIFI_NOT_CONNECT) { + set_last_error(error); + } + runtime->associated = false; + runtime->online = false; + clear_station_network_snapshot(); +} + +static void stop_radio(manager_runtime_t *runtime) +{ + if (!runtime->radio_started) { + return; + } + + if (runtime->associated || runtime->online) { + mark_intentional_disconnect(runtime); + } + + esp_err_t error = esp_wifi_stop(); + if (error != ESP_OK && error != ESP_ERR_WIFI_NOT_STARTED) { + set_last_error(error); + ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(error)); + } + + wifi_app_config_t config; + copy_working_config(&config); + if (runtime->ap_enabled) { + note_ap_running(false, config.ap_channel); + } + wifi_config_secure_wipe(&config, sizeof(config)); + + runtime->radio_started = false; + runtime->ap_enabled = false; + runtime->associated = false; + runtime->online = false; + runtime->advance_after_disconnect = false; + runtime->attempt_deadline = 0; + runtime->disconnect_deadline = 0; + runtime->backoff_deadline = 0; + runtime->stable_deadline = 0; + runtime->profile_count = 0U; + runtime->next_profile = 0U; + set_active_profile(-1, NULL); + clear_station_network_snapshot(); +} + +static void schedule_cycle_retry(manager_runtime_t *runtime) +{ + wifi_app_config_t config; + copy_working_config(&config); + + lock_shared(); + ++s_shared.snapshot.counters.profile_cycles; + unlock_shared(); + + if (config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK) { + esp_err_t error = set_runtime_ap_enabled(runtime, true); + if (error != ESP_OK) { + set_last_error(error); + ESP_LOGW(TAG, "failed to enable fallback AP: %s", esp_err_to_name(error)); + } + } + + uint32_t delay_seconds = runtime->next_backoff_seconds; + if (delay_seconds < WIFI_MANAGER_INITIAL_BACKOFF_SECONDS) { + delay_seconds = WIFI_MANAGER_INITIAL_BACKOFF_SECONDS; + } + runtime->backoff_deadline = esp_timer_get_time() + + ((int64_t)delay_seconds * 1000LL * 1000LL); + runtime->attempt_deadline = 0; + runtime->disconnect_deadline = 0; + runtime->stable_deadline = 0; + runtime->associated = false; + runtime->online = false; + set_active_profile(-1, NULL); + + lock_shared(); + ++s_shared.snapshot.counters.retries; + s_shared.snapshot.retry_seconds = delay_seconds; + s_shared.snapshot.state = WIFI_MANAGER_STATE_BACKOFF; + unlock_shared(); + + if (delay_seconds >= WIFI_MANAGER_MAX_BACKOFF_SECONDS) { + runtime->next_backoff_seconds = WIFI_MANAGER_MAX_BACKOFF_SECONDS; + } else if (delay_seconds > WIFI_MANAGER_MAX_BACKOFF_SECONDS / 2U) { + runtime->next_backoff_seconds = WIFI_MANAGER_MAX_BACKOFF_SECONDS; + } else { + runtime->next_backoff_seconds = delay_seconds * 2U; + } + + wifi_config_secure_wipe(&config, sizeof(config)); +} + +static void start_next_profile(manager_runtime_t *runtime) +{ + wifi_app_config_t config; + copy_working_config(&config); + + while (runtime->next_profile < runtime->profile_count) { + uint8_t slot = runtime->profile_order[runtime->next_profile++]; + const wifi_config_sta_profile_t *profile = &config.profiles[slot]; + + set_active_profile((int8_t)slot, profile); + set_state(WIFI_MANAGER_STATE_CONNECTING); + + esp_err_t error = configure_station(profile); + if (error == ESP_OK) { + error = esp_wifi_connect(); + } + + lock_shared(); + ++s_shared.snapshot.counters.connect_attempts; + unlock_shared(); + + if (error == ESP_OK) { + runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US; + runtime->backoff_deadline = 0; + runtime->stable_deadline = 0; + wifi_config_secure_wipe(&config, sizeof(config)); + return; + } + + set_last_error(error); + ESP_LOGW(TAG, "profile %u connect attempt failed: %s", (unsigned)slot, + esp_err_to_name(error)); + } + + wifi_config_secure_wipe(&config, sizeof(config)); + schedule_cycle_retry(runtime); +} + +static void start_profile_cycle(manager_runtime_t *runtime) +{ + wifi_app_config_t config; + copy_working_config(&config); + + runtime->profile_count = build_profile_order(runtime->profile_order, &config); + runtime->next_profile = 0U; + runtime->advance_after_disconnect = false; + runtime->attempt_deadline = 0; + runtime->disconnect_deadline = 0; + runtime->backoff_deadline = 0; + runtime->stable_deadline = 0; + runtime->associated = false; + runtime->online = false; + + wifi_config_secure_wipe(&config, sizeof(config)); + if (runtime->profile_count == 0U) { + set_active_profile(-1, NULL); + return; + } + start_next_profile(runtime); +} + +static void start_radio_and_policy(manager_runtime_t *runtime) +{ + wifi_app_config_t config; + copy_working_config(&config); + runtime->profile_count = build_profile_order(runtime->profile_order, &config); + runtime->next_profile = 0U; + runtime->next_backoff_seconds = WIFI_MANAGER_INITIAL_BACKOFF_SECONDS; + runtime->advance_after_disconnect = false; + runtime->stop_pending = false; + runtime->restart_pending = false; + runtime->restart_waits_for_ap_stop = false; + runtime->restart_deadline = 0; + runtime->attempt_deadline = 0; + runtime->disconnect_deadline = 0; + runtime->backoff_deadline = 0; + runtime->stable_deadline = 0; + runtime->associated = false; + runtime->online = false; + + bool want_ap = config.ap_policy == WIFI_CONFIG_AP_POLICY_ALWAYS || + (config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK && + runtime->profile_count == 0U); + wifi_mode_t mode = want_ap + ? (runtime->profile_count == 0U ? WIFI_MODE_AP + : WIFI_MODE_APSTA) + : WIFI_MODE_STA; + + esp_err_t error = esp_wifi_set_mode(mode); + if (error == ESP_OK && want_ap) { + error = configure_ap(&config); + } + if (error == ESP_OK) { + error = esp_wifi_start(); + } + if (error != ESP_OK) { + set_last_error(error); + set_state(WIFI_MANAGER_STATE_ERROR); + ESP_LOGE(TAG, "Wi-Fi start failed: %s", esp_err_to_name(error)); + wifi_config_secure_wipe(&config, sizeof(config)); + return; + } + + runtime->radio_started = true; + runtime->ap_enabled = want_ap; + set_last_error(ESP_OK); + note_ap_running(want_ap, config.ap_channel); + + if (runtime->profile_count == 0U) { + set_active_profile(-1, NULL); + lock_shared(); + s_shared.snapshot.retry_seconds = 0U; + if (want_ap) { + s_shared.snapshot.state = WIFI_MANAGER_STATE_AP_ONLY; + } else { + s_shared.snapshot.last_error = ESP_ERR_NOT_FOUND; + s_shared.snapshot.state = WIFI_MANAGER_STATE_ERROR; + } + unlock_shared(); + wifi_config_secure_wipe(&config, sizeof(config)); + return; + } + + wifi_config_secure_wipe(&config, sizeof(config)); + start_next_profile(runtime); +} + +static void update_connected_snapshot(const manager_message_t *message, + bool ap_enabled) +{ + size_t length = message->data.connected.ssid_len; + if (length > WIFI_CONFIG_SSID_MAX_LEN) { + length = WIFI_CONFIG_SSID_MAX_LEN; + } + + lock_shared(); + memset(s_shared.snapshot.sta_ssid, 0, sizeof(s_shared.snapshot.sta_ssid)); + memcpy(s_shared.snapshot.sta_ssid, message->data.connected.ssid, length); + s_shared.snapshot.sta_ssid[length] = '\0'; + s_shared.snapshot.sta_ssid_len = (uint8_t)length; + s_shared.snapshot.sta_channel = message->data.connected.channel; + s_shared.snapshot.sta_auth = message->data.connected.auth; + if (ap_enabled) { + /* In APSTA mode the SoftAP follows the station's radio channel. */ + s_shared.snapshot.ap_channel = message->data.connected.channel; + } + ++s_shared.snapshot.counters.associations; + s_shared.snapshot.state = WIFI_MANAGER_STATE_WAITING_IP; + unlock_shared(); +} + +static void handle_got_ip(manager_runtime_t *runtime, + const manager_message_t *message) +{ + esp_netif_ip_info_t current_ip; + wifi_ap_record_t ap_record; + memset(¤t_ip, 0, sizeof(current_ip)); + memset(&ap_record, 0, sizeof(ap_record)); + + /* Driver/netif state is authoritative when old queued events arrive late. */ + if (esp_netif_get_ip_info(s_sta_netif, ¤t_ip) != ESP_OK || + current_ip.ip.addr == 0U || + current_ip.ip.addr != message->data.got_ip.ip || + esp_wifi_sta_get_ap_info(&ap_record) != ESP_OK) { + return; + } + + runtime->associated = true; + runtime->online = true; + runtime->intentional_disconnects = 0U; + runtime->advance_after_disconnect = false; + runtime->attempt_deadline = 0; + runtime->disconnect_deadline = 0; + runtime->backoff_deadline = 0; + runtime->next_backoff_seconds = WIFI_MANAGER_INITIAL_BACKOFF_SECONDS; + + wifi_app_config_t config; + copy_working_config(&config); + runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK + ? esp_timer_get_time() + WIFI_MANAGER_STABLE_US + : 0; + + lock_shared(); + s_shared.snapshot.ip = message->data.got_ip.ip; + s_shared.snapshot.netmask = message->data.got_ip.netmask; + s_shared.snapshot.gateway = message->data.got_ip.gateway; + s_shared.snapshot.sta_channel = ap_record.primary; + s_shared.snapshot.sta_rssi = ap_record.rssi; + s_shared.snapshot.sta_auth = ap_record.authmode; + if (runtime->ap_enabled) { + s_shared.snapshot.ap_channel = ap_record.primary; + } + s_shared.snapshot.retry_seconds = 0U; + s_shared.snapshot.last_error = ESP_OK; + s_shared.snapshot.state = WIFI_MANAGER_STATE_ONLINE; + ++s_shared.snapshot.counters.got_ip; + unlock_shared(); + + wifi_config_secure_wipe(&config, sizeof(config)); +} + +static void handle_sta_disconnected(manager_runtime_t *runtime, + const manager_message_t *message) +{ + wifi_ap_record_t current_ap; + memset(¤t_ap, 0, sizeof(current_ap)); + if (esp_wifi_sta_get_ap_info(¤t_ap) == ESP_OK) { + /* A newer live association supersedes this delayed disconnect event. */ + if (runtime->intentional_disconnects > 0U) { + --runtime->intentional_disconnects; + } + runtime->advance_after_disconnect = false; + runtime->disconnect_deadline = 0; + return; + } + + bool had_attempt = runtime->attempt_deadline != 0; + bool was_online = runtime->online; + + runtime->associated = false; + runtime->online = false; + runtime->attempt_deadline = 0; + runtime->disconnect_deadline = 0; + runtime->stable_deadline = 0; + clear_station_network_snapshot(); + + lock_shared(); + s_shared.snapshot.last_disconnect_reason = message->data.disconnected.reason; + ++s_shared.snapshot.counters.disconnects; + unlock_shared(); + + if (runtime->intentional_disconnects > 0U) { + --runtime->intentional_disconnects; + if (runtime->advance_after_disconnect && manager_is_started()) { + runtime->advance_after_disconnect = false; + start_next_profile(runtime); + } + return; + } + if (!manager_is_started()) { + return; + } + + if (had_attempt) { + start_next_profile(runtime); + } else if (was_online) { + start_profile_cycle(runtime); + } +} + +static void handle_message(manager_runtime_t *runtime, + const manager_message_t *message) +{ + switch (message->type) { + case MESSAGE_COMMAND_START: + lock_shared(); + if (s_shared.snapshot.started) { + unlock_shared(); + break; + } + s_shared.snapshot.started = true; + s_shared.snapshot.state = WIFI_MANAGER_STATE_STARTING; + ++s_shared.snapshot.counters.starts; + unlock_shared(); + if (runtime->stop_pending) { + /* A rapid STOP→START waits for the old driver's stop boundary. */ + runtime->restart_pending = true; + } else { + start_radio_and_policy(runtime); + } + break; + + case MESSAGE_COMMAND_STOP: + /* Explicit stop cancels any previously requested automatic restart. */ + runtime->restart_pending = false; + lock_shared(); + if (!s_shared.snapshot.started) { + unlock_shared(); + break; + } + s_shared.snapshot.started = false; + ++s_shared.snapshot.counters.stops; + unlock_shared(); + if (runtime->radio_started) { + bool wait_for_ap_stop = runtime->ap_enabled && + runtime->profile_count == 0U; + stop_radio(runtime); + runtime->stop_pending = true; + runtime->restart_waits_for_ap_stop = wait_for_ap_stop; + runtime->restart_deadline = esp_timer_get_time() + + WIFI_MANAGER_DISCONNECT_SETTLE_US; + } + set_state(WIFI_MANAGER_STATE_STOPPED); + break; + + case MESSAGE_COMMAND_APPLY: + case MESSAGE_COMMAND_RECONNECT: + if (manager_is_started()) { + set_state(WIFI_MANAGER_STATE_STARTING); + if (runtime->radio_started) { + bool wait_for_ap_stop = runtime->ap_enabled && + runtime->profile_count == 0U; + stop_radio(runtime); + /* Old disconnect events are ordered before the matching stop event. */ + runtime->stop_pending = true; + runtime->restart_pending = true; + runtime->restart_waits_for_ap_stop = wait_for_ap_stop; + runtime->restart_deadline = esp_timer_get_time() + + WIFI_MANAGER_DISCONNECT_SETTLE_US; + } else if (runtime->stop_pending) { + runtime->restart_pending = true; + } else if (!runtime->restart_pending) { + start_radio_and_policy(runtime); + } + } + break; + + case MESSAGE_STA_CONNECTED: + if (!manager_is_started() || + !connected_event_matches_active_profile(message)) { + break; + } + runtime->associated = true; + runtime->online = false; + if (runtime->attempt_deadline == 0) { + runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US; + } + update_connected_snapshot(message, runtime->ap_enabled); + break; + + case MESSAGE_STA_DISCONNECTED: + handle_sta_disconnected(runtime, message); + break; + + case MESSAGE_STA_GOT_IP: + if (manager_is_started()) { + handle_got_ip(runtime, message); + } + break; + + case MESSAGE_STA_LOST_IP: + if (manager_is_started() && runtime->online) { + esp_netif_ip_info_t current_ip; + memset(¤t_ip, 0, sizeof(current_ip)); + if (esp_netif_get_ip_info(s_sta_netif, ¤t_ip) == ESP_OK && + current_ip.ip.addr != 0U) { + /* Ignore a delayed loss event after a newer DHCP lease. */ + break; + } + runtime->online = false; + runtime->stable_deadline = 0; + runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US; + clear_station_network_snapshot(); + set_state(WIFI_MANAGER_STATE_WAITING_IP); + } + break; + + case MESSAGE_STA_STOPPED: + case MESSAGE_AP_STOPPED: { + bool expected_stop = + (message->type == MESSAGE_AP_STOPPED) == + runtime->restart_waits_for_ap_stop; + if (runtime->stop_pending && expected_stop) { + runtime->stop_pending = false; + runtime->restart_waits_for_ap_stop = false; + runtime->restart_deadline = 0; + runtime->intentional_disconnects = 0U; + if (runtime->restart_pending && manager_is_started()) { + runtime->restart_pending = false; + start_radio_and_policy(runtime); + } + } + break; + } + + case MESSAGE_AP_CLIENT_JOINED: + lock_shared(); + ++s_shared.snapshot.counters.client_joins; + if (s_shared.snapshot.ap_running && s_shared.snapshot.ap_client_count < UINT8_MAX) { + ++s_shared.snapshot.ap_client_count; + } + unlock_shared(); + break; + + case MESSAGE_AP_CLIENT_LEFT: + lock_shared(); + ++s_shared.snapshot.counters.client_leaves; + if (s_shared.snapshot.ap_client_count > 0U) { + --s_shared.snapshot.ap_client_count; + } + unlock_shared(); + break; + + default: + break; + } +} + +static int64_t next_runtime_deadline(const manager_runtime_t *runtime) +{ + int64_t deadline = 0; + const int64_t candidates[] = { + runtime->attempt_deadline, + runtime->disconnect_deadline, + runtime->restart_deadline, + runtime->backoff_deadline, + runtime->stable_deadline, + }; + + for (size_t i = 0U; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (candidates[i] != 0 && (deadline == 0 || candidates[i] < deadline)) { + deadline = candidates[i]; + } + } + return deadline; +} + +static TickType_t runtime_wait_ticks(const manager_runtime_t *runtime) +{ + int64_t deadline = next_runtime_deadline(runtime); + if (deadline == 0) { + return portMAX_DELAY; + } + + int64_t remaining_us = deadline - esp_timer_get_time(); + if (remaining_us <= 0) { + return 0; + } + + uint32_t remaining_ms = (uint32_t)((remaining_us + 999LL) / 1000LL); + TickType_t ticks = pdMS_TO_TICKS(remaining_ms); + return ticks == 0 ? 1 : ticks; +} + +static void handle_expired_deadlines(manager_runtime_t *runtime) +{ + int64_t now = esp_timer_get_time(); + + if (runtime->attempt_deadline != 0 && now >= runtime->attempt_deadline) { + esp_netif_ip_info_t current_ip; + memset(¤t_ip, 0, sizeof(current_ip)); + if (esp_netif_get_ip_info(s_sta_netif, ¤t_ip) == ESP_OK && + current_ip.ip.addr != 0U) { + /* Recover if the bounded manager queue dropped GOT_IP. */ + manager_message_t synthetic = { + .type = MESSAGE_STA_GOT_IP, + .data.got_ip = { + .ip = current_ip.ip.addr, + .netmask = current_ip.netmask.addr, + .gateway = current_ip.gw.addr, + }, + }; + handle_got_ip(runtime, &synthetic); + if (runtime->online) { + return; + } + } + + runtime->attempt_deadline = 0; + uint32_t intentional_before = runtime->intentional_disconnects; + mark_intentional_disconnect(runtime); + if (runtime->intentional_disconnects > intentional_before) { + /* esp_wifi_set_config() must wait until the old attempt is gone. */ + runtime->advance_after_disconnect = true; + runtime->disconnect_deadline = now + WIFI_MANAGER_DISCONNECT_SETTLE_US; + } else { + start_next_profile(runtime); + } + return; + } + + if (runtime->disconnect_deadline != 0 && now >= runtime->disconnect_deadline) { + runtime->disconnect_deadline = 0; + wifi_ap_record_t current_ap; + memset(¤t_ap, 0, sizeof(current_ap)); + if (esp_wifi_sta_get_ap_info(¤t_ap) == ESP_OK) { + /* The driver is still associated; request disconnect and check again. */ + (void)esp_wifi_disconnect(); + runtime->disconnect_deadline = now + WIFI_MANAGER_DISCONNECT_SETTLE_US; + } else { + /* The event was dropped; authoritative driver state permits failover. */ + runtime->intentional_disconnects = 0U; + runtime->advance_after_disconnect = false; + start_next_profile(runtime); + } + return; + } + + if (runtime->restart_deadline != 0 && now >= runtime->restart_deadline) { + runtime->restart_deadline = 0; + if (runtime->stop_pending) { + /* Recover if the bounded queue dropped the driver's stop event. */ + runtime->stop_pending = false; + runtime->restart_waits_for_ap_stop = false; + runtime->intentional_disconnects = 0U; + if (runtime->restart_pending && manager_is_started()) { + runtime->restart_pending = false; + start_radio_and_policy(runtime); + } + } + return; + } + + if (runtime->backoff_deadline != 0 && now >= runtime->backoff_deadline) { + runtime->backoff_deadline = 0; + lock_shared(); + s_shared.snapshot.retry_seconds = 0U; + unlock_shared(); + start_profile_cycle(runtime); + return; + } + + if (runtime->stable_deadline != 0 && now >= runtime->stable_deadline) { + runtime->stable_deadline = 0; + if (runtime->online) { + wifi_app_config_t config; + copy_working_config(&config); + if (config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK) { + esp_err_t error = set_runtime_ap_enabled(runtime, false); + if (error != ESP_OK) { + set_last_error(error); + ESP_LOGW(TAG, "failed to disable stable fallback AP: %s", + esp_err_to_name(error)); + } + } + wifi_config_secure_wipe(&config, sizeof(config)); + } + } +} + +static void manager_task(void *context) +{ + (void)context; + manager_runtime_t runtime; + memset(&runtime, 0, sizeof(runtime)); + runtime.next_backoff_seconds = WIFI_MANAGER_INITIAL_BACKOFF_SECONDS; + + for (;;) { + manager_message_t message; + TickType_t wait = runtime_wait_ticks(&runtime); + if (xQueueReceive(s_queue, &message, wait) == pdTRUE) { + handle_message(&runtime, &message); + } + /* Checking after every message prevents a busy queue from starving timers. */ + handle_expired_deadlines(&runtime); + } +} + +static void wifi_event_callback(void *argument, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + (void)argument; + manager_message_t message; + memset(&message, 0, sizeof(message)); + + if (event_base != WIFI_EVENT) { + return; + } + + switch (event_id) { + case WIFI_EVENT_STA_CONNECTED: { + const wifi_event_sta_connected_t *event = event_data; + if (event == NULL) { + return; + } + message.type = MESSAGE_STA_CONNECTED; + message.data.connected.channel = event->channel; + message.data.connected.auth = event->authmode; + message.data.connected.ssid_len = event->ssid_len; + size_t length = event->ssid_len; + if (length > WIFI_CONFIG_SSID_MAX_LEN) { + length = WIFI_CONFIG_SSID_MAX_LEN; + } + memcpy(message.data.connected.ssid, event->ssid, length); + break; + } + case WIFI_EVENT_STA_DISCONNECTED: { + const wifi_event_sta_disconnected_t *event = event_data; + if (event == NULL) { + return; + } + message.type = MESSAGE_STA_DISCONNECTED; + message.data.disconnected.reason = event->reason; + break; + } + case WIFI_EVENT_STA_STOP: + message.type = MESSAGE_STA_STOPPED; + break; + case WIFI_EVENT_AP_STOP: + message.type = MESSAGE_AP_STOPPED; + break; + case WIFI_EVENT_AP_STACONNECTED: + message.type = MESSAGE_AP_CLIENT_JOINED; + break; + case WIFI_EVENT_AP_STADISCONNECTED: + message.type = MESSAGE_AP_CLIENT_LEFT; + break; + default: + return; + } + + /* No policy, Wi-Fi, NVS, logging, or delay is permitted in this callback. */ + (void)enqueue_message(&message); +} + +static void ip_event_callback(void *argument, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + (void)argument; + manager_message_t message; + memset(&message, 0, sizeof(message)); + + if (event_base != IP_EVENT) { + return; + } + + if (event_id == IP_EVENT_STA_GOT_IP) { + const ip_event_got_ip_t *event = event_data; + if (event == NULL) { + return; + } + message.type = MESSAGE_STA_GOT_IP; + message.data.got_ip.ip = event->ip_info.ip.addr; + message.data.got_ip.netmask = event->ip_info.netmask.addr; + message.data.got_ip.gateway = event->ip_info.gw.addr; + } else if (event_id == IP_EVENT_STA_LOST_IP) { + message.type = MESSAGE_STA_LOST_IP; + } else { + return; + } + + /* Keep IP events compact; address formatting belongs outside the callback. */ + (void)enqueue_message(&message); +} + +static esp_err_t create_default_wifi_netifs(void) +{ + /* + * The ESP-IDF convenience creators abort on internal failures. Build the + * same default netifs explicitly so Wi-Fi failure cannot take down UART/USB. + */ + esp_netif_config_t sta_config = ESP_NETIF_DEFAULT_WIFI_STA(); + s_sta_netif = esp_netif_new(&sta_config); + if (s_sta_netif == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t error = esp_netif_attach_wifi_station(s_sta_netif); + if (error == ESP_OK) { + error = esp_wifi_set_default_wifi_sta_handlers(); + } + if (error != ESP_OK) { + return error; + } + + esp_netif_config_t ap_config = ESP_NETIF_DEFAULT_WIFI_AP(); + s_ap_netif = esp_netif_new(&ap_config); + if (s_ap_netif == NULL) { + return ESP_ERR_NO_MEM; + } + + error = esp_netif_attach_wifi_ap(s_ap_netif); + if (error == ESP_OK) { + error = esp_wifi_set_default_wifi_ap_handlers(); + } + return error; +} + +static void cleanup_failed_init(bool wifi_initialized, bool wifi_handler_registered, + bool ip_handler_registered, bool netif_initialized) +{ + if (wifi_initialized) { + (void)esp_wifi_deinit(); + } + if (ip_handler_registered) { + (void)esp_event_handler_instance_unregister(IP_EVENT, ESP_EVENT_ANY_ID, + s_ip_handler); + } + if (wifi_handler_registered) { + (void)esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, + s_wifi_handler); + } + if (s_ap_netif != NULL) { + esp_netif_destroy_default_wifi(s_ap_netif); + s_ap_netif = NULL; + } + if (s_sta_netif != NULL) { + esp_netif_destroy_default_wifi(s_sta_netif); + s_sta_netif = NULL; + } + if (s_event_loop_owned) { + (void)esp_event_loop_delete_default(); + s_event_loop_owned = false; + } + if (netif_initialized) { + (void)esp_netif_deinit(); + } + if (s_queue != NULL) { + vQueueDelete(s_queue); + s_queue = NULL; + } + if (s_mutex != NULL) { + vSemaphoreDelete(s_mutex); + s_mutex = NULL; + } + s_task = NULL; +} + +esp_err_t wifi_manager_init(const wifi_app_config_t *config) +{ + esp_err_t error = wifi_config_validate(config); + if (error != ESP_OK) { + return error; + } + if (s_mutex != NULL) { + return ESP_ERR_INVALID_STATE; + } + + s_mutex = xSemaphoreCreateMutex(); + if (s_mutex == NULL) { + return ESP_ERR_NO_MEM; + } + s_queue = xQueueCreate(WIFI_MANAGER_QUEUE_LENGTH, sizeof(manager_message_t)); + if (s_queue == NULL) { + cleanup_failed_init(false, false, false, false); + return ESP_ERR_NO_MEM; + } + + memset(&s_shared, 0, sizeof(s_shared)); + s_shared.config = *config; + s_shared.snapshot.state = WIFI_MANAGER_STATE_STOPPED; + s_shared.snapshot.active_profile = -1; + s_shared.snapshot.ap_policy = config->ap_policy; + s_shared.snapshot.ap_channel = config->ap_channel; + s_shared.snapshot.sta_auth = WIFI_AUTH_OPEN; + s_shared.snapshot.last_error = ESP_OK; + s_shared.snapshot.config_generation = 1U; + portENTER_CRITICAL(&s_drop_mux); + s_queue_drops = 0U; + portEXIT_CRITICAL(&s_drop_mux); + + bool netif_initialized = false; + bool wifi_handler_registered = false; + bool ip_handler_registered = false; + bool wifi_initialized = false; + + error = esp_netif_init(); + if (error != ESP_OK) { + cleanup_failed_init(false, false, false, false); + return error; + } + netif_initialized = true; + + error = esp_event_loop_create_default(); + if (error == ESP_OK) { + s_event_loop_owned = true; + } else if (error != ESP_ERR_INVALID_STATE) { + cleanup_failed_init(false, false, false, netif_initialized); + return error; + } + + error = create_default_wifi_netifs(); + if (error != ESP_OK) { + cleanup_failed_init(false, false, false, netif_initialized); + return error; + } + + error = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + wifi_event_callback, NULL, + &s_wifi_handler); + if (error != ESP_OK) { + cleanup_failed_init(false, false, false, netif_initialized); + return error; + } + wifi_handler_registered = true; + + error = esp_event_handler_instance_register(IP_EVENT, ESP_EVENT_ANY_ID, + ip_event_callback, NULL, + &s_ip_handler); + if (error != ESP_OK) { + cleanup_failed_init(false, wifi_handler_registered, false, + netif_initialized); + return error; + } + ip_handler_registered = true; + + wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT(); + error = esp_wifi_init(&init_config); + if (error != ESP_OK) { + cleanup_failed_init(false, wifi_handler_registered, ip_handler_registered, + netif_initialized); + return error; + } + wifi_initialized = true; + + /* Credentials must remain in volatile driver memory, never flash storage. */ + error = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (error != ESP_OK) { + cleanup_failed_init(wifi_initialized, wifi_handler_registered, + ip_handler_registered, netif_initialized); + return error; + } + error = esp_wifi_set_ps(WIFI_PS_NONE); + if (error != ESP_OK) { + cleanup_failed_init(wifi_initialized, wifi_handler_registered, + ip_handler_registered, netif_initialized); + return error; + } + + if (xTaskCreate(manager_task, "wifi_manager", WIFI_MANAGER_TASK_STACK_SIZE, + NULL, WIFI_MANAGER_TASK_PRIORITY, &s_task) != pdPASS) { + cleanup_failed_init(wifi_initialized, wifi_handler_registered, + ip_handler_registered, netif_initialized); + return ESP_ERR_NO_MEM; + } + + lock_shared(); + s_shared.snapshot.initialized = true; + unlock_shared(); + return ESP_OK; +} + +esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config) +{ + if (config == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (s_mutex == NULL) { + return ESP_ERR_INVALID_STATE; + } + + copy_working_config(config); + return ESP_OK; +} + +esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config) +{ + esp_err_t error = wifi_config_validate(config); + if (error != ESP_OK) { + return error; + } + if (s_mutex == NULL) { + return ESP_ERR_INVALID_STATE; + } + + manager_message_t message = {.type = MESSAGE_COMMAND_APPLY}; + lock_shared(); + if (!enqueue_message(&message)) { + unlock_shared(); + return ESP_ERR_TIMEOUT; + } + s_shared.config = *config; + ++s_shared.snapshot.config_generation; + if (s_shared.snapshot.config_generation == 0U) { + s_shared.snapshot.config_generation = 1U; + } + s_shared.snapshot.ap_policy = config->ap_policy; + s_shared.snapshot.ap_channel = config->ap_channel; + ++s_shared.snapshot.counters.applies; + unlock_shared(); + return ESP_OK; +} + +static esp_err_t enqueue_lifecycle_command(manager_message_type_t type, + int enabled_at_boot) +{ + if (s_mutex == NULL) { + return ESP_ERR_INVALID_STATE; + } + + manager_message_t message = {.type = type}; + lock_shared(); + if (!enqueue_message(&message)) { + unlock_shared(); + return ESP_ERR_TIMEOUT; + } + + if (enabled_at_boot >= 0 && + s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) { + s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot; + ++s_shared.snapshot.config_generation; + if (s_shared.snapshot.config_generation == 0U) { + s_shared.snapshot.config_generation = 1U; + } + } + unlock_shared(); + return ESP_OK; +} + +esp_err_t wifi_manager_start(void) +{ + return enqueue_lifecycle_command(MESSAGE_COMMAND_START, 1); +} + +esp_err_t wifi_manager_stop(void) +{ + return enqueue_lifecycle_command(MESSAGE_COMMAND_STOP, 0); +} + +esp_err_t wifi_manager_reconnect(void) +{ + return enqueue_lifecycle_command(MESSAGE_COMMAND_RECONNECT, -1); +} + +esp_err_t wifi_manager_get_snapshot(wifi_manager_snapshot_t *snapshot) +{ + if (snapshot == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (s_mutex == NULL) { + return ESP_ERR_INVALID_STATE; + } + + lock_shared(); + *snapshot = s_shared.snapshot; + portENTER_CRITICAL(&s_drop_mux); + snapshot->counters.queue_drops = s_queue_drops; + portEXIT_CRITICAL(&s_drop_mux); + unlock_shared(); + return ESP_OK; +} + +esp_err_t wifi_manager_clear_counters(void) +{ + if (s_mutex == NULL) { + return ESP_ERR_INVALID_STATE; + } + + lock_shared(); + memset(&s_shared.snapshot.counters, 0, sizeof(s_shared.snapshot.counters)); + portENTER_CRITICAL(&s_drop_mux); + s_queue_drops = 0U; + portEXIT_CRITICAL(&s_drop_mux); + unlock_shared(); + return ESP_OK; +} + +const char *wifi_manager_state_to_string(wifi_manager_state_t state) +{ + switch (state) { + case WIFI_MANAGER_STATE_STOPPED: + return "stopped"; + case WIFI_MANAGER_STATE_STARTING: + return "starting"; + case WIFI_MANAGER_STATE_CONNECTING: + return "connecting"; + case WIFI_MANAGER_STATE_WAITING_IP: + return "waiting-ip"; + case WIFI_MANAGER_STATE_ONLINE: + return "online"; + case WIFI_MANAGER_STATE_BACKOFF: + return "backoff"; + case WIFI_MANAGER_STATE_AP_ONLY: + return "ap-only"; + case WIFI_MANAGER_STATE_ERROR: + return "error"; + default: + return "unknown"; + } +} diff --git a/src/wifi_manager.h b/src/wifi_manager.h new file mode 100644 index 0000000..a3f1a11 --- /dev/null +++ b/src/wifi_manager.h @@ -0,0 +1,102 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Native ESP-IDF Wi-Fi lifecycle and connection policy manager. */ + +#pragma once + +#include +#include + +#include "esp_err.h" +#include "esp_wifi_types.h" +#include "wifi_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + WIFI_MANAGER_STATE_STOPPED = 0, + WIFI_MANAGER_STATE_STARTING, + WIFI_MANAGER_STATE_CONNECTING, + WIFI_MANAGER_STATE_WAITING_IP, + WIFI_MANAGER_STATE_ONLINE, + WIFI_MANAGER_STATE_BACKOFF, + WIFI_MANAGER_STATE_AP_ONLY, + WIFI_MANAGER_STATE_ERROR, +} wifi_manager_state_t; + +typedef struct { + uint64_t starts; + uint64_t stops; + uint64_t applies; + uint64_t connect_attempts; + uint64_t associations; + uint64_t got_ip; + uint64_t disconnects; + uint64_t profile_cycles; + uint64_t retries; + uint64_t ap_starts; + uint64_t ap_stops; + uint64_t client_joins; + uint64_t client_leaves; + uint64_t queue_drops; +} wifi_manager_counters_t; + +typedef struct { + bool initialized; + bool started; + wifi_manager_state_t state; + + /* -1 means that no station profile is currently being attempted or used. */ + int8_t active_profile; + uint8_t sta_ssid_len; + char sta_ssid[WIFI_CONFIG_SSID_MAX_LEN + 1U]; + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + uint8_t sta_channel; + int8_t sta_rssi; + wifi_auth_mode_t sta_auth; + + wifi_config_ap_policy_t ap_policy; + bool ap_running; + uint8_t ap_client_count; + uint8_t ap_channel; + + uint16_t last_disconnect_reason; + esp_err_t last_error; + uint32_t retry_seconds; + uint32_t config_generation; + wifi_manager_counters_t counters; +} wifi_manager_snapshot_t; + +/* + * Initializes ESP-NETIF, the default event loop, both default Wi-Fi netifs, + * Wi-Fi itself, and the permanent policy task. The manager never aborts the + * application on failure; callers may keep UART/USB services operational. + */ +esp_err_t wifi_manager_init(const wifi_app_config_t *config); + +/* Returns a copy of the RAM working configuration, including credentials. */ +esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config); + +/* + * Replaces the RAM working configuration. Application is asynchronous; when + * Wi-Fi is running, the manager task restarts it using the newest generation. + */ +esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config); + +/* Lifecycle requests are asynchronous and serialized by the manager task. */ +esp_err_t wifi_manager_start(void); +esp_err_t wifi_manager_stop(void); +esp_err_t wifi_manager_reconnect(void); + +/* Snapshot data never contains station or AP passwords. */ +esp_err_t wifi_manager_get_snapshot(wifi_manager_snapshot_t *snapshot); +esp_err_t wifi_manager_clear_counters(void); + +const char *wifi_manager_state_to_string(wifi_manager_state_t state); + +#ifdef __cplusplus +} +#endif diff --git a/wiring.md b/wiring.md index 2028236..6346284 100644 --- a/wiring.md +++ b/wiring.md @@ -85,6 +85,7 @@ The breakout's `OFF` pin is connected to the MAX3243 active-low `!FORCEOFF` inpu - The breakout is not isolated: ESP32 ground, breakout ground, USB ground, and DE-9 pin 5 are electrically connected. - The breakout is electrically DTE-style: `TX`, `RTS`, and `DTR` drive the DE-9, while `RX`, `CTS`, `DSR`, `DCD`, and `RI` receive from it. - A straight-through male-to-male gender changer changes connector gender only. Use a straight-through cable for a DCE peer and an appropriate null-modem cable for another DTE peer. +- The current female Adafruit breakout was validated in a temporary DTE-to-DTE lab chain using a straight-through male-to-male gender changer, a null-modem cable, and a USB RS-232 adapter. The gender changer corrected only the physical connector while the null-modem cable crossed the signal directions. A male version of the Adafruit breakout is planned as the cleaner permanent connector arrangement. GPIO8 and GPIO9 are not adjacent on the official J1 header. GPIO3 and GPIO46 lie between them and are boot-strapping pins, so follow the printed GPIO labels instead of counting header positions.