feat(wifi): SoftAP captive provisioning portal

This commit is contained in:
Clément Saillant
2026-06-14 19:38:43 +02:00
parent 7e69182d7a
commit de0b01ad3c
11 changed files with 696 additions and 14 deletions
@@ -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).
@@ -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 : `<select>`/liste des SSID scannés +
`<input password>` + 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.
+3
View File
@@ -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)
+97 -13
View File
@@ -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");
}
+38
View File
@@ -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;
}
+61
View File
@@ -0,0 +1,61 @@
// UDP reporter — see udp_log.h.
#include "net/udp_log.h"
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include <fcntl.h>
#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));
}
+111 -1
View File
@@ -8,6 +8,7 @@
#include "lisael_config.h"
#include <string.h>
#include <stdlib.h>
#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;
}
+16
View File
@@ -3,6 +3,7 @@
#include <stdbool.h>
#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
+236
View File
@@ -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 <string.h>
#include <stdlib.h>
#include <ctype.h>
#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,
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Lisael Box - WiFi</title></head>"
"<body style='font-family:sans-serif;max-width:420px;margin:24px auto;padding:0 16px'>"
"<h2>Reglage WiFi - Lisael Box</h2>"
"<form method='POST' action='/save'>"
"<label>Reseau :</label><br>"
"<select name='ssid' style='width:100%%;padding:8px;font-size:16px'>");
for (int i = 0; i < n && o < 3500; i++) {
o += snprintf(buf + o, 4096 - o,
"<option value=\"%s\">%s (%d dBm)</option>",
(char *)aps[i].ssid, (char *)aps[i].ssid, aps[i].rssi);
}
o += snprintf(buf + o, 4096 - o,
"</select><br><br>"
"<label>Mot de passe :</label><br>"
"<input type='password' name='pass' style='width:100%%;padding:8px;font-size:16px'>"
"<br><br>"
"<button type='submit' style='padding:10px 18px;font-size:16px'>Valider</button>"
"</form></body></html>");
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,
"<!DOCTYPE html><html><head><meta charset='utf-8'></head>"
"<body style='font-family:sans-serif;text-align:center;margin-top:48px'>"
"<h2>Connexion en cours...</h2>"
"<p>Tu peux fermer cette page.</p></body></html>");
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");
}
+20
View File
@@ -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
+1
View File
@@ -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).