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:
48
crates/client-esp32/src/provisioning/html.rs
Normal file
48
crates/client-esp32/src/provisioning/html.rs
Normal 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>"#;
|
||||
65
crates/client-esp32/src/provisioning/mod.rs
Normal file
65
crates/client-esp32/src/provisioning/mod.rs
Normal 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())
|
||||
}
|
||||
201
crates/client-esp32/src/provisioning/portal.rs
Normal file
201
crates/client-esp32/src/provisioning/portal.rs
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user