1use std::io::Read;
13use std::sync::{Arc, RwLock};
14use std::time::Duration;
15
16use axum::body::{Body, Bytes};
17use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
18use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
19use axum::http::{
20 StatusCode,
21 header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE},
22};
23use axum::middleware::{self, Next};
24use axum::response::{IntoResponse, Response};
25use axum::routing::{any, get, post};
26use axum::{Json, Router};
27use futures_util::StreamExt;
28use serde::Deserialize;
29use serde_json::json;
30use tokio::io::AsyncWriteExt;
31use tokio::sync::watch;
32
33#[cfg(feature = "dashboard")]
34use super::assets::static_handler;
35#[cfg(test)]
36use super::camera::NoCamera;
37use super::camera::{CameraSource, ExternalCamera, open_mjpeg_stream, url_stream_opener};
38#[cfg(test)]
39use super::control::FakeController;
40use super::control::{
41 Axis, ControlAction, ControlError, Controller, HomeAxes, TempPart, temp_line,
42};
43#[cfg(test)]
44use super::files::FakeFiles;
45use super::files::FileStore;
46#[cfg(test)]
47use super::start::FakeStarter;
48use super::start::{StartRequest, Starter};
49use super::timelapse::{
50 DEFAULT_SMOOTH_BURST_MS, FrameGrab, PlainCapture, TimelapseManager, real_park_spawn,
51 real_segment_spawn,
52};
53use crate::core::command::{AmsControl, LedNode, SpeedLevel};
54use crate::core::park::ParkTuning;
55use crate::core::safety::{GcodeVerdict, TempLimits, check_extrude, check_gcode, check_jog};
56use crate::core::session::CommandOutcome;
57use crate::core::status::{Ams, AmsTray, AmsUnit, Filament, LightReport, Online, PrinterStatus};
58use crate::park::{ParkCapture, SegmentCapture};
59
60pub trait PrinterSource: Send + Sync {
64 fn current(&self) -> PrinterStatus;
66 fn subscribe(&self) -> watch::Receiver<PrinterStatus>;
70}
71
72pub struct FakeSource {
77 tx: watch::Sender<PrinterStatus>,
78 _keepalive: watch::Receiver<PrinterStatus>,
81}
82
83impl FakeSource {
84 pub fn idle() -> Self {
86 let (tx, rx) = watch::channel(PrinterStatus {
87 gcode_state: Some("IDLE".to_string()),
88 print_error: Some(0),
89 ..Default::default()
90 });
91 Self { tx, _keepalive: rx }
92 }
93
94 pub fn ramping(interval: Duration) -> Self {
100 let initial = PrinterStatus {
101 gcode_state: Some("RUNNING".to_string()),
102 print_error: Some(0),
103 subtask_name: Some("benchy_2c.3mf".to_string()),
104 gcode_file: Some("benchy_2c.3mf".to_string()),
105 print_type: Some("local".to_string()),
106 nozzle_target: Some(220.0),
107 bed_target: Some(60.0),
108 nozzle_temper: Some(25.0),
109 bed_temper: Some(25.0),
110 mc_percent: Some(0),
111 layer_num: Some(0),
112 total_layer_num: Some(200),
113 remaining_time_min: Some(72),
114 spd_lvl: Some(2),
115 spd_mag: Some(100),
116 cooling_fan_speed: Some(0),
117 big_fan1_speed: Some(0),
118 heatbreak_fan_speed: Some(7000),
119 nozzle_diameter: Some("0.4".to_string()),
120 nozzle_type: Some("stainless_steel".to_string()),
121 sdcard: Some(true),
122 wifi_signal: Some("-58dBm".to_string()),
123 online: Some(Online {
124 ahb: Some(false),
129 rfid: Some(false),
130 version: Some(1),
131 }),
132 filament: Some(Filament {
133 location: "ams0".to_string(),
134 material: Some("PLA".to_string()),
135 name: Some("PLA Matte".to_string()),
136 color: Some("DE4343FF".to_string()),
137 }),
138 ams: Some(fake_ams()),
139 lights: vec![LightReport {
140 node: "chamber_light".to_string(),
141 mode: "off".to_string(),
142 }],
143 ..Default::default()
144 };
145 let (tx, rx) = watch::channel(initial.clone());
146 let task_tx = tx.clone();
147 tokio::spawn(async move {
148 let mut s = initial;
149 let mut tick: i64 = 0;
150 const PRINT: i64 = 100;
154 const CYCLE: i64 = 115;
155 loop {
156 tokio::time::sleep(interval).await;
157 tick += 1;
158 let p = tick % CYCLE;
159 if p == 1 {
160 s.nozzle_temper = Some(25.0);
162 s.bed_temper = Some(25.0);
163 }
164 if (1..=PRINT).contains(&p) {
165 s.gcode_state = Some("RUNNING".to_string());
166 s.nozzle_temper = Some(approach(s.nozzle_temper.unwrap_or(25.0), 220.0, 8.0));
167 s.bed_temper = Some(approach(s.bed_temper.unwrap_or(25.0), 60.0, 4.0));
168 let hot = s.nozzle_temper.unwrap_or(0.0) >= 200.0;
170 s.cooling_fan_speed = Some(if hot { 100 } else { 0 });
171 s.mc_percent = Some(p);
172 s.layer_num = Some(p * 2); s.remaining_time_min = Some((PRINT - p) * 72 / 100);
174 } else {
175 s.gcode_state = Some("FINISH".to_string());
177 s.mc_percent = Some(100);
178 s.layer_num = Some(200);
179 s.remaining_time_min = Some(0);
180 s.cooling_fan_speed = Some(0);
181 s.nozzle_temper = Some(approach(s.nozzle_temper.unwrap_or(220.0), 30.0, 12.0));
182 s.bed_temper = Some(approach(s.bed_temper.unwrap_or(60.0), 30.0, 6.0));
183 }
184 if task_tx.send(s.clone()).is_err() {
185 break; }
187 }
188 });
189 Self { tx, _keepalive: rx }
190 }
191}
192
193fn fake_ams() -> Ams {
195 let tray = |id: &str, material: &str, name: &str, color: &str, active: bool| AmsTray {
196 id: id.to_string(),
197 material: Some(material.to_string()),
198 name: Some(name.to_string()),
199 color: Some(color.to_string()),
200 cols: vec![color.to_string()],
201 remain: Some(-1), state: Some(3),
203 id_name: Some(format!("A01-R{id}")),
208 uuid: Some(format!("FACADE0000000000000000000000000{id}")),
209 nozzle_temp_min: Some(if material == "PETG" { 230 } else { 190 }),
210 nozzle_temp_max: Some(if material == "PETG" { 260 } else { 230 }),
211 is_active: active,
212 is_target: active,
213 ..Default::default()
214 };
215 Ams {
216 units: vec![AmsUnit {
217 id: "0".to_string(),
218 humidity: Some(5),
219 humidity_raw: Some(28),
220 temp: Some(0.0),
221 dry_time: None,
222 trays: vec![
223 tray("0", "PLA", "PLA Matte Red", "DE4343FF", true),
224 tray("1", "PLA", "PLA Basic Black", "000000FF", false),
225 tray("2", "PETG", "PETG Translucent", "D6ABFF80", false),
226 tray("3", "PLA", "PLA Wood", "918669FF", false),
227 ],
228 }],
229 external: None,
230 active_tray: Some("0".to_string()),
231 target_tray: Some("0".to_string()),
232 previous_tray: Some("255".to_string()),
233 ams_exist_bits: Some("1".to_string()),
234 tray_exist_bits: Some("f".to_string()),
235 tray_is_bbl_bits: Some("f".to_string()),
236 }
237}
238
239fn approach(current: f64, target: f64, step: f64) -> f64 {
241 if current < target {
242 (current + step).min(target)
243 } else {
244 (current - step).max(target)
245 }
246}
247
248impl PrinterSource for FakeSource {
249 fn current(&self) -> PrinterStatus {
250 self.tx.borrow().clone()
251 }
252 fn subscribe(&self) -> watch::Receiver<PrinterStatus> {
253 self.tx.subscribe()
254 }
255}
256
257#[derive(Clone)]
259pub struct AppState {
260 pub source: Arc<dyn PrinterSource>,
261 pub controller: Arc<dyn Controller>,
262 pub files: Arc<dyn FileStore>,
263 pub starter: Arc<dyn Starter>,
264 pub password: Option<String>,
267 pub start_lock: Arc<tokio::sync::Mutex<()>>,
270 pub external_cameras: Arc<RwLock<Vec<ExternalCamera>>>,
276 pub internal_camera: Arc<dyn CameraSource>,
279 pub timelapse: Arc<TimelapseManager>,
282}
283
284fn is_safe_remote_path(p: &str) -> bool {
286 p.starts_with('/')
287 && p.len() > 1
288 && !p.contains("..")
289 && !p.contains("//")
290 && !p.contains('\\')
291 && !p.contains(':')
292}
293
294impl AppState {
295 #[cfg(test)]
296 pub fn fake() -> Self {
297 Self {
298 source: Arc::new(FakeSource::idle()),
299 controller: Arc::new(FakeController::verified()),
300 files: Arc::new(FakeFiles),
301 starter: Arc::new(FakeStarter),
302 password: None,
303 start_lock: Arc::new(tokio::sync::Mutex::new(())),
304 external_cameras: Arc::new(RwLock::new(Vec::new())),
305 internal_camera: Arc::new(NoCamera),
306 timelapse: Default::default(),
307 }
308 }
309}
310
311pub fn router(state: AppState) -> Router {
314 let reads = Router::new()
315 .route("/api/status", get(status))
316 .route("/api/ws", get(status_ws))
317 .route("/api/file", get(list_files))
318 .route("/api/file/thumbnail", get(file_thumbnail))
319 .route("/api/file/raw", get(file_raw))
320 .route("/api/file/gcode", get(file_gcode))
321 .route("/api/file/inspect", get(file_inspect))
322 .route("/api/file/mesh", get(file_mesh))
323 .route("/api/camera", get(cameras_list))
324 .route("/api/camera/{id}/snapshot", get(camera_snapshot))
325 .route("/api/camera/{id}/stream", get(camera_stream))
326 .route("/api/camera/{id}/park", get(park_index))
327 .route("/api/camera/{id}/park/{n}", get(camera_park_frame))
328 .route("/api/timelapse", get(timelapse_status))
329 .route("/api/capture", get(captures_list))
330 .route("/api/capture/{run}/{cam}/video.mp4", get(capture_video))
331 .route("/api/capture/{run}/{cam}/thumb.jpg", get(capture_thumb));
332 let writes = Router::new()
333 .route("/api/job/pause", post(job_pause))
334 .route("/api/job/resume", post(job_resume))
335 .route("/api/job/stop", post(job_stop))
336 .route("/api/job/clear-error", post(job_clear_error))
337 .route("/api/job/start", post(job_start))
338 .route("/api/light", post(light))
339 .route("/api/speed", post(speed))
340 .route("/api/gcode", post(gcode))
341 .route("/api/home", post(home))
342 .route("/api/move", post(move_axis))
343 .route("/api/extrude", post(extrude))
344 .route("/api/temp", post(temp))
345 .route("/api/calibrate", post(calibrate))
346 .route("/api/ams", post(ams))
347 .route("/api/ams/change", post(ams_change))
348 .route("/api/reboot", post(reboot))
349 .route("/api/steppers", post(steppers))
350 .route(
351 "/api/camera/config",
352 get(cameras_config_get).post(cameras_config_set),
353 )
354 .route("/api/timelapse/start", post(timelapse_start))
355 .route("/api/timelapse/stop", post(timelapse_stop))
356 .route(
358 "/api/file/upload",
359 post(upload_file).layer(DefaultBodyLimit::max(512 * 1024 * 1024)),
360 )
361 .route(
362 "/api/job/upload-start",
363 post(job_upload_start).layer(DefaultBodyLimit::max(512 * 1024 * 1024)),
364 )
365 .layer(middleware::from_fn_with_state(
366 state.clone(),
367 require_password,
368 ));
369 let app = reads
373 .merge(writes)
374 .route("/api/{*rest}", any(api_not_found));
375 #[cfg(feature = "dashboard")]
376 let app = app.fallback(static_handler);
377 app.with_state(state)
378}
379
380async fn api_not_found() -> Response {
382 (
383 StatusCode::NOT_FOUND,
384 Json(json!({ "error": "unknown API endpoint" })),
385 )
386 .into_response()
387}
388
389async fn status(State(st): State<AppState>) -> Json<PrinterStatus> {
390 Json(st.source.current())
391}
392
393#[derive(Deserialize, Default)]
398struct ConfirmBody {
399 #[serde(default)]
400 confirm: bool,
401}
402
403#[derive(Deserialize)]
404struct LightBody {
405 node: String,
406 on: bool,
407}
408
409#[derive(Deserialize)]
410struct SpeedBody {
411 level: String,
412}
413
414async fn job_pause(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
415 run_confirmed(st, ControlAction::Pause, body).await
416}
417async fn job_resume(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
418 run_confirmed(st, ControlAction::Resume, body).await
419}
420async fn job_stop(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
421 run_confirmed(st, ControlAction::Stop, body).await
422}
423async fn job_clear_error(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
427 run_confirmed(st, ControlAction::ClearError, body).await
428}
429
430async fn light(State(st): State<AppState>, Json(b): Json<LightBody>) -> Response {
431 let node = match b.node.as_str() {
432 "chamber" => LedNode::ChamberLight,
433 "work" => LedNode::WorkLight,
434 other => return bad_request(format!("unknown light node {other:?}")),
435 };
436 execute(st, ControlAction::Light { node, on: b.on }).await
437}
438
439async fn speed(State(st): State<AppState>, Json(b): Json<SpeedBody>) -> Response {
440 let level = match b.level.as_str() {
441 "silent" => SpeedLevel::Silent,
442 "standard" => SpeedLevel::Standard,
443 "sport" => SpeedLevel::Sport,
444 "ludicrous" => SpeedLevel::Ludicrous,
445 other => return bad_request(format!("unknown speed level {other:?}")),
446 };
447 execute(st, ControlAction::Speed(level)).await
448}
449
450#[derive(Deserialize)]
451struct GcodeBody {
452 line: String,
453 #[serde(default)]
454 confirm: bool,
455 #[serde(default)]
457 force: bool,
458}
459
460async fn gcode(State(st): State<AppState>, Json(b): Json<GcodeBody>) -> Response {
463 if b.line.trim().is_empty() {
464 return bad_request("empty gcode line".to_string());
465 }
466 if !b.confirm {
467 return (
468 StatusCode::PRECONDITION_REQUIRED,
469 Json(json!({ "error": "confirm required: POST {\"confirm\": true}" })),
470 )
471 .into_response();
472 }
473 if !b.force
474 && let GcodeVerdict::Block(reason) = check_gcode(&b.line, &TempLimits::default())
475 {
476 return bad_request(format!("unsafe gcode (use force to override): {reason}"));
477 }
478 execute(st, ControlAction::Gcode(b.line)).await
479}
480
481async fn run_confirmed(
483 st: AppState,
484 action: ControlAction,
485 body: Option<Json<ConfirmBody>>,
486) -> Response {
487 if !body.map(|b| b.confirm).unwrap_or(false) {
488 return (
489 StatusCode::PRECONDITION_REQUIRED,
490 Json(json!({ "error": "confirm required: POST {\"confirm\": true}" })),
491 )
492 .into_response();
493 }
494 execute(st, action).await
495}
496
497async fn execute(st: AppState, action: ControlAction) -> Response {
499 let controller = st.controller.clone();
500 let res = tokio::task::spawn_blocking(move || controller.execute(action)).await;
501 verify_response(res)
502}
503
504type VerifyJoin = Result<Result<CommandOutcome, ControlError>, tokio::task::JoinError>;
507
508fn verify_response(res: VerifyJoin) -> Response {
511 match res {
512 Ok(Ok(outcome)) => {
513 let code = match &outcome {
514 CommandOutcome::Verified => StatusCode::OK,
515 CommandOutcome::Unverified { .. } => StatusCode::ACCEPTED,
516 CommandOutcome::Rejected { .. } => StatusCode::CONFLICT,
517 };
518 (code, Json(outcome)).into_response()
519 }
520 Ok(Err(ControlError::Transport(e))) => {
521 (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response()
522 }
523 Err(_) => (
524 StatusCode::INTERNAL_SERVER_ERROR,
525 Json(json!({ "error": "control task failed" })),
526 )
527 .into_response(),
528 }
529}
530
531fn bad_request(msg: String) -> Response {
532 (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
533}
534
535fn require_idle(st: &AppState) -> Option<Response> {
541 let state = st
542 .source
543 .current()
544 .gcode_state
545 .unwrap_or_default()
546 .to_ascii_uppercase();
547 if matches!(state.as_str(), "RUNNING" | "PAUSE" | "PREPARE" | "SLICING") {
548 return Some(
549 (
550 StatusCode::CONFLICT,
551 Json(json!({ "error": format!("printer is busy ({state}); operation refused") })),
552 )
553 .into_response(),
554 );
555 }
556 None
557}
558
559fn need_confirm(confirm: bool) -> Option<Response> {
562 if confirm {
563 return None;
564 }
565 Some(
566 (
567 StatusCode::PRECONDITION_REQUIRED,
568 Json(json!({ "error": "confirm required: POST {\"confirm\": true}" })),
569 )
570 .into_response(),
571 )
572}
573
574#[derive(Deserialize)]
577struct HomeBody {
578 #[serde(default = "default_axes")]
579 axes: String,
580}
581
582fn default_axes() -> String {
583 "all".to_string()
584}
585
586async fn home(State(st): State<AppState>, Json(b): Json<HomeBody>) -> Response {
588 let axes = match b.axes.as_str() {
589 "all" => HomeAxes::All,
590 "x" => HomeAxes::X,
591 "y" => HomeAxes::Y,
592 "z" => HomeAxes::Z,
593 other => return bad_request(format!("unknown axes {other:?}")),
594 };
595 if let Some(busy) = require_idle(&st) {
596 return busy;
597 }
598 execute(st, ControlAction::Home(axes)).await
599}
600
601#[derive(Deserialize)]
602struct MoveBody {
603 axis: String,
604 delta: f64,
605 #[serde(default = "default_move_feedrate")]
606 feedrate: u32,
607}
608
609fn default_move_feedrate() -> u32 {
610 3000
611}
612
613async fn move_axis(State(st): State<AppState>, Json(b): Json<MoveBody>) -> Response {
616 let axis = match b.axis.as_str() {
617 "x" => Axis::X,
618 "y" => Axis::Y,
619 "z" => Axis::Z,
620 other => return bad_request(format!("unknown axis {other:?}")),
621 };
622 if let GcodeVerdict::Block(reason) = check_jog(b.delta) {
623 return bad_request(reason);
624 }
625 if !(60..=6000).contains(&b.feedrate) {
626 return bad_request(format!("feedrate {} out of range (60..=6000)", b.feedrate));
627 }
628 if let Some(busy) = require_idle(&st) {
629 return busy;
630 }
631 execute(
632 st,
633 ControlAction::Move {
634 axis,
635 delta: b.delta,
636 feedrate: b.feedrate,
637 },
638 )
639 .await
640}
641
642#[derive(Deserialize)]
643struct ExtrudeBody {
644 delta: f64,
645 #[serde(default = "default_extrude_feedrate")]
646 feedrate: u32,
647}
648
649fn default_extrude_feedrate() -> u32 {
650 300
651}
652
653async fn extrude(State(st): State<AppState>, Json(b): Json<ExtrudeBody>) -> Response {
657 let nozzle_temper = st.source.current().nozzle_temper;
658 if let GcodeVerdict::Block(reason) = check_extrude(b.delta, nozzle_temper) {
659 return bad_request(reason);
660 }
661 if !(60..=6000).contains(&b.feedrate) {
662 return bad_request(format!("feedrate {} out of range (60..=6000)", b.feedrate));
663 }
664 if let Some(busy) = require_idle(&st) {
665 return busy;
666 }
667 execute(
668 st,
669 ControlAction::Extrude {
670 delta: b.delta,
671 feedrate: b.feedrate,
672 },
673 )
674 .await
675}
676
677#[derive(Deserialize)]
678struct TempBody {
679 part: String,
680 celsius: u32,
681 #[serde(default)]
682 confirm: bool,
683 #[serde(default)]
685 force: bool,
686}
687
688async fn temp(State(st): State<AppState>, Json(b): Json<TempBody>) -> Response {
693 let part = match b.part.as_str() {
694 "nozzle" => TempPart::Nozzle,
695 "bed" => TempPart::Bed,
696 other => return bad_request(format!("unknown part {other:?}")),
697 };
698 let line = temp_line(part, b.celsius);
699 if !b.force
700 && let GcodeVerdict::Block(reason) = check_gcode(&line, &TempLimits::default())
701 {
702 return bad_request(format!(
703 "unsafe temperature (use force to override): {reason}"
704 ));
705 }
706 if b.celsius > 0
708 && let Some(unconfirmed) = need_confirm(b.confirm)
709 {
710 return unconfirmed;
711 }
712 execute(
713 st,
714 ControlAction::SetTemp {
715 part,
716 celsius: b.celsius,
717 },
718 )
719 .await
720}
721
722#[derive(Deserialize)]
723struct CalibrateBody {
724 #[serde(default)]
725 bed_level: bool,
726 #[serde(default)]
727 vibration: bool,
728 #[serde(default)]
729 motor_noise: bool,
730 #[serde(default)]
731 confirm: bool,
732}
733
734async fn calibrate(State(st): State<AppState>, Json(b): Json<CalibrateBody>) -> Response {
737 if !(b.bed_level || b.vibration || b.motor_noise) {
738 return bad_request(
739 "select at least one calibration (bed_level/vibration/motor_noise)".to_string(),
740 );
741 }
742 if let Some(unconfirmed) = need_confirm(b.confirm) {
743 return unconfirmed;
744 }
745 if let Some(busy) = require_idle(&st) {
746 return busy;
747 }
748 execute(
749 st,
750 ControlAction::Calibrate {
751 bed_level: b.bed_level,
752 vibration: b.vibration,
753 motor_noise: b.motor_noise,
754 },
755 )
756 .await
757}
758
759#[derive(Deserialize)]
760struct AmsBody {
761 action: String,
762 #[serde(default)]
763 confirm: bool,
764}
765
766async fn ams(State(st): State<AppState>, Json(b): Json<AmsBody>) -> Response {
769 let action = match b.action.as_str() {
770 "resume" => AmsControl::Resume,
771 "reset" => AmsControl::Reset,
772 "pause" => AmsControl::Pause,
773 other => return bad_request(format!("unknown ams action {other:?}")),
774 };
775 if !matches!(action, AmsControl::Resume) {
777 if let Some(unconfirmed) = need_confirm(b.confirm) {
778 return unconfirmed;
779 }
780 if let Some(busy) = require_idle(&st) {
781 return busy;
782 }
783 }
784 execute(st, ControlAction::Ams(action)).await
785}
786
787#[derive(Deserialize)]
788struct AmsChangeBody {
789 target: u32,
791 tar_temp: i64,
793 curr_temp: Option<i64>,
796 #[serde(default)]
797 confirm: bool,
798 #[serde(default)]
799 dry_run: bool,
800}
801
802async fn ams_change(State(st): State<AppState>, Json(b): Json<AmsChangeBody>) -> Response {
808 if !matches!(b.target, 0..=3 | 254 | 255) {
810 return bad_request(format!(
811 "target {} invalid (trays 0..3, 254 external spool, or 255 unload)",
812 b.target
813 ));
814 }
815 let curr = b.curr_temp.unwrap_or(b.tar_temp);
816 let max = TempLimits::default().max_nozzle as i64;
817 for (label, t) in [("tar_temp", b.tar_temp), ("curr_temp", curr)] {
818 if !(0..=max).contains(&t) {
819 return bad_request(format!("{label} {t}°C is out of range (0..={max})"));
820 }
821 }
822 if b.dry_run {
825 return Json(json!({ "plan": {
826 "command": "ams_change_filament",
827 "target": b.target,
828 "curr_temp": curr,
829 "tar_temp": b.tar_temp,
830 }}))
831 .into_response();
832 }
833 if let Some(unconfirmed) = need_confirm(b.confirm) {
834 return unconfirmed;
835 }
836 if let Some(busy) = require_idle(&st) {
837 return busy;
838 }
839 execute(
840 st,
841 ControlAction::AmsChange {
842 target: b.target,
843 curr_temp: curr,
844 tar_temp: b.tar_temp,
845 },
846 )
847 .await
848}
849
850async fn reboot(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
853 if let Some(unconfirmed) = need_confirm(body.map(|b| b.confirm).unwrap_or(false)) {
854 return unconfirmed;
855 }
856 if let Some(busy) = require_idle(&st) {
857 return busy;
858 }
859 execute(st, ControlAction::Reboot).await
860}
861
862async fn steppers(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
864 if let Some(unconfirmed) = need_confirm(body.map(|b| b.confirm).unwrap_or(false)) {
865 return unconfirmed;
866 }
867 if let Some(busy) = require_idle(&st) {
868 return busy;
869 }
870 execute(st, ControlAction::DisableSteppers).await
871}
872
873#[derive(Deserialize)]
876struct StartBody {
877 file: String,
878 #[serde(default = "default_plate")]
879 plate: u32,
880 #[serde(default)]
881 confirm: bool,
882 #[serde(default)]
883 use_ams: bool,
884 #[serde(default)]
885 ams_map: Vec<i32>,
886 bed_type: Option<String>,
887 #[serde(default)]
890 timelapse: bool,
891 #[serde(default)]
892 dry_run: bool,
893}
894
895fn default_plate() -> u32 {
896 1
897}
898
899async fn job_start(State(st): State<AppState>, Json(b): Json<StartBody>) -> Response {
903 let lower = b.file.to_ascii_lowercase();
904 if !is_safe_remote_path(&b.file) {
907 return bad_request(format!(
908 "file must be an absolute printer path: {:?}",
909 b.file
910 ));
911 }
912 if !(lower.ends_with(".3mf") || lower.ends_with(".gcode")) {
913 return bad_request("file must be a .3mf or .gcode".to_string());
914 }
915 if b.use_ams {
916 for (i, v) in b.ams_map.iter().enumerate() {
917 if !(-1..=3).contains(v) {
918 return bad_request(format!(
919 "ams_map[{i}]={v} out of range (trays 0..3, or -1 external)"
920 ));
921 }
922 }
923 }
924 let req = StartRequest {
925 file: b.file.clone(),
926 plate: b.plate,
927 use_ams: b.use_ams,
928 ams_map: b.ams_map.clone(),
929 bed_type: b.bed_type.clone().unwrap_or_else(|| "auto".to_string()),
930 timelapse: b.timelapse,
931 inspection: None,
934 };
935
936 if b.dry_run {
937 let has_timelapse_blocks = if req.file.to_ascii_lowercase().ends_with(".3mf") {
942 let (files, file, plate) = (st.files.clone(), req.file.clone(), req.plate);
943 match tokio::task::spawn_blocking(move || {
944 files.fetch(&file).and_then(|bytes| {
945 crate::core::project::inspect_plate(&bytes, plate).map_err(|e| e.to_string())
946 })
947 })
948 .await
949 {
950 Ok(Ok(insp)) => Some(insp.has_timelapse_blocks),
951 _ => None,
952 }
953 } else {
954 None
955 };
956 return Json(json!({ "plan": {
957 "file": req.file,
958 "plate": req.plate,
959 "use_ams": req.use_ams,
960 "ams_map": req.ams_map,
961 "bed_type": req.bed_type,
962 "timelapse": req.timelapse,
963 "has_timelapse_blocks": has_timelapse_blocks,
964 }}))
965 .into_response();
966 }
967 if !b.confirm {
968 return (
969 StatusCode::PRECONDITION_REQUIRED,
970 Json(json!({ "error": "confirm required: POST {\"confirm\": true} (try dry_run first)" })),
971 )
972 .into_response();
973 }
974 let Ok(_guard) = st.start_lock.try_lock() else {
976 return (
977 StatusCode::CONFLICT,
978 Json(json!({ "error": "a print start is already in progress" })),
979 )
980 .into_response();
981 };
982 if let Some(busy) = require_idle(&st) {
984 return busy;
985 }
986 let starter = st.starter.clone();
987 let res = tokio::task::spawn_blocking(move || starter.start(&req)).await;
988 verify_response(res)
989}
990
991#[derive(Deserialize)]
994struct ListQuery {
995 dir: Option<String>,
996}
997
998async fn list_files(State(st): State<AppState>, Query(q): Query<ListQuery>) -> Response {
1000 let dir = q.dir.unwrap_or_else(|| "/".to_string());
1001 let files = st.files.clone();
1002 match tokio::task::spawn_blocking(move || files.list(&dir)).await {
1003 Ok(Ok(names)) => Json(json!({ "files": names })).into_response(),
1004 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1005 Err(_) => (
1006 StatusCode::INTERNAL_SERVER_ERROR,
1007 Json(json!({ "error": "file task failed" })),
1008 )
1009 .into_response(),
1010 }
1011}
1012
1013#[derive(Deserialize)]
1014struct ThumbQuery {
1015 name: String,
1016 #[serde(default = "default_plate")]
1017 plate: u32,
1018}
1019
1020async fn file_thumbnail(State(st): State<AppState>, Query(q): Query<ThumbQuery>) -> Response {
1022 let remote = if q.name.starts_with('/') {
1023 q.name.clone()
1024 } else {
1025 format!("/{}", q.name)
1026 };
1027 if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
1030 return bad_request(format!("thumbnail needs a .3mf printer path: {:?}", q.name));
1031 }
1032 if !(1..=64).contains(&q.plate) {
1033 return bad_request("plate out of range (1..64)".to_string());
1034 }
1035 let files = st.files.clone();
1036 let plate = q.plate;
1037 match tokio::task::spawn_blocking(move || files.thumbnail(&remote, plate)).await {
1038 Ok(Ok(Some(png))) => ([(CONTENT_TYPE, "image/png")], png).into_response(),
1039 Ok(Ok(None)) => StatusCode::NOT_FOUND.into_response(),
1040 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1041 Err(_) => (
1042 StatusCode::INTERNAL_SERVER_ERROR,
1043 Json(json!({ "error": "thumbnail task failed" })),
1044 )
1045 .into_response(),
1046 }
1047}
1048
1049#[derive(Deserialize)]
1050struct RawQuery {
1051 name: String,
1052}
1053
1054async fn file_raw(State(st): State<AppState>, Query(q): Query<RawQuery>) -> Response {
1057 let remote = if q.name.starts_with('/') {
1058 q.name.clone()
1059 } else {
1060 format!("/{}", q.name)
1061 };
1062 let lower = remote.to_ascii_lowercase();
1063 if !is_safe_remote_path(&remote) || !(lower.ends_with(".3mf") || lower.ends_with(".gcode")) {
1064 return bad_request(format!("viewer needs a .3mf/.gcode path: {:?}", q.name));
1065 }
1066 let ctype = if lower.ends_with(".gcode") {
1067 "text/plain; charset=utf-8"
1068 } else {
1069 "application/octet-stream"
1070 };
1071 let files = st.files.clone();
1072 match tokio::task::spawn_blocking(move || files.fetch(&remote)).await {
1073 Ok(Ok(bytes)) => ([(CONTENT_TYPE, ctype)], bytes).into_response(),
1074 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1075 Err(_) => server_error("fetch task failed".to_string()),
1076 }
1077}
1078
1079#[derive(Deserialize)]
1080struct GcodeFileQuery {
1081 name: String,
1082 #[serde(default = "default_plate")]
1083 plate: u32,
1084}
1085
1086async fn file_gcode(State(st): State<AppState>, Query(q): Query<GcodeFileQuery>) -> Response {
1093 let remote = if q.name.starts_with('/') {
1094 q.name.clone()
1095 } else {
1096 format!("/{}", q.name)
1097 };
1098 if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
1101 return bad_request(format!("gcode needs a .3mf printer path: {:?}", q.name));
1102 }
1103 if !(1..=64).contains(&q.plate) {
1104 return bad_request("plate out of range (1..64)".to_string());
1105 }
1106 let files = st.files.clone();
1107 let plate = q.plate;
1108 match tokio::task::spawn_blocking(move || files.gcode(&remote, plate)).await {
1109 Ok(Ok(Some(gcode))) => {
1110 ([(CONTENT_TYPE, "text/plain; charset=utf-8")], gcode).into_response()
1111 }
1112 Ok(Ok(None)) => StatusCode::NOT_FOUND.into_response(),
1113 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1114 Err(_) => server_error("gcode task failed".to_string()),
1115 }
1116}
1117
1118#[derive(Deserialize)]
1119struct InspectQuery {
1120 name: String,
1121 #[serde(default = "default_plate")]
1122 plate: u32,
1123}
1124
1125async fn file_inspect(State(st): State<AppState>, Query(q): Query<InspectQuery>) -> Response {
1132 let remote = if q.name.starts_with('/') {
1133 q.name.clone()
1134 } else {
1135 format!("/{}", q.name)
1136 };
1137 if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
1138 return Json(json!({ "inspected": false, "error": "not a .3mf printer path" }))
1139 .into_response();
1140 }
1141 if !(1..=64).contains(&q.plate) {
1142 return bad_request("plate out of range (1..64)".to_string());
1143 }
1144 let (files, plate) = (st.files.clone(), q.plate);
1145 match tokio::task::spawn_blocking(move || {
1146 files
1147 .fetch(&remote)
1148 .and_then(|b| crate::core::project::inspect_plate(&b, plate).map_err(|e| e.to_string()))
1149 })
1150 .await
1151 {
1152 Ok(Ok(i)) => Json(json!({
1153 "inspected": true,
1154 "plate": i.plate,
1155 "has_timelapse_blocks": i.has_timelapse_blocks,
1156 "gcode_md5": i.gcode_md5,
1157 "bed_type": i.bed_type,
1158 "filament_colors": i.filament_colors,
1159 }))
1160 .into_response(),
1161 Ok(Err(e)) => Json(json!({ "inspected": false, "error": e })).into_response(),
1162 Err(_) => server_error("inspect task failed".to_string()),
1163 }
1164}
1165
1166#[derive(Deserialize)]
1167struct MeshQuery {
1168 name: String,
1169}
1170
1171async fn file_mesh(State(st): State<AppState>, Query(q): Query<MeshQuery>) -> Response {
1176 let remote = if q.name.starts_with('/') {
1177 q.name.clone()
1178 } else {
1179 format!("/{}", q.name)
1180 };
1181 if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
1182 return bad_request(format!("mesh needs a .3mf printer path: {:?}", q.name));
1183 }
1184 let files = st.files.clone();
1185 match tokio::task::spawn_blocking(move || files.models(&remote)).await {
1186 Ok(Ok(models)) => Json(json!({ "models": models })).into_response(),
1187 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1188 Err(_) => server_error("mesh task failed".to_string()),
1189 }
1190}
1191
1192const CAMERA_MAX_BYTES: u64 = 32 * 1024 * 1024;
1203const CAMERA_TIMEOUT: Duration = Duration::from_secs(8);
1205
1206async fn cameras_list(State(st): State<AppState>) -> Json<serde_json::Value> {
1209 let mut cameras = Vec::new();
1210 if st.internal_camera.configured() {
1211 cameras.push(json!({ "id": "internal", "kind": "internal", "label": "built-in camera" }));
1212 }
1213 for (i, c) in st.external_cameras.read().unwrap().iter().enumerate() {
1214 cameras.push(json!({
1215 "id": format!("ext-{i}"),
1216 "kind": "external",
1217 "label": c.label,
1218 "stream": c.stream_url.is_some(),
1221 "park": c.stream_url.is_some() && c.park_tuning.is_some(),
1224 "segment": c.stream_url.is_some()
1228 && c.park_tuning.is_some()
1229 && c.select_tuning.is_some(),
1230 }));
1231 }
1232 Json(json!({ "cameras": cameras }))
1233}
1234
1235async fn camera_snapshot(State(st): State<AppState>, Path(id): Path<String>) -> Response {
1239 if id == "internal" {
1240 if !st.internal_camera.configured() {
1241 return StatusCode::NOT_FOUND.into_response();
1242 }
1243 let cam = st.internal_camera.clone();
1244 return match tokio::task::spawn_blocking(move || cam.snapshot()).await {
1245 Ok(Ok(bytes)) => (
1246 [
1247 (CONTENT_TYPE, "image/jpeg".to_string()),
1248 (CACHE_CONTROL, "no-store".to_string()),
1249 ],
1250 bytes,
1251 )
1252 .into_response(),
1253 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1254 Err(_) => server_error("camera task failed".to_string()),
1255 };
1256 }
1257 let url = id
1258 .strip_prefix("ext-")
1259 .and_then(|n| n.parse::<usize>().ok())
1260 .and_then(|i| {
1261 st.external_cameras
1262 .read()
1263 .unwrap()
1264 .get(i)
1265 .map(|c| c.url.clone())
1266 });
1267 let Some(url) = url else {
1268 return StatusCode::NOT_FOUND.into_response();
1269 };
1270 match tokio::task::spawn_blocking(move || fetch_camera_frame(&url)).await {
1271 Ok(Ok((ctype, bytes))) => (
1272 [
1273 (CONTENT_TYPE, ctype),
1274 (CACHE_CONTROL, "no-store".to_string()),
1275 ],
1276 bytes,
1277 )
1278 .into_response(),
1279 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
1280 Err(_) => server_error("camera task failed".to_string()),
1281 }
1282}
1283
1284fn resolve_stream_url(id: &str, externals: &[ExternalCamera]) -> Option<String> {
1288 id.strip_prefix("ext-")
1289 .and_then(|n| n.parse::<usize>().ok())
1290 .and_then(|i| externals.get(i))
1291 .and_then(|c| c.stream_url.clone())
1292}
1293
1294async fn camera_stream(State(st): State<AppState>, Path(id): Path<String>) -> Response {
1300 let Some(url) = resolve_stream_url(&id, &st.external_cameras.read().unwrap()) else {
1301 return StatusCode::NOT_FOUND.into_response();
1302 };
1303 let opened = tokio::task::spawn_blocking(move || open_mjpeg_stream(&url)).await;
1306 let (ctype, reader) = match opened {
1307 Ok(Ok(s)) => (s.content_type, s.reader),
1308 Ok(Err(e)) => {
1309 return (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response();
1310 }
1311 Err(_) => return server_error("camera stream task failed".to_string()),
1312 };
1313 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(8);
1314 tokio::task::spawn_blocking(move || {
1315 let mut reader = reader;
1316 let mut buf = vec![0u8; 32 * 1024];
1317 loop {
1318 match reader.read(&mut buf) {
1319 Ok(0) => break,
1320 Ok(n) => {
1321 if tx
1324 .blocking_send(Ok(Bytes::copy_from_slice(&buf[..n])))
1325 .is_err()
1326 {
1327 break;
1328 }
1329 }
1330 Err(e) => {
1331 let _ = tx.blocking_send(Err(e));
1332 break;
1333 }
1334 }
1335 }
1336 });
1337 let body = Body::from_stream(futures_util::stream::unfold(rx, |mut rx| async move {
1338 rx.recv().await.map(|item| (item, rx))
1339 }));
1340 Response::builder()
1341 .header(CONTENT_TYPE, ctype)
1342 .header(CACHE_CONTROL, "no-store")
1343 .body(body)
1344 .unwrap()
1345}
1346
1347fn parse_parks_index(contents: &str) -> Vec<serde_json::Value> {
1356 let mut by_n: std::collections::BTreeMap<u64, serde_json::Value> =
1357 std::collections::BTreeMap::new();
1358 for line in contents.lines() {
1359 let line = line.trim();
1360 if line.is_empty() {
1361 continue;
1362 }
1363 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1364 continue;
1365 };
1366 let Some(n) = v.get("n").and_then(serde_json::Value::as_u64) else {
1367 continue;
1368 };
1369 by_n.insert(
1370 n,
1371 json!({
1372 "n": n,
1373 "t": v.get("t").and_then(serde_json::Value::as_f64),
1374 "confidence": v.get("confidence").and_then(serde_json::Value::as_f64),
1375 }),
1376 );
1377 }
1378 by_n.into_values().collect()
1379}
1380
1381fn live_park_source(st: &AppState) -> Option<(String, Vec<String>, bool)> {
1391 let sources = [
1397 st.timelapse.status_segment(),
1398 st.timelapse.status_park(),
1399 st.timelapse.status_smooth(),
1400 ];
1401 if let Some(s) = sources.iter().find(|s| s.running && s.out_dir.is_some()) {
1404 return Some((s.out_dir.clone().unwrap(), s.cameras.clone(), s.running));
1405 }
1406 sources
1410 .into_iter()
1411 .filter(|s| s.out_dir.is_some())
1412 .max_by_key(|s| run_dir_epoch(s.out_dir.as_deref().unwrap_or("")))
1413 .map(|s| (s.out_dir.unwrap(), s.cameras, s.running))
1414}
1415
1416fn run_dir_epoch(dir: &str) -> u64 {
1419 std::path::Path::new(dir)
1420 .file_name()
1421 .and_then(|f| f.to_str())
1422 .and_then(|f| f.split('_').next())
1423 .and_then(|e| e.parse::<u64>().ok())
1424 .unwrap_or(0)
1425}
1426
1427async fn park_index(State(st): State<AppState>, Path(id): Path<String>) -> Response {
1428 let Some((dir, cameras, running)) = live_park_source(&st) else {
1429 return StatusCode::NOT_FOUND.into_response();
1430 };
1431 if !cameras.iter().any(|c| c == &id) {
1432 return StatusCode::NOT_FOUND.into_response();
1433 }
1434 let jsonl = std::path::Path::new(&dir).join(&id).join("parks.jsonl");
1435 let parks = match tokio::fs::read_to_string(&jsonl).await {
1436 Ok(s) => parse_parks_index(&s),
1437 Err(_) => Vec::new(), };
1439 Json(json!({ "running": running, "count": parks.len(), "parks": parks })).into_response()
1440}
1441
1442async fn camera_park_frame(
1446 State(st): State<AppState>,
1447 Path((id, n)): Path<(String, u64)>,
1448) -> Response {
1449 let Some((dir, cameras, _)) = live_park_source(&st) else {
1450 return StatusCode::NOT_FOUND.into_response();
1451 };
1452 if !cameras.iter().any(|c| c == &id) {
1453 return StatusCode::NOT_FOUND.into_response();
1454 }
1455 let path = std::path::Path::new(&dir)
1456 .join(&id)
1457 .join(format!("park_{n:06}.jpg"));
1458 match tokio::fs::read(&path).await {
1459 Ok(bytes) => (
1460 [
1461 (CONTENT_TYPE, "image/jpeg".to_string()),
1462 (CACHE_CONTROL, "no-store".to_string()),
1463 ],
1464 bytes,
1465 )
1466 .into_response(),
1467 Err(_) => StatusCode::NOT_FOUND.into_response(),
1468 }
1469}
1470
1471fn tuning_json(c: &ExternalCamera) -> serde_json::Value {
1476 let Some(park) = &c.park_tuning else {
1477 return serde_json::Value::Null;
1478 };
1479 let mut v = serde_json::to_value(park).unwrap_or_else(|_| json!({}));
1480 if let (Some(obj), Some(sel)) = (v.as_object_mut(), c.select_tuning)
1481 && let Ok(serde_json::Value::Object(sobj)) = serde_json::to_value(sel)
1482 {
1483 for (k, val) in sobj {
1485 obj.entry(k).or_insert(val);
1486 }
1487 }
1488 v
1489}
1490
1491fn external_json(st: &AppState) -> Vec<serde_json::Value> {
1493 st.external_cameras
1494 .read()
1495 .unwrap()
1496 .iter()
1497 .enumerate()
1498 .map(|(i, c)| {
1499 json!({ "id": format!("ext-{i}"), "label": c.label, "url": c.url,
1500 "stream_url": c.stream_url, "park_tuning": tuning_json(c) })
1501 })
1502 .collect()
1503}
1504
1505async fn cameras_config_get(State(st): State<AppState>) -> Json<serde_json::Value> {
1509 Json(json!({ "external": external_json(&st) }))
1510}
1511
1512#[derive(Deserialize)]
1513struct ExternalCameraInput {
1514 label: Option<String>,
1515 url: String,
1516 #[serde(default)]
1518 stream_url: Option<String>,
1519 #[serde(default)]
1525 park_tuning: Option<serde_json::Value>,
1526}
1527
1528fn is_http_url(u: &str) -> bool {
1532 u.starts_with("http://")
1533}
1534
1535#[derive(Deserialize)]
1536struct CamerasConfigBody {
1537 external: Vec<ExternalCameraInput>,
1538}
1539
1540async fn cameras_config_set(
1545 State(st): State<AppState>,
1546 Json(b): Json<CamerasConfigBody>,
1547) -> Response {
1548 let mut next = Vec::with_capacity(b.external.len());
1549 for (i, e) in b.external.into_iter().enumerate() {
1550 let url = e.url.trim().to_string();
1551 if !is_http_url(&url) {
1552 return (
1553 StatusCode::BAD_REQUEST,
1554 Json(json!({ "error": "camera URL must start with http:// (the proxy is plain-HTTP; no TLS)" })),
1555 )
1556 .into_response();
1557 }
1558 let stream_url = e
1560 .stream_url
1561 .map(|s| s.trim().to_string())
1562 .filter(|s| !s.is_empty());
1563 if let Some(s) = &stream_url
1564 && !is_http_url(s)
1565 {
1566 return (
1567 StatusCode::BAD_REQUEST,
1568 Json(json!({ "error": "camera stream URL must start with http:// (the proxy is plain-HTTP; no TLS)" })),
1569 )
1570 .into_response();
1571 }
1572 let (park, select) = match e.park_tuning {
1576 Some(v) => {
1577 let park: ParkTuning = match serde_json::from_value(v.clone()) {
1578 Ok(p) => p,
1579 Err(err) => return bad_request(format!("invalid park_tuning: {err}")),
1580 };
1581 (Some(park), serde_json::from_value(v).ok())
1582 }
1583 None => (None, None),
1584 };
1585 next.push(
1586 ExternalCamera::new(e.label, url, stream_url, i)
1587 .with_park_tuning(park)
1588 .with_select_tuning(select),
1589 );
1590 }
1591 *st.external_cameras.write().unwrap() = next;
1592 Json(json!({ "external": external_json(&st) })).into_response()
1593}
1594
1595fn fetch_camera_frame(url: &str) -> Result<(String, Vec<u8>), String> {
1599 let agent = ureq::AgentBuilder::new()
1602 .timeout(CAMERA_TIMEOUT)
1603 .redirects(0)
1604 .build();
1605 let resp = agent.get(url).call().map_err(|e| e.to_string())?;
1606 let ctype = resp
1608 .header("content-type")
1609 .map(str::to_string)
1610 .unwrap_or_else(|| "image/jpeg".to_string());
1611 let mut bytes = Vec::new();
1612 resp.into_reader()
1613 .take(CAMERA_MAX_BYTES)
1614 .read_to_end(&mut bytes)
1615 .map_err(|e| e.to_string())?;
1616 if bytes.is_empty() {
1617 return Err("camera returned an empty body".to_string());
1618 }
1619 Ok((ctype, bytes))
1620}
1621
1622#[derive(Deserialize)]
1623struct TimelapseStartBody {
1624 #[serde(default)]
1627 camera: Option<String>,
1628 #[serde(default)]
1631 cameras: Vec<String>,
1632 #[serde(default)]
1636 mode: Option<String>,
1637 #[serde(default = "default_every")]
1639 every: u64,
1640 #[serde(default)]
1642 interval_ms: Option<u64>,
1643 #[serde(default)]
1648 burst_offsets_ms: Option<Vec<u64>>,
1649 #[serde(default)]
1654 window_ms: Option<u64>,
1655}
1656fn default_every() -> u64 {
1657 1
1658}
1659
1660#[derive(Deserialize, Default)]
1661struct TimelapseStopBody {
1662 #[serde(default)]
1664 mode: Option<String>,
1665}
1666
1667fn timelapse_status_json(st: &AppState) -> serde_json::Value {
1671 let smooth = st.timelapse.status_smooth();
1672 let plain = st.timelapse.status_plain();
1673 let park = st.timelapse.status_park();
1674 let segment = st.timelapse.status_segment();
1675 let mut out = smooth.to_json();
1676 if let Some(o) = out.as_object_mut() {
1677 o.insert(
1678 "running".to_string(),
1679 json!(smooth.running || plain.running || park.running || segment.running),
1680 );
1681 o.insert("smooth".to_string(), smooth.to_json());
1682 o.insert("plain".to_string(), plain.to_json());
1683 o.insert("park".to_string(), park.to_json());
1684 o.insert("segment".to_string(), segment.to_json());
1685 }
1686 out
1687}
1688
1689fn resolve_grab(st: &AppState, camera: &str) -> Option<(String, FrameGrab)> {
1692 if camera == "internal" {
1693 if !st.internal_camera.configured() {
1694 return None;
1695 }
1696 let cam = st.internal_camera.clone();
1697 return Some((camera.to_string(), Arc::new(move || cam.snapshot())));
1698 }
1699 let idx = camera.strip_prefix("ext-")?.parse::<usize>().ok()?;
1700 let url = st.external_cameras.read().unwrap().get(idx)?.url.clone();
1701 Some((
1702 camera.to_string(),
1703 Arc::new(move || fetch_camera_frame(&url).map(|(_, bytes)| bytes)),
1704 ))
1705}
1706
1707fn sanitize_hint(s: &str) -> String {
1709 let cleaned: String = s
1710 .chars()
1711 .map(|c| {
1712 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1713 c
1714 } else {
1715 '_'
1716 }
1717 })
1718 .take(40)
1719 .collect();
1720 let trimmed = cleaned.trim_matches('_');
1721 if trimmed.is_empty() {
1722 "print".to_string()
1723 } else {
1724 trimmed.to_string()
1725 }
1726}
1727
1728fn captures_root() -> std::path::PathBuf {
1731 std::path::PathBuf::from("captures")
1732}
1733
1734fn run_out_dir(st: &AppState, mode: &str) -> std::path::PathBuf {
1737 let epoch = std::time::SystemTime::now()
1738 .duration_since(std::time::UNIX_EPOCH)
1739 .map(|d| d.as_secs())
1740 .unwrap_or(0);
1741 let hint = sanitize_hint(
1742 st.source
1743 .current()
1744 .subtask_name
1745 .as_deref()
1746 .unwrap_or("print"),
1747 );
1748 captures_root().join(format!("{epoch}_{hint}_{mode}"))
1749}
1750
1751async fn captures_list(State(_st): State<AppState>) -> Response {
1755 let runs = tokio::task::spawn_blocking(|| crate::captures::list_captures(&captures_root()))
1756 .await
1757 .unwrap_or_default();
1758 Json(json!({ "captures": runs })).into_response()
1759}
1760
1761fn is_safe_segment(s: &str) -> bool {
1764 !s.is_empty()
1765 && s.len() <= 128
1766 && !s.starts_with('.')
1767 && !s.contains('/')
1768 && !s.contains('\\')
1769 && s != ".."
1770}
1771
1772#[derive(Deserialize)]
1773struct CaptureVideoQuery {
1774 #[serde(default = "default_fps")]
1776 fps: u32,
1777}
1778fn default_fps() -> u32 {
1779 10
1780}
1781
1782async fn capture_video(
1787 State(st): State<AppState>,
1788 Path((run, cam)): Path<(String, String)>,
1789 Query(q): Query<CaptureVideoQuery>,
1790) -> Response {
1791 if !is_safe_segment(&run) || !is_safe_segment(&cam) {
1792 return bad_request("invalid capture path".to_string());
1793 }
1794 let fps = q.fps.clamp(1, 60);
1795 let select_tuning = cam
1798 .strip_prefix("ext-")
1799 .and_then(|n| n.parse::<usize>().ok())
1800 .and_then(|i| {
1801 st.external_cameras
1802 .read()
1803 .unwrap()
1804 .get(i)
1805 .and_then(|c| c.select_tuning)
1806 });
1807 let run_dir = captures_root().join(&run);
1808 let sub = run_dir.join(&cam);
1809 let cam_dir = if sub.is_dir() { sub } else { run_dir };
1810 let path = tokio::task::spawn_blocking(move || -> Result<std::path::PathBuf, String> {
1811 use crate::captures::{CaptureKind, assemble_mp4, classify};
1812 let files: Vec<String> = std::fs::read_dir(&cam_dir)
1813 .map_err(|e| e.to_string())?
1814 .flatten()
1815 .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
1816 .filter_map(|e| e.file_name().into_string().ok())
1817 .collect();
1818 match classify(&files).ok_or("no recording")?.kind {
1819 CaptureKind::Video => {
1820 let mp4 = cam_dir.join("plain.mp4");
1821 if mp4.is_file() {
1822 return Ok(mp4);
1823 }
1824 let mjpeg = cam_dir.join("plain.mjpeg");
1826 if mjpeg.is_file() {
1827 crate::captures::transcode_mp4(&mjpeg, &mp4)?;
1828 return Ok(mp4);
1829 }
1830 Err("video not available".to_string())
1831 }
1832 CaptureKind::Smooth => {
1833 let out = cam_dir.join("timelapse.mp4");
1834 let selected = select_tuning.is_some_and(|sel| {
1837 crate::captures::assemble_smooth_selected_mp4(&cam_dir, &sel, &out, fps).is_ok()
1838 });
1839 if !selected {
1840 assemble_mp4(&cam_dir, CaptureKind::Smooth, &out, fps)?;
1841 }
1842 Ok(out)
1843 }
1844 kind => {
1845 let out = cam_dir.join("timelapse.mp4");
1846 assemble_mp4(&cam_dir, kind, &out, fps)?;
1847 Ok(out)
1848 }
1849 }
1850 })
1851 .await;
1852 let p = match path {
1853 Ok(Ok(p)) => p,
1854 Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(),
1855 Err(_) => return server_error("assemble task failed".to_string()),
1856 };
1857 let Ok(file) = tokio::fs::File::open(&p).await else {
1860 return StatusCode::NOT_FOUND.into_response();
1861 };
1862 let stream = futures_util::stream::unfold(Some(file), |st| async move {
1863 let mut f = st?;
1864 let mut buf = vec![0u8; 64 * 1024];
1865 match tokio::io::AsyncReadExt::read(&mut f, &mut buf).await {
1866 Ok(0) => None,
1867 Ok(n) => {
1868 buf.truncate(n);
1869 Some((Ok::<Bytes, std::io::Error>(Bytes::from(buf)), Some(f)))
1870 }
1871 Err(e) => Some((Err(e), None)),
1872 }
1873 });
1874 (
1875 [
1876 (CONTENT_TYPE, "video/mp4".to_string()),
1877 (CACHE_CONTROL, "no-store".to_string()),
1878 ],
1879 Body::from_stream(stream),
1880 )
1881 .into_response()
1882}
1883
1884async fn capture_thumb(Path((run, cam)): Path<(String, String)>) -> Response {
1889 if !is_safe_segment(&run) || !is_safe_segment(&cam) {
1890 return bad_request("invalid capture path".to_string());
1891 }
1892 let run_dir = captures_root().join(&run);
1893 let sub = run_dir.join(&cam);
1894 let cam_dir = if sub.is_dir() { sub } else { run_dir };
1895 let path = tokio::task::spawn_blocking(move || -> Result<std::path::PathBuf, String> {
1896 use crate::captures::{CaptureKind, classify, extract_video_thumb, thumb_frame};
1897 let files: Vec<String> = std::fs::read_dir(&cam_dir)
1898 .map_err(|e| e.to_string())?
1899 .flatten()
1900 .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
1901 .filter_map(|e| e.file_name().into_string().ok())
1902 .collect();
1903 let kind = classify(&files).ok_or("no recording")?.kind;
1904 match kind {
1905 CaptureKind::Video => {
1906 let thumb = cam_dir.join("thumb.jpg");
1907 if !thumb.is_file() {
1908 let mp4 = cam_dir.join("plain.mp4");
1909 let src = if mp4.is_file() {
1910 mp4
1911 } else {
1912 cam_dir.join("plain.mjpeg")
1913 };
1914 if !src.is_file() {
1915 return Err("no video".to_string());
1916 }
1917 extract_video_thumb(&src, &thumb)?;
1918 }
1919 Ok(thumb)
1920 }
1921 kind => Ok(cam_dir.join(thumb_frame(&files, kind).ok_or("no frame")?)),
1922 }
1923 })
1924 .await;
1925 let p = match path {
1926 Ok(Ok(p)) => p,
1927 Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(),
1928 Err(_) => return server_error("thumb task failed".to_string()),
1929 };
1930 match tokio::fs::read(&p).await {
1931 Ok(bytes) => (
1932 [
1933 (CONTENT_TYPE, "image/jpeg".to_string()),
1934 (CACHE_CONTROL, "no-store".to_string()),
1937 ],
1938 bytes,
1939 )
1940 .into_response(),
1941 Err(_) => StatusCode::NOT_FOUND.into_response(),
1942 }
1943}
1944
1945fn start_park_run(
1951 st: &AppState,
1952 ids: &[String],
1953 out_dir: std::path::PathBuf,
1954 rx: watch::Receiver<PrinterStatus>,
1955) -> Response {
1956 let externals = st.external_cameras.read().unwrap();
1957 let mut caps = Vec::new();
1958 let mut skipped = Vec::new();
1959 for id in ids {
1960 let cap = id
1961 .strip_prefix("ext-")
1962 .and_then(|n| n.parse::<usize>().ok())
1963 .and_then(|i| externals.get(i))
1964 .and_then(|c| match (&c.stream_url, &c.park_tuning) {
1965 (Some(url), Some(t)) => Some(ParkCapture {
1966 id: id.clone(),
1967 stream_url: url.clone(),
1968 tuning: t.clone(),
1969 }),
1970 _ => None,
1971 });
1972 match cap {
1973 Some(c) => caps.push(c),
1974 None => skipped.push(id.clone()),
1975 }
1976 }
1977 drop(externals);
1978 if caps.is_empty() {
1979 return bad_request(format!(
1980 "no park-capable cameras among {ids:?} — each needs a stream_url and a park_tuning"
1981 ));
1982 }
1983 match st
1984 .timelapse
1985 .start_park(caps, rx, out_dir, real_park_spawn())
1986 {
1987 Ok(()) => {
1988 let mut body = timelapse_status_json(st);
1989 if let Some(o) = body.as_object_mut() {
1990 o.insert("skipped".to_string(), json!(skipped));
1991 }
1992 Json(body).into_response()
1993 }
1994 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1995 }
1996}
1997
1998fn start_segment_run(
2005 st: &AppState,
2006 ids: &[String],
2007 window_ms: u64,
2008 out_dir: std::path::PathBuf,
2009 rx: watch::Receiver<PrinterStatus>,
2010) -> Response {
2011 let externals = st.external_cameras.read().unwrap();
2012 let mut caps = Vec::new();
2013 let mut skipped = Vec::new();
2014 for id in ids {
2015 let cap = id
2016 .strip_prefix("ext-")
2017 .and_then(|n| n.parse::<usize>().ok())
2018 .and_then(|i| externals.get(i))
2019 .and_then(|c| match (&c.stream_url, &c.park_tuning, c.select_tuning) {
2020 (Some(url), Some(park), Some(select)) => Some(SegmentCapture {
2021 id: id.clone(),
2022 stream_url: url.clone(),
2023 fps: park.fps,
2024 window_ms,
2025 select_tuning: select,
2026 }),
2027 _ => None,
2028 });
2029 match cap {
2030 Some(c) => caps.push(c),
2031 None => skipped.push(id.clone()),
2032 }
2033 }
2034 drop(externals);
2035 if caps.is_empty() {
2036 return bad_request(format!(
2037 "no segment-capable cameras among {ids:?} — each needs a stream_url, a park_tuning, and a select_tuning"
2038 ));
2039 }
2040 match st
2041 .timelapse
2042 .start_segment(caps, rx, out_dir, real_segment_spawn())
2043 {
2044 Ok(()) => {
2045 let mut body = timelapse_status_json(st);
2046 if let Some(o) = body.as_object_mut() {
2047 o.insert("skipped".to_string(), json!(skipped));
2048 }
2049 Json(body).into_response()
2050 }
2051 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
2052 }
2053}
2054
2055async fn timelapse_start(
2059 State(st): State<AppState>,
2060 Json(b): Json<TimelapseStartBody>,
2061) -> Response {
2062 let mode = b.mode.as_deref().unwrap_or("smooth");
2065 let interval_ms = b.interval_ms.unwrap_or(3000);
2066 let window_ms = b.window_ms.unwrap_or(120_000);
2070 let burst_offsets = b
2071 .burst_offsets_ms
2072 .clone()
2073 .unwrap_or_else(|| DEFAULT_SMOOTH_BURST_MS.to_vec());
2074 match mode {
2075 "smooth" => {
2076 if b.every < 1 {
2077 return bad_request("every must be >= 1".to_string());
2078 }
2079 if burst_offsets.is_empty() {
2080 return bad_request("burst_offsets_ms must have at least one offset".to_string());
2081 }
2082 if burst_offsets.len() > 16 {
2083 return bad_request("burst_offsets_ms: at most 16 offsets".to_string());
2084 }
2085 if let Some(&o) = burst_offsets.iter().find(|&&o| o > 10_000) {
2086 return bad_request(format!("burst_offsets_ms: {o} ms exceeds the 10000 ms cap"));
2087 }
2088 }
2089 "plain" => {
2090 if interval_ms < 100 {
2091 return bad_request("interval_ms must be >= 100".to_string());
2092 }
2093 }
2094 "park" => {}
2097 "segment" => {
2098 if !(5_000..=600_000).contains(&window_ms) {
2101 return bad_request("window_ms must be between 5000 and 600000".to_string());
2102 }
2103 }
2104 other => {
2105 return bad_request(format!(
2106 "unknown mode {other:?} (use smooth, plain, park, or segment)"
2107 ));
2108 }
2109 }
2110 let mut ids: Vec<String> = if !b.cameras.is_empty() {
2112 b.cameras.clone()
2113 } else {
2114 b.camera.clone().into_iter().collect()
2115 };
2116 ids.dedup();
2117 if ids.is_empty() {
2118 return bad_request("specify a camera or cameras to capture".to_string());
2119 }
2120 if mode == "park" {
2123 let out_dir = run_out_dir(&st, mode);
2124 let rx = st.source.subscribe();
2125 return start_park_run(&st, &ids, out_dir, rx);
2126 }
2127 if mode == "segment" {
2129 let out_dir = run_out_dir(&st, mode);
2130 let rx = st.source.subscribe();
2131 return start_segment_run(&st, &ids, window_ms, out_dir, rx);
2132 }
2133 let mut grabs = Vec::with_capacity(ids.len());
2134 for id in &ids {
2135 let Some(resolved) = resolve_grab(&st, id) else {
2136 return (
2137 StatusCode::NOT_FOUND,
2138 Json(json!({ "error": format!("unknown or unconfigured camera: {id}") })),
2139 )
2140 .into_response();
2141 };
2142 grabs.push(resolved);
2143 }
2144 let out_dir = run_out_dir(&st, mode);
2146 let rx = st.source.subscribe();
2147 let res = match mode {
2148 "plain" => {
2149 let externals = st.external_cameras.read().unwrap();
2152 let caps: Vec<PlainCapture> = ids
2153 .iter()
2154 .zip(grabs)
2155 .map(
2156 |(id, (gid, grab))| match resolve_stream_url(id, &externals) {
2157 Some(url) => PlainCapture::Stream {
2158 id: gid,
2159 open: url_stream_opener(url),
2160 },
2161 None => PlainCapture::Sample { id: gid, grab },
2162 },
2163 )
2164 .collect();
2165 drop(externals);
2166 st.timelapse.start_plain(caps, interval_ms, rx, out_dir)
2167 }
2168 _ => {
2169 let externals = st.external_cameras.read().unwrap();
2173 let selects: Vec<Option<crate::core::park::SelectTuning>> = ids
2174 .iter()
2175 .map(|id| {
2176 id.strip_prefix("ext-")
2177 .and_then(|n| n.parse::<usize>().ok())
2178 .and_then(|i| externals.get(i))
2179 .and_then(|c| c.select_tuning)
2180 })
2181 .collect();
2182 drop(externals);
2183 st.timelapse.start_smooth_with_select(
2184 grabs,
2185 b.every,
2186 burst_offsets,
2187 rx,
2188 out_dir,
2189 selects,
2190 )
2191 }
2192 };
2193 match res {
2194 Ok(()) => Json(timelapse_status_json(&st)).into_response(),
2195 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
2196 }
2197}
2198
2199async fn timelapse_stop(
2204 State(st): State<AppState>,
2205 body: Option<Json<TimelapseStopBody>>,
2206) -> Response {
2207 let mode = body
2208 .and_then(|b| b.0.mode)
2209 .unwrap_or_else(|| "all".to_string());
2210 match mode.as_str() {
2211 "smooth" => {
2212 st.timelapse.stop_smooth();
2213 }
2214 "plain" => {
2215 st.timelapse.stop_plain();
2216 }
2217 "park" => {
2218 st.timelapse.stop_park();
2219 }
2220 "segment" => {
2221 st.timelapse.stop_segment();
2222 }
2223 "all" => {
2224 st.timelapse.stop_smooth();
2225 st.timelapse.stop_plain();
2226 st.timelapse.stop_park();
2227 st.timelapse.stop_segment();
2228 }
2229 other => {
2230 return bad_request(format!(
2231 "unknown mode {other:?} (use smooth, plain, park, segment, or all)"
2232 ));
2233 }
2234 }
2235 Json(timelapse_status_json(&st)).into_response()
2236}
2237
2238async fn timelapse_status(State(st): State<AppState>) -> Json<serde_json::Value> {
2240 Json(timelapse_status_json(&st))
2241}
2242
2243#[derive(Deserialize)]
2244struct UploadQuery {
2245 dir: Option<String>,
2246 name: String,
2247}
2248
2249async fn upload_file(
2253 State(st): State<AppState>,
2254 Query(q): Query<UploadQuery>,
2255 body: Body,
2256) -> Response {
2257 if q.name.is_empty() || q.name.contains('/') || q.name.contains('\\') || q.name.contains("..") {
2259 return bad_request(format!("invalid filename {:?}", q.name));
2260 }
2261 let dir = q.dir.unwrap_or_else(|| "/".to_string());
2262 if dir != "/" && !is_safe_remote_path(&dir) {
2264 return bad_request(format!("invalid dir {dir:?}"));
2265 }
2266 let remote = format!("{}/{}", dir.trim_end_matches('/'), q.name);
2267
2268 let tmp = match tempfile::Builder::new().prefix("bambu-upload-").tempfile() {
2270 Ok(t) => t,
2271 Err(e) => return server_error(e.to_string()),
2272 };
2273 {
2274 let mut file = match tokio::fs::File::create(tmp.path()).await {
2275 Ok(f) => f,
2276 Err(e) => return server_error(e.to_string()),
2277 };
2278 let mut stream = body.into_data_stream();
2279 while let Some(chunk) = stream.next().await {
2280 let chunk = match chunk {
2281 Ok(c) => c,
2282 Err(_) => return bad_request("upload stream error".to_string()),
2283 };
2284 if file.write_all(&chunk).await.is_err() {
2285 return server_error("writing upload".to_string());
2286 }
2287 }
2288 if file.flush().await.is_err() {
2289 return server_error("flushing upload".to_string());
2290 }
2291 }
2292
2293 let name = q.name.clone();
2294 let path = tmp.path().to_path_buf();
2295 let files = st.files.clone();
2296 let res = tokio::task::spawn_blocking(move || files.upload(&remote, &path)).await;
2297 drop(tmp); match res {
2299 Ok(Ok(())) => Json(json!({ "uploaded": name })).into_response(),
2300 Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
2301 Err(_) => server_error("upload task failed".to_string()),
2302 }
2303}
2304
2305fn server_error(msg: String) -> Response {
2306 (
2307 StatusCode::INTERNAL_SERVER_ERROR,
2308 Json(json!({ "error": msg })),
2309 )
2310 .into_response()
2311}
2312
2313const MAX_UPLOAD_BYTES: u64 = 512 * 1024 * 1024;
2315
2316#[derive(Deserialize)]
2317struct UploadStartQuery {
2318 name: String,
2319 dir: Option<String>,
2320 #[serde(default = "default_plate")]
2321 plate: u32,
2322 #[serde(default)]
2323 timelapse: bool,
2324 bed_type: Option<String>,
2325 #[serde(default)]
2326 confirm: bool,
2327 #[serde(default)]
2328 dry_run: bool,
2329 #[serde(default)]
2330 overwrite: bool,
2331}
2332
2333async fn job_upload_start(
2340 State(st): State<AppState>,
2341 Query(q): Query<UploadStartQuery>,
2342 body: Body,
2343) -> Response {
2344 if q.name.is_empty() || q.name.contains('/') || q.name.contains('\\') || q.name.contains("..") {
2346 return bad_request(format!("invalid filename {:?}", q.name));
2347 }
2348 let dir = q.dir.clone().unwrap_or_else(|| "/".to_string());
2351 if dir != "/" && !is_safe_remote_path(&dir) {
2352 return bad_request(format!("invalid dir {dir:?}"));
2353 }
2354 let remote = format!("{}/{}", dir.trim_end_matches('/'), q.name);
2355 let is_3mf = q.name.to_ascii_lowercase().ends_with(".3mf");
2356
2357 if !q.confirm && !q.dry_run {
2360 return (
2361 StatusCode::PRECONDITION_REQUIRED,
2362 Json(
2363 json!({ "error": "confirm required: add &confirm=true (try &dry_run=true first)" }),
2364 ),
2365 )
2366 .into_response();
2367 }
2368
2369 let tmp = match tempfile::Builder::new().prefix("bambu-upload-").tempfile() {
2372 Ok(t) => t,
2373 Err(e) => return server_error(e.to_string()),
2374 };
2375 {
2376 let mut file = match tokio::fs::File::create(tmp.path()).await {
2377 Ok(f) => f,
2378 Err(e) => return server_error(e.to_string()),
2379 };
2380 let mut stream = body.into_data_stream();
2381 let mut written: u64 = 0;
2382 while let Some(chunk) = stream.next().await {
2383 let chunk = match chunk {
2384 Ok(c) => c,
2385 Err(_) => return bad_request("upload stream error".to_string()),
2386 };
2387 written += chunk.len() as u64;
2388 if written > MAX_UPLOAD_BYTES {
2389 return (
2390 StatusCode::PAYLOAD_TOO_LARGE,
2391 Json(json!({ "error": "upload exceeds the 512 MiB limit" })),
2392 )
2393 .into_response();
2394 }
2395 if file.write_all(&chunk).await.is_err() {
2396 return server_error("writing upload".to_string());
2397 }
2398 }
2399 if file.flush().await.is_err() {
2400 return server_error("flushing upload".to_string());
2401 }
2402 }
2403
2404 let inspection = if is_3mf {
2406 match std::fs::read(tmp.path())
2407 .map_err(|e| e.to_string())
2408 .and_then(|b| {
2409 crate::core::project::inspect_plate(&b, q.plate).map_err(|e| e.to_string())
2410 }) {
2411 Ok(insp) => Some(insp),
2412 Err(e) => return bad_request(format!("3mf inspection: {e}")),
2413 }
2414 } else {
2415 None
2416 };
2417 let bed_type = q.bed_type.clone().unwrap_or_else(|| "auto".to_string());
2418 let md5 = inspection.as_ref().map(|i| i.gcode_md5.clone());
2419
2420 if q.dry_run {
2421 return Json(json!({ "plan": {
2422 "file": remote,
2423 "plate": q.plate,
2424 "use_ams": false,
2425 "bed_type": bed_type,
2426 "timelapse": q.timelapse,
2427 "md5": md5,
2428 "has_timelapse_blocks": inspection.as_ref().map(|i| i.has_timelapse_blocks),
2429 "overwrite": q.overwrite,
2430 }}))
2431 .into_response();
2432 }
2433 let Ok(_guard) = st.start_lock.try_lock() else {
2438 return (
2439 StatusCode::CONFLICT,
2440 Json(json!({ "error": "a print start is already in progress" })),
2441 )
2442 .into_response();
2443 };
2444 if let Some(busy) = require_idle(&st) {
2445 return busy;
2446 }
2447
2448 if !q.overwrite {
2450 let files = st.files.clone();
2451 let dir_for_check = dir.clone();
2452 let name = q.name.clone();
2453 if let Ok(Ok(entries)) =
2454 tokio::task::spawn_blocking(move || files.list(&dir_for_check)).await
2455 && entries.iter().any(|e| e.name == name)
2456 {
2457 return (
2458 StatusCode::CONFLICT,
2459 Json(json!({ "error": format!("{remote} already exists (add &overwrite=true to replace it)") })),
2460 )
2461 .into_response();
2462 }
2463 }
2464
2465 let files = st.files.clone();
2467 let path = tmp.path().to_path_buf();
2468 let remote_for_upload = remote.clone();
2469 let up = tokio::task::spawn_blocking(move || files.upload(&remote_for_upload, &path)).await;
2470 drop(tmp); match up {
2472 Ok(Ok(())) => {}
2473 Ok(Err(e)) => {
2474 return (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response();
2475 }
2476 Err(_) => return server_error("upload task failed".to_string()),
2477 }
2478
2479 let req = StartRequest {
2480 file: remote,
2481 plate: q.plate,
2482 use_ams: false,
2483 ams_map: Vec::new(),
2484 bed_type,
2485 timelapse: q.timelapse,
2486 inspection,
2487 };
2488 let starter = st.starter.clone();
2489 let res = tokio::task::spawn_blocking(move || starter.start(&req)).await;
2490 verify_response(res)
2491}
2492
2493async fn status_ws(State(st): State<AppState>, ws: WebSocketUpgrade) -> Response {
2496 eprintln!("ws: client upgrade accepted");
2497 ws.on_upgrade(move |socket| async move {
2498 stream_status(socket, st.source.clone()).await;
2499 eprintln!("ws: client disconnected");
2500 })
2501}
2502
2503async fn stream_status(mut socket: WebSocket, source: Arc<dyn PrinterSource>) {
2504 let mut rx = source.subscribe();
2505 loop {
2506 let snapshot = rx.borrow_and_update().clone();
2509 let Ok(json) = serde_json::to_string(&snapshot) else {
2510 break;
2511 };
2512 if socket.send(Message::Text(json.into())).await.is_err() {
2513 break; }
2515 if rx.changed().await.is_err() {
2516 break; }
2518 }
2519}
2520
2521async fn require_password(State(st): State<AppState>, req: Request, next: Next) -> Response {
2525 let Some(pw) = st.password.as_deref() else {
2526 return next.run(req).await; };
2528 let given = req
2530 .headers()
2531 .get(AUTHORIZATION)
2532 .and_then(|v| v.to_str().ok())
2533 .and_then(|v| v.split_once(' '))
2534 .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
2535 .map(|(_, tok)| tok.trim());
2536 if given.is_some_and(|tok| constant_time_eq(tok.as_bytes(), pw.as_bytes())) {
2537 next.run(req).await
2538 } else {
2539 eprintln!("auth: rejected write {} {}", req.method(), req.uri().path());
2540 (
2541 StatusCode::UNAUTHORIZED,
2542 Json(json!({ "error": "password required" })),
2543 )
2544 .into_response()
2545 }
2546}
2547
2548fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2550 if a.len() != b.len() {
2551 return false;
2552 }
2553 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
2554}
2555
2556#[cfg(test)]
2557mod tests {
2558 use super::*;
2559 use crate::core::session::VerifyStage;
2560 use axum_test::TestServer;
2561
2562 fn app(password: Option<&str>, controller: impl Controller + 'static) -> TestServer {
2564 let state = AppState {
2565 source: Arc::new(FakeSource::idle()),
2566 controller: Arc::new(controller),
2567 files: Arc::new(FakeFiles),
2568 starter: Arc::new(FakeStarter),
2569 password: password.map(str::to_owned),
2570 start_lock: Arc::new(tokio::sync::Mutex::new(())),
2571 external_cameras: Arc::new(RwLock::new(Vec::new())),
2572 internal_camera: Arc::new(NoCamera),
2573 timelapse: Default::default(),
2574 };
2575 TestServer::new(router(state))
2576 }
2577
2578 #[tokio::test]
2580 async fn status_is_open_and_returns_printer_status_json() {
2581 let res = app(None, FakeController::verified())
2582 .get("/api/status")
2583 .await;
2584 res.assert_status_ok();
2585 let body: serde_json::Value = res.json();
2586 assert_eq!(body["gcode_state"], "IDLE");
2587 assert_eq!(body["print_error"], 0);
2588 }
2589
2590 #[tokio::test]
2591 async fn status_is_open_even_when_a_password_is_set() {
2592 app(Some("secret"), FakeController::verified())
2594 .get("/api/status")
2595 .await
2596 .assert_status_ok();
2597 }
2598
2599 #[tokio::test]
2601 async fn job_stop_needs_confirmation() {
2602 app(None, FakeController::verified())
2603 .post("/api/job/stop")
2604 .await
2605 .assert_status(StatusCode::PRECONDITION_REQUIRED);
2606 }
2607
2608 #[tokio::test]
2609 async fn job_pause_confirmed_returns_verified() {
2610 let res = app(None, FakeController::verified())
2611 .post("/api/job/pause")
2612 .json(&json!({ "confirm": true }))
2613 .await;
2614 res.assert_status_ok();
2615 assert_eq!(res.json::<serde_json::Value>()["outcome"], "verified");
2616 }
2617
2618 #[tokio::test]
2619 async fn job_clear_error_needs_confirmation() {
2620 app(None, FakeController::verified())
2621 .post("/api/job/clear-error")
2622 .await
2623 .assert_status(StatusCode::PRECONDITION_REQUIRED);
2624 }
2625
2626 #[tokio::test]
2627 async fn job_clear_error_confirmed_returns_verified() {
2628 let res = app(None, FakeController::verified())
2629 .post("/api/job/clear-error")
2630 .json(&json!({ "confirm": true }))
2631 .await;
2632 res.assert_status_ok();
2633 assert_eq!(res.json::<serde_json::Value>()["outcome"], "verified");
2634 }
2635
2636 #[tokio::test]
2638 async fn upload_start_needs_confirmation() {
2639 app(None, FakeController::verified())
2640 .post("/api/job/upload-start?name=x.gcode")
2641 .bytes(b"G28\n".to_vec().into())
2642 .await
2643 .assert_status(StatusCode::PRECONDITION_REQUIRED);
2644 }
2645
2646 #[tokio::test]
2647 async fn upload_start_confirmed_uploads_then_starts() {
2648 let res = app(None, FakeController::verified())
2651 .post("/api/job/upload-start?name=x.gcode&confirm=true")
2652 .bytes(b"G28\n".to_vec().into())
2653 .await;
2654 res.assert_status_ok();
2655 assert_eq!(res.json::<serde_json::Value>()["outcome"], "verified");
2656 }
2657
2658 #[tokio::test]
2659 async fn upload_start_dry_run_plans_without_starting() {
2660 let res = app(None, FakeController::verified())
2661 .post("/api/job/upload-start?name=x.gcode&dry_run=true")
2662 .bytes(b"G28\n".to_vec().into())
2663 .await;
2664 res.assert_status_ok();
2665 let v = res.json::<serde_json::Value>();
2666 assert_eq!(v["plan"]["file"], "/x.gcode");
2669 }
2670
2671 #[tokio::test]
2672 async fn upload_start_rejects_a_traversal_name() {
2673 app(None, FakeController::verified())
2674 .post("/api/job/upload-start?name=../evil.gcode&confirm=true")
2675 .bytes(b"x".to_vec().into())
2676 .await
2677 .assert_status(StatusCode::BAD_REQUEST);
2678 }
2679
2680 #[tokio::test]
2681 async fn upload_start_is_gated_by_password() {
2682 app(Some("hunter2"), FakeController::verified())
2683 .post("/api/job/upload-start?name=x.gcode&confirm=true")
2684 .bytes(b"G28\n".to_vec().into())
2685 .await
2686 .assert_status(StatusCode::UNAUTHORIZED);
2687 }
2688
2689 #[tokio::test]
2691 async fn rejected_outcome_maps_to_409() {
2692 let c = FakeController::returning(CommandOutcome::Rejected {
2693 reason: "busy".into(),
2694 });
2695 app(None, c)
2696 .post("/api/job/stop")
2697 .json(&json!({ "confirm": true }))
2698 .await
2699 .assert_status(StatusCode::CONFLICT);
2700 }
2701
2702 #[tokio::test]
2703 async fn unverified_outcome_maps_to_202() {
2704 let c = FakeController::returning(CommandOutcome::Unverified {
2705 stage: VerifyStage::Effect,
2706 });
2707 app(None, c)
2708 .post("/api/light")
2709 .json(&json!({ "node": "chamber", "on": true }))
2710 .await
2711 .assert_status(StatusCode::ACCEPTED);
2712 }
2713
2714 #[tokio::test]
2715 async fn transport_failure_maps_to_502() {
2716 app(None, FakeController::failing())
2717 .post("/api/light")
2718 .json(&json!({ "node": "chamber", "on": false }))
2719 .await
2720 .assert_status(StatusCode::BAD_GATEWAY);
2721 }
2722
2723 #[tokio::test]
2725 async fn unknown_light_node_is_400() {
2726 app(None, FakeController::verified())
2727 .post("/api/light")
2728 .json(&json!({ "node": "kitchen", "on": true }))
2729 .await
2730 .assert_status(StatusCode::BAD_REQUEST);
2731 }
2732
2733 #[tokio::test]
2734 async fn speed_level_sets_ok() {
2735 app(None, FakeController::verified())
2736 .post("/api/speed")
2737 .json(&json!({ "level": "standard" }))
2738 .await
2739 .assert_status_ok();
2740 }
2741
2742 #[tokio::test]
2744 async fn write_without_password_is_401_when_one_is_set() {
2745 app(Some("secret"), FakeController::verified())
2746 .post("/api/light")
2747 .json(&json!({ "node": "chamber", "on": true }))
2748 .await
2749 .assert_status(StatusCode::UNAUTHORIZED);
2750 }
2751
2752 #[tokio::test]
2753 async fn write_with_correct_password_is_allowed() {
2754 app(Some("secret"), FakeController::verified())
2755 .post("/api/light")
2756 .authorization_bearer("secret")
2757 .json(&json!({ "node": "chamber", "on": true }))
2758 .await
2759 .assert_status_ok();
2760 }
2761
2762 #[tokio::test]
2764 async fn gcode_needs_confirmation() {
2765 app(None, FakeController::verified())
2766 .post("/api/gcode")
2767 .json(&json!({ "line": "G28" }))
2768 .await
2769 .assert_status(StatusCode::PRECONDITION_REQUIRED);
2770 }
2771
2772 #[tokio::test]
2773 async fn gcode_safe_line_runs() {
2774 app(None, FakeController::verified())
2775 .post("/api/gcode")
2776 .json(&json!({ "line": "G28", "confirm": true }))
2777 .await
2778 .assert_status_ok();
2779 }
2780
2781 #[tokio::test]
2782 async fn gcode_unsafe_line_is_blocked_unless_forced() {
2783 let s = app(None, FakeController::verified());
2784 s.post("/api/gcode")
2786 .json(&json!({ "line": "M104 S999", "confirm": true }))
2787 .await
2788 .assert_status(StatusCode::BAD_REQUEST);
2789 s.post("/api/gcode")
2791 .json(&json!({ "line": "M104 S999", "confirm": true, "force": true }))
2792 .await
2793 .assert_status_ok();
2794 }
2795
2796 #[tokio::test]
2798 async fn list_files_is_open() {
2799 let res = app(Some("secret"), FakeController::verified())
2800 .get("/api/file")
2801 .await;
2802 res.assert_status_ok();
2803 let body: serde_json::Value = res.json();
2804 let files = body["files"].as_array().unwrap();
2805 assert!(
2806 files
2807 .iter()
2808 .any(|f| f["name"] == "coin2c.gcode.3mf" && f["is_dir"] == false)
2809 );
2810 assert!(
2811 files
2812 .iter()
2813 .any(|f| f["name"] == "cache" && f["is_dir"] == true)
2814 );
2815 }
2816
2817 #[tokio::test]
2818 async fn thumbnail_returns_png() {
2819 let res = app(None, FakeController::verified())
2820 .get("/api/file/thumbnail?name=coin2c.gcode.3mf")
2821 .await;
2822 res.assert_status_ok();
2823 assert_eq!(res.header("content-type"), "image/png");
2824 }
2825
2826 #[tokio::test]
2827 async fn raw_serves_3mf_bytes() {
2828 let res = app(None, FakeController::verified())
2829 .get("/api/file/raw?name=/cache/coin.gcode.3mf")
2830 .await;
2831 res.assert_status_ok();
2832 assert_eq!(res.header("content-type"), "application/octet-stream");
2833 }
2834
2835 #[tokio::test]
2836 async fn raw_rejects_other_extensions() {
2837 app(None, FakeController::verified())
2838 .get("/api/file/raw?name=/secret.txt")
2839 .await
2840 .assert_status(StatusCode::BAD_REQUEST);
2841 }
2842
2843 #[tokio::test]
2844 async fn gcode_file_serves_plate_toolpath() {
2845 let res = app(None, FakeController::verified())
2846 .get("/api/file/gcode?name=/coin2c.gcode.3mf&plate=1")
2847 .await;
2848 res.assert_status_ok();
2849 assert!(
2850 res.header("content-type")
2851 .to_str()
2852 .unwrap()
2853 .starts_with("text/plain")
2854 );
2855 assert!(res.text().contains("G1"));
2856 }
2857
2858 #[tokio::test]
2859 async fn gcode_file_rejects_non_3mf() {
2860 app(None, FakeController::verified())
2861 .get("/api/file/gcode?name=/raw.gcode")
2862 .await
2863 .assert_status(StatusCode::BAD_REQUEST);
2864 }
2865
2866 #[tokio::test]
2867 async fn mesh_file_serves_object_models() {
2868 let res = app(None, FakeController::verified())
2869 .get("/api/file/mesh?name=/coin2c.gcode.3mf")
2870 .await;
2871 res.assert_status_ok();
2872 let body: serde_json::Value = res.json();
2873 let models = body["models"].as_array().unwrap();
2874 assert_eq!(models.len(), 1);
2875 assert!(models[0].as_str().unwrap().contains("<triangle "));
2876 }
2877
2878 #[tokio::test]
2879 async fn mesh_file_rejects_non_3mf() {
2880 app(None, FakeController::verified())
2881 .get("/api/file/mesh?name=/raw.gcode")
2882 .await
2883 .assert_status(StatusCode::BAD_REQUEST);
2884 }
2885
2886 #[tokio::test]
2888 async fn cameras_list_is_empty_without_built_in_or_external() {
2889 let res = app(None, FakeController::verified())
2891 .get("/api/camera")
2892 .await;
2893 res.assert_status_ok();
2894 assert_eq!(
2895 res.json::<serde_json::Value>()["cameras"]
2896 .as_array()
2897 .unwrap()
2898 .len(),
2899 0
2900 );
2901 }
2902
2903 #[tokio::test]
2904 async fn camera_snapshot_is_404_for_unknown_id() {
2905 let server = app(None, FakeController::verified());
2906 for id in ["internal", "ext-0", "bogus"] {
2907 server
2908 .get(&format!("/api/camera/{id}/snapshot"))
2909 .await
2910 .assert_status(StatusCode::NOT_FOUND);
2911 }
2912 }
2913
2914 #[tokio::test]
2915 async fn external_cameras_can_be_set_then_listed_and_cleared() {
2916 let server = app(None, FakeController::verified());
2917 let res = server
2919 .post("/api/camera/config")
2920 .json(&json!({
2921 "external": [
2922 { "label": "front", "url": "http://cam.local/a.jpg" },
2923 { "url": "http://cam.local/b.jpg" }
2924 ]
2925 }))
2926 .await;
2927 res.assert_status_ok();
2928 let list = server.get("/api/camera").await.json::<serde_json::Value>();
2930 let cams = list["cameras"].as_array().unwrap();
2931 assert_eq!(cams.len(), 2);
2932 assert_eq!(cams[0]["id"], "ext-0");
2933 assert_eq!(cams[0]["label"], "front");
2934 assert_eq!(cams[0]["kind"], "external");
2935 assert_eq!(cams[1]["label"], "external 2"); assert!(cams[0].get("url").is_none()); let cfg = server
2939 .get("/api/camera/config")
2940 .await
2941 .json::<serde_json::Value>();
2942 assert_eq!(cfg["external"][0]["url"], "http://cam.local/a.jpg");
2943 server
2945 .post("/api/camera/config")
2946 .json(&json!({ "external": [] }))
2947 .await
2948 .assert_status_ok();
2949 let list = server.get("/api/camera").await.json::<serde_json::Value>();
2950 assert_eq!(list["cameras"].as_array().unwrap().len(), 0);
2951 }
2952
2953 #[tokio::test]
2954 async fn camera_config_rejects_non_http_url() {
2955 let server = app(None, FakeController::verified());
2956 server
2957 .post("/api/camera/config")
2958 .json(&json!({ "external": [{ "url": "file:///etc/passwd" }] }))
2959 .await
2960 .assert_status(StatusCode::BAD_REQUEST);
2961 server
2964 .post("/api/camera/config")
2965 .json(&json!({ "external": [{ "url": "https://cam.local/a.jpg" }] }))
2966 .await
2967 .assert_status(StatusCode::BAD_REQUEST);
2968 }
2969
2970 #[tokio::test]
2971 async fn external_camera_stream_url_round_trips_and_flags_the_list() {
2972 let server = app(None, FakeController::verified());
2973 server
2974 .post("/api/camera/config")
2975 .json(&json!({
2976 "external": [
2977 { "label": "front", "url": "http://cam.local/snapshot",
2978 "stream_url": "http://cam.local/stream" },
2979 { "url": "http://cam.local/b.jpg" }
2980 ]
2981 }))
2982 .await
2983 .assert_status_ok();
2984 let list = server.get("/api/camera").await.json::<serde_json::Value>();
2987 let cams = list["cameras"].as_array().unwrap();
2988 assert_eq!(cams[0]["stream"], true);
2989 assert_eq!(cams[1]["stream"], false);
2990 assert!(cams[0].get("url").is_none());
2991 let cfg = server
2993 .get("/api/camera/config")
2994 .await
2995 .json::<serde_json::Value>();
2996 assert_eq!(cfg["external"][0]["stream_url"], "http://cam.local/stream");
2997 assert!(cfg["external"][1]["stream_url"].is_null());
2998 }
2999
3000 #[tokio::test]
3001 async fn park_tuning_round_trips_and_flags_capability() {
3002 let server = app(None, FakeController::verified());
3003 let tuning = json!({ "fps": 4, "left_frac": 0.33, "ema_seconds": 30, "abs_floor": 1500,
3004 "mad_k": 6, "merge_gap_s": 1.2, "max_island_s": 3, "min_sep_s": 3,
3005 "candidate_frac": 0.75, "warmup_s": 4, "baseline_s": 90 });
3006 server
3007 .post("/api/camera/config")
3008 .json(&json!({ "external": [
3009 { "label": "front", "url": "http://cam.local/snap",
3010 "stream_url": "http://cam.local/stream", "park_tuning": tuning },
3011 { "url": "http://cam.local/b.jpg", "stream_url": "http://cam.local/bstream" },
3013 ]}))
3014 .await
3015 .assert_status_ok();
3016 let list = server.get("/api/camera").await.json::<serde_json::Value>();
3018 let cams = list["cameras"].as_array().unwrap();
3019 assert_eq!(cams[0]["park"], true);
3020 assert_eq!(
3021 cams[1]["park"], false,
3022 "stream but no tuning → not park-capable"
3023 );
3024 let cfg = server
3026 .get("/api/camera/config")
3027 .await
3028 .json::<serde_json::Value>();
3029 assert!(cfg["external"][0]["park_tuning"].is_object());
3030 assert_eq!(cfg["external"][0]["park_tuning"]["fps"], json!(4.0));
3031 assert!(cfg["external"][1]["park_tuning"].is_null());
3032 }
3033
3034 #[tokio::test]
3035 async fn select_tuning_round_trips_and_flags_segment_capability() {
3036 let server = app(None, FakeController::verified());
3037 let full = json!({ "fps": 15, "left_frac": 0.33, "ema_seconds": 30, "abs_floor": 150,
3040 "mad_k": 3, "merge_gap_s": 1.2, "max_island_s": 3, "min_sep_s": 3,
3041 "candidate_frac": 0.75, "warmup_s": 4, "baseline_s": 90,
3042 "min_outlier": 2.5, "min_left_density": 3.0, "min_confidence": 0.4,
3043 "select_candidate_frac": 0.6 });
3044 let park_only = json!({ "fps": 4, "left_frac": 0.33, "ema_seconds": 30, "abs_floor": 1500,
3046 "mad_k": 6, "merge_gap_s": 1.2, "max_island_s": 3, "min_sep_s": 3,
3047 "candidate_frac": 0.75, "warmup_s": 4, "baseline_s": 90 });
3048 server
3049 .post("/api/camera/config")
3050 .json(&json!({ "external": [
3051 { "url": "http://cam.local/s", "stream_url": "http://cam.local/stream", "park_tuning": full },
3052 { "url": "http://cam.local/b", "stream_url": "http://cam.local/bstream", "park_tuning": park_only },
3053 ]}))
3054 .await
3055 .assert_status_ok();
3056 let list = server.get("/api/camera").await.json::<serde_json::Value>();
3058 let cams = list["cameras"].as_array().unwrap();
3059 assert_eq!(
3060 cams[0]["segment"], true,
3061 "stream + park + select → segment-capable"
3062 );
3063 assert_eq!(cams[0]["park"], true);
3064 assert_eq!(
3065 cams[1]["segment"], false,
3066 "no select knobs → not segment-capable"
3067 );
3068 assert_eq!(cams[1]["park"], true, "still park-capable");
3069 let cfg = server
3072 .get("/api/camera/config")
3073 .await
3074 .json::<serde_json::Value>();
3075 assert_eq!(cfg["external"][0]["park_tuning"]["min_outlier"], json!(2.5));
3076 assert_eq!(cfg["external"][0]["park_tuning"]["fps"], json!(15.0));
3077 assert!(
3078 cfg["external"][1]["park_tuning"]
3079 .get("min_outlier")
3080 .is_none(),
3081 "park-only camera's echo carries no select knobs"
3082 );
3083 }
3084
3085 #[tokio::test]
3086 async fn camera_config_rejects_a_partial_park_tuning() {
3087 let server = app(None, FakeController::verified());
3090 let res = server
3091 .post("/api/camera/config")
3092 .json(&json!({ "external": [
3093 { "url": "http://cam.local/a.jpg", "stream_url": "http://cam.local/s",
3094 "park_tuning": { "fps": 4, "left_frac": 0.33 } },
3095 ]}))
3096 .await;
3097 assert!(
3098 !res.status_code().is_success(),
3099 "partial tuning must be rejected"
3100 );
3101 }
3102
3103 #[tokio::test]
3104 async fn timelapse_start_park_rejects_without_a_capable_camera() {
3105 let server = app(None, FakeController::verified());
3106 server
3107 .post("/api/camera/config")
3108 .json(&json!({ "external": [ { "url": "http://cam.local/a.jpg" } ] }))
3109 .await
3110 .assert_status_ok();
3111 server
3112 .post("/api/timelapse/start")
3113 .json(&json!({ "mode": "park", "camera": "ext-0" }))
3114 .await
3115 .assert_status_bad_request();
3116 }
3117
3118 #[tokio::test]
3119 async fn timelapse_start_segment_rejects_without_a_capable_camera() {
3120 let server = app(None, FakeController::verified());
3123 server
3124 .post("/api/camera/config")
3125 .json(&json!({ "external": [
3126 { "url": "http://cam.local/a.jpg", "stream_url": "http://cam.local/s" },
3127 ]}))
3128 .await
3129 .assert_status_ok();
3130 server
3131 .post("/api/timelapse/start")
3132 .json(&json!({ "mode": "segment", "camera": "ext-0" }))
3133 .await
3134 .assert_status_bad_request();
3135 }
3136
3137 #[test]
3138 fn run_dir_epoch_orders_runs_by_recency() {
3139 assert_eq!(
3142 super::run_dir_epoch("captures/1718900000_benchy_segment"),
3143 1718900000
3144 );
3145 assert!(
3146 super::run_dir_epoch("captures/1718900500_x_park")
3147 > super::run_dir_epoch("captures/1718900000_x_segment"),
3148 "a later epoch is more recent regardless of slot"
3149 );
3150 assert_eq!(
3151 super::run_dir_epoch("captures/not-a-run"),
3152 0,
3153 "unparseable sorts oldest"
3154 );
3155 }
3156
3157 #[tokio::test]
3158 async fn timelapse_start_segment_rejects_a_bad_window() {
3159 app(None, FakeController::verified())
3162 .post("/api/timelapse/start")
3163 .json(&json!({ "mode": "segment", "camera": "ext-0", "window_ms": 50 }))
3164 .await
3165 .assert_status_bad_request();
3166 }
3167
3168 #[test]
3169 fn parse_parks_index_dedupes_by_n_keeps_the_replace_and_sorts() {
3170 let jsonl = concat!(
3174 "{\"n\":1,\"idx\":20,\"t\":5.0,\"confidence\":0.70,\"replace\":false}\n",
3175 "{\"n\":0,\"idx\":10,\"t\":2.5,\"confidence\":0.80,\"replace\":false}\n",
3176 "{\"n\":1,\"idx\":22,\"t\":5.6,\"confidence\":0.95,\"replace\":true}\n",
3177 "not json\n",
3178 "\n",
3179 );
3180 let idx = parse_parks_index(jsonl);
3181 assert_eq!(idx.len(), 2, "two distinct frames: {idx:?}");
3182 assert_eq!(idx[0]["n"], 0, "sorted by n");
3183 assert_eq!(idx[1]["n"], 1);
3184 assert_eq!(
3185 idx[1]["confidence"], 0.95,
3186 "the replace's stronger metadata wins"
3187 );
3188 assert_eq!(idx[1]["t"], 5.6);
3189 }
3190
3191 fn app_with_timelapse(
3194 controller: impl Controller + 'static,
3195 ) -> (TestServer, Arc<TimelapseManager>) {
3196 let tl: Arc<TimelapseManager> = Default::default();
3197 let state = AppState {
3198 source: Arc::new(FakeSource::idle()),
3199 controller: Arc::new(controller),
3200 files: Arc::new(FakeFiles),
3201 starter: Arc::new(FakeStarter),
3202 password: None,
3203 start_lock: Arc::new(tokio::sync::Mutex::new(())),
3204 external_cameras: Arc::new(RwLock::new(Vec::new())),
3205 internal_camera: Arc::new(NoCamera),
3206 timelapse: tl.clone(),
3207 };
3208 (TestServer::new(router(state)), tl)
3209 }
3210
3211 fn test_tuning() -> ParkTuning {
3212 ParkTuning {
3213 fps: 4.0,
3214 left_frac: 0.33,
3215 ema_seconds: 6.0,
3216 abs_floor: 1500.0,
3217 mad_k: 6.0,
3218 merge_gap_s: 1.2,
3219 max_island_s: 3.0,
3220 min_sep_s: 3.0,
3221 candidate_frac: 0.75,
3222 warmup_s: 0.5,
3223 baseline_s: 20.0,
3224 }
3225 }
3226
3227 fn install_park_run(
3230 tl: &Arc<TimelapseManager>,
3231 out: &std::path::Path,
3232 id: &str,
3233 ) -> watch::Sender<PrinterStatus> {
3234 let (tx, rx) = watch::channel(PrinterStatus::default());
3235 let noop: crate::server::timelapse::ParkSpawn =
3236 Arc::new(|_, _, _, _| tokio::task::spawn_blocking(|| {}));
3237 tl.start_park(
3238 vec![ParkCapture {
3239 id: id.to_string(),
3240 stream_url: "http://cam/stream".into(),
3241 tuning: test_tuning(),
3242 }],
3243 rx,
3244 out.to_path_buf(),
3245 noop,
3246 )
3247 .unwrap();
3248 tx
3249 }
3250
3251 #[tokio::test]
3252 async fn parks_index_and_indexed_frames_serve_during_and_after_a_run() {
3253 let dir = std::env::temp_dir().join(format!("bambu-api-parks-{}", std::process::id()));
3254 let _ = std::fs::remove_dir_all(&dir);
3255 let cam = dir.join("ext-0");
3256 std::fs::create_dir_all(&cam).unwrap();
3257
3258 let (server, tl) = app_with_timelapse(FakeController::verified());
3259 let _tx = install_park_run(&tl, &dir, "ext-0");
3260
3261 std::fs::write(cam.join("park_000000.jpg"), b"FRAME0").unwrap();
3262 std::fs::write(cam.join("park_000001.jpg"), b"FRAME1").unwrap();
3263 std::fs::write(
3264 cam.join("parks.jsonl"),
3265 "{\"n\":0,\"t\":1.0,\"confidence\":0.8}\n{\"n\":1,\"t\":2.0,\"confidence\":0.9}\n",
3266 )
3267 .unwrap();
3268
3269 let res = server.get("/api/camera/ext-0/park").await;
3271 res.assert_status_ok();
3272 let body: serde_json::Value = res.json();
3273 assert_eq!(body["count"], 2);
3274 assert_eq!(body["parks"][0]["n"], 0);
3275 assert_eq!(body["parks"][1]["n"], 1);
3276
3277 let f1 = server.get("/api/camera/ext-0/park/1").await;
3279 f1.assert_status_ok();
3280 assert_eq!(f1.as_bytes().as_ref(), b"FRAME1");
3281
3282 server
3284 .get("/api/camera/ext-0/park/9")
3285 .await
3286 .assert_status_not_found();
3287 server
3288 .get("/api/camera/ext-1/park")
3289 .await
3290 .assert_status_not_found();
3291 server
3292 .get("/api/camera/ext-1/park/0")
3293 .await
3294 .assert_status_not_found();
3295
3296 tl.stop_park();
3298 let after = server.get("/api/camera/ext-0/park").await;
3299 after.assert_status_ok();
3300 assert_eq!(after.json::<serde_json::Value>()["count"], 2);
3301 server
3302 .get("/api/camera/ext-0/park/0")
3303 .await
3304 .assert_status_ok();
3305
3306 let _ = std::fs::remove_dir_all(&dir);
3307 }
3308
3309 struct OneFile(Vec<u8>);
3312 impl crate::server::files::FileStore for OneFile {
3313 fn list(&self, _: &str) -> Result<Vec<crate::ftp::FileEntry>, String> {
3314 Ok(vec![])
3315 }
3316 fn upload(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
3317 Ok(())
3318 }
3319 fn thumbnail(&self, _: &str, _: u32) -> Result<Option<Vec<u8>>, String> {
3320 Ok(None)
3321 }
3322 fn fetch(&self, _: &str) -> Result<Vec<u8>, String> {
3323 Ok(self.0.clone())
3324 }
3325 fn gcode(&self, _: &str, _: u32) -> Result<Option<Vec<u8>>, String> {
3326 Ok(None)
3327 }
3328 fn models(&self, _: &str) -> Result<Vec<String>, String> {
3329 Ok(vec![])
3330 }
3331 }
3332
3333 fn three_mf_with_timelapse(markers: usize) -> Vec<u8> {
3335 use std::io::Write;
3336 use zip::write::SimpleFileOptions;
3337 let mut gcode = String::from("; time_lapse_gcode = ;SKIPTYPE: timelapse template\n");
3338 for i in 0..markers {
3339 gcode.push_str(&format!("G1 Z{i}\n; SKIPTYPE: timelapse\nM1004 S5 P1\n"));
3340 }
3341 let mut buf = Vec::new();
3342 {
3343 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
3344 zip.start_file("Metadata/plate_1.gcode", SimpleFileOptions::default())
3345 .unwrap();
3346 zip.write_all(gcode.as_bytes()).unwrap();
3347 zip.finish().unwrap();
3348 }
3349 buf
3350 }
3351
3352 #[tokio::test]
3353 async fn file_inspect_reports_timelapse_capability_open() {
3354 let state = AppState {
3355 source: Arc::new(FakeSource::idle()),
3356 controller: Arc::new(FakeController::verified()),
3357 files: Arc::new(OneFile(three_mf_with_timelapse(3))),
3358 starter: Arc::new(FakeStarter),
3359 password: Some("secret".to_string()),
3361 start_lock: Arc::new(tokio::sync::Mutex::new(())),
3362 external_cameras: Arc::new(RwLock::new(Vec::new())),
3363 internal_camera: Arc::new(NoCamera),
3364 timelapse: Default::default(),
3365 };
3366 let server = TestServer::new(router(state));
3367 let res = server
3368 .get("/api/file/inspect?name=/cube.gcode.3mf&plate=1")
3369 .await;
3370 res.assert_status_ok();
3371 let body: serde_json::Value = res.json();
3372 assert_eq!(body["inspected"], true);
3373 assert_eq!(body["has_timelapse_blocks"], true);
3374 }
3375
3376 #[tokio::test]
3377 async fn file_inspect_degrades_to_not_inspected() {
3378 let res = app(None, FakeController::verified())
3380 .get("/api/file/inspect?name=/x.gcode.3mf&plate=1")
3381 .await;
3382 res.assert_status_ok();
3383 assert_eq!(res.json::<serde_json::Value>()["inspected"], false);
3384 let res2 = app(None, FakeController::verified())
3386 .get("/api/file/inspect?name=/notes.txt")
3387 .await;
3388 res2.assert_status_ok();
3389 assert_eq!(res2.json::<serde_json::Value>()["inspected"], false);
3390 }
3391
3392 #[tokio::test]
3393 async fn job_start_dry_run_reports_timelapse_block_capability() {
3394 let state = AppState {
3395 source: Arc::new(FakeSource::idle()),
3396 controller: Arc::new(FakeController::verified()),
3397 files: Arc::new(OneFile(three_mf_with_timelapse(3))),
3398 starter: Arc::new(FakeStarter),
3399 password: None,
3400 start_lock: Arc::new(tokio::sync::Mutex::new(())),
3401 external_cameras: Arc::new(RwLock::new(Vec::new())),
3402 internal_camera: Arc::new(NoCamera),
3403 timelapse: Default::default(),
3404 };
3405 let server = TestServer::new(router(state));
3406 let res = server
3407 .post("/api/job/start")
3408 .json(&json!({ "file": "/cube.gcode.3mf", "plate": 1, "dry_run": true, "timelapse": true }))
3409 .await;
3410 res.assert_status_ok();
3411 assert_eq!(
3413 res.json::<serde_json::Value>()["plan"]["has_timelapse_blocks"],
3414 true
3415 );
3416 }
3417
3418 #[tokio::test]
3419 async fn job_start_dry_run_timelapse_blocks_null_when_uninspectable() {
3420 let res = app(None, FakeController::verified())
3422 .post("/api/job/start")
3423 .json(&json!({ "file": "/x.gcode.3mf", "plate": 1, "dry_run": true }))
3424 .await;
3425 res.assert_status_ok();
3426 assert!(
3427 res.json::<serde_json::Value>()["plan"]["has_timelapse_blocks"].is_null(),
3428 "uninspectable file → unknown (null), gracefully"
3429 );
3430 }
3431
3432 #[tokio::test]
3433 async fn captures_list_is_open_and_returns_an_array() {
3434 let res = app(Some("secret"), FakeController::verified())
3437 .get("/api/capture")
3438 .await;
3439 res.assert_status_ok();
3440 assert!(res.json::<serde_json::Value>()["captures"].is_array());
3441 }
3442
3443 #[tokio::test]
3444 async fn unknown_api_path_404s_as_json() {
3445 let server = app(None, FakeController::verified());
3446 let res = server.get("/api/nope").await;
3448 res.assert_status_not_found();
3449 assert!(res.json::<serde_json::Value>()["error"].is_string());
3450 server
3452 .get("/api/camera/x/bogus")
3453 .await
3454 .assert_status_not_found();
3455 server.get("/api/status").await.assert_status_ok();
3457 }
3458
3459 #[test]
3460 fn is_safe_segment_blocks_traversal() {
3461 assert!(is_safe_segment("1781634785_cube_smooth"));
3462 assert!(is_safe_segment("ext-0"));
3463 assert!(is_safe_segment("default"));
3464 assert!(!is_safe_segment(""));
3465 assert!(!is_safe_segment(".."));
3466 assert!(!is_safe_segment(".hidden"));
3467 assert!(!is_safe_segment("a/b"));
3468 assert!(!is_safe_segment("a\\b"));
3469 }
3470
3471 #[tokio::test]
3472 async fn capture_video_rejects_unsafe_and_unknown() {
3473 let server = app(None, FakeController::verified());
3474 server
3476 .get("/api/capture/.evil/cam/video.mp4")
3477 .await
3478 .assert_status_bad_request();
3479 server
3481 .get("/api/capture/no_such_run_zzz/cam/video.mp4")
3482 .await
3483 .assert_status_not_found();
3484 }
3485
3486 #[tokio::test]
3487 async fn capture_thumb_rejects_unsafe_and_unknown() {
3488 let server = app(None, FakeController::verified());
3489 server
3491 .get("/api/capture/.evil/cam/thumb.jpg")
3492 .await
3493 .assert_status_bad_request();
3494 server
3495 .get("/api/capture/no_such_run_zzz/cam/thumb.jpg")
3496 .await
3497 .assert_status_not_found();
3498 }
3499
3500 #[tokio::test]
3501 async fn parks_index_and_frame_are_404_without_a_run() {
3502 let server = app(None, FakeController::verified());
3503 server
3504 .get("/api/camera/ext-0/park")
3505 .await
3506 .assert_status_not_found();
3507 server
3508 .get("/api/camera/ext-0/park/0")
3509 .await
3510 .assert_status_not_found();
3511 }
3512
3513 #[tokio::test]
3514 async fn camera_config_rejects_non_http_stream_url() {
3515 let server = app(None, FakeController::verified());
3516 server
3517 .post("/api/camera/config")
3518 .json(&json!({
3519 "external": [
3520 { "url": "http://cam.local/a.jpg", "stream_url": "file:///etc/passwd" }
3521 ]
3522 }))
3523 .await
3524 .assert_status(StatusCode::BAD_REQUEST);
3525 server
3527 .post("/api/camera/config")
3528 .json(&json!({
3529 "external": [
3530 { "url": "http://cam.local/a.jpg", "stream_url": "https://cam.local/stream" }
3531 ]
3532 }))
3533 .await
3534 .assert_status(StatusCode::BAD_REQUEST);
3535 }
3536
3537 #[test]
3538 fn resolve_stream_url_only_for_ext_with_a_stream() {
3539 use super::{ExternalCamera, resolve_stream_url};
3540 let cams = vec![
3541 ExternalCamera::new(
3542 Some("a".into()),
3543 "http://x/snap".into(),
3544 Some("http://x/stream".into()),
3545 0,
3546 ),
3547 ExternalCamera::new(None, "http://y/snap".into(), None, 1),
3548 ];
3549 assert_eq!(
3550 resolve_stream_url("ext-0", &cams).as_deref(),
3551 Some("http://x/stream")
3552 );
3553 assert_eq!(resolve_stream_url("ext-1", &cams), None); assert_eq!(resolve_stream_url("ext-9", &cams), None); assert_eq!(resolve_stream_url("internal", &cams), None);
3556 assert_eq!(resolve_stream_url("bogus", &cams), None);
3557 }
3558
3559 #[tokio::test]
3560 async fn camera_stream_relays_the_upstream_multipart_body() {
3561 use std::io::{Read as _, Write as _};
3562 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3566 let addr = listener.local_addr().unwrap();
3567 let upstream = std::thread::spawn(move || {
3568 if let Ok((mut sock, _)) = listener.accept() {
3569 let mut buf = [0u8; 1024];
3570 let _ = sock.read(&mut buf); let mut body = Vec::new();
3572 body.extend_from_slice(b"--FRAME\r\nContent-Type: image/jpeg\r\n\r\n");
3573 body.extend_from_slice(&[0xff, 0xd8, 0xff, b'D', b'A', b'T', b'A']);
3574 body.extend_from_slice(b"\r\n--FRAME--\r\n");
3575 let head = "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; \
3576 boundary=FRAME\r\nConnection: close\r\n\r\n";
3577 let _ = sock.write_all(head.as_bytes());
3578 let _ = sock.write_all(&body);
3579 }
3580 });
3581 let server = app(None, FakeController::verified());
3582 server
3583 .post("/api/camera/config")
3584 .json(&json!({ "external": [
3585 { "url": format!("http://{addr}/snap"),
3586 "stream_url": format!("http://{addr}/stream") }
3587 ] }))
3588 .await
3589 .assert_status_ok();
3590 let res = server.get("/api/camera/ext-0/stream").await;
3591 res.assert_status_ok();
3592 assert!(
3593 res.header("content-type")
3594 .to_str()
3595 .unwrap()
3596 .starts_with("multipart/x-mixed-replace")
3597 );
3598 let bytes = res.as_bytes();
3600 assert!(bytes.windows(7).any(|w| w == b"--FRAME"));
3601 assert!(bytes.windows(4).any(|w| w == b"DATA"));
3602 upstream.join().unwrap();
3603 }
3604
3605 #[tokio::test]
3606 async fn timelapse_status_is_open_and_initially_idle() {
3607 let res = app(None, FakeController::verified())
3608 .get("/api/timelapse")
3609 .await;
3610 res.assert_status_ok();
3611 assert_eq!(res.json::<serde_json::Value>()["running"], false);
3612 }
3613
3614 #[tokio::test]
3615 async fn timelapse_start_rejects_unknown_camera() {
3616 app(None, FakeController::verified())
3617 .post("/api/timelapse/start")
3618 .json(&json!({ "camera": "ext-9" }))
3619 .await
3620 .assert_status(StatusCode::NOT_FOUND);
3621 }
3622
3623 #[tokio::test]
3624 async fn timelapse_start_rejects_every_zero() {
3625 app(None, FakeController::verified())
3626 .post("/api/timelapse/start")
3627 .json(&json!({ "camera": "ext-0", "every": 0 }))
3628 .await
3629 .assert_status(StatusCode::BAD_REQUEST);
3630 }
3631
3632 #[tokio::test]
3633 async fn timelapse_start_rejects_unknown_mode() {
3634 app(None, FakeController::verified())
3635 .post("/api/timelapse/start")
3636 .json(&json!({ "camera": "ext-0", "mode": "fancy" }))
3637 .await
3638 .assert_status(StatusCode::BAD_REQUEST);
3639 }
3640
3641 #[tokio::test]
3642 async fn timelapse_plain_rejects_too_fast_interval() {
3643 app(None, FakeController::verified())
3646 .post("/api/timelapse/start")
3647 .json(&json!({ "camera": "ext-0", "mode": "plain", "interval_ms": 10 }))
3648 .await
3649 .assert_status(StatusCode::BAD_REQUEST);
3650 }
3651
3652 #[tokio::test]
3653 async fn timelapse_smooth_rejects_an_out_of_range_burst_offset() {
3654 app(None, FakeController::verified())
3657 .post("/api/timelapse/start")
3658 .json(&json!({ "camera": "ext-0", "burst_offsets_ms": [800, 99999] }))
3659 .await
3660 .assert_status(StatusCode::BAD_REQUEST);
3661 }
3662
3663 #[tokio::test]
3664 async fn timelapse_smooth_rejects_an_empty_burst() {
3665 app(None, FakeController::verified())
3666 .post("/api/timelapse/start")
3667 .json(&json!({ "camera": "ext-0", "burst_offsets_ms": [] }))
3668 .await
3669 .assert_status(StatusCode::BAD_REQUEST);
3670 }
3671
3672 #[tokio::test]
3673 async fn timelapse_stop_rejects_unknown_mode() {
3674 app(None, FakeController::verified())
3677 .post("/api/timelapse/stop")
3678 .json(&json!({ "mode": "plian" }))
3679 .await
3680 .assert_status(StatusCode::BAD_REQUEST);
3681 }
3682
3683 #[tokio::test]
3684 async fn timelapse_stop_without_a_mode_is_ok() {
3685 app(None, FakeController::verified())
3687 .post("/api/timelapse/stop")
3688 .await
3689 .assert_status_ok();
3690 }
3691
3692 #[tokio::test]
3693 async fn timelapse_start_stop_are_gated_by_password() {
3694 let server = app(Some("hunter2"), FakeController::verified());
3695 server
3696 .post("/api/timelapse/start")
3697 .json(&json!({ "camera": "ext-0" }))
3698 .await
3699 .assert_status(StatusCode::UNAUTHORIZED);
3700 server
3701 .post("/api/timelapse/stop")
3702 .await
3703 .assert_status(StatusCode::UNAUTHORIZED);
3704 server.get("/api/timelapse").await.assert_status_ok();
3706 }
3707
3708 #[tokio::test]
3709 async fn camera_config_is_gated_by_password() {
3710 app(Some("hunter2"), FakeController::verified())
3711 .post("/api/camera/config")
3712 .json(&json!({ "external": [{ "url": "http://cam.local/a.jpg" }] }))
3713 .await
3714 .assert_status(StatusCode::UNAUTHORIZED);
3715 }
3716
3717 #[tokio::test]
3718 async fn thumbnail_rejects_non_3mf() {
3719 app(None, FakeController::verified())
3720 .get("/api/file/thumbnail?name=/timelapse/video.mp4")
3721 .await
3722 .assert_status(StatusCode::BAD_REQUEST);
3723 }
3724
3725 #[tokio::test]
3726 async fn start_rejects_relative_path() {
3727 app(None, FakeController::verified())
3729 .post("/api/job/start")
3730 .json(&json!({ "file": "host/evil.3mf", "confirm": true }))
3731 .await
3732 .assert_status(StatusCode::BAD_REQUEST);
3733 }
3734
3735 #[tokio::test]
3736 async fn upload_rejects_traversal_dir() {
3737 app(None, FakeController::verified())
3738 .post("/api/file/upload?dir=../etc&name=a.3mf")
3739 .bytes(b"data".to_vec().into())
3740 .await
3741 .assert_status(StatusCode::BAD_REQUEST);
3742 }
3743
3744 #[tokio::test]
3745 async fn upload_open_when_no_password() {
3746 app(None, FakeController::verified())
3747 .post("/api/file/upload?name=part.gcode.3mf")
3748 .bytes(b"PK\x03\x04 fake 3mf".to_vec().into())
3749 .await
3750 .assert_status_ok();
3751 }
3752
3753 #[tokio::test]
3754 async fn upload_needs_password_when_set() {
3755 app(Some("secret"), FakeController::verified())
3756 .post("/api/file/upload?name=part.gcode.3mf")
3757 .bytes(b"data".to_vec().into())
3758 .await
3759 .assert_status(StatusCode::UNAUTHORIZED);
3760 }
3761
3762 #[tokio::test]
3763 async fn upload_rejects_path_traversal() {
3764 app(None, FakeController::verified())
3765 .post("/api/file/upload?name=../etc/passwd")
3766 .bytes(b"data".to_vec().into())
3767 .await
3768 .assert_status(StatusCode::BAD_REQUEST);
3769 }
3770
3771 #[tokio::test]
3773 async fn start_dry_run_returns_plan_without_confirm() {
3774 let res = app(None, FakeController::verified())
3775 .post("/api/job/start")
3776 .json(&json!({ "file": "/coin.gcode.3mf", "plate": 2, "dry_run": true }))
3777 .await;
3778 res.assert_status_ok();
3779 let body: serde_json::Value = res.json();
3780 assert_eq!(body["plan"]["plate"], 2);
3781 assert_eq!(body["plan"]["bed_type"], "auto");
3782 }
3783
3784 #[tokio::test]
3785 async fn start_needs_confirmation() {
3786 app(None, FakeController::verified())
3787 .post("/api/job/start")
3788 .json(&json!({ "file": "/coin.gcode.3mf" }))
3789 .await
3790 .assert_status(StatusCode::PRECONDITION_REQUIRED);
3791 }
3792
3793 #[tokio::test]
3794 async fn start_confirmed_on_idle_printer_verifies() {
3795 app(None, FakeController::verified())
3797 .post("/api/job/start")
3798 .json(&json!({ "file": "/coin.gcode.3mf", "confirm": true }))
3799 .await
3800 .assert_status_ok();
3801 }
3802
3803 #[tokio::test]
3804 async fn start_rejects_bad_filetype_and_traversal() {
3805 let s = app(None, FakeController::verified());
3806 s.post("/api/job/start")
3807 .json(&json!({ "file": "/notes.txt", "confirm": true }))
3808 .await
3809 .assert_status(StatusCode::BAD_REQUEST);
3810 s.post("/api/job/start")
3811 .json(&json!({ "file": "../secret.3mf", "confirm": true }))
3812 .await
3813 .assert_status(StatusCode::BAD_REQUEST);
3814 }
3815
3816 #[tokio::test]
3817 async fn start_rejects_out_of_range_ams_map() {
3818 app(None, FakeController::verified())
3819 .post("/api/job/start")
3820 .json(&json!({ "file": "/c.3mf", "confirm": true, "use_ams": true, "ams_map": [0, 9] }))
3821 .await
3822 .assert_status(StatusCode::BAD_REQUEST);
3823 }
3824
3825 #[tokio::test]
3826 async fn start_on_busy_printer_is_409() {
3827 let state = AppState {
3829 source: Arc::new(FakeSource::ramping(Duration::from_millis(50))),
3830 controller: Arc::new(FakeController::verified()),
3831 files: Arc::new(FakeFiles),
3832 starter: Arc::new(FakeStarter),
3833 password: None,
3834 start_lock: Arc::new(tokio::sync::Mutex::new(())),
3835 external_cameras: Arc::new(RwLock::new(Vec::new())),
3836 internal_camera: Arc::new(NoCamera),
3837 timelapse: Default::default(),
3838 };
3839 TestServer::new(router(state))
3840 .post("/api/job/start")
3841 .json(&json!({ "file": "/c.3mf", "confirm": true }))
3842 .await
3843 .assert_status(StatusCode::CONFLICT);
3844 }
3845
3846 fn busy_app(controller: impl Controller + 'static) -> TestServer {
3850 let state = AppState {
3851 source: Arc::new(FakeSource::ramping(Duration::from_millis(50))),
3852 controller: Arc::new(controller),
3853 files: Arc::new(FakeFiles),
3854 starter: Arc::new(FakeStarter),
3855 password: None,
3856 start_lock: Arc::new(tokio::sync::Mutex::new(())),
3857 external_cameras: Arc::new(RwLock::new(Vec::new())),
3858 internal_camera: Arc::new(NoCamera),
3859 timelapse: Default::default(),
3860 };
3861 TestServer::new(router(state))
3862 }
3863
3864 struct HotSource(watch::Sender<PrinterStatus>);
3866 impl HotSource {
3867 fn new() -> Self {
3868 let (tx, _rx) = watch::channel(PrinterStatus {
3869 gcode_state: Some("IDLE".to_string()),
3870 print_error: Some(0),
3871 nozzle_temper: Some(220.0),
3872 ..Default::default()
3873 });
3874 Self(tx)
3875 }
3876 }
3877 impl PrinterSource for HotSource {
3878 fn current(&self) -> PrinterStatus {
3879 self.0.borrow().clone()
3880 }
3881 fn subscribe(&self) -> watch::Receiver<PrinterStatus> {
3882 self.0.subscribe()
3883 }
3884 }
3885
3886 fn hot_app(controller: impl Controller + 'static) -> TestServer {
3888 let state = AppState {
3889 source: Arc::new(HotSource::new()),
3890 controller: Arc::new(controller),
3891 files: Arc::new(FakeFiles),
3892 starter: Arc::new(FakeStarter),
3893 password: None,
3894 start_lock: Arc::new(tokio::sync::Mutex::new(())),
3895 external_cameras: Arc::new(RwLock::new(Vec::new())),
3896 internal_camera: Arc::new(NoCamera),
3897 timelapse: Default::default(),
3898 };
3899 TestServer::new(router(state))
3900 }
3901
3902 #[tokio::test]
3904 async fn home_all_on_idle_runs() {
3905 app(None, FakeController::verified())
3906 .post("/api/home")
3907 .json(&json!({}))
3908 .await
3909 .assert_status_ok();
3910 }
3911
3912 #[tokio::test]
3913 async fn home_does_not_require_confirm() {
3914 app(None, FakeController::verified())
3915 .post("/api/home")
3916 .json(&json!({ "axes": "z" }))
3917 .await
3918 .assert_status_ok();
3919 }
3920
3921 #[tokio::test]
3922 async fn home_on_busy_printer_is_409() {
3923 busy_app(FakeController::verified())
3924 .post("/api/home")
3925 .json(&json!({ "axes": "all" }))
3926 .await
3927 .assert_status(StatusCode::CONFLICT);
3928 }
3929
3930 #[tokio::test]
3931 async fn home_unknown_axes_is_400() {
3932 app(None, FakeController::verified())
3933 .post("/api/home")
3934 .json(&json!({ "axes": "w" }))
3935 .await
3936 .assert_status(StatusCode::BAD_REQUEST);
3937 }
3938
3939 #[tokio::test]
3941 async fn move_in_range_on_idle_runs_without_confirm() {
3942 app(None, FakeController::verified())
3943 .post("/api/move")
3944 .json(&json!({ "axis": "x", "delta": 10.0 }))
3945 .await
3946 .assert_status_ok();
3947 }
3948
3949 #[tokio::test]
3950 async fn move_over_bound_is_400() {
3951 app(None, FakeController::verified())
3952 .post("/api/move")
3953 .json(&json!({ "axis": "x", "delta": 999.0 }))
3954 .await
3955 .assert_status(StatusCode::BAD_REQUEST);
3956 }
3957
3958 #[tokio::test]
3959 async fn move_zero_delta_is_400() {
3960 app(None, FakeController::verified())
3961 .post("/api/move")
3962 .json(&json!({ "axis": "y", "delta": 0.0 }))
3963 .await
3964 .assert_status(StatusCode::BAD_REQUEST);
3965 }
3966
3967 #[tokio::test]
3968 async fn move_out_of_range_feedrate_is_400() {
3969 app(None, FakeController::verified())
3970 .post("/api/move")
3971 .json(&json!({ "axis": "x", "delta": 5.0, "feedrate": 1 }))
3972 .await
3973 .assert_status(StatusCode::BAD_REQUEST);
3974 }
3975
3976 #[tokio::test]
3977 async fn move_on_busy_printer_is_409() {
3978 busy_app(FakeController::verified())
3979 .post("/api/move")
3980 .json(&json!({ "axis": "x", "delta": 5.0 }))
3981 .await
3982 .assert_status(StatusCode::CONFLICT);
3983 }
3984
3985 #[tokio::test]
3986 async fn move_unknown_axis_is_400() {
3987 app(None, FakeController::verified())
3988 .post("/api/move")
3989 .json(&json!({ "axis": "w", "delta": 5.0 }))
3990 .await
3991 .assert_status(StatusCode::BAD_REQUEST);
3992 }
3993
3994 #[tokio::test]
3996 async fn extrude_on_cold_nozzle_is_400() {
3997 app(None, FakeController::verified())
3999 .post("/api/extrude")
4000 .json(&json!({ "delta": 5.0 }))
4001 .await
4002 .assert_status(StatusCode::BAD_REQUEST);
4003 }
4004
4005 #[tokio::test]
4006 async fn extrude_cold_guard_has_no_force_bypass() {
4007 app(None, FakeController::verified())
4010 .post("/api/extrude")
4011 .json(&json!({ "delta": 5.0, "force": true }))
4012 .await
4013 .assert_status(StatusCode::BAD_REQUEST);
4014 }
4015
4016 #[tokio::test]
4017 async fn extrude_on_hot_idle_nozzle_runs() {
4018 hot_app(FakeController::verified())
4019 .post("/api/extrude")
4020 .json(&json!({ "delta": 5.0 }))
4021 .await
4022 .assert_status_ok();
4023 }
4024
4025 #[tokio::test]
4026 async fn extrude_over_bound_is_400() {
4027 hot_app(FakeController::verified())
4028 .post("/api/extrude")
4029 .json(&json!({ "delta": 999.0 }))
4030 .await
4031 .assert_status(StatusCode::BAD_REQUEST);
4032 }
4033
4034 #[tokio::test]
4035 async fn extrude_zero_delta_is_400() {
4036 hot_app(FakeController::verified())
4037 .post("/api/extrude")
4038 .json(&json!({ "delta": 0.0 }))
4039 .await
4040 .assert_status(StatusCode::BAD_REQUEST);
4041 }
4042
4043 #[tokio::test]
4045 async fn temp_setpoint_needs_confirm() {
4046 app(None, FakeController::verified())
4047 .post("/api/temp")
4048 .json(&json!({ "part": "nozzle", "celsius": 210 }))
4049 .await
4050 .assert_status(StatusCode::PRECONDITION_REQUIRED);
4051 }
4052
4053 #[tokio::test]
4054 async fn temp_setpoint_confirmed_runs() {
4055 app(None, FakeController::verified())
4056 .post("/api/temp")
4057 .json(&json!({ "part": "nozzle", "celsius": 210, "confirm": true }))
4058 .await
4059 .assert_status_ok();
4060 }
4061
4062 #[tokio::test]
4063 async fn temp_cooldown_is_allowed_without_confirm() {
4064 busy_app(FakeController::verified())
4066 .post("/api/temp")
4067 .json(&json!({ "part": "nozzle", "celsius": 0 }))
4068 .await
4069 .assert_status_ok();
4070 }
4071
4072 #[tokio::test]
4073 async fn temp_over_limit_is_400_unless_forced() {
4074 let s = app(None, FakeController::verified());
4075 s.post("/api/temp")
4076 .json(&json!({ "part": "nozzle", "celsius": 999, "confirm": true }))
4077 .await
4078 .assert_status(StatusCode::BAD_REQUEST);
4079 s.post("/api/temp")
4081 .json(&json!({ "part": "nozzle", "celsius": 999, "confirm": true, "force": true }))
4082 .await
4083 .assert_status_ok();
4084 }
4085
4086 #[tokio::test]
4087 async fn temp_unknown_part_is_400() {
4088 app(None, FakeController::verified())
4089 .post("/api/temp")
4090 .json(&json!({ "part": "chamber", "celsius": 50 }))
4091 .await
4092 .assert_status(StatusCode::BAD_REQUEST);
4093 }
4094
4095 #[tokio::test]
4096 async fn temp_is_not_idle_gated_for_a_setpoint() {
4097 busy_app(FakeController::verified())
4099 .post("/api/temp")
4100 .json(&json!({ "part": "bed", "celsius": 60, "confirm": true }))
4101 .await
4102 .assert_status_ok();
4103 }
4104
4105 #[tokio::test]
4107 async fn calibrate_needs_confirm() {
4108 app(None, FakeController::verified())
4109 .post("/api/calibrate")
4110 .json(&json!({ "bed_level": true }))
4111 .await
4112 .assert_status(StatusCode::PRECONDITION_REQUIRED);
4113 }
4114
4115 #[tokio::test]
4116 async fn calibrate_with_no_flags_is_400() {
4117 app(None, FakeController::verified())
4118 .post("/api/calibrate")
4119 .json(&json!({ "confirm": true }))
4120 .await
4121 .assert_status(StatusCode::BAD_REQUEST);
4122 }
4123
4124 #[tokio::test]
4125 async fn calibrate_confirmed_on_idle_runs() {
4126 app(None, FakeController::verified())
4127 .post("/api/calibrate")
4128 .json(&json!({ "bed_level": true, "confirm": true }))
4129 .await
4130 .assert_status_ok();
4131 }
4132
4133 #[tokio::test]
4134 async fn calibrate_on_busy_printer_is_409() {
4135 busy_app(FakeController::verified())
4136 .post("/api/calibrate")
4137 .json(&json!({ "vibration": true, "confirm": true }))
4138 .await
4139 .assert_status(StatusCode::CONFLICT);
4140 }
4141
4142 #[tokio::test]
4144 async fn ams_reset_needs_confirm() {
4145 app(None, FakeController::verified())
4146 .post("/api/ams")
4147 .json(&json!({ "action": "reset" }))
4148 .await
4149 .assert_status(StatusCode::PRECONDITION_REQUIRED);
4150 }
4151
4152 #[tokio::test]
4153 async fn ams_reset_confirmed_on_idle_runs() {
4154 app(None, FakeController::verified())
4155 .post("/api/ams")
4156 .json(&json!({ "action": "reset", "confirm": true }))
4157 .await
4158 .assert_status_ok();
4159 }
4160
4161 #[tokio::test]
4162 async fn ams_reset_on_busy_printer_is_409() {
4163 busy_app(FakeController::verified())
4164 .post("/api/ams")
4165 .json(&json!({ "action": "reset", "confirm": true }))
4166 .await
4167 .assert_status(StatusCode::CONFLICT);
4168 }
4169
4170 #[tokio::test]
4171 async fn ams_resume_is_allowed_without_confirm_even_when_busy() {
4172 busy_app(FakeController::verified())
4174 .post("/api/ams")
4175 .json(&json!({ "action": "resume" }))
4176 .await
4177 .assert_status_ok();
4178 }
4179
4180 #[tokio::test]
4181 async fn ams_unknown_action_is_400() {
4182 app(None, FakeController::verified())
4183 .post("/api/ams")
4184 .json(&json!({ "action": "eject" }))
4185 .await
4186 .assert_status(StatusCode::BAD_REQUEST);
4187 }
4188
4189 #[tokio::test]
4191 async fn ams_change_needs_confirm() {
4192 app(None, FakeController::verified())
4194 .post("/api/ams/change")
4195 .json(&json!({ "target": 255, "tar_temp": 220 }))
4196 .await
4197 .assert_status(StatusCode::PRECONDITION_REQUIRED);
4198 }
4199
4200 #[tokio::test]
4201 async fn ams_change_confirmed_on_idle_runs() {
4202 app(None, FakeController::verified())
4203 .post("/api/ams/change")
4204 .json(&json!({ "target": 1, "tar_temp": 220, "confirm": true }))
4205 .await
4206 .assert_status_ok();
4207 }
4208
4209 #[tokio::test]
4210 async fn ams_unload_target_255_confirmed_runs() {
4211 app(None, FakeController::verified())
4213 .post("/api/ams/change")
4214 .json(&json!({ "target": 255, "tar_temp": 250, "confirm": true }))
4215 .await
4216 .assert_status_ok();
4217 }
4218
4219 #[tokio::test]
4220 async fn ams_change_on_busy_printer_is_409() {
4221 busy_app(FakeController::verified())
4222 .post("/api/ams/change")
4223 .json(&json!({ "target": 0, "tar_temp": 220, "confirm": true }))
4224 .await
4225 .assert_status(StatusCode::CONFLICT);
4226 }
4227
4228 #[tokio::test]
4229 async fn ams_change_over_limit_temp_is_400() {
4230 app(None, FakeController::verified())
4232 .post("/api/ams/change")
4233 .json(&json!({ "target": 1, "tar_temp": 999, "confirm": true }))
4234 .await
4235 .assert_status(StatusCode::BAD_REQUEST);
4236 }
4237
4238 #[tokio::test]
4239 async fn ams_change_curr_temp_is_also_clamped() {
4240 app(None, FakeController::verified())
4241 .post("/api/ams/change")
4242 .json(&json!({ "target": 1, "tar_temp": 220, "curr_temp": 999, "confirm": true }))
4243 .await
4244 .assert_status(StatusCode::BAD_REQUEST);
4245 }
4246
4247 #[tokio::test]
4248 async fn ams_change_unknown_target_is_400() {
4249 app(None, FakeController::verified())
4251 .post("/api/ams/change")
4252 .json(&json!({ "target": 7, "tar_temp": 220, "confirm": true }))
4253 .await
4254 .assert_status(StatusCode::BAD_REQUEST);
4255 }
4256
4257 #[tokio::test]
4258 async fn ams_change_dry_run_previews_without_confirm_or_idle() {
4259 let res = busy_app(FakeController::verified())
4262 .post("/api/ams/change")
4263 .json(&json!({ "target": 255, "tar_temp": 250, "dry_run": true }))
4264 .await;
4265 res.assert_status_ok();
4266 let body: serde_json::Value = res.json();
4267 assert_eq!(body["plan"]["command"], "ams_change_filament");
4268 assert_eq!(body["plan"]["target"], 255);
4269 assert_eq!(body["plan"]["tar_temp"], 250);
4270 assert_eq!(body["plan"]["curr_temp"], 250);
4272 }
4273
4274 #[tokio::test]
4276 async fn reboot_needs_confirm() {
4277 app(None, FakeController::verified())
4278 .post("/api/reboot")
4279 .await
4280 .assert_status(StatusCode::PRECONDITION_REQUIRED);
4281 }
4282
4283 #[tokio::test]
4284 async fn reboot_confirmed_on_idle_is_202() {
4285 let c = FakeController::returning(CommandOutcome::Unverified {
4287 stage: VerifyStage::Ack,
4288 });
4289 app(None, c)
4290 .post("/api/reboot")
4291 .json(&json!({ "confirm": true }))
4292 .await
4293 .assert_status(StatusCode::ACCEPTED);
4294 }
4295
4296 #[tokio::test]
4297 async fn reboot_on_busy_printer_is_409() {
4298 busy_app(FakeController::verified())
4299 .post("/api/reboot")
4300 .json(&json!({ "confirm": true }))
4301 .await
4302 .assert_status(StatusCode::CONFLICT);
4303 }
4304
4305 #[tokio::test]
4307 async fn steppers_needs_confirm() {
4308 app(None, FakeController::verified())
4309 .post("/api/steppers")
4310 .await
4311 .assert_status(StatusCode::PRECONDITION_REQUIRED);
4312 }
4313
4314 #[tokio::test]
4315 async fn steppers_confirmed_on_idle_runs() {
4316 app(None, FakeController::verified())
4317 .post("/api/steppers")
4318 .json(&json!({ "confirm": true }))
4319 .await
4320 .assert_status_ok();
4321 }
4322
4323 #[tokio::test]
4324 async fn steppers_on_busy_printer_is_409() {
4325 busy_app(FakeController::verified())
4326 .post("/api/steppers")
4327 .json(&json!({ "confirm": true }))
4328 .await
4329 .assert_status(StatusCode::CONFLICT);
4330 }
4331
4332 fn ws_server(state: AppState) -> TestServer {
4334 TestServer::builder().http_transport().build(router(state))
4335 }
4336
4337 #[tokio::test]
4338 async fn ws_is_open_and_pushes_initial_status() {
4339 let mut ws = ws_server(AppState::fake())
4340 .get_websocket("/api/ws")
4341 .await
4342 .into_websocket()
4343 .await;
4344 let msg: serde_json::Value = ws.receive_json().await;
4345 assert_eq!(msg["gcode_state"], "IDLE");
4346 assert_eq!(msg["print_error"], 0);
4347 }
4348
4349 #[tokio::test]
4350 async fn ws_streams_subsequent_updates_from_a_ramping_source() {
4351 let state = AppState {
4352 source: Arc::new(FakeSource::ramping(Duration::from_millis(5))),
4353 controller: Arc::new(FakeController::verified()),
4354 files: Arc::new(FakeFiles),
4355 starter: Arc::new(FakeStarter),
4356 password: None,
4357 start_lock: Arc::new(tokio::sync::Mutex::new(())),
4358 external_cameras: Arc::new(RwLock::new(Vec::new())),
4359 internal_camera: Arc::new(NoCamera),
4360 timelapse: Default::default(),
4361 };
4362 let mut ws = ws_server(state)
4363 .get_websocket("/api/ws")
4364 .await
4365 .into_websocket()
4366 .await;
4367 let first: serde_json::Value = ws.receive_json().await;
4369 assert_eq!(first["gcode_state"], "RUNNING");
4370 let start = first["nozzle_temper"].as_f64().unwrap_or(0.0);
4371 let mut hotter = false;
4372 for _ in 0..5 {
4373 let next: serde_json::Value = ws.receive_json().await;
4374 if next["nozzle_temper"].as_f64().unwrap_or(0.0) > start {
4375 hotter = true;
4376 break;
4377 }
4378 }
4379 assert!(hotter, "ramping source should push rising nozzle temps");
4380 }
4381}