esp32: wifi provisioning via AP captive portal

Replace compile-time env!() wifi/server config with NVS-based
runtime provisioning. Boot checks NVS — if no config, starts
AP mode (KFrame-Setup) with DNS responder + HTTP config form.
WiFi failure clears config and reboots into setup mode.
This commit is contained in:
2026-06-19 01:38:48 +02:00
parent 1d7b5324d6
commit 4139330234
6 changed files with 389 additions and 15 deletions

View File

@@ -2,8 +2,6 @@ use esp_idf_hal::delay::{Delay, Ets};
use esp_idf_hal::gpio::{AnyIOPin, AnyOutputPin, PinDriver}; use esp_idf_hal::gpio::{AnyIOPin, AnyOutputPin, PinDriver};
use esp_idf_hal::spi::{SpiDeviceDriver, SpiDriver, SpiDriverConfig, SPI2, config::Config as SpiConfig}; use esp_idf_hal::spi::{SpiDeviceDriver, SpiDriver, SpiDriverConfig, SPI2, config::Config as SpiConfig};
use mipidsi::{Builder, models::ILI9341Rgb565, options::{ColorOrder, Orientation, Rotation}, interface::SpiInterface}; use mipidsi::{Builder, models::ILI9341Rgb565, options::{ColorOrder, Orientation, Rotation}, interface::SpiInterface};
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use log::info; use log::info;
use crate::config::{self, SPI_BAUDRATE, SPI_BUFFER_SIZE}; use crate::config::{self, SPI_BAUDRATE, SPI_BUFFER_SIZE};

View File

@@ -4,12 +4,16 @@ use esp_idf_hal::modem::Modem;
use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::wifi::{ use esp_idf_svc::wifi::{
AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi, AccessPointConfiguration, AuthMethod, BlockingWifi, ClientConfiguration,
Configuration, EspWifi,
}; };
use log::{info, error}; use log::{info, error};
const MAX_RETRIES: u32 = 5; const MAX_RETRIES: u32 = 5;
const RETRY_DELAY: Duration = Duration::from_secs(3); const RETRY_DELAY: Duration = Duration::from_secs(3);
const AP_SSID: &str = "KFrame-Setup";
const AP_CHANNEL: u8 = 1;
const AP_MAX_CONNECTIONS: u16 = 4;
pub fn init<'d>( pub fn init<'d>(
modem: Modem<'d>, modem: Modem<'d>,
@@ -54,3 +58,29 @@ pub fn init<'d>(
Err(format!("WiFi failed after {MAX_RETRIES} attempts")) Err(format!("WiFi failed after {MAX_RETRIES} attempts"))
} }
pub fn init_ap<'d>(
modem: Modem<'d>,
sysloop: EspSystemEventLoop,
nvs: EspDefaultNvsPartition,
) -> Result<BlockingWifi<EspWifi<'d>>, String> {
let esp_wifi = EspWifi::new(modem, sysloop.clone(), Some(nvs))
.map_err(|e| format!("wifi new: {e:?}"))?;
let mut wifi = BlockingWifi::wrap(esp_wifi, sysloop)
.map_err(|e| format!("wifi wrap: {e:?}"))?;
let config = Configuration::AccessPoint(AccessPointConfiguration {
ssid: AP_SSID.try_into().unwrap(),
auth_method: AuthMethod::None,
channel: AP_CHANNEL,
max_connections: AP_MAX_CONNECTIONS,
..Default::default()
});
wifi.set_configuration(&config).map_err(|e| format!("wifi ap config: {e:?}"))?;
wifi.start().map_err(|e| format!("wifi ap start: {e:?}"))?;
info!("AP started: SSID={AP_SSID}");
Ok(wifi)
}

View File

@@ -2,6 +2,7 @@ mod adapters;
mod boot; mod boot;
mod config; mod config;
mod hal; mod hal;
mod provisioning;
mod tasks; mod tasks;
use std::sync::mpsc; use std::sync::mpsc;
@@ -10,10 +11,6 @@ use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::nvs::EspDefaultNvsPartition;
use log::info; use log::info;
const WIFI_SSID: &str = env!("KFRAME_WIFI_SSID");
const WIFI_PASS: &str = env!("KFRAME_WIFI_PASS");
const SERVER_ADDR: &str = env!("KFRAME_SERVER_ADDR");
fn main() { fn main() {
esp_idf_svc::sys::link_patches(); esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default(); esp_idf_svc::log::EspLogger::initialize_default();
@@ -24,7 +21,7 @@ fn main() {
let sysloop = EspSystemEventLoop::take().unwrap(); let sysloop = EspSystemEventLoop::take().unwrap();
let nvs = EspDefaultNvsPartition::take().unwrap(); let nvs = EspDefaultNvsPartition::take().unwrap();
let display = hal::display::init(hal::display::DisplayHardware { let mut display = hal::display::init(hal::display::DisplayHardware {
spi: peripherals.spi2, spi: peripherals.spi2,
sclk: peripherals.pins.gpio18.into(), sclk: peripherals.pins.gpio18.into(),
mosi: peripherals.pins.gpio23.into(), mosi: peripherals.pins.gpio23.into(),
@@ -34,11 +31,46 @@ fn main() {
}); });
info!("Display ready"); info!("Display ready");
info!("Connecting WiFi..."); match provisioning::read_config(nvs.clone()) {
let _wifi = hal::wifi::init(peripherals.modem, sysloop, nvs, WIFI_SSID, WIFI_PASS) Some(cfg) => run_station(peripherals.modem, sysloop, nvs, cfg, display),
.expect("WiFi init failed"); None => {
info!("No config found, entering setup mode");
let (tx, rx) = mpsc::channel(); run_setup(peripherals.modem, sysloop, nvs, &mut display);
tasks::network::spawn(SERVER_ADDR.into(), tx); }
tasks::render::run(config::SCREEN, display, rx); }
}
fn run_station(
modem: esp_idf_hal::modem::Modem<'static>,
sysloop: EspSystemEventLoop,
nvs: EspDefaultNvsPartition,
cfg: provisioning::DeviceConfig,
display: adapters::display::Esp32DisplayAdapter,
) {
info!("Connecting WiFi...");
match hal::wifi::init(modem, sysloop.clone(), nvs.clone(), &cfg.wifi_ssid, &cfg.wifi_pass) {
Ok(_wifi) => {
let (tx, rx) = mpsc::channel();
tasks::network::spawn(cfg.server_addr, tx);
tasks::render::run(config::SCREEN, display, rx);
}
Err(e) => {
info!("WiFi failed ({e}), clearing config and rebooting to setup mode");
provisioning::clear_config(nvs);
std::thread::sleep(std::time::Duration::from_secs(1));
unsafe { esp_idf_svc::sys::esp_restart(); }
}
}
}
fn run_setup(
modem: esp_idf_hal::modem::Modem<'static>,
sysloop: EspSystemEventLoop,
nvs: EspDefaultNvsPartition,
display: &mut adapters::display::Esp32DisplayAdapter,
) {
let _wifi = hal::wifi::init_ap(modem, sysloop, nvs.clone())
.expect("AP mode failed");
provisioning::portal::run_captive_portal(nvs, display);
} }

View File

@@ -0,0 +1,48 @@
pub const SETUP_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>K-Frame Setup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:16px}
.card{background:#16213e;border-radius:12px;padding:24px;width:100%;max-width:360px}
h1{font-size:20px;text-align:center;margin-bottom:20px;color:#e94560}
label{display:block;font-size:13px;margin-bottom:4px;color:#a0a0a0}
input{width:100%;padding:10px;margin-bottom:14px;border:1px solid #333;border-radius:6px;background:#0f3460;color:#fff;font-size:15px}
input:focus{outline:none;border-color:#e94560}
button{width:100%;padding:12px;background:#e94560;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer}
button:active{background:#c73650}
.ok{text-align:center;color:#4ecca3;margin-top:16px;display:none}
</style>
</head>
<body>
<div class="card">
<h1>K-Frame Setup</h1>
<form id="f" method="POST" action="/save">
<label for="s">WiFi SSID</label>
<input id="s" name="ssid" required autocomplete="off">
<label for="p">WiFi Password</label>
<input id="p" name="pass" type="password">
<label for="a">Server Address</label>
<input id="a" name="server" placeholder="192.168.x.x:2699" required>
<button type="submit">Save & Reboot</button>
</form>
<div class="ok" id="ok">Saved! Rebooting...</div>
</div>
<script>
document.getElementById('f').onsubmit=function(e){
e.preventDefault();
var d=new FormData(this);
var x=new XMLHttpRequest();
x.open('POST','/save');
x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
x.onload=function(){
document.getElementById('f').style.display='none';
document.getElementById('ok').style.display='block';
};
x.send('ssid='+encodeURIComponent(d.get('ssid'))+'&pass='+encodeURIComponent(d.get('pass'))+'&server='+encodeURIComponent(d.get('server')));
}
</script>
</body>
</html>"#;

View File

@@ -0,0 +1,65 @@
pub mod html;
pub mod portal;
use esp_idf_svc::nvs::{EspNvs, EspNvsPartition, NvsDefault};
use log::{info, error};
const NVS_NAMESPACE: &str = "kframe";
const KEY_SSID: &str = "wifi_ssid";
const KEY_PASS: &str = "wifi_pass";
const KEY_SERVER: &str = "server_addr";
pub struct DeviceConfig {
pub wifi_ssid: String,
pub wifi_pass: String,
pub server_addr: String,
}
pub fn read_config(nvs_partition: EspNvsPartition<NvsDefault>) -> Option<DeviceConfig> {
let nvs = EspNvs::new(nvs_partition, NVS_NAMESPACE, true).ok()?;
let ssid = read_string(&nvs, KEY_SSID)?;
let pass = read_string(&nvs, KEY_PASS)?;
let server = read_string(&nvs, KEY_SERVER)?;
if ssid.is_empty() {
return None;
}
info!("NVS config found: ssid={ssid}, server={server}");
Some(DeviceConfig {
wifi_ssid: ssid,
wifi_pass: pass,
server_addr: server,
})
}
pub fn save_config(nvs_partition: EspNvsPartition<NvsDefault>, config: &DeviceConfig) -> Result<(), String> {
let nvs = EspNvs::new(nvs_partition, NVS_NAMESPACE, true)
.map_err(|e| format!("nvs open: {e:?}"))?;
nvs.set_str(KEY_SSID, &config.wifi_ssid).map_err(|e| format!("nvs set ssid: {e:?}"))?;
nvs.set_str(KEY_PASS, &config.wifi_pass).map_err(|e| format!("nvs set pass: {e:?}"))?;
nvs.set_str(KEY_SERVER, &config.server_addr).map_err(|e| format!("nvs set server: {e:?}"))?;
info!("Config saved to NVS");
Ok(())
}
pub fn clear_config(nvs_partition: EspNvsPartition<NvsDefault>) {
match EspNvs::new(nvs_partition, NVS_NAMESPACE, true) {
Ok(nvs) => {
let _ = nvs.remove(KEY_SSID);
let _ = nvs.remove(KEY_PASS);
let _ = nvs.remove(KEY_SERVER);
info!("NVS config cleared");
}
Err(e) => error!("Failed to clear NVS: {e:?}"),
}
}
fn read_string(nvs: &EspNvs<NvsDefault>, key: &str) -> Option<String> {
let len = nvs.str_len(key).ok()??;
let mut buf = vec![0u8; len];
nvs.get_str(key, &mut buf).ok()?.map(|s| s.to_string())
}

View File

@@ -0,0 +1,201 @@
use std::net::UdpSocket;
use std::thread;
use esp_idf_hal::io::Write;
use esp_idf_svc::http::server::{Configuration as HttpConfig, EspHttpServer};
use esp_idf_svc::nvs::{EspNvsPartition, NvsDefault};
use client_domain::{BoundingBox, DisplayPort};
use log::{info, error};
use super::{DeviceConfig, save_config};
use super::html::SETUP_HTML;
const AP_IP: [u8; 4] = [192, 168, 4, 1];
pub fn run_captive_portal<D: DisplayPort>(
nvs: EspNvsPartition<NvsDefault>,
display: &mut D,
) {
draw_setup_screen(display);
spawn_dns_responder();
let nvs_clone = nvs.clone();
let mut server = EspHttpServer::new(&HttpConfig {
http_port: 80,
..Default::default()
}).expect("HTTP server start failed");
server
.fn_handler::<esp_idf_svc::io::EspIOError, _>("/", esp_idf_svc::http::Method::Get, |req| {
req.into_ok_response()?
.write_all(SETUP_HTML.as_bytes())?;
Ok(())
})
.expect("GET / handler failed");
server
.fn_handler::<esp_idf_svc::io::EspIOError, _>("/save", esp_idf_svc::http::Method::Post, move |mut req| {
let mut body = vec![0u8; 512];
let len = req.read(&mut body).unwrap_or(0);
let body_str = std::str::from_utf8(&body[..len]).unwrap_or("");
let config = parse_form(body_str);
info!("Portal received config: ssid={}", config.wifi_ssid);
if let Err(e) = save_config(nvs_clone.clone(), &config) {
error!("Save failed: {e}");
req.into_ok_response()?.write_all(b"Error saving config")?;
return Ok(());
}
req.into_ok_response()?.write_all(b"OK")?;
thread::spawn(|| {
thread::sleep(std::time::Duration::from_secs(1));
unsafe { esp_idf_svc::sys::esp_restart(); }
});
Ok(())
})
.expect("POST /save handler failed");
server
.fn_handler::<esp_idf_svc::io::EspIOError, _>("/generate_204", esp_idf_svc::http::Method::Get, |req| {
redirect(req)
})
.expect("generate_204 handler failed");
server
.fn_handler::<esp_idf_svc::io::EspIOError, _>("/hotspot-detect.html", esp_idf_svc::http::Method::Get, |req| {
redirect(req)
})
.expect("hotspot-detect handler failed");
server
.fn_handler::<esp_idf_svc::io::EspIOError, _>("/canonical.html", esp_idf_svc::http::Method::Get, |req| {
redirect(req)
})
.expect("canonical handler failed");
info!("Captive portal running on http://192.168.4.1");
loop {
thread::sleep(std::time::Duration::from_secs(60));
}
}
fn redirect(req: esp_idf_svc::http::server::Request<&mut esp_idf_svc::http::server::EspHttpConnection>) -> Result<(), esp_idf_svc::io::EspIOError> {
let mut resp = req.into_response(302, None, &[("Location", "http://192.168.4.1/")])?;
resp.write_all(b"")?;
Ok(())
}
fn spawn_dns_responder() {
thread::Builder::new()
.stack_size(4096)
.name("dns".into())
.spawn(dns_responder)
.expect("DNS thread spawn failed");
}
fn dns_responder() {
let socket = match UdpSocket::bind("0.0.0.0:53") {
Ok(s) => s,
Err(e) => {
error!("DNS bind failed: {e}");
return;
}
};
info!("DNS responder started on :53");
let mut buf = [0u8; 512];
loop {
let (len, src) = match socket.recv_from(&mut buf) {
Ok(r) => r,
Err(_) => continue,
};
if len < 12 {
continue;
}
let mut resp = Vec::with_capacity(len + 16);
// Header: copy ID, set response flags
resp.extend_from_slice(&buf[0..2]);
resp.extend_from_slice(&[0x81, 0x80]);
resp.extend_from_slice(&buf[4..6]);
resp.extend_from_slice(&buf[4..6]);
resp.extend_from_slice(&[0, 0, 0, 0]);
// Copy question section
resp.extend_from_slice(&buf[12..len]);
// Answer: A record pointing to AP IP
resp.extend_from_slice(&[0xC0, 0x0C]); // Name pointer
resp.extend_from_slice(&[0, 1]); // Type A
resp.extend_from_slice(&[0, 1]); // Class IN
resp.extend_from_slice(&[0, 0, 0, 60]); // TTL 60s
resp.extend_from_slice(&[0, 4]); // Data length
resp.extend_from_slice(&AP_IP);
let _ = socket.send_to(&resp, src);
}
}
fn parse_form(body: &str) -> DeviceConfig {
let mut ssid = String::new();
let mut pass = String::new();
let mut server = String::new();
for pair in body.split('&') {
let mut parts = pair.splitn(2, '=');
let key = parts.next().unwrap_or("");
let val = url_decode(parts.next().unwrap_or(""));
match key {
"ssid" => ssid = val,
"pass" => pass = val,
"server" => server = val,
_ => {}
}
}
DeviceConfig { wifi_ssid: ssid, wifi_pass: pass, server_addr: server }
}
fn url_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.bytes();
while let Some(b) = chars.next() {
match b {
b'+' => result.push(' '),
b'%' => {
let hi = chars.next().and_then(hex_val);
let lo = chars.next().and_then(hex_val);
if let (Some(h), Some(l)) = (hi, lo) {
result.push((h << 4 | l) as char);
}
}
_ => result.push(b as char),
}
}
result
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
const FULL_SCREEN: BoundingBox = BoundingBox { x: 0, y: 0, width: 320, height: 240 };
fn draw_setup_screen<D: DisplayPort>(display: &mut D) {
let _ = display.fill_background(FULL_SCREEN);
let _ = display.draw_text("K-Frame Setup", 0, 0, BoundingBox { x: 80, y: 50, width: 160, height: 20 });
let _ = display.draw_text("Connect to WiFi:", 0, 0, BoundingBox { x: 40, y: 90, width: 240, height: 14 });
let _ = display.draw_text("KFrame-Setup", 0, 0, BoundingBox { x: 80, y: 110, width: 160, height: 14 });
let _ = display.draw_text("Then open browser", 0, 0, BoundingBox { x: 40, y: 150, width: 240, height: 14 });
let _ = display.draw_text("to configure", 0, 0, BoundingBox { x: 60, y: 170, width: 200, height: 14 });
}