device-envoy-rp 0.1.0

Build Pico applications with LED panels, easy Wi-Fi, and composable device abstractions
Documentation
use core::cell::RefCell;

use defmt::{info, unwrap, warn};
use embassy_executor::Spawner;
use embassy_net::{Stack, tcp::TcpSocket};
use embassy_sync::blocking_mutex::{Mutex, raw::CriticalSectionRawMutex};
use embassy_sync::channel::Channel;
use embassy_time::{Duration, Timer};
use embedded_io_async::Write as _;
use heapless::Vec;
use static_cell::StaticCell;

use crate::Result;
use device_envoy_core::wifi_auto::{
    HtmlBuffer, WifiAutoField as CoreWifiAutoField, WifiCredentials,
};

static CREDENTIAL_CHANNEL: Channel<CriticalSectionRawMutex, WifiCredentials, 1> = Channel::new();

#[derive(Clone)]
struct FormState {
    defaults: Option<WifiCredentials>,
}

static FORM_STATE: Mutex<CriticalSectionRawMutex, RefCell<FormState>> =
    Mutex::new(RefCell::new(FormState { defaults: None }));

static FORM_FIELDS: Mutex<
    CriticalSectionRawMutex,
    RefCell<&'static [&'static (dyn CoreWifiAutoField<Error = crate::Error> + Sync)]>,
> = Mutex::new(RefCell::new(&[]));

pub async fn collect_credentials(
    stack: &'static Stack<'static>,
    spawner: Spawner,
    defaults: Option<&WifiCredentials>,
    fields: &'static [&'static (dyn CoreWifiAutoField<Error = crate::Error> + Sync)],
) -> Result<WifiCredentials> {
    info!(
        "WifiAutoRp portal registering {} custom fields",
        fields.len()
    );
    FORM_STATE.lock(|state| {
        state.borrow_mut().defaults = defaults.cloned();
    });
    FORM_FIELDS.lock(|slot| {
        *slot.borrow_mut() = fields;
    });

    let http_server_token = unwrap!(http_server_task(stack));
    spawner.spawn(http_server_token);

    let submission = CREDENTIAL_CHANNEL.receive().await;
    Ok(submission)
}

#[embassy_executor::task]
async fn http_server_task(stack: &'static Stack<'static>) -> ! {
    info!("WifiAutoRp HTTP portal starting");

    static RX_BUFFER: StaticCell<[u8; 2048]> = StaticCell::new();
    static TX_BUFFER: StaticCell<[u8; 4096]> = StaticCell::new();
    static REQUEST_BUFFER: StaticCell<[u8; 1024]> = StaticCell::new();

    let rx_buffer = RX_BUFFER.init([0; 2048]);
    let tx_buffer = TX_BUFFER.init([0; 4096]);
    let request = REQUEST_BUFFER.init([0; 1024]);

    loop {
        let mut socket = TcpSocket::new(*stack, rx_buffer, tx_buffer);
        socket.set_timeout(Some(Duration::from_secs(30)));

        info!("Waiting for HTTP connection...");
        if let Err(err) = socket.accept(80).await {
            warn!("Accept error: {:?}", err);
            Timer::after_millis(500).await;
            continue;
        }

        let request_len = match socket.read(request).await {
            Ok(0) => {
                info!("Client closed connection");
                socket.flush().await.ok();
                socket.close();
                continue;
            }
            Ok(n) => n,
            Err(err) => {
                warn!("HTTP read error: {:?}", err);
                socket.flush().await.ok();
                socket.close();
                continue;
            }
        };

        let request_text = core::str::from_utf8(&request[..request_len]).unwrap_or("");
        let mut lines = request_text.lines();
        let request_line = lines.next().unwrap_or("");
        let mut parts = request_line.split_whitespace();
        let method = parts.next().unwrap_or("");

        let response = match method {
            "GET" => {
                let state_snapshot = FORM_STATE.lock(|state| state.borrow().clone());
                let fields_snapshot = FORM_FIELDS.lock(|fields| *fields.borrow());
                generate_config_page(state_snapshot.defaults.as_ref(), fields_snapshot)
                    .unwrap_or_else(|| static_page(generate_error_page()))
            }
            "POST" => {
                let state_snapshot = FORM_STATE.lock(|state| state.borrow().clone());
                let fields_snapshot = FORM_FIELDS.lock(|fields| *fields.borrow());
                if let Some(credentials) = parse_post(
                    request_text,
                    state_snapshot.defaults.as_ref(),
                    fields_snapshot,
                ) {
                    CREDENTIAL_CHANNEL.send(credentials).await;
                    static_page(generate_success_page())
                } else {
                    warn!("WifiAutoRp portal failed to parse POST");
                    static_page(generate_error_page())
                }
            }
            _ => static_page(generate_error_page()),
        };

        if let Err(err) = socket.write_all(response.as_bytes()).await {
            warn!("HTTP write error: {:?}", err);
        }

        socket.flush().await.ok();
        socket.close();
        Timer::after_millis(100).await;
    }
}

fn parse_post(
    request: &str,
    defaults: Option<&WifiCredentials>,
    fields: &[&'static (dyn CoreWifiAutoField<Error = crate::Error> + Sync)],
) -> Option<WifiCredentials> {
    let core_fields = core_fields(fields)?;
    device_envoy_core::wifi_auto::parse_post(request, defaults, core_fields.as_slice())
}

fn generate_config_page(
    defaults: Option<&WifiCredentials>,
    fields: &[&'static (dyn CoreWifiAutoField<Error = crate::Error> + Sync)],
) -> Option<HtmlBuffer> {
    info!("WifiAutoRp portal rendering {} fields", fields.len());
    let core_fields = core_fields(fields)?;
    Some(device_envoy_core::wifi_auto::generate_config_page(
        defaults,
        core_fields.as_slice(),
    ))
}

fn generate_success_page() -> &'static str {
    "HTTP/1.1 200 OK\r\n\
     Content-Type: text/html\r\n\
     Connection: close\r\n\
     \r\n\
     <!DOCTYPE html>\
     <html>\
     <head>\
         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
         <title>Configuration Saved</title>\
         <style>\
             body { font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; padding: 20px; text-align: center; }\
             h1 { color: #4CAF50; }\
         </style>\
     </head>\
     <body>\
         <h1>Configuration Saved!</h1>\
         <p>WiFi credentials have been received.</p>\
         <p>The device will restart and connect to your network.</p>\
         <p>You can close this page.</p>\
     </body>\
     </html>"
}

fn generate_error_page() -> &'static str {
    "HTTP/1.1 400 Bad Request\r\n\
     Content-Type: text/html\r\n\
     Connection: close\r\n\
     \r\n\
     <!DOCTYPE html>\
     <html>\
     <head>\
         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
         <title>Error</title>\
         <style>\
             body { font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; padding: 20px; text-align: center; }\
             h1 { color: #f44336; }\
         </style>\
     </head>\
     <body>\
         <h1>Error</h1>\
         <p>Failed to process your request.</p>\
         <p><a href=\"/\">Try again</a></p>\
     </body>\
     </html>"
}

fn static_page(content: &'static str) -> HtmlBuffer {
    let mut page = HtmlBuffer::new();
    page.push_str(content)
        .expect("static content exceeds page capacity");
    page
}

fn core_fields(
    fields: &[&'static (dyn CoreWifiAutoField<Error = crate::Error> + Sync)],
) -> Option<Vec<&'static dyn CoreWifiAutoField<Error = crate::Error>, 16>> {
    let mut core_fields: Vec<&'static dyn CoreWifiAutoField<Error = crate::Error>, 16> = Vec::new();
    for field in fields {
        if core_fields
            .push(*field as &'static dyn CoreWifiAutoField<Error = crate::Error>)
            .is_err()
        {
            warn!("WifiAutoRp portal has too many fields to parse");
            return None;
        }
    }
    Some(core_fields)
}