feat(wifi): SoftAP captive provisioning portal
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user