Skip to main content

embedded_wg_display/http_server/
mod.rs

1//! Web UI and REST API server using [picoserve](https://github.com/sammhicks/picoserve).
2//!
3//! ## Configuration
4//!
5//! [`WEB_TASK_POOL_SIZE`] sets the number of concurrent web task. Greatly increased RAM usage with each task.
6//! [`TCP_BUFFER_SIZE`] sets the size of the TCP buffer for each connection.
7//! [`HTTP_BUFFER_SIZE`] sets the size of the HTTP buffer. This greatly impact the upload speed of assets and files.
8//!
9//! ## Routed Endpoints
10//!
11//! | Route | Method | Backing handler or asset |
12//! |---|---|---|
13//! | `/get_store_items` | GET | [`get_store_items`] |
14//! | `/install_widget` | POST | [`post_install_widget`] |
15//! | `/wifi_mode` | GET | [`get_wifi_mode`] |
16//! | `/wifi_credentials` | POST | [`post_wifi_credentials`] |
17//! | `/system_config` | GET | [`get_system_config`] |
18//! | `/system_config` | POST | [`post_system_config`] |
19//! | `/deinstall_widget/<widget_name>` | GET | [`deinstall_widget`] |
20//! | `/config_schema/<widget_name>` | GET | [`get_config_schema`] |
21//! | `/widget_config/<widget_name>` | POST | [`post_widget_config`] |
22//! | `/widget_configuration/<widget_name>` | GET | [`get_widget_config`] |
23//! | `/` | GET | [`frontend::INDEX_HTML`]  |
24//! | `/frontend.js` | GET | [`frontend::FRONTEND_JS`]  |
25//! | `/frontend_bg.wasm` | GET | [`frontend::FRONTEND_WASM_GZ`]  |
26//! | `/output.css` | GET | [`frontend::OUTPUT_CSS`]  |
27//! | `/assets/logo.png` | GET | [`frontend::LOGO_PNG`]  |
28//! | `/assets/css/bootstrap.css` | GET | [`frontend::BOOTSTRAP_CSS`]  |
29//! | `/assets/js/jquery.min.js` | GET | [`frontend::JQUERY_JS`]  |
30//! | `/assets/js/underscore.js` | GET | [`frontend::UNDERSCORE_JS`]  |
31//! | `/assets/js/jsonform.js` | GET | [`frontend::JSONFORM_JS`]  |
32//! | `/assets/js/jsonform-defaults.js` | GET | [`frontend::JSONFORM_DEFAULTS_JS`]  |
33//! | `/assets/js/jsonform-split.js` | GET | [`frontend::JSONFORM_SPLIT_JS`]  |
34//! | `/assets/html/widget_config.html` | GET | [`frontend::WIDGET_CONFIG_HTML`]  |
35//! | `/assets/fonts/glyphicons-halflings-regular.eot` | GET | [`frontend::FONT_GLYPHS_EOT`]  |
36//! | `/assets/fonts/glyphicons-halflings-regular.svg` | GET | [`frontend::FONT_GLYPHS_SVG`]  |
37//! | `/assets/fonts/glyphicons-halflings-regular.ttf` | GET | [`frontend::FONT_GLYPHS_TTF`]  |
38//! | `/assets/fonts/glyphicons-halflings-regular.woff` | GET | [`frontend::FONT_GLYPHS_WOFF`]  |
39//! | `/assets/fonts/glyphicons-halflings-regular.woff2` | GET | [`frontend::FONT_GLYPHS_WOFF2`]  |
40//!
41//!
42use alloc::format;
43use defmt::{error, info};
44use embassy_executor::Spawner;
45use embassy_net::Stack;
46use embassy_time::{Duration, Timer};
47use esp_alloc as _;
48use esp_hal::system::software_reset;
49use picoserve::{
50    AppBuilder, AppRouter, Router,
51    extract::JsonWithUnescapeBufferSize,
52    response::{File, Json},
53    routing::{self, parse_path_segment},
54};
55
56use crate::util::globals;
57use crate::widget::manager::WidgetManager;
58use common::models::WidgetStoreItem;
59use common::models::{InstallAction, SystemConfiguration, WifiCredentials, WifiModeResponse};
60
61mod custom_types;
62mod frontend;
63
64use custom_types::{ConfigWrapper, Error, HandlerResult, JsonStringResponse};
65
66pub const WEB_TASK_POOL_SIZE: usize = 2;
67const TCP_BUFFER_SIZE: usize = 8192;
68const HTTP_BUFFER_SIZE: usize = 16384;
69const INDEX_CACHE_HEADER: (&str, &str) = ("Cache-Control", "no-cache, no-store, must-revalidate");
70/// Asset http headers
71const ASSET_HEADER: (&str, &str) = ("Cache-Control", "no-cache, must-revalidate");
72
73pub struct Application;
74
75impl AppBuilder for Application {
76    type PathRouter = impl routing::PathRouter;
77
78    /// creates all routes, including static frontend assets
79    fn build_app(self) -> picoserve::Router<Self::PathRouter> {
80        picoserve::Router::new()
81            .route("/get_store_items", routing::get(get_store_items))
82            .route("/install_widget", routing::post(post_install_widget))
83            .route("/wifi_mode", routing::get(get_wifi_mode))
84            .route("/wifi_credentials", routing::post(post_wifi_credentials))
85            .route(
86                "/system_config",
87                routing::get(get_system_config).post(post_system_config),
88            )
89            // "/deinstall_widget/<widget_name>"
90            .route(
91                (
92                    "/deinstall_widget",
93                    parse_path_segment::<alloc::string::String>(),
94                ),
95                routing::get(deinstall_widget),
96            )
97            // "/config_schema/<widget_name>"
98            .route(
99                (
100                    "/config_schema",
101                    parse_path_segment::<alloc::string::String>(),
102                ),
103                routing::get(get_config_schema),
104            )
105            // "/widget_config/<widget_name>"
106            .route(
107                (
108                    "/widget_config",
109                    parse_path_segment::<alloc::string::String>(),
110                ),
111                routing::post(post_widget_config),
112            )
113            // "/widget_configuration/<widget_name>"
114            .route(
115                (
116                    "/widget_configuration",
117                    parse_path_segment::<alloc::string::String>(),
118                ),
119                routing::get_service(File::with_content_type_and_headers(
120                    "text/html",
121                    frontend::WIDGET_CONFIG_HTML,
122                    &[INDEX_CACHE_HEADER],
123                )),
124            )
125            // routes to serve frontend files
126            .route(
127                "/",
128                routing::get_service(File::with_content_type_and_headers(
129                    "text/html",
130                    frontend::INDEX_HTML,
131                    &[INDEX_CACHE_HEADER],
132                )),
133            )
134            .route(
135                "/frontend.js",
136                routing::get_service(File::with_content_type_and_headers(
137                    "application/javascript",
138                    frontend::FRONTEND_JS,
139                    &[ASSET_HEADER],
140                )),
141            )
142            .route(
143                "/frontend_bg.wasm",
144                routing::get_service(File::with_content_type_and_headers(
145                    "application/wasm",
146                    frontend::FRONTEND_WASM_GZ,
147                    &[("Content-Encoding", "gzip"), ASSET_HEADER],
148                )),
149            )
150            .route(
151                "/output.css",
152                routing::get_service(File::with_content_type_and_headers(
153                    "text/css",
154                    frontend::OUTPUT_CSS,
155                    &[ASSET_HEADER],
156                )),
157            )
158            .route(
159                "/assets/logo.png",
160                routing::get_service(File::with_content_type_and_headers(
161                    "image/png",
162                    frontend::LOGO_PNG,
163                    &[ASSET_HEADER],
164                )),
165            )
166            .route(
167                "/assets/css/bootstrap.css",
168                routing::get_service(File::with_content_type_and_headers(
169                    "text/css",
170                    frontend::BOOTSTRAP_CSS,
171                    &[ASSET_HEADER],
172                )),
173            )
174            .route(
175                "/assets/js/jquery.min.js",
176                routing::get_service(File::with_content_type_and_headers(
177                    "application/javascript",
178                    frontend::JQUERY_JS,
179                    &[ASSET_HEADER],
180                )),
181            )
182            .route(
183                "/assets/js/underscore.js",
184                routing::get_service(File::with_content_type_and_headers(
185                    "application/javascript",
186                    frontend::UNDERSCORE_JS,
187                    &[ASSET_HEADER],
188                )),
189            )
190            .route(
191                "/assets/js/jsonform.js",
192                routing::get_service(File::with_content_type_and_headers(
193                    "application/javascript",
194                    frontend::JSONFORM_JS,
195                    &[ASSET_HEADER],
196                )),
197            )
198            .route(
199                "/assets/js/jsonform-defaults.js",
200                routing::get_service(File::with_content_type_and_headers(
201                    "application/javascript",
202                    frontend::JSONFORM_DEFAULTS_JS,
203                    &[ASSET_HEADER],
204                )),
205            )
206            .route(
207                "/assets/js/jsonform-split.js",
208                routing::get_service(File::with_content_type_and_headers(
209                    "application/javascript",
210                    frontend::JSONFORM_SPLIT_JS,
211                    &[ASSET_HEADER],
212                )),
213            )
214            // .route(
215            //     "/assets/html/widget_config.html",
216            //     routing::get_service(File::with_content_type_and_headers(
217            //         "text/html",
218            //         frontend::WIDGET_CONFIG_HTML,
219            //         &[INDEX_CACHE_HEADER],
220            //     )),
221            // )
222            .route(
223                "/assets/fonts/glyphicons-halflings-regular.eot",
224                routing::get_service(File::with_content_type_and_headers(
225                    "application/vnd.ms-fontobject",
226                    frontend::FONT_GLYPHS_EOT,
227                    &[ASSET_HEADER],
228                )),
229            )
230            .route(
231                "/assets/fonts/glyphicons-halflings-regular.svg",
232                routing::get_service(File::with_content_type_and_headers(
233                    "image/svg+xml",
234                    frontend::FONT_GLYPHS_SVG,
235                    &[ASSET_HEADER],
236                )),
237            )
238            .route(
239                "/assets/fonts/glyphicons-halflings-regular.ttf",
240                routing::get_service(File::with_content_type_and_headers(
241                    "font/ttf",
242                    frontend::FONT_GLYPHS_TTF,
243                    &[ASSET_HEADER],
244                )),
245            )
246            .route(
247                "/assets/fonts/glyphicons-halflings-regular.woff",
248                routing::get_service(File::with_content_type_and_headers(
249                    "font/woff",
250                    frontend::FONT_GLYPHS_WOFF,
251                    &[ASSET_HEADER],
252                )),
253            )
254            .route(
255                "/assets/fonts/glyphicons-halflings-regular.woff2",
256                routing::get_service(File::with_content_type_and_headers(
257                    "font/woff2",
258                    frontend::FONT_GLYPHS_WOFF2,
259                    &[ASSET_HEADER],
260                )),
261            )
262    }
263}
264
265// TODO: create WidetStore instance in globals and init the store on boot that unnecessary wait time can be avoided
266/// gets and returns all widget store items as JSON.
267async fn get_store_items() -> HandlerResult<JsonStringResponse> {
268    let json = serde_json::to_string(&globals::get_store_items().await)
269        .map_err(|_| Error::new("Failed to serialize widget store"))?;
270    info!("Serving store items: {}", json.as_str());
271    Ok(JsonStringResponse(json))
272}
273
274/// gets and returns the currently stored system config, create a new default config if none is present (first boot)
275async fn get_system_config() -> HandlerResult<Json<SystemConfiguration>> {
276    match globals::with_storage(|storage| storage.get_system_config()).await {
277        Ok(config) => Ok(Json(config)),
278        Err(_) => {
279            // try to create default config
280            let default_config = SystemConfiguration::default();
281            globals::with_storage(|storage| storage.save_system_config(&default_config))
282                .await
283                .map_err(|e| {
284                    Error::new(format!("Failed to save default system config: {:?}", e))
285                })?;
286            Ok(Json(default_config))
287        }
288    }
289}
290
291/// gets the current wifi mode (AP or Station) to let the frontend decide which components to show
292async fn get_wifi_mode() -> HandlerResult<Json<WifiModeResponse>> {
293    let mode = globals::with_storage(|storage| storage.config_get("wifi_mode"))
294        .await
295        .unwrap_or_else(|_| alloc::string::String::from("ap"));
296
297    Ok(Json(WifiModeResponse {
298        is_ap_mode: mode == "ap",
299    }))
300}
301
302/// receives wifi credentials from the frontend, saves them to storage and reboots
303async fn post_wifi_credentials(Json(credentials): Json<WifiCredentials>) -> HandlerResult<()> {
304    let ssid = credentials.ssid;
305    let password = credentials.password;
306
307    if ssid.trim().is_empty() {
308        return Err(Error::new("SSID must not be empty"));
309    }
310
311    info!("Received WiFi credentials for SSID '{}'", ssid.as_str());
312
313    globals::with_storage(|storage| {
314        storage.config_set("ssid", ssid.as_str())?;
315        storage.config_set("pw", password.as_str())?;
316        storage.config_set("wifi_mode", "station")?;
317        Ok::<(), crate::storage::StorageError>(())
318    })
319    .await
320    .map_err(|e| Error::new(format!("Failed to save WiFi credentials: {:?}", e)))?;
321
322    info!("Rebooting device due to Wifi config change");
323    Timer::after(Duration::from_millis(250)).await;
324    software_reset();
325}
326
327/// Installs a widget from a given URL or from the widget store, determined by the `InstallAction` payload.
328async fn post_install_widget(Json(action): Json<InstallAction>) -> HandlerResult<()> {
329    let (download_url, description) = match action {
330        InstallAction::FromUrl(url) => (url, alloc::string::String::from("No description")),
331        InstallAction::FromStoreItemName(name) => {
332            let store_items = globals::get_store_items().await;
333            let item = store_items
334                .iter()
335                .find(|item: &&WidgetStoreItem| item.name == name)
336                .ok_or_else(|| Error::new(format!("Widget '{}' not found", name)))?;
337            (item.get_download_url(), item.description.clone())
338        }
339    };
340    info!("Installing widget from URL {}", download_url.as_str());
341    WidgetManager::install_widget(download_url.as_str(), &description)
342        .await
343        .map_err(|e| Error::new(format!("Failed to install widget: {:?}", e)))
344}
345
346/// receives an updated system configuration from the frontend and saves it to NVS. The config is only saved if there are changes to avoid flash wear.
347async fn post_system_config(
348    JsonWithUnescapeBufferSize(config): JsonWithUnescapeBufferSize<
349        SystemConfiguration,
350        HTTP_BUFFER_SIZE,
351    >,
352) -> HandlerResult<()> {
353    info!(
354        "Received new system config: {:?}",
355        defmt::Debug2Format(&config)
356    );
357    globals::with_storage(|storage| storage.save_system_config(&config))
358        .await
359        .map_err(|e| Error::new(format!("Failed to save system config: {:?}", e)))
360}
361
362/// Remove widget from system config and storage
363async fn deinstall_widget(widget_name: alloc::string::String) -> HandlerResult<()> {
364    WidgetManager::deinstall_widget(widget_name.as_str())
365        .await
366        .map_err(|e| Error::new(format!("Failed to deinstall widget: {:?}", e)))
367}
368
369/// gets the JSON config schema for a given widget
370async fn get_config_schema(
371    widget_name: alloc::string::String,
372) -> HandlerResult<JsonStringResponse> {
373    let system_config = globals::with_storage(|storage| storage.get_system_config())
374        .await
375        .map_err(|e| Error::new(format!("Failed to get system config: {:?}", e)))?;
376
377    let widget = system_config
378        .widgets
379        .iter()
380        .find(|w| w.name == widget_name.as_str())
381        .ok_or_else(|| Error::new(format!("Widget '{}' not found", widget_name.as_str())))?;
382
383    let config = widget.json_config_schema.clone();
384
385    serde_json::from_str::<serde_json::Value>(&config)
386        .map_err(|_| Error::new("Widget config schema is not valid JSON"))?;
387
388    Ok(JsonStringResponse(config))
389}
390
391/// update the JSON config for a given widget
392async fn post_widget_config(
393    widget_name: alloc::string::String,
394    JsonWithUnescapeBufferSize(config): JsonWithUnescapeBufferSize<ConfigWrapper, HTTP_BUFFER_SIZE>,
395) -> HandlerResult<()> {
396    info!(
397        "POST /widget_config/{} - received config",
398        widget_name.as_str()
399    );
400
401    let config_string = config.config;
402
403    let mut system_config = globals::with_storage(|storage| storage.get_system_config())
404        .await
405        .map_err(|e| Error::new(format!("Failed to get system config: {:?}", e)))?;
406
407    if let Some(widget) = system_config
408        .widgets
409        .iter_mut()
410        .find(|w| w.name == widget_name.as_str())
411    {
412        info!("Updating widget config for: {}", widget_name.as_str());
413        widget.json_config = config_string;
414    } else {
415        error!("Widget not found: {}", widget_name.as_str());
416        return Err(Error::new(format!(
417            "Widget '{}' not found",
418            widget_name.as_str()
419        )));
420    }
421
422    globals::with_storage(|storage| storage.save_system_config(&system_config))
423        .await
424        .map_err(|e| Error::new(format!("Failed to save widget config: {:?}", e)))
425}
426
427pub struct WebApp {
428    pub router: &'static Router<<Application as AppBuilder>::PathRouter>,
429    pub config: &'static picoserve::Config,
430}
431
432impl Default for WebApp {
433    fn default() -> Self {
434        let router = picoserve::make_static!(AppRouter<Application>, Application.build_app());
435
436        let config = picoserve::make_static!(
437            picoserve::Config,
438            picoserve::Config::new(picoserve::Timeouts {
439                start_read_request: Duration::from_secs(5),
440                read_request: Duration::from_secs(5),
441                write: Duration::from_secs(15),
442                persistent_start_read_request: Duration::from_secs(5),
443            })
444            .keep_connection_alive()
445        );
446
447        Self { router, config }
448    }
449}
450
451pub fn start(stack: Stack<'static>, _tls_seed: u64, spawner: &Spawner) {
452    info!(
453        "Starting web server with {} concurrent tasks...",
454        WEB_TASK_POOL_SIZE
455    );
456
457    let web_app = WebApp::default();
458
459    // Spawn multiple web tasks to handle concurrent connections
460    for task_id in 0..WEB_TASK_POOL_SIZE {
461        spawner.must_spawn(web_task(task_id, stack, web_app.router, web_app.config));
462    }
463
464    info!(
465        "Web server started on port 80 with {} tasks",
466        WEB_TASK_POOL_SIZE
467    );
468}
469
470#[embassy_executor::task(pool_size = WEB_TASK_POOL_SIZE)]
471pub async fn web_task(
472    task_id: usize,
473    stack: Stack<'static>,
474    router: &'static AppRouter<Application>,
475    config: &'static picoserve::Config,
476) -> ! {
477    let port = 80;
478    // Useing vec![] to avoid stack temporaries
479    let mut tcp_rx_buffer = alloc::vec![0u8; TCP_BUFFER_SIZE].into_boxed_slice();
480    let mut tcp_tx_buffer = alloc::vec![0u8; TCP_BUFFER_SIZE].into_boxed_slice();
481    let mut http_buffer = alloc::vec![0u8; HTTP_BUFFER_SIZE].into_boxed_slice();
482
483    picoserve::Server::new(router, config, &mut http_buffer)
484        .listen_and_serve(task_id, stack, port, &mut tcp_rx_buffer, &mut tcp_tx_buffer)
485        .await
486        .into_never()
487}