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 = 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");
70const 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 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;
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
327async 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
346async 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
362async 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
369async 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
391async 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 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 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}