1use 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 = 5;
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");
70const ASSET_HEADER: (&str, &str) = ("Cache-Control", "public, max-age=86400, immutable");
72
73pub struct Application;
74
75impl AppBuilder for Application {
76 type PathRouter = impl routing::PathRouter;
77
78 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 .route(
91 (
92 "/deinstall_widget",
93 parse_path_segment::<alloc::string::String>(),
94 ),
95 routing::get(deinstall_widget),
96 )
97 .route(
99 (
100 "/config_schema",
101 parse_path_segment::<alloc::string::String>(),
102 ),
103 routing::get(get_config_schema),
104 )
105 .route(
107 (
108 "/widget_config",
109 parse_path_segment::<alloc::string::String>(),
110 ),
111 routing::post(post_widget_config),
112 )
113 .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 .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(
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
265async 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
274async 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 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
291async 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
302async fn post_wifi_credentials(Json(credentials): Json<WifiCredentials>) -> HandlerResult<()> {
304 let ssid = credentials.ssid.clone();
305
306 if ssid.trim().is_empty() {
307 return Err(Error::new("SSID must not be empty"));
308 }
309
310 info!("Received WiFi credentials for SSID '{}'", ssid.as_str());
311
312 globals::with_storage(|storage| {
313 storage.set_wifi_credentials_and_mode(credentials, "station")?;
314 Ok::<(), crate::storage::StorageError>(())
315 })
316 .await
317 .map_err(|e| Error::new(format!("Failed to save WiFi credentials: {:?}", e)))?;
318
319 info!("Rebooting device due to Wifi config change");
320 Timer::after(Duration::from_millis(250)).await;
321 software_reset();
322}
323
324async fn post_install_widget(Json(action): Json<InstallAction>) -> HandlerResult<()> {
326 let (download_url, description) = match action {
327 InstallAction::FromUrl(url) => (url, alloc::string::String::from("No description")),
328 InstallAction::FromStoreItemName(name) => {
329 let store_items = globals::get_store_items().await;
330 let item = store_items
331 .iter()
332 .find(|item: &&WidgetStoreItem| item.name == name)
333 .ok_or_else(|| Error::new(format!("Widget '{}' not found", name)))?;
334 (item.get_download_url(), item.description.clone())
335 }
336 };
337 info!("Installing widget from URL {}", download_url.as_str());
338 WidgetManager::install_widget(download_url.as_str(), &description)
339 .await
340 .map_err(|e| Error::new(format!("Failed to install widget: {:?}", e)))
341}
342
343async fn post_system_config(
345 JsonWithUnescapeBufferSize(config): JsonWithUnescapeBufferSize<
346 SystemConfiguration,
347 HTTP_BUFFER_SIZE,
348 >,
349) -> HandlerResult<()> {
350 info!(
351 "Received new system config: {:?}",
352 defmt::Debug2Format(&config)
353 );
354 globals::with_storage(|storage| storage.save_system_config(&config))
355 .await
356 .map_err(|e| Error::new(format!("Failed to save system config: {:?}", e)))
357}
358
359async fn deinstall_widget(widget_name: alloc::string::String) -> HandlerResult<()> {
361 WidgetManager::deinstall_widget(widget_name.as_str())
362 .await
363 .map_err(|e| Error::new(format!("Failed to deinstall widget: {:?}", e)))
364}
365
366async fn get_config_schema(
368 widget_name: alloc::string::String,
369) -> HandlerResult<JsonStringResponse> {
370 let system_config = globals::with_storage(|storage| storage.get_system_config())
371 .await
372 .map_err(|e| Error::new(format!("Failed to get system config: {:?}", e)))?;
373
374 let widget = system_config
375 .widgets
376 .iter()
377 .find(|w| w.name == widget_name.as_str())
378 .ok_or_else(|| Error::new(format!("Widget '{}' not found", widget_name.as_str())))?;
379
380 let config = widget.json_config_schema.clone();
381
382 serde_json::from_str::<serde_json::Value>(&config)
383 .map_err(|_| Error::new("Widget config schema is not valid JSON"))?;
384
385 Ok(JsonStringResponse(config))
386}
387
388async fn post_widget_config(
390 widget_name: alloc::string::String,
391 JsonWithUnescapeBufferSize(config): JsonWithUnescapeBufferSize<ConfigWrapper, HTTP_BUFFER_SIZE>,
392) -> HandlerResult<()> {
393 info!(
394 "POST /widget_config/{} - received config",
395 widget_name.as_str()
396 );
397
398 let config_string = config.config;
399
400 let mut system_config = globals::with_storage(|storage| storage.get_system_config())
401 .await
402 .map_err(|e| Error::new(format!("Failed to get system config: {:?}", e)))?;
403
404 if let Some(widget) = system_config
405 .widgets
406 .iter_mut()
407 .find(|w| w.name == widget_name.as_str())
408 {
409 info!("Updating widget config for: {}", widget_name.as_str());
410 widget.json_config = config_string;
411 } else {
412 error!("Widget not found: {}", widget_name.as_str());
413 return Err(Error::new(format!(
414 "Widget '{}' not found",
415 widget_name.as_str()
416 )));
417 }
418
419 globals::with_storage(|storage| storage.save_system_config(&system_config))
420 .await
421 .map_err(|e| Error::new(format!("Failed to save widget config: {:?}", e)))
422}
423
424pub struct WebApp {
425 pub router: &'static Router<<Application as AppBuilder>::PathRouter>,
426 pub config: &'static picoserve::Config,
427}
428
429impl Default for WebApp {
430 fn default() -> Self {
431 let router = picoserve::make_static!(AppRouter<Application>, Application.build_app());
432
433 let config = picoserve::make_static!(
434 picoserve::Config,
435 picoserve::Config::new(picoserve::Timeouts {
436 start_read_request: Duration::from_secs(5),
437 read_request: Duration::from_secs(5),
438 write: Duration::from_secs(15),
439 persistent_start_read_request: Duration::from_secs(5),
440 })
441 .keep_connection_alive()
442 );
443
444 Self { router, config }
445 }
446}
447
448pub fn start(stack: Stack<'static>, _tls_seed: u64, spawner: &Spawner) {
449 info!(
450 "Starting web server with {} concurrent tasks...",
451 WEB_TASK_POOL_SIZE
452 );
453
454 let web_app = WebApp::default();
455
456 for task_id in 0..WEB_TASK_POOL_SIZE {
458 spawner.must_spawn(web_task(task_id, stack, web_app.router, web_app.config));
459 }
460
461 info!(
462 "Web server started on port 80 with {} tasks",
463 WEB_TASK_POOL_SIZE
464 );
465}
466
467#[embassy_executor::task(pool_size = WEB_TASK_POOL_SIZE)]
468pub async fn web_task(
469 task_id: usize,
470 stack: Stack<'static>,
471 router: &'static AppRouter<Application>,
472 config: &'static picoserve::Config,
473) -> ! {
474 let port = 80;
475 let mut tcp_rx_buffer = alloc::vec![0u8; TCP_BUFFER_SIZE].into_boxed_slice();
477 let mut tcp_tx_buffer = alloc::vec![0u8; TCP_BUFFER_SIZE].into_boxed_slice();
478 let mut http_buffer = alloc::vec![0u8; HTTP_BUFFER_SIZE].into_boxed_slice();
479
480 picoserve::Server::new(router, config, &mut http_buffer)
481 .listen_and_serve(task_id, stack, port, &mut tcp_rx_buffer, &mut tcp_tx_buffer)
482 .await
483 .into_never()
484}