From de0b01ad3c5a1874b9fb75cf4d72fb8cc9311411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Saillant?= Date: Sun, 14 Jun 2026 19:38:43 +0200 Subject: [PATCH] feat(wifi): SoftAP captive provisioning portal --- ...2026-06-14-lisael-box-wifi-provisioning.md | 18 ++ ...-14-lisael-box-wifi-provisioning-design.md | 95 +++++++ main/CMakeLists.txt | 3 + main/app_main.c | 110 +++++++- main/modes/wifi_setup.c | 38 +++ main/net/udp_log.c | 61 +++++ main/net/wifi.c | 112 ++++++++- main/net/wifi.h | 16 ++ main/net/wifi_portal.c | 236 ++++++++++++++++++ main/net/wifi_portal.h | 20 ++ main/ui/ui.h | 1 + 11 files changed, 696 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-14-lisael-box-wifi-provisioning.md create mode 100644 docs/superpowers/specs/2026-06-14-lisael-box-wifi-provisioning-design.md create mode 100644 main/modes/wifi_setup.c create mode 100644 main/net/udp_log.c create mode 100644 main/net/wifi_portal.c create mode 100644 main/net/wifi_portal.h diff --git a/docs/superpowers/plans/2026-06-14-lisael-box-wifi-provisioning.md b/docs/superpowers/plans/2026-06-14-lisael-box-wifi-provisioning.md new file mode 100644 index 0000000..47379b7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-lisael-box-wifi-provisioning.md @@ -0,0 +1,18 @@ +# Provisioning WiFi par SoftAP captif — Plan + +> REQUIRED SUB-SKILL: subagent-driven-development (code) + flash/recette inline (matériel). + +**Goal:** AP-fallback de provisioning : STA échoue → SoftAP `Lisael-Box` (ouvert) + portail captif (page de config WiFi) → save NVS → reconnexion → arrêt AP. + +**Tech:** ESP-IDF v5.4, esp_wifi APSTA, esp_http_server, mini serveur DNS captif, LVGL (écran d'info). + +Voir le détail complet : `docs/superpowers/specs/2026-06-14-lisael-box-wifi-provisioning-design.md`. + +### Tâches +1. **wifi.c/.h** — `lisael_wifi_start_ap()`, `lisael_wifi_stop_ap()`, `lisael_wifi_apply_sta(ssid,pass)`, `lisael_wifi_scan(out,max)`. (build vert) +2. **net/wifi_portal.c/.h** — esp_http_server (`/` page+form, `/save` POST, 404→302 captif) + tâche DNS catch-all UDP:53 → 192.168.4.1. `lisael_wifi_portal_start/stop`. (build vert) +3. **modes/wifi_setup.c** + `ui.h` — écran « Réglage WiFi / rejoins Lisael-Box ». (build vert) +4. **app_main net_task** — STA 20 s ; sinon AP+portail+écran ; watcher → connexion → teardown + retour Accueil + SNTP/météo. CMakeLists (+wifi_portal, +wifi_setup, REQUIRES esp_http_server). (build vert) +5. **Flash + recette inline** (matériel) : le Mac rejoint `Lisael-Box`, portail s'ouvre, config WiFi, reconnexion ; + capture UDP des pochettes par-dessus l'AP. + +Vérif par tâche = `idf.py build` vert ; recette finale sur carte (Task 5). diff --git a/docs/superpowers/specs/2026-06-14-lisael-box-wifi-provisioning-design.md b/docs/superpowers/specs/2026-06-14-lisael-box-wifi-provisioning-design.md new file mode 100644 index 0000000..b022ccc --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-lisael-box-wifi-provisioning-design.md @@ -0,0 +1,95 @@ +# Lisael Box — Provisioning WiFi par SoftAP (captif) — design + +**Date:** 2026-06-14 +**Statut:** validé (brainstorming), prêt pour le plan. +**Projet:** firmware Lisael Box (ESP32-S3-BOX-3), ESP-IDF v5.4. + +## But + +Permettre de configurer le WiFi de la box **sans reflasher** : quand la box ne se +connecte à aucun réseau connu, elle devient un **point d'accès `Lisael-Box`** et +sert un **portail captif** où l'on choisit son réseau WiFi et saisit le mot de +passe ; la box enregistre en NVS et se reconnecte. Débloque aussi le debug (le +Mac rejoint l'AP → même sous-réseau → logs UDP). + +## Décisions (validées) +- **Portail captif** : DNS catch-all → la page s'ouvre automatiquement en rejoignant l'AP. +- **AP ouvert** (sans mot de passe), SSID **`Lisael-Box`**. +- Construit maintenant. + +## Comportement + +1. Au boot, `net_task` tente le **STA** avec les creds NVS/menuconfig (existant). +2. Si **non connecté après ~20 s** (ou aucune cred enregistrée) → bascule en + **APSTA** : SoftAP `Lisael-Box` (ouvert, IP 192.168.4.1) + démarre le **portail**. + Le STA reste actif (tentatives de reconnexion en tâche de fond). +3. La box affiche un **écran WiFi** : « Connecte-toi au réseau **Lisael-Box** — + la page de configuration s'ouvre toute seule (sinon va sur 192.168.4.1) ». +4. L'utilisateur rejoint `Lisael-Box`. Le **portail captif** redirige tout → la + page de config : **liste des réseaux scannés** (boutons) + champ mot de passe. +5. À la validation → la box **enregistre les creds (NVS)** via + `lisael_wifi_save_credentials()`, applique la config STA et **reconnecte**. +6. Dès connexion STA réussie → la box **arrête l'AP + le portail**, quitte l'écran + WiFi (retour Accueil), poursuit SNTP/météo/etc. + +## Composants + +- **`main/net/wifi.c` / `.h`** (modif) : ajouter + - `esp_err_t lisael_wifi_start_ap(void)` — passe en `WIFI_MODE_APSTA`, crée le + netif AP (`esp_netif_create_default_wifi_ap`), configure SoftAP `Lisael-Box` + (`WIFI_AUTH_OPEN`, `max_connection=4`, canal 1), `esp_wifi_start` (idempotent + si déjà démarré → juste `set_mode` + AP config). + - `esp_err_t lisael_wifi_stop_ap(void)` — repasse en `WIFI_MODE_STA`. + - `esp_err_t lisael_wifi_apply_sta(const char *ssid, const char *pass)` — + `esp_wifi_set_config(STA)` + `esp_wifi_connect()` (réutilisé par le portail). + - `int lisael_wifi_scan(wifi_ap_record_t *out, int max)` — scan bloquant court, + renvoie le nb de réseaux (dédupliqués par SSID). + - exposer `lisael_wifi_is_connected()` (existe déjà). +- **`main/net/wifi_portal.c` / `.h`** (neuf) : + - `void lisael_wifi_portal_start(void)` — démarre **esp_http_server** (port 80) + + un **serveur DNS captif** (tâche : socket UDP 53, répond à toute requête A + par 192.168.4.1). + - `void lisael_wifi_portal_stop(void)` — arrête HTTP + DNS. + - Routes HTTP : `GET /` → page HTML (form : `` + submit) ; `GET /scan` → JSON `[{ssid,rssi}]` ; + `POST /save` (form-urlencoded `ssid`,`pass`) → `lisael_wifi_save_credentials` + + `lisael_wifi_apply_sta` → page « Connexion en cours… » ; **catch-all** (404 + handler) → redirection 302 vers `http://192.168.4.1/` (déclenche le portail + captif des OS, ex. `/generate_204`, `/hotspot-detect.html`). +- **`main/app_main.c`** (modif `net_task`) : après `lisael_wifi_wait_connected(20s)` + échoué → `lisael_wifi_start_ap()` + `lisael_wifi_portal_start()` + afficher + l'écran WiFi ; lancer une petite tâche qui attend `lisael_wifi_is_connected()` + puis `lisael_wifi_portal_stop()` + `lisael_wifi_stop_ap()` + retour Accueil + + SNTP/météo. +- **`main/modes/wifi_setup.c`** (neuf) + déclaration dans `ui.h` : + `lv_obj_t *lisael_screen_wifi_setup(void)` — écran plein : titre « Réglage WiFi », + texte « Connecte-toi au réseau **Lisael-Box** sur ton téléphone, la page s'ouvre + toute seule (sinon : 192.168.4.1) ». Affiché via un nouveau mode/loader dédié + (pas une tuile pour l'instant). Mis à jour « Connexion… » puis disparaît. +- **`main/CMakeLists.txt`** : ajouter `net/wifi_portal.c`, `modes/wifi_setup.c` ; + `REQUIRES` += `esp_http_server`. +- **sdkconfig** : `esp_http_server` (composant, pas de Kconfig spécifique) ; SoftAP + ne demande rien de spécial. `CONFIG_LWIP_MAX_SOCKETS` suffisant (DNS+HTTP). + +## Gestion d'erreurs / robustesse +- RAM tendue : le portail (HTTP + DNS) ne tourne **que** en mode AP (sinon arrêté). + Scan bref. Buffers HTTP modestes. +- Si `esp_wifi_init` a échoué (offline total, déjà géré) → pas d'AP non plus (log). +- Mauvais mot de passe → la reconnexion échoue → on **re-bascule en AP/portail** + (la page indique « échec, réessaie »). Boucle jusqu'à succès. +- Le portail capte aussi les URL de détection OS (302 → `/`). + +## Hors-scope (v2) +- Tuile « Réglages WiFi » à l'accueil pour relancer le provisioning à la demande. +- Mot de passe sur l'AP, page stylée, multi-langue, mémorisation de plusieurs réseaux. + +## Tests (acceptation, sur carte) +1. Effacer/!connecter le STA → au boot la box montre l'écran « Lisael-Box ». +2. Le Mac/téléphone voit le réseau `Lisael-Box`, le rejoint → la page de config + **s'ouvre toute seule** (portail captif). +3. La page liste les réseaux WiFi proches ; on choisit, on saisit le mot de passe, + on valide. +4. La box se connecte, arrête l'AP, revient à l'Accueil ; date/météo repartent. +5. (debug) Pendant que le Mac est sur `Lisael-Box`, les logs UDP de la box + arrivent (écouteur 9999) → permet de diagnostiquer le bug des pochettes. +6. Mauvais mot de passe → retour au portail avec message d'échec. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index fdce708..9883041 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -13,6 +13,7 @@ set(srcs "app_main.c" "net/wifi.c" + "net/wifi_portal.c" "net/sntp_time.c" "net/weather.c" "net/udp_log.c" @@ -26,6 +27,7 @@ set(srcs "modes/games.c" "modes/balloons.c" "modes/histoires.c" + "modes/wifi_setup.c" "audio/audio_player.c" "audio/radio_pipeline.c" "audio/sd_player.c" @@ -51,6 +53,7 @@ set(reqs lwip # lwip/sockets.h for the UDP log sink esp-tls esp_http_client + esp_http_server # SoftAP captive provisioning portal mbedtls # esp_crt_bundle_attach (CA bundle for HTTPS) esp-box-3 # BSP: display, touch, codec, sdcard, sensors json # IDF built-in cJSON component (provides cJSON.h) diff --git a/main/app_main.c b/main/app_main.c index 3749302..083e79d 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -19,15 +19,58 @@ #include "net/weather.h" #include "audio/audio_player.h" #include "audio/sd_player.h" +#include "audio/aac_player.h" +#include "audio/radio_pipeline.h" +#include "net/podcast.h" +#include "net/udp_log.h" +#include "net/wifi_portal.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/idf_additions.h" #include "esp_log.h" +#include "esp_heap_caps.h" #include "nvs_flash.h" #include "bsp/esp-box-3.h" static const char *TAG = "app"; +// Set to 1 to auto-exercise radio + podcast at boot (autonomous testing). +// OFF for normal use (the podcast download still resets the box, so leaving the +// self-test on would loop-reboot). Radio works; podcast download = known TODO. +#define LISAEL_SELFTEST 0 + +#if LISAEL_SELFTEST +static void selftest_task(void *arg) +{ + (void)arg; + lisael_udp_send("ST: === RADIO test: Radio Doudou ==="); + esp_err_t r = lisael_radio_play( + "https://listen.radioking.com/radio/11565/stream/22961"); + lisael_udp_send("ST: radio_play ret=%s", esp_err_to_name(r)); + for (int i = 0; i < 10; i++) { + vTaskDelay(pdMS_TO_TICKS(2000)); + lisael_udp_send("ST: radio playing=%d (t=%ds)", + lisael_radio_is_playing(), (i + 1) * 2); + } + lisael_radio_stop(); + lisael_udp_send("ST: === radio test done ==="); + + vTaskDelay(pdMS_TO_TICKS(1500)); + lisael_udp_send("ST: === PODCAST test: Oli sync (download) ==="); + esp_err_t e = lisael_podcast_sync(0); + lisael_udp_send("ST: podcast sync(0)=%s", esp_err_to_name(e)); + if (e == ESP_OK) { + char p[96]; + lisael_podcast_local_path(0, p, sizeof(p)); + lisael_aac_play_file(p); + lisael_udp_send("ST: aac_play started playing=%d", lisael_aac_is_playing()); + } + lisael_udp_send("ST: === selftest done ==="); + vTaskDeleteWithCaps(NULL); +} +#endif + // Called by the weather task whenever a fetch completes — push to the home UI. static void on_weather_update(const lisael_weather_t *w) { @@ -52,19 +95,43 @@ static void tick_task(void *arg) static void net_task(void *arg) { (void)arg; - ESP_ERROR_CHECK(lisael_wifi_start()); - - if (lisael_wifi_wait_connected(30000)) { - ESP_LOGI(TAG, "Wi-Fi up — starting SNTP + weather"); - lisael_sntp_start(); - lisael_weather_start(on_weather_update); - // Nudge the clock once time is likely valid. - vTaskDelay(pdMS_TO_TICKS(3000)); - lisael_home_update_clock(); - } else { - ESP_LOGW(TAG, "Wi-Fi not connected after 30s — offline mode"); - // UI still works (radio/weather show "—"); Wi-Fi keeps retrying. + if (lisael_wifi_start() != ESP_OK) { + ESP_LOGW(TAG, "Wi-Fi unavailable — offline mode (UI still works)"); + vTaskDelete(NULL); + return; } + + lisael_udp_log_start(9999); // debug sink; also reaches a host on the SoftAP + + if (!lisael_wifi_wait_connected(20000)) { + // No known network — run a SoftAP captive portal so the Wi-Fi can be + // chosen and its password entered from a phone, then reconnect. + ESP_LOGW(TAG, "no Wi-Fi after 20s — starting captive provisioning portal"); + if (bsp_display_lock(100)) { + lv_screen_load(lisael_screen_wifi_setup()); + bsp_display_unlock(); + } + lisael_wifi_start_ap(); + lisael_wifi_portal_start(); + while (!lisael_wifi_is_connected()) { + vTaskDelay(pdMS_TO_TICKS(500)); + } + lisael_wifi_portal_stop(); + lisael_wifi_stop_ap(); + if (bsp_display_lock(100)) { + lisael_ui_goto(LISAEL_MODE_HOME); + bsp_display_unlock(); + } + } + + // Connected (directly, or after provisioning): clock + weather. + ESP_LOGI(TAG, "Wi-Fi up — starting SNTP + weather"); + lisael_sntp_start(); + lisael_weather_start(on_weather_update); + for (int i = 0; i < 60 && !lisael_time_is_valid(); i++) { + vTaskDelay(pdMS_TO_TICKS(250)); + } + lisael_home_update_clock(); vTaskDelete(NULL); } @@ -82,7 +149,23 @@ void app_main(void) // --- 2. BSP: I2C, display + touch + LVGL -------------------------------- ESP_ERROR_CHECK(bsp_i2c_init()); - bsp_display_start(); // brings up LCD, touch and the LVGL task + // LVGL draw buffer in INTERNAL DMA RAM. A PSRAM framebuffer intermittently + // wedged the LVGL task in wait_for_flushing (SPI-DMA-from-PSRAM flush-done + // notification sometimes never fired -> task_wdt -> UI freeze). Internal + // DMA flushes reliably. We keep it small (32 lines, ~20 KB) so it coexists + // with Wi-Fi/TLS: the Wi-Fi memory was retuned (dynamic TX buffers go to + // PSRAM, static RX trimmed, MBEDTLS_DYNAMIC_BUFFER) which freed the internal + // DMA heap this buffer needs. + bsp_display_cfg_t disp_cfg = { + .lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(), + .buffer_size = BSP_LCD_H_RES * 16, + .double_buffer = 0, + .flags = { + .buff_dma = true, + .buff_spiram = false, + }, + }; + bsp_display_start_with_config(&disp_cfg); // LCD, touch and the LVGL task bsp_display_backlight_on(); bsp_display_brightness_set(80); // comfortable default brightness @@ -103,6 +186,7 @@ void app_main(void) // --- 6 & 7. Networking + periodic refresh tasks ------------------------- xTaskCreatePinnedToCore(net_task, "net", 8192, NULL, 4, NULL, tskNO_AFFINITY); xTaskCreatePinnedToCore(tick_task, "tick", 4096, NULL, 3, NULL, tskNO_AFFINITY); + // (boot AAC self-test removed — on-device AAC playback is validated) ESP_LOGI(TAG, "Lisael Box ready"); } diff --git a/main/modes/wifi_setup.c b/main/modes/wifi_setup.c new file mode 100644 index 0000000..3efdb73 --- /dev/null +++ b/main/modes/wifi_setup.c @@ -0,0 +1,38 @@ +// "Reglage WiFi" screen — shown while the SoftAP captive portal is active so the +// user knows to join the "Lisael-Box" network and configure Wi-Fi from a phone. +// Not a home tile / mode: app_main loads it directly during provisioning and +// returns to Home once the station reconnects. + +#include "ui/ui.h" + +static lv_obj_t *s_root; + +lv_obj_t *lisael_screen_wifi_setup(void) +{ + if (s_root) { + return s_root; + } + s_root = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_root, lv_color_hex(0x1B2A4A), 0); + lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0x0E1726), 0); + lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0); + + lv_obj_t *title = lv_label_create(s_root); + lv_label_set_text(title, "Réglage WiFi"); + lv_obj_set_style_text_color(title, lv_color_white(), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 22); + + lv_obj_t *msg = lv_label_create(s_root); + lv_label_set_text(msg, + "Pas de WiFi connu.\n\n" + "Sur ton téléphone, connecte-toi\n" + "au réseau \"Lisael-Box\".\n\n" + "La page de réglage s'ouvre toute seule\n" + "(sinon va sur 192.168.4.1)."); + lv_obj_set_style_text_color(msg, lv_color_hex(0xDCE6FF), 0); + lv_obj_set_style_text_align(msg, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(msg, 300); + lv_obj_align(msg, LV_ALIGN_CENTER, 0, 16); + + return s_root; +} diff --git a/main/net/udp_log.c b/main/net/udp_log.c new file mode 100644 index 0000000..add29d4 --- /dev/null +++ b/main/net/udp_log.c @@ -0,0 +1,61 @@ +// UDP reporter — see udp_log.h. + +#include "net/udp_log.h" + +#include +#include +#include +#include + +#include "lwip/sockets.h" + +static int s_sock = -1; +static struct sockaddr_in s_dest; // 255.255.255.255 (STA LAN) +static struct sockaddr_in s_dest_ap; // 192.168.4.255 (SoftAP subnet) + +void lisael_udp_log_start(uint16_t port) +{ + if (s_sock >= 0) { + return; + } + s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s_sock < 0) { + return; + } + int yes = 1; + setsockopt(s_sock, SOL_SOCKET, SO_BROADCAST, &yes, sizeof(yes)); + fcntl(s_sock, F_SETFL, O_NONBLOCK); // never block a caller + + memset(&s_dest, 0, sizeof(s_dest)); + s_dest.sin_family = AF_INET; + s_dest.sin_port = htons(port); + s_dest.sin_addr.s_addr = htonl(INADDR_BROADCAST); // 255.255.255.255 + + // Directed broadcast on the SoftAP subnet so logs reach a host joined to the + // "Lisael-Box" AP (the limited broadcast above egresses the default netif). + memset(&s_dest_ap, 0, sizeof(s_dest_ap)); + s_dest_ap.sin_family = AF_INET; + s_dest_ap.sin_port = htons(port); + s_dest_ap.sin_addr.s_addr = htonl(0xC0A804FF); // 192.168.4.255 +} + +void lisael_udp_send(const char *fmt, ...) +{ + if (s_sock < 0) { + return; + } + char line[320]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + if (n <= 0) { + return; + } + if (n > (int)sizeof(line) - 1) { + n = sizeof(line) - 1; + } + line[n] = '\n'; + sendto(s_sock, line, n + 1, 0, (struct sockaddr *)&s_dest, sizeof(s_dest)); + sendto(s_sock, line, n + 1, 0, (struct sockaddr *)&s_dest_ap, sizeof(s_dest_ap)); +} diff --git a/main/net/wifi.c b/main/net/wifi.c index 2cab469..c5ace82 100644 --- a/main/net/wifi.c +++ b/main/net/wifi.c @@ -8,6 +8,7 @@ #include "lisael_config.h" #include +#include #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" @@ -89,7 +90,15 @@ esp_err_t lisael_wifi_start(void) esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + esp_err_t init_err = esp_wifi_init(&cfg); + if (init_err != ESP_OK) { + // Most likely ESP_ERR_NO_MEM: the Wi-Fi static DMA buffers didn't fit + // in internal RAM. Don't abort the whole box — run offline (the UI, + // radio buttons and clock still work; date/weather just stay blank). + ESP_LOGE(TAG, "esp_wifi_init failed: %s — running offline", + esp_err_to_name(init_err)); + return init_err; + } ESP_ERROR_CHECK(esp_event_handler_instance_register( WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi_event, NULL, NULL)); @@ -108,6 +117,11 @@ esp_err_t lisael_wifi_start(void) ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); + // Disable Wi-Fi modem power-save: with it on, the chip sleeps between + // beacons, which stalls the USB-Serial-JTAG console (logs stop flushing + // after association) AND can cause webradio stream under-runs. The box is + // mains-powered, so keep the radio fully awake. + esp_wifi_set_ps(WIFI_PS_NONE); ESP_LOGI(TAG, "Wi-Fi started, SSID=\"%s\"", wifi_config.sta.ssid); return ESP_OK; @@ -146,3 +160,99 @@ esp_err_t lisael_wifi_save_credentials(const char *ssid, const char *pass) nvs_close(nvs); return err; } + +// --- SoftAP provisioning ---------------------------------------------------- + +esp_err_t lisael_wifi_start_ap(void) +{ + static esp_netif_t *s_ap_netif; // created once + if (!s_ap_netif) { + s_ap_netif = esp_netif_create_default_wifi_ap(); + } + wifi_config_t ap = {0}; + const char *ssid = "Lisael-Box"; + strlcpy((char *)ap.ap.ssid, ssid, sizeof(ap.ap.ssid)); + ap.ap.ssid_len = strlen(ssid); + ap.ap.channel = 1; + ap.ap.max_connection = 4; + ap.ap.authmode = WIFI_AUTH_OPEN; + + // esp_wifi is already started (STA) — switching to APSTA at runtime is fine. + esp_err_t err = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set APSTA failed: %s", esp_err_to_name(err)); + return err; + } + err = esp_wifi_set_config(WIFI_IF_AP, &ap); + ESP_LOGI(TAG, "SoftAP \"%s\" up (open, 192.168.4.1)", ssid); + return err; +} + +esp_err_t lisael_wifi_stop_ap(void) +{ + return esp_wifi_set_mode(WIFI_MODE_STA); // back to station-only +} + +esp_err_t lisael_wifi_apply_sta(const char *ssid, const char *pass) +{ + if (!ssid || !ssid[0]) { + return ESP_ERR_INVALID_ARG; + } + wifi_config_t c = {0}; + strlcpy((char *)c.sta.ssid, ssid, sizeof(c.sta.ssid)); + strlcpy((char *)c.sta.password, pass ? pass : "", sizeof(c.sta.password)); + c.sta.threshold.authmode = (pass && pass[0]) ? WIFI_AUTH_WPA2_PSK + : WIFI_AUTH_OPEN; + c.sta.pmf_cfg.capable = true; + c.sta.pmf_cfg.required = false; + + esp_err_t err = esp_wifi_set_config(WIFI_IF_STA, &c); + if (err != ESP_OK) { + return err; + } + s_retry_count = 0; + esp_wifi_disconnect(); + ESP_LOGI(TAG, "applying station credentials, SSID=\"%s\"", ssid); + return esp_wifi_connect(); +} + +int lisael_wifi_scan(wifi_ap_record_t *out, int max) +{ + if (!out || max <= 0) { + return 0; + } + if (esp_wifi_scan_start(NULL, true) != ESP_OK) { + return 0; + } + uint16_t found = 0; + esp_wifi_scan_get_ap_num(&found); + if (found == 0) { + return 0; + } + wifi_ap_record_t *recs = calloc(found, sizeof(wifi_ap_record_t)); + if (!recs) { + esp_wifi_clear_ap_list(); + return 0; + } + esp_wifi_scan_get_ap_records(&found, recs); // also frees the internal list + + int cnt = 0; + for (int i = 0; i < found && cnt < max; i++) { + if (recs[i].ssid[0] == '\0') { + continue; // hidden network + } + bool dup = false; + for (int j = 0; j < cnt; j++) { + if (strcmp((char *)out[j].ssid, (char *)recs[i].ssid) == 0) { + dup = true; + break; + } + } + if (!dup) { + out[cnt++] = recs[i]; + } + } + free(recs); + ESP_LOGI(TAG, "scan: %d network(s)", cnt); + return cnt; +} diff --git a/main/net/wifi.h b/main/net/wifi.h index 111e66f..eef62a4 100644 --- a/main/net/wifi.h +++ b/main/net/wifi.h @@ -3,6 +3,7 @@ #include #include "esp_err.h" +#include "esp_wifi.h" // wifi_ap_record_t (scan results) #ifdef __cplusplus extern "C" { @@ -30,6 +31,21 @@ bool lisael_wifi_is_connected(void); // Persist new credentials to NVS (used by an eventual provisioning UI/console). esp_err_t lisael_wifi_save_credentials(const char *ssid, const char *pass); +// --- SoftAP provisioning (captive portal) ----------------------------------- +// Bring up a "Lisael-Box" SoftAP alongside the station (APSTA), so a phone can +// connect and configure Wi-Fi. Idempotent. +esp_err_t lisael_wifi_start_ap(void); + +// Tear the SoftAP back down (return to station-only mode). +esp_err_t lisael_wifi_stop_ap(void); + +// Apply new station credentials at runtime and (re)connect. Used by the portal. +esp_err_t lisael_wifi_apply_sta(const char *ssid, const char *pass); + +// Blocking scan of nearby networks; fills out[] (deduped by SSID, skips hidden), +// returns the count (<= max). +int lisael_wifi_scan(wifi_ap_record_t *out, int max); + #ifdef __cplusplus } #endif diff --git a/main/net/wifi_portal.c b/main/net/wifi_portal.c new file mode 100644 index 0000000..585855c --- /dev/null +++ b/main/net/wifi_portal.c @@ -0,0 +1,236 @@ +// Captive Wi-Fi provisioning portal — see wifi_portal.h. +// +// HTTP config page (esp_http_server) + a tiny catch-all DNS server so any host +// on the SoftAP resolves to 192.168.4.1 and the OS opens the portal page. + +#include "net/wifi_portal.h" +#include "net/wifi.h" + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/idf_additions.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_http_server.h" +#include "esp_wifi.h" +#include "lwip/sockets.h" + +static const char *TAG = "portal"; + +static httpd_handle_t s_httpd; +static volatile bool s_dns_run; + +// --- url-decode an application/x-www-form-urlencoded value ------------------ +static void url_decode(char *dst, const char *src, size_t dstsz) +{ + size_t di = 0; + for (size_t i = 0; src[i] && di + 1 < dstsz; i++) { + if (src[i] == '+') { + dst[di++] = ' '; + } else if (src[i] == '%' && isxdigit((unsigned char)src[i + 1]) && + isxdigit((unsigned char)src[i + 2])) { + char h[3] = { src[i + 1], src[i + 2], '\0' }; + dst[di++] = (char)strtol(h, NULL, 16); + i += 2; + } else { + dst[di++] = src[i]; + } + } + dst[di] = '\0'; +} + +// Extract & decode form field `key` from a urlencoded body. Returns false if absent. +static bool form_field(const char *body, const char *key, char *out, size_t outsz) +{ + size_t klen = strlen(key); + const char *p = body; + while (p && *p) { + if (strncmp(p, key, klen) == 0 && p[klen] == '=') { + const char *v = p + klen + 1; + const char *end = strchr(v, '&'); + size_t vlen = end ? (size_t)(end - v) : strlen(v); + char raw[256]; + if (vlen >= sizeof(raw)) { + vlen = sizeof(raw) - 1; + } + memcpy(raw, v, vlen); + raw[vlen] = '\0'; + url_decode(out, raw, outsz); + return true; + } + p = strchr(p, '&'); + if (p) { + p++; + } + } + return false; +} + +// --- HTTP handlers ---------------------------------------------------------- +static esp_err_t root_get(httpd_req_t *req) +{ + wifi_ap_record_t aps[16]; + int n = lisael_wifi_scan(aps, 16); + + char *buf = malloc(4096); + if (!buf) { + httpd_resp_sendstr(req, "out of memory"); + return ESP_OK; + } + int o = snprintf(buf, 4096, + "" + "" + "Lisael Box - WiFi" + "" + "

Reglage WiFi - Lisael Box

" + "
" + "
" + "

" + "
" + "" + "

" + "" + "
"); + + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, buf, o); + free(buf); + return ESP_OK; +} + +static esp_err_t save_post(httpd_req_t *req) +{ + char body[512]; + int len = req->content_len < (int)sizeof(body) - 1 + ? req->content_len : (int)sizeof(body) - 1; + int r = httpd_req_recv(req, body, len); + if (r <= 0) { + return ESP_FAIL; + } + body[r] = '\0'; + + char ssid[64] = {0}, pass[128] = {0}; + form_field(body, "ssid", ssid, sizeof(ssid)); + form_field(body, "pass", pass, sizeof(pass)); + ESP_LOGI(TAG, "provision request: ssid='%s'", ssid); + + if (ssid[0]) { + lisael_wifi_save_credentials(ssid, pass); + lisael_wifi_apply_sta(ssid, pass); + } + + httpd_resp_set_type(req, "text/html"); + httpd_resp_sendstr(req, + "" + "" + "

Connexion en cours...

" + "

Tu peux fermer cette page.

"); + return ESP_OK; +} + +// Any unknown URL (incl. OS captive-check probes) -> bounce to the portal. +static esp_err_t redirect_404(httpd_req_t *req, httpd_err_code_t err) +{ + (void)err; + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +// --- catch-all DNS (every query -> 192.168.4.1) ----------------------------- +static void dns_task(void *arg) +{ + (void)arg; + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + vTaskDeleteWithCaps(NULL); + return; + } + struct sockaddr_in sa = { + .sin_family = AF_INET, + .sin_port = htons(53), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + close(sock); + vTaskDeleteWithCaps(NULL); + return; + } + struct timeval tv = { .tv_sec = 1, .tv_usec = 0 }; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + uint8_t buf[256]; + while (s_dns_run) { + struct sockaddr_in src; + socklen_t sl = sizeof(src); + int n = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *)&src, &sl); + if (n < 12) { + continue; + } + if (n + 16 > (int)sizeof(buf)) { + continue; + } + buf[2] = 0x81; buf[3] = 0x80; // QR=1, RA=1 + buf[6] = 0x00; buf[7] = 0x01; // ANCOUNT=1 + buf[8] = 0x00; buf[9] = 0x00; // NSCOUNT=0 + buf[10] = 0x00; buf[11] = 0x00; // ARCOUNT=0 + uint8_t *a = buf + n; // answer after the (echoed) question + a[0] = 0xC0; a[1] = 0x0C; // name -> pointer to the question + a[2] = 0x00; a[3] = 0x01; // type A + a[4] = 0x00; a[5] = 0x01; // class IN + a[6] = 0x00; a[7] = 0x00; a[8] = 0x00; a[9] = 0x3C; // TTL 60 + a[10] = 0x00; a[11] = 0x04; // RDLENGTH 4 + a[12] = 192; a[13] = 168; a[14] = 4; a[15] = 1; // 192.168.4.1 + sendto(sock, buf, n + 16, 0, (struct sockaddr *)&src, sl); + } + close(sock); + vTaskDeleteWithCaps(NULL); +} + +// --- lifecycle -------------------------------------------------------------- +void lisael_wifi_portal_start(void) +{ + if (s_httpd) { + return; + } + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.stack_size = 8192; // root_get scans + renders a page + cfg.lru_purge_enable = true; + cfg.max_uri_handlers = 8; + if (httpd_start(&s_httpd, &cfg) == ESP_OK) { + httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = root_get }; + httpd_register_uri_handler(s_httpd, &root); + httpd_uri_t save = { .uri = "/save", .method = HTTP_POST, .handler = save_post }; + httpd_register_uri_handler(s_httpd, &save); + httpd_register_err_handler(s_httpd, HTTPD_404_NOT_FOUND, redirect_404); + } else { + ESP_LOGE(TAG, "httpd_start failed"); + } + + s_dns_run = true; + xTaskCreatePinnedToCoreWithCaps(dns_task, "captdns", 4096, NULL, 5, NULL, + tskNO_AFFINITY, MALLOC_CAP_SPIRAM); + ESP_LOGI(TAG, "captive portal up (http://192.168.4.1)"); +} + +void lisael_wifi_portal_stop(void) +{ + s_dns_run = false; // dns_task exits within ~1s (recv timeout) and self-deletes + if (s_httpd) { + httpd_stop(s_httpd); + s_httpd = NULL; + } + ESP_LOGI(TAG, "captive portal stopped"); +} diff --git a/main/net/wifi_portal.h b/main/net/wifi_portal.h new file mode 100644 index 0000000..783ad11 --- /dev/null +++ b/main/net/wifi_portal.h @@ -0,0 +1,20 @@ +// Captive Wi-Fi provisioning portal for Lisael Box. +// +// Brought up while the box runs as a SoftAP (see lisael_wifi_start_ap): an HTTP +// server on 192.168.4.1 serves a page to pick a Wi-Fi network and enter its +// password, plus a catch-all DNS that points every host at the box so the +// captive-portal page opens automatically. On submit, credentials are saved to +// NVS and applied; the orchestrator (app_main) tears the portal down once the +// station reconnects. +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +void lisael_wifi_portal_start(void); +void lisael_wifi_portal_stop(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/ui/ui.h b/main/ui/ui.h index 0f52039..12d5eac 100644 --- a/main/ui/ui.h +++ b/main/ui/ui.h @@ -49,6 +49,7 @@ lv_obj_t *lisael_screen_clock(void); lv_obj_t *lisael_screen_games(void); lv_obj_t *lisael_screen_balloons(void); // "Calme ta colère" anger game lv_obj_t *lisael_screen_histoires(void); // "Histoires" — vignette podcasts +lv_obj_t *lisael_screen_wifi_setup(void); // SoftAP provisioning info screen // Home screen live-update hooks (called from background tasks; they take the // display lock internally — do NOT hold it when calling these).