diff --git a/crates/client-esp32/src/hal/display.rs b/crates/client-esp32/src/hal/display.rs index 2aea773..724c89d 100644 --- a/crates/client-esp32/src/hal/display.rs +++ b/crates/client-esp32/src/hal/display.rs @@ -2,8 +2,6 @@ use esp_idf_hal::delay::{Delay, Ets}; use esp_idf_hal::gpio::{AnyIOPin, AnyOutputPin, PinDriver}; 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 embedded_graphics::pixelcolor::Rgb565; -use embedded_graphics::prelude::*; use log::info; use crate::config::{self, SPI_BAUDRATE, SPI_BUFFER_SIZE}; diff --git a/crates/client-esp32/src/hal/wifi.rs b/crates/client-esp32/src/hal/wifi.rs index eaf66e5..401a3c6 100644 --- a/crates/client-esp32/src/hal/wifi.rs +++ b/crates/client-esp32/src/hal/wifi.rs @@ -4,12 +4,16 @@ use esp_idf_hal::modem::Modem; use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::wifi::{ - AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi, + AccessPointConfiguration, AuthMethod, BlockingWifi, ClientConfiguration, + Configuration, EspWifi, }; use log::{info, error}; const MAX_RETRIES: u32 = 5; 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>( modem: Modem<'d>, @@ -54,3 +58,29 @@ pub fn init<'d>( Err(format!("WiFi failed after {MAX_RETRIES} attempts")) } + +pub fn init_ap<'d>( + modem: Modem<'d>, + sysloop: EspSystemEventLoop, + nvs: EspDefaultNvsPartition, +) -> Result>, 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) +} diff --git a/crates/client-esp32/src/main.rs b/crates/client-esp32/src/main.rs index 73cfec3..4ee9fb7 100644 --- a/crates/client-esp32/src/main.rs +++ b/crates/client-esp32/src/main.rs @@ -2,6 +2,7 @@ mod adapters; mod boot; mod config; mod hal; +mod provisioning; mod tasks; use std::sync::mpsc; @@ -10,10 +11,6 @@ use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::nvs::EspDefaultNvsPartition; 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() { esp_idf_svc::sys::link_patches(); esp_idf_svc::log::EspLogger::initialize_default(); @@ -24,7 +21,7 @@ fn main() { let sysloop = EspSystemEventLoop::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, sclk: peripherals.pins.gpio18.into(), mosi: peripherals.pins.gpio23.into(), @@ -34,11 +31,46 @@ fn main() { }); info!("Display ready"); - info!("Connecting WiFi..."); - let _wifi = hal::wifi::init(peripherals.modem, sysloop, nvs, WIFI_SSID, WIFI_PASS) - .expect("WiFi init failed"); - - let (tx, rx) = mpsc::channel(); - tasks::network::spawn(SERVER_ADDR.into(), tx); - tasks::render::run(config::SCREEN, display, rx); + match provisioning::read_config(nvs.clone()) { + Some(cfg) => run_station(peripherals.modem, sysloop, nvs, cfg, display), + None => { + info!("No config found, entering setup mode"); + run_setup(peripherals.modem, sysloop, nvs, &mut display); + } + } +} + +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); } diff --git a/crates/client-esp32/src/provisioning/html.rs b/crates/client-esp32/src/provisioning/html.rs new file mode 100644 index 0000000..d0d0760 --- /dev/null +++ b/crates/client-esp32/src/provisioning/html.rs @@ -0,0 +1,48 @@ +pub const SETUP_HTML: &str = r#" + + + +K-Frame Setup + + + +
+

K-Frame Setup

+
+ + + + + + + +
+
Saved! Rebooting...
+
+ + +"#; diff --git a/crates/client-esp32/src/provisioning/mod.rs b/crates/client-esp32/src/provisioning/mod.rs new file mode 100644 index 0000000..491f568 --- /dev/null +++ b/crates/client-esp32/src/provisioning/mod.rs @@ -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) -> Option { + 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, 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) { + 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, key: &str) -> Option { + 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()) +} diff --git a/crates/client-esp32/src/provisioning/portal.rs b/crates/client-esp32/src/provisioning/portal.rs new file mode 100644 index 0000000..660ead0 --- /dev/null +++ b/crates/client-esp32/src/provisioning/portal.rs @@ -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( + nvs: EspNvsPartition, + 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::http::Method::Get, |req| { + req.into_ok_response()? + .write_all(SETUP_HTML.as_bytes())?; + Ok(()) + }) + .expect("GET / handler failed"); + + server + .fn_handler::("/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::("/generate_204", esp_idf_svc::http::Method::Get, |req| { + redirect(req) + }) + .expect("generate_204 handler failed"); + + server + .fn_handler::("/hotspot-detect.html", esp_idf_svc::http::Method::Get, |req| { + redirect(req) + }) + .expect("hotspot-detect handler failed"); + + server + .fn_handler::("/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 { + 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(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 }); +}