Skip to main content

bambu_rs/server/
api.rs

1//! The axum router, app state, the printer-source seam + fake source, and the
2//! HTTP API (reads + control).
3//!
4//! Auth model: **reads** (`/api/status`, `/api/ws`) are always open; **writes**
5//! (control) are gated by an optional password (`None` = open). The token concept
6//! is gone — there's nothing to put in a URL.
7//!
8//! `PrinterSource`/`Controller` are the seams that keep the API testable without a
9//! real printer: tests and `--fake` use [`FakeSource`]/[`FakeController`]; live
10//! mode uses [`super::LiveSource`]/[`super::control::LiveController`].
11
12use 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
60/// Something that can provide the printer's current status and a live stream of
61/// updates. Abstracted so the server is testable without a network: tests and
62/// `--fake` mode use [`FakeSource`], the live source (P2) wraps the MQTT monitor.
63pub trait PrinterSource: Send + Sync {
64    /// The latest known status.
65    fn current(&self) -> PrinterStatus;
66    /// Subscribe to status updates. The receiver's *current* value is whatever
67    /// the source last held; callers should send it first (via `borrow_and_update`)
68    /// and then await `changed()` for each subsequent update.
69    fn subscribe(&self) -> watch::Receiver<PrinterStatus>;
70}
71
72/// A fake source for `--fake` mode and tests, backed by a [`watch`] channel so it
73/// can stream like the real one. [`FakeSource::idle`] is static; [`FakeSource::ramping`]
74/// simulates a running print (temps climb toward target, progress advances) so
75/// the live charts have moving data to draw.
76pub struct FakeSource {
77    tx: watch::Sender<PrinterStatus>,
78    // Held only to keep the channel's receiver count ≥ 1, so a ramping task's
79    // `send` never sees "no receivers" and stops early when no client is attached.
80    _keepalive: watch::Receiver<PrinterStatus>,
81}
82
83impl FakeSource {
84    /// An idle, fault-free printer. Static — never emits an update.
85    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    /// A printer simulating a 2-colour print: nozzle/bed temps ramp toward
95    /// target, fans spin up, progress advances one layer per `interval`, and a
96    /// loaded AMS (4 trays) is reported — enough to exercise every dashboard
97    /// card. Runs to 100% then reports `FINISH`. Spawns a task on the current
98    /// tokio runtime.
99    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                // The A1 mini (AMS lite) reports ahb/rfid false regardless of the reader
125                // actually working — these are X1/P1 "AMS hub / RFID bus" flags. The fake
126                // mirrors that so the dashboard's read-derived RFID indicator is exercised
127                // against a false online.rfid (the old false-alarm source).
128                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            // Perpetual cycle so a left-open demo never goes stale: ~100 ticks
151            // printing (heat + progress), then ~15 ticks FINISH/cool-down, then
152            // a fresh print. The sparkline shows the resulting saw-tooth.
153            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                    // A new print starts cold.
161                    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                    // Part-cooling fan spins up once the hotend is near temperature.
169                    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); // 200 total layers
173                    s.remaining_time_min = Some((PRINT - p) * 72 / 100);
174                } else {
175                    // Finished: hold at 100% and cool toward ambient.
176                    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; // all receivers gone
186                }
187            }
188        });
189        Self { tx, _keepalive: rx }
190    }
191}
192
193/// A loaded AMS for the fake: 1 unit, 4 spools, red (tray 0) active.
194fn 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), // A1 spools don't report a usable remaining %
202        state: Some(3),
203        // A genuine (Bambu) spool carries an RFID tag, so a successful read fills in a
204        // non-empty uuid + SKU id_name. The fake sets these — combined with the A1's
205        // `online.rfid: false` below — so the dashboard exercises the real scenario: the
206        // reader works (tags read) even though the online flag is a meaningless placeholder.
207        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
239/// Move `current` toward `target` by at most `step` (a simple ramp for the fake).
240fn 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/// Shared server state.
258#[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    /// Optional password gating **write** (control) requests; `None` = control is
265    /// open. Reads are always unauthenticated.
266    pub password: Option<String>,
267    /// Held for the duration of a `job/start` so two concurrent starts can't both
268    /// pass the idle check.
269    pub start_lock: Arc<tokio::sync::Mutex<()>>,
270    /// **External** IP cameras the server proxies (single-JPEG-per-GET). Held
271    /// behind a lock so the dashboard can add/remove them at runtime
272    /// (`/api/camera/config`); seeded from `--camera-url` and in-memory only.
273    /// Proxied server-side so a browser that can't reach the LAN cam (e.g. over
274    /// Tailscale) still gets a live view.
275    pub external_cameras: Arc<RwLock<Vec<ExternalCamera>>>,
276    /// The **built-in** (printer chamber) camera, grabbed over TCP:6000 in live
277    /// mode; [`NoCamera`](super::camera::NoCamera) in fake / no-target mode.
278    pub internal_camera: Arc<dyn CameraSource>,
279    /// Serve-internal per-layer timelapse capture, driven off `source`'s status
280    /// feed and controlled at runtime by camera id. At most one runs at a time.
281    pub timelapse: Arc<TimelapseManager>,
282}
283
284/// A safe absolute path on the printer: starts with `/`, no traversal or scheme.
285fn 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
311/// Build the API router: open reads, password-gated writes, and — when the
312/// `dashboard` feature is on — the embedded SPA as the fallback.
313pub 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        // Uploads stream to a temp file, so the cap bounds disk, not memory.
357        .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    // Unknown `/api/*` paths 404 as JSON (a typo'd endpoint shouldn't fall through to the
370    // SPA and get HTML 200). Specific routes above are more specific than this catch-all,
371    // so they still win; only unmatched API paths land here.
372    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
380/// 404 for an unmatched `/api/*` path — JSON, not the SPA fallback's HTML.
381async 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// ── Control (write) endpoints ──────────────────────────────────────────────
394
395/// Body for a destructive job action — requires explicit `{"confirm": true}`,
396/// mirroring the CLI's `--confirm` (an absent/empty body is "not confirmed").
397#[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}
423/// Dismiss a print error (`clean_print_error`) — narrow: it only acknowledges
424/// the error popup (the way Studio clears one), it does not stop/resume the job.
425/// Gated by confirm like the other job controls.
426async 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    /// Override the safety blocklist (over-limit temps / cold extrusion).
456    #[serde(default)]
457    force: bool,
458}
459
460/// Send a raw gcode line. Mirrors the CLI `gcode`: requires confirm (428), and
461/// the safety blocklist refuses dangerous lines (400) unless `force`.
462async 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
481/// Require `{"confirm": true}` before running a destructive action (428 if not).
482async 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
497/// Run a control action on the blocking pool and map the verify outcome to HTTP.
498async 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
504/// Result of running a verify on the blocking pool: the verdict (or transport
505/// error), wrapped in the `spawn_blocking` join result.
506type VerifyJoin = Result<Result<CommandOutcome, ControlError>, tokio::task::JoinError>;
507
508/// Map a `spawn_blocking` verify result to HTTP: verified → 200, unverified →
509/// 202, rejected → 409, transport error → 502, join error → 500.
510fn 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
535// ── Shared control gates ─────────────────────────────────────────────────────
536
537/// Refuse a control action while the printer is busy (409). The predicate
538/// mirrors `job_start`'s idle guard exactly: any of RUNNING/PAUSE/PREPARE/SLICING
539/// (case-insensitive) is "busy". `None` ⇒ idle, run the action.
540fn 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
559/// Require explicit `{"confirm": true}` before a destructive action (428 if not).
560/// `None` ⇒ confirmed, proceed.
561fn 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// ── Machine control (write) endpoints ────────────────────────────────────────
575
576#[derive(Deserialize)]
577struct HomeBody {
578    #[serde(default = "default_axes")]
579    axes: String,
580}
581
582fn default_axes() -> String {
583    "all".to_string()
584}
585
586/// Home one or all axes (`G28`). Idle-gated (no confirm).
587async 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
613/// Jog a single axis a relative distance (`G91; G1; G90`). Idle-gated, no
614/// confirm; the distance and feedrate are bounds-checked.
615async 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
653/// Extrude or retract filament (`M83; G1 E; M82`). Idle-gated, no confirm. The
654/// cold-extrusion guard reads the live nozzle temperature and has **no** force
655/// bypass.
656async 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    /// Override the temperature ceiling (over-limit setpoint).
684    #[serde(default)]
685    force: bool,
686}
687
688/// Set a heater target (`M104`/`M140`). Not idle-gated — a cooldown (`celsius:
689/// 0`) is the abort valve and is always allowed without confirm. A non-zero
690/// setpoint needs confirm (428) and must clear the safety ceiling (400) unless
691/// `force` overrides it, exactly like `/api/gcode`.
692async 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    // A cooldown (0 °C) is always allowed — it's the panic "turn it off" valve.
707    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
734/// Run one or more calibrations. Requires at least one flag (400), confirm
735/// (428), and an idle printer (409).
736async 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
766/// AMS control. `resume` clears a pause and is allowed any time (no confirm,
767/// no idle gate); `reset`/`pause` are destructive — confirm (428) + idle (409).
768async 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    // resume is the "carry on" action; reset/pause change AMS state, so gate them.
776    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    /// Tray to load (0..3), `254` (external spool), or `255` (unload).
790    target: u32,
791    /// Target nozzle temp for the new filament.
792    tar_temp: i64,
793    /// Temp to soften the *current* filament for retraction; defaults to
794    /// `tar_temp` when omitted.
795    curr_temp: Option<i64>,
796    #[serde(default)]
797    confirm: bool,
798    #[serde(default)]
799    dry_run: bool,
800}
801
802/// Change/unload filament via the AMS (`ams_change_filament`). This physically
803/// moves filament, so it mirrors the CLI's `ams change`: nozzle temps are
804/// clamped to the safe ceiling (no force bypass — an AMS change should never
805/// command an unsafe temp), `dry_run` previews the resolved command without
806/// sending, and a real send needs confirm (428) + idle (409).
807async fn ams_change(State(st): State<AppState>, Json(b): Json<AmsChangeBody>) -> Response {
808    // Only meaningful targets: AMS trays, the external spool, or unload.
809    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    // dry_run previews the resolved command without sending — no confirm/idle
823    // gate, so it works even on a busy printer.
824    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
850/// Reboot the printer (`system.reboot`). Confirm (428) + idle (409). Fire-and-
851/// forget — there's no ACK to read back, so a success is 202 (Unverified).
852async 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
862/// Disable the stepper motors (`M84`). Confirm (428) + idle (409).
863async 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// ── Print start ────────────────────────────────────────────────────────────
874
875#[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    /// Arm the printer-side timelapse (needed for Smooth-mode's per-layer park +
888    /// spiral Z-hop to actually run, not just to record the built-in camera).
889    #[serde(default)]
890    timelapse: bool,
891    #[serde(default)]
892    dry_run: bool,
893}
894
895fn default_plate() -> u32 {
896    1
897}
898
899/// Start a print. Safety mirrors the CLI: file/AMS-map validation, a `dry_run`
900/// that returns the resolved plan without sending, a `confirm` gate (428), and
901/// an idle check against the live status (409 if the printer is busy).
902async fn job_start(State(st): State<AppState>, Json(b): Json<StartBody>) -> Response {
903    let lower = b.file.to_ascii_lowercase();
904    // Must be an absolute on-printer path — a relative one like `host/x.3mf`
905    // would become `ftp://host/x.3mf` and escape the printer's namespace.
906    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        // The file is already on the printer here; we don't have its bytes to
932        // inspect, so no md5 check (the upload-start path supplies one).
933        inspection: None,
934    };
935
936    if b.dry_run {
937        // Best-effort: download + inspect the on-printer .3mf so the plan can say whether
938        // this plate supports a clean per-layer timelapse (the head-park blocks). Never
939        // fatal — a failed download/inspect just leaves it `null` ("unknown"), like the
940        // CLI's best-effort dry-run. Mirrors `PlateInspection::has_timelapse_blocks`.
941        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    // Serialize starts so two concurrent requests can't both pass the idle check.
975    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    // Idle guard: refuse to start over an active job.
983    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// ── File endpoints ─────────────────────────────────────────────────────────
992
993#[derive(Deserialize)]
994struct ListQuery {
995    dir: Option<String>,
996}
997
998/// List files on the printer (open read). `?dir=` defaults to `/`.
999async 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
1020/// Serve the embedded plate preview PNG for a `.3mf` (open read). 404 if absent.
1021async 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    // Restrict the open thumbnail read to .3mf at a safe absolute path — it
1028    // downloads the whole file, so don't let it pull arbitrary large files.
1029    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
1054/// Serve a `.3mf`/`.gcode`'s raw bytes for the 3D viewer (open read). Restricted
1055/// to those extensions at a safe path; size-capped in [`FileStore::fetch`].
1056async 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
1086/// Serve a sliced `.3mf`'s plate gcode (`Metadata/plate_N.gcode`) as plain text
1087/// for the 3D viewer's toolpath render (open read). 404 if the plate has none.
1088///
1089/// Why a dedicated endpoint instead of `raw`: three's `3MFLoader` doesn't follow
1090/// Bambu's external-component mesh refs (`3D/Objects/*.model`), so a sliced
1091/// `.gcode.3mf` renders empty. The embedded gcode toolpath always renders.
1092async 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    // Like the thumbnail read: .3mf at a safe path, bounded plate — it downloads
1099    // the whole file, so don't let it pull arbitrary files.
1100    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
1125/// Inspect an on-printer `.3mf` plate (open read): download + parse it, reporting whether
1126/// the sliced gcode supports a clean per-layer timelapse (`has_timelapse_blocks`) plus the
1127/// md5 / bed / filament metadata. Lets the start dialog show a file's timelapse capability
1128/// the moment it opens — without a write-gated dry-run. Best-effort: a non-3mf or
1129/// unreadable file returns `{ "inspected": false, ... }` rather than an error status, so
1130/// the dialog degrades to "unknown" cleanly.
1131async 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
1171/// Serve a `.3mf`'s embedded object meshes as `{ "models": [<3MF model XML>, …] }`
1172/// for the 3D viewer's solid-mesh render (open read). The viewer parses the mesh
1173/// XML itself because three's `3MFLoader` won't follow Bambu's external-component
1174/// refs. Empty `models` when the file embeds no mesh.
1175async 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
1192// ── Cameras ──────────────────────────────────────────────────────────────────
1193// The dashboard shows cameras as switchable tabs. Two kinds of source, listed
1194// together by /api/camera: the **built-in** printer chamber camera (TCP:6000,
1195// often dead on the A1) and any number of **external** IP cameras the server
1196// proxies (e.g. ATOM Cams over LAN). Externals can be set at launch (--camera-url,
1197// repeatable) and edited at runtime via the gated config endpoint. IDs are
1198// positional: "internal" for the built-in, "ext-{i}" for the i-th external.
1199
1200/// Cap a proxied camera frame to bound server memory (a single JPEG is well under
1201/// this; a misbehaving upstream can't OOM us).
1202const CAMERA_MAX_BYTES: u64 = 32 * 1024 * 1024;
1203/// Upstream fetch timeout — a stalled camera shouldn't hang the request.
1204const CAMERA_TIMEOUT: Duration = Duration::from_secs(8);
1205
1206/// List the available cameras (open read) as `{id, kind, label}`. URLs are never
1207/// exposed here — only the proxied snapshot is reachable, by id.
1208async 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            // Whether a live MJPEG stream is proxiable for this camera (so the
1219            // frontend uses `/stream` instead of snapshot polling).
1220            "stream": c.stream_url.is_some(),
1221            // Whether this camera can run the live park preview: it needs both a
1222            // stream and a calibrated park_tuning (the dashboard shows a tile only then).
1223            "park": c.stream_url.is_some() && c.park_tuning.is_some(),
1224            // Whether it's ready for the robust dense-stream `segment` capture: a stream,
1225            // a park_tuning (for the capture fps), AND a select_tuning (the median-subtract
1226            // knobs). The dashboard prefers this over `park` when present.
1227            "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
1235/// Proxy a single JPEG for one camera by id (open read). `internal` grabs the
1236/// built-in cam over TCP:6000; `ext-{i}` proxies that external camera's URL. 404
1237/// for an unknown id / unconfigured source; 502 when the grab fails.
1238async 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
1284/// Resolve the live-stream URL for a camera id. Only `ext-{i}` cameras that have
1285/// a configured `stream_url` stream; `internal` and unknown ids yield `None` (the
1286/// built-in TCP:6000 cam has no MJPEG stream). Pure, so the routing is testable.
1287fn 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
1294/// Reverse-proxy a camera's live MJPEG stream (open read). `ext-{i}` with a
1295/// configured stream URL only; otherwise 404. The endless upstream multipart body
1296/// is relayed chunk-by-chunk through a bounded channel, so a fast camera can't
1297/// outrun a slow client into unbounded memory (the reader blocks when the channel
1298/// is full; a dropped receiver — client gone — ends it). 502 if the connect fails.
1299async 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    // Connect first (blocking) to learn the upstream content-type — we need the
1304    // multipart boundary before we can set our own response headers.
1305    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                    // blocking_send applies backpressure and fails once the client
1322                    // (receiver) is gone — either way we then stop reading upstream.
1323                    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
1347// The MJPEG stream opener now lives in `super::camera` (open_mjpeg_stream), shared
1348// with the plain-timelapse stream recorder.
1349
1350/// Parse a park run's `parks.jsonl` (one line per write) into a per-frame index for the
1351/// player: one entry per distinct frame `n` (a `replace` line re-uses an `n`, so the last
1352/// one — the stronger frame — wins), sorted by n, with malformed/blank lines skipped. Each
1353/// entry is `{ n, t, confidence }` — enough for a scrubber with a timestamp readout. Pure,
1354/// so it's unit-tested without the filesystem.
1355fn 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
1381/// The park index `/api/camera/{id}/park`: a camera's captured park frames for the
1382/// dashboard player (open read), `{ running, count, parks: [{n, t, confidence}, …] }` from
1383/// `<out>/<id>/parks.jsonl`. The individual frames are `…/park/{n}` ([`camera_park_frame`]).
1384/// Available while the run is active AND after it stops (until the next run replaces the
1385/// status's out_dir), so the whole filmstrip stays reviewable. 404 when no park run owns
1386/// `id`. The id is matched against the run's own camera list, never joined blindly.
1387/// The source dir for the live park preview: the `park` run if one owns it, else the
1388/// `smooth` run (its live per-layer selection publishes the same `park_*.jpg`/`parks.jsonl`
1389/// into its dir). Returns `(out_dir, cameras, running)`.
1390fn live_park_source(st: &AppState) -> Option<(String, Vec<String>, bool)> {
1391    // The park preview reads `latest_park.jpg`/`parks.jsonl`, which three slots can produce:
1392    // `segment` (the dense-stream robust path), `park` (the image-change miner), and a
1393    // `smooth` run with live selection. Prefer a RUNNING one (so the preview follows the
1394    // active capture when several have been started this session), else the most recent to
1395    // have a dir. Within each tier order segment → park → smooth.
1396    let sources = [
1397        st.timelapse.status_segment(),
1398        st.timelapse.status_park(),
1399        st.timelapse.status_smooth(),
1400    ];
1401    // Prefer a RUNNING run so the preview follows the active capture (segment → park →
1402    // smooth on a tie, but they're rarely all live at once).
1403    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    // Otherwise the most RECENT completed run, not a fixed slot order — the run dir is
1407    // `captures/<epoch>_<hint>_<mode>`, so its leading epoch orders them by recency with no
1408    // extra bookkeeping (else a stale segment dir would shadow a newer park/smooth one).
1409    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
1416/// Recency key for a run dir (`captures/<epoch>_<hint>_<mode>`): its leading epoch. An
1417/// unparseable dir sorts oldest, so a real run always wins over a malformed one.
1418fn 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(), // run started, no park written yet → an empty filmstrip
1438    };
1439    Json(json!({ "running": running, "count": parks.len(), "parks": parks })).into_response()
1440}
1441
1442/// Serve one indexed park frame `/api/camera/{id}/park/{n}` (`park_NNNNNN.jpg`) for a
1443/// camera (open read). Same gating and lifetime as the [`park_index`] it belongs to. `n`
1444/// is numeric, so it can't traverse; an index with no file (out of range / pruned) 404s.
1445async 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
1471/// The tuning echo for the manage form: the park knobs MERGED with the select knobs into
1472/// one object, mirroring the single combined object the form posts. Without the merge a
1473/// re-save would drop `select_tuning` (it's stored separately but lives in the same posted
1474/// object). `null` when the camera has no tuning at all.
1475fn 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        // Add the select-only knobs; shared keys (e.g. left_frac) keep the park value.
1484        for (k, val) in sobj {
1485            obj.entry(k).or_insert(val);
1486        }
1487    }
1488    v
1489}
1490
1491/// Serialise the external list (with URLs) for the gated config endpoints.
1492fn 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
1505/// Current external-camera config (gated read) — includes URLs so the dashboard's
1506/// manage form can prefill. The built-in camera isn't configurable, so it's not
1507/// listed here.
1508async 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    /// Optional live MJPEG stream URL (reverse-proxied at `/stream`).
1517    #[serde(default)]
1518    stream_url: Option<String>,
1519    /// Optional per-camera tuning, a raw JSON object (same shape as the CLI
1520    /// `--cameras-config`): parsed below into [`ParkTuning`] (STRICT — no baked defaults, a
1521    /// partial object is rejected) AND `SelectTuning` (best-effort — the select knobs live
1522    /// in the same object), so an HTTP-configured camera is `park`/`segment`-capable exactly
1523    /// like a CLI-seeded one.
1524    #[serde(default)]
1525    park_tuning: Option<serde_json::Value>,
1526}
1527
1528/// Both proxied URLs (snapshot + stream) must be `http://` — the proxy's `ureq`
1529/// is built without TLS (LAN IP cameras are plain HTTP), so `https://` would only
1530/// 502 at fetch time; rejecting it here also blocks `file:`/`gopher:` SSRF.
1531fn is_http_url(u: &str) -> bool {
1532    u.starts_with("http://")
1533}
1534
1535#[derive(Deserialize)]
1536struct CamerasConfigBody {
1537    external: Vec<ExternalCameraInput>,
1538}
1539
1540/// Replace the external-camera list (write). Each URL must be `http(s)` (the proxy
1541/// only speaks HTTP, and refusing other schemes blocks `file:`/`gopher:` SSRF). The
1542/// list is in-memory only — it resets on restart; `--camera-url` is the persistent
1543/// path. The built-in camera is untouched.
1544async 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        // A stream URL, if given, is proxied too — apply the same scheme guard.
1559        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        // One raw tuning object → ParkTuning (strict; a partial object is a 400, no baked
1573        // defaults) AND SelectTuning (best-effort — the select knobs share the object), the
1574        // same split the CLI `--cameras-config` does, so both paths stay consistent.
1575        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
1595/// Blocking single-shot GET of the camera URL. Returns `(content_type, bytes)` or
1596/// an error string. The body is read with a hard byte cap so a bad upstream can't
1597/// exhaust memory.
1598fn fetch_camera_frame(url: &str) -> Result<(String, Vec<u8>), String> {
1599    // A snapshot CGI never legitimately redirects; disallowing redirects keeps
1600    // the server-side fetch from being bounced to an internal address (SSRF).
1601    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    // Default to image/jpeg if the camera omits a content-type.
1607    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    /// Single camera id to capture from: `internal` or `ext-{i}`. Convenience
1625    /// for the common case; `cameras` takes precedence when both are given.
1626    #[serde(default)]
1627    camera: Option<String>,
1628    /// Capture several cameras at once (multi-angle) — each gets a frame per
1629    /// trigger under its own subdir. Falls back to `camera` when empty.
1630    #[serde(default)]
1631    cameras: Vec<String>,
1632    /// `"smooth"` (default): one frame per layer, synced to the printer's park.
1633    /// `"plain"`: one frame every `interval_ms`, head in shot. They're separate
1634    /// runs, so both can be on at once for the same print.
1635    #[serde(default)]
1636    mode: Option<String>,
1637    /// Smooth: capture every Nth layer.
1638    #[serde(default = "default_every")]
1639    every: u64,
1640    /// Plain: sampling period in ms (default 3000).
1641    #[serde(default)]
1642    interval_ms: Option<u64>,
1643    /// Smooth: per-layer park-capture burst, ms after the layer edge (default
1644    /// [`DEFAULT_SMOOTH_BURST_MS`]). The native park lands ~0.4–1.2 s after the
1645    /// `layer_num` increment, so a burst brackets the window; each frame is tagged
1646    /// with its offset. Exposed so the offsets can be calibrated without a rebuild.
1647    #[serde(default)]
1648    burst_offsets_ms: Option<Vec<u64>>,
1649    /// Segment: per-layer accumulation SAFETY CAP in ms (default 120000). The native park is
1650    /// a layer-change event, so the segment spans the WHOLE layer (finalized by the next
1651    /// layer edge) and median-subtract selection finds the park wherever it lands; this cap
1652    /// only forces a selection if `layer_num` stalls, so it sits well above any layer time.
1653    #[serde(default)]
1654    window_ms: Option<u64>,
1655}
1656fn default_every() -> u64 {
1657    1
1658}
1659
1660#[derive(Deserialize, Default)]
1661struct TimelapseStopBody {
1662    /// Which run to stop: `"smooth"`, `"plain"`, or `"all"` (default).
1663    #[serde(default)]
1664    mode: Option<String>,
1665}
1666
1667/// Combined status for both runs: a back-compat flat view mirroring the smooth
1668/// run (so older single-run readers keep working), plus nested `smooth`/`plain`.
1669/// Top-level `running` is true if *either* run is active.
1670fn 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
1689/// Resolve a camera id to a blocking frame-grabber + a stable label, captured at
1690/// start so a later `/api/camera/config` edit can't repoint a running capture.
1691fn 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
1707/// Sanitise a print name into a filesystem-safe run-dir suffix.
1708fn 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
1728/// The root all capture runs are written under (relative to the serve's CWD). One place,
1729/// so the listing endpoint and the writers agree.
1730fn captures_root() -> std::path::PathBuf {
1731    std::path::PathBuf::from("captures")
1732}
1733
1734/// `captures/<epoch>_<print-hint>_<mode>/` — the per-run output dir (per-mode so a
1735/// concurrent smooth/plain/park run never mixes frames).
1736fn 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
1751/// List finished/in-progress capture runs on disk (open read): each run's recordings, so
1752/// the dashboard can review and download them. Reads `captures/` lazily off the blocking
1753/// pool. An absent root → an empty list, never an error.
1754async 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
1761/// A single path segment safe to join under the captures root: no traversal, no separators,
1762/// no leading dot, bounded length.
1763fn 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    /// Playback fps for an assembled image-sequence timelapse.
1775    #[serde(default = "default_fps")]
1776    fps: u32,
1777}
1778fn default_fps() -> u32 {
1779    10
1780}
1781
1782/// One capture's mp4 (open read, playable/downloadable): a Video streams its `plain.mp4`; a
1783/// Park/Smooth is assembled from its frames on demand (→ `timelapse.mp4`) and streamed.
1784/// `run`/`cam` are validated as plain dir segments (no traversal); a missing `cam` subdir
1785/// maps back to the run dir (old single-dir layout). 404 when there's nothing to serve.
1786async 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    // A smooth recording is a per-layer BURST; if this camera has select tuning, assemble a
1796    // CLEAN one-frame-per-layer timelapse (pick the parked frame). `ext-N` → external_cameras[N].
1797    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                // ffmpeg was absent at capture → only a raw plain.mjpeg. Transcode it now.
1825                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                // Clean per-layer selection when tuned; otherwise the raw all-frames assemble
1835                // (a burst-y timelapse, but better than nothing for an untuned camera).
1836                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    // Stream the file rather than buffering it — a full-print video or a long timelapse can
1858    // be large, and several may download at once.
1859    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
1884/// One capture's thumbnail (open read): a representative still for the recordings list. A
1885/// Park/Smooth serves its last frame straight off disk (no transcode); a Video extracts a
1886/// poster with ffmpeg, cached as `thumb.jpg`. Path-safe like [`capture_video`]; 404 when
1887/// there's nothing to show (incl. ffmpeg missing for a Video).
1888async 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                // The /thumb.jpg URL tracks the run's *latest* frame, which moves while a run
1935                // is live — don't let a stale poster stick. (Finished runs re-read a tiny jpeg.)
1936                (CACHE_CONTROL, "no-store".to_string()),
1937            ],
1938            bytes,
1939        )
1940            .into_response(),
1941        Err(_) => StatusCode::NOT_FOUND.into_response(),
1942    }
1943}
1944
1945/// Resolve the park-capable cameras among `ids` and start the live park slot. A camera is
1946/// capable iff it's an external camera with BOTH a stream and a calibrated `park_tuning`;
1947/// non-capable requested cameras are skipped (reported in `skipped`), and it's a 400 if
1948/// none qualify. Each emits `<out>/<id>/latest_park.jpg` per layer, served (open) by
1949/// `/api/camera/{id}/park`.
1950fn 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
1998/// Resolve the segment-capable cameras among `ids` and start the dense-stream segmented
1999/// slot. A camera qualifies iff it's an external camera with a stream, a `park_tuning` (for
2000/// the capture fps), AND a `select_tuning` (the median-subtract knobs) — i.e. fully
2001/// calibrated for park capture. Non-capable requested cameras are skipped; it's a 400 if
2002/// none qualify. Output is the same `latest_park.jpg`/`parks.jsonl` layout `park` produces,
2003/// served by `/api/camera/{id}/park`.
2004fn 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
2055/// Start a per-layer timelapse capture from a configured camera (gated write).
2056/// 409 if one is already running; 404 for an unknown/unconfigured camera. Frames
2057/// land in `./captures/<epoch>_<print-hint>/`.
2058async fn timelapse_start(
2059    State(st): State<AppState>,
2060    Json(b): Json<TimelapseStartBody>,
2061) -> Response {
2062    // Validate the mode's cadence up front (before resolving cameras), so a bad
2063    // `every`/`interval_ms` is a clean 400 regardless of camera config.
2064    let mode = b.mode.as_deref().unwrap_or("smooth");
2065    let interval_ms = b.interval_ms.unwrap_or(3000);
2066    // Default: a full-layer safety cap (well above any real layer time). The native park is
2067    // a layer-change event, so the segment must span the WHOLE layer to contain it — the
2068    // next layer edge finalizes first; this only bites if layer_num stalls.
2069    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 has no cadence knobs; its requirement (a stream + park_tuning per camera)
2095        // is enforced when the cameras resolve below.
2096        "park" => {}
2097        "segment" => {
2098            // window_ms is the per-layer accumulation SAFETY CAP, not a gate — it must
2099            // comfortably exceed a layer's print time so the next layer edge finalizes first.
2100            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    // `cameras` wins; fall back to the single `camera`. De-dupe but keep order.
2111    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    // Park reads the camera stream (not snapshot grabs) and needs per-camera tuning, so it
2121    // resolves cameras differently — branch before the grab resolution the others need.
2122    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    // Segment likewise reads the stream; it additionally needs select tuning + a window.
2128    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    // Per-mode dir so a concurrent smooth + plain run never mix their frames.
2145    let out_dir = run_out_dir(&st, mode);
2146    let rx = st.source.subscribe();
2147    let res = match mode {
2148        "plain" => {
2149            // A camera with a configured stream URL records its real MJPEG stream;
2150            // a snapshot-only camera keeps time-sampling. Resolved once, at start.
2151            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            // Per-camera select tuning (index-aligned with `grabs`/`ids`): when present, the
2170            // smooth run live-selects the parked frame per layer so the dashboard's live park
2171            // preview advances during the capture and the finished run reads back clean.
2172            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
2199/// Stop a capture run (gated write; idempotent). `{"mode":"smooth"|"plain"|"park"}`
2200/// stops just that one; no body / `"all"` stops every slot. An unrecognized mode is a
2201/// 400 rather than a silent "all" — a typo must not abort a run the caller meant to keep
2202/// going (the slots are independently controlled).
2203async 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
2238/// Current capture status (open read).
2239async 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
2249/// Upload a file to the printer (write). The body is streamed straight to a temp
2250/// file (not buffered in memory), then handed to the FTPS upload. `?name=` is the
2251/// filename and `?dir=` the destination (default `/`).
2252async fn upload_file(
2253    State(st): State<AppState>,
2254    Query(q): Query<UploadQuery>,
2255    body: Body,
2256) -> Response {
2257    // Reject path-traversal / nested names — `name` is a single filename.
2258    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    // Validate the destination dir too (root is allowed; otherwise a safe path).
2263    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    // Stream the request body to a temp file.
2269    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); // remove the staged file after the upload completes
2298    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
2313/// Cap for a streamed upload body (DefaultBodyLimit can't bound a raw `Body`).
2314const 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
2333/// One-shot **upload + start**: stream the body to a temp file, (for a `.3mf`)
2334/// inspect it for the plate-gcode md5, then FTPS-upload it and start the print —
2335/// the dashboard's single request instead of `/files/upload` then `/job/start`.
2336/// Reuses the upload guards (filename traversal, safe dir) and the start guards
2337/// (confirm, idle, the held `start_lock`); the command is built by the shared
2338/// `core::start` builder, with the md5 stamped in so the printer verifies the file.
2339async fn job_upload_start(
2340    State(st): State<AppState>,
2341    Query(q): Query<UploadStartQuery>,
2342    body: Body,
2343) -> Response {
2344    // Same filename/dir guards as the plain upload (single filename, safe dir).
2345    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    // Default to the printer root: the A1 mini prints from `/`, and a print start
2349    // that reads an uploaded file from `/cache` fails with 0x0500C010 (verified).
2350    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    // Reject before reading the (possibly huge) body: an unconfirmed, non-dry-run
2358    // request can't do anything, so don't stream it to disk first.
2359    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    // Stream the body to a temp file (never buffered in memory). DefaultBodyLimit
2370    // does NOT bound a raw `Body` we consume ourselves, so count bytes and cap.
2371    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    // For a .3mf, read the plate-gcode md5 from the bytes we just staged.
2405    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    // (confirm is guaranteed here — the early gate rejected !confirm && !dry_run,
2434    // and dry_run returned above.)
2435
2436    // Hold the start lock across upload+start so two requests can't both pass idle.
2437    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    // Conservative overwrite guard (list the dir; a listing error doesn't block).
2449    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    // Upload the staged file, then start from its on-printer path.
2466    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); // remove the staged file once uploaded (or on error)
2471    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
2493/// Upgrade to a WebSocket that pushes a `PrinterStatus` JSON frame on connect and
2494/// on every subsequent change.
2495async 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        // Send the current snapshot, marking it seen so `changed()` waits for the
2507        // *next* update regardless of the receiver's initial seen-state.
2508        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; // client gone
2514        }
2515        if rx.changed().await.is_err() {
2516            break; // source dropped
2517        }
2518    }
2519}
2520
2521/// Gate **write** requests on the optional password. `None` ⇒ control is open
2522/// (the default). When set, the password must arrive as `Authorization: Bearer
2523/// <password>`. Reads never reach this middleware.
2524async 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; // no password configured: control is open
2527    };
2528    // Accept any-case `Bearer <pw>`; compare in constant time.
2529    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
2548/// Length-independent byte equality, to avoid leaking the password via timing.
2549fn 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    /// Build a test server with a chosen password + controller (idle source).
2563    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    // ── reads are always open ──
2579    #[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        // A password gates writes only — reads stay open.
2593        app(Some("secret"), FakeController::verified())
2594            .get("/api/status")
2595            .await
2596            .assert_status_ok();
2597    }
2598
2599    // ── control: confirm gating ──
2600    #[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    // ── upload-then-start (one-shot) ──
2637    #[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        // A raw .gcode skips 3mf inspection, so the fake files+starter carry it
2649        // end to end: upload succeeds, the print verifies.
2650        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        // Default destination is the printer root — the A1 mini prints from `/`,
2667        // and reading an uploaded file from `/cache` fails with 0x0500C010.
2668        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    // ── control: outcome → HTTP status ──
2690    #[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    // ── control: input validation ──
2724    #[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    // ── control: password gating ──
2743    #[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    // ── gcode ──
2763    #[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        // An over-limit nozzle temp is on the blocklist.
2785        s.post("/api/gcode")
2786            .json(&json!({ "line": "M104 S999", "confirm": true }))
2787            .await
2788            .assert_status(StatusCode::BAD_REQUEST);
2789        // force overrides it.
2790        s.post("/api/gcode")
2791            .json(&json!({ "line": "M104 S999", "confirm": true, "force": true }))
2792            .await
2793            .assert_status_ok();
2794    }
2795
2796    // ── files ──
2797    #[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    // ── cameras (built-in + external proxies, listed as switchable sources) ──
2887    #[tokio::test]
2888    async fn cameras_list_is_empty_without_built_in_or_external() {
2889        // Fake/test mode has no built-in camera and no external URLs.
2890        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        // Configure two external cameras (one labelled, one auto-labelled).
2918        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        // The open listing now shows both, with ids and labels but no URLs.
2929        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"); // auto-labelled
2936        assert!(cams[0].get("url").is_none()); // URL never exposed on the open list
2937        // The gated config read echoes URLs back for the manage form.
2938        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        // Replacing with an empty list clears them.
2944        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        // The proxy's ureq is built without TLS, so an https camera would only
2962        // fail later with a 502 — reject it up front rather than advertise it.
2963        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        // The open list flags whether a live MJPEG stream is available, so the
2985        // frontend can pick stream vs snapshot-poll — still without leaking URLs.
2986        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        // The gated config read echoes the stream URL for the manage form.
2992        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                // a stream camera WITHOUT tuning — not park-capable
3012                { "url": "http://cam.local/b.jpg", "stream_url": "http://cam.local/bstream" },
3013            ]}))
3014            .await
3015            .assert_status_ok();
3016        // park-capability needs BOTH a stream and a tuning.
3017        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        // The gated config read echoes the tuning so the manage form can prefill.
3025        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        // ONE combined tuning object (park + select knobs), the shape the CLI seeds and the
3038        // manage form posts — parsed into ParkTuning AND SelectTuning.
3039        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        // park knobs only — park-capable but NOT segment-capable (no select knobs).
3045        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        // segment-capability needs a stream + park_tuning + select_tuning; park needs the first two.
3057        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        // The echo MERGES the select knobs back into the tuning object, so a manage-form
3070        // re-save round-trips them (they'd otherwise be dropped, breaking segment).
3071        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        // No baked defaults: a park_tuning missing a knob (abs_floor) must be rejected,
3088        // not run with a wrong value.
3089        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        // Segment needs a stream + park_tuning + select_tuning; a stream-only camera
3121        // (no tuning) isn't capable → 400, like park.
3122        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        // The live park preview falls back to the most recent completed run; the dir's
3140        // leading epoch is the recency key (mode suffix and hint underscores don't matter).
3141        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        // window_ms is validated up front (before camera resolution), so an out-of-range
3160        // value is a clean 400 regardless of camera config.
3161        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        // parks.jsonl has one line per WRITE: a `replace` re-uses the prior `n` with a
3171        // stronger frame, so the index must keep one entry per distinct n (the last/
3172        // stronger metadata wins), sorted by n, and skip malformed/blank lines.
3173        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    /// Build a test server that shares its [`TimelapseManager`] handle, so a test can
3192    /// install a park run (out_dir + cameras) and seed its on-disk frames.
3193    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    /// Install a park run for `id` writing into `out`, with a no-op worker (the test seeds
3228    /// the frame files itself). Returns the status `tx` to keep the run's channel alive.
3229    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        // The index lists both frames, sorted, with a count.
3270        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        // An indexed frame serves its exact JPEG bytes.
3278        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        // Out-of-range index → 404; a camera not in the run → 404 (no traversal join).
3283        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        // After the run STOPS, the filmstrip stays reviewable (until the next run).
3297        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    /// A files store that returns one chosen `.3mf` for `fetch()`, to drive the dry-run's
3310    /// best-effort timelapse-block inspection. The other methods aren't exercised here.
3311    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    /// A minimal `.3mf` whose plate gcode injects `markers` per-layer timelapse blocks.
3334    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            // A password is set, to prove the inspect read stays OPEN (no auth needed).
3360            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        // FakeFiles.fetch returns junk (not a zip) → inspected:false, never an error status.
3379        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        // A non-3mf is also a clean "not inspected", not a 4xx.
3385        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        // The on-printer file was downloaded + scanned: it has the per-layer park blocks.
3412        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        // FakeFiles.fetch returns junk (not a zip) → inspection fails → null, not an error.
3421        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        // Open read; shape is `{ captures: [...] }` (contents depend on ./captures, which
3435        // may be empty). The listing logic itself is unit-tested in `crate::captures`.
3436        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        // A typo'd / unknown API path → JSON 404, not the SPA's HTML 200.
3447        let res = server.get("/api/nope").await;
3448        res.assert_status_not_found();
3449        assert!(res.json::<serde_json::Value>()["error"].is_string());
3450        // A deeper unmatched path under a real prefix, too.
3451        server
3452            .get("/api/camera/x/bogus")
3453            .await
3454            .assert_status_not_found();
3455        // A real route is NOT shadowed by the catch-all.
3456        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        // A leading-dot (traversal-ish) segment is refused outright.
3475        server
3476            .get("/api/capture/.evil/cam/video.mp4")
3477            .await
3478            .assert_status_bad_request();
3479        // A safe-but-nonexistent run → nothing to serve.
3480        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        // Same path-safety + missing-run handling as the video endpoint.
3490        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        // Same TLS-less reason as the snapshot URL: no https streams.
3526        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); // snapshot-only
3554        assert_eq!(resolve_stream_url("ext-9", &cams), None); // out of range
3555        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        // Throwaway upstream: answer one request with a short multipart MJPEG body
3563        // (including a non-UTF8 JPEG start marker), then close so the relayed
3564        // stream ends and the test can read it in full.
3565        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); // drain the request line/headers
3571                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        // The upstream body is relayed through verbatim (incl. the binary marker).
3599        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        // The cadence is validated before camera resolution, so this is a 400 even
3644        // though ext-0 isn't configured in the fake app.
3645        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        // Burst offsets are validated up front (before camera resolution), like the
3655        // cadence — so an offset past the 10s cap is a 400 even with no camera.
3656        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        // A typo like "plian" must NOT silently fall through to stopping both runs
3675        // — that would abort the other capture the caller meant to keep going.
3676        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        // No body (or an explicit "all") stops both and is the documented default.
3686        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        // ...but status stays an open read.
3705        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        // A non-absolute path would become ftp://host/x and escape the printer.
3728        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    // ── print start ──
3772    #[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        // AppState::fake() source is IDLE, so the idle guard passes.
3796        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        // A RUNNING source → idle guard refuses.
3828        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    // ── machine control: helpers ──
3847
3848    /// A test server whose source is RUNNING (busy), to exercise the idle guard.
3849    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    /// An idle source reporting a hot nozzle, so the cold-extrude guard passes.
3865    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    /// A test server with an idle, hot-nozzle source (for extrude success).
3887    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    // ── machine control: home ──
3903    #[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    // ── machine control: move (jog) ──
3940    #[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    // ── machine control: extrude ──
3995    #[tokio::test]
3996    async fn extrude_on_cold_nozzle_is_400() {
3997        // idle source has nozzle_temper = None (cold) → refused.
3998        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        // The cold guard takes no force field, but even an over-limit-style
4008        // attempt can't bypass it: a cold nozzle stays a 400.
4009        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    // ── machine control: temp ──
4044    #[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        // celsius:0 is the abort valve — no confirm, and allowed even while busy.
4065        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        // force overrides the ceiling, exactly like /api/gcode.
4080        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        // A non-zero setpoint with confirm runs even on a busy printer.
4098        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    // ── machine control: calibrate ──
4106    #[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    // ── machine control: ams ──
4143    #[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        // resume clears a pause — no confirm, no idle gate.
4173        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    // ── machine control: ams change/unload ──
4190    #[tokio::test]
4191    async fn ams_change_needs_confirm() {
4192        // Moving filament is physical — an unconfirmed request is a 428.
4193        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        // 255 is the unload sentinel — the whole reason this endpoint exists.
4212        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        // An AMS change must not command an unsafe nozzle temp (no force here).
4231        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        // Trays 0..3, 254 (external spool), 255 (unload) are meaningful; 7 isn't.
4250        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        // dry_run echoes the resolved command without sending — usable even on a
4260        // busy printer and with no confirm, mirroring job_start's preview.
4261        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        // curr_temp defaults to tar_temp when omitted.
4271        assert_eq!(body["plan"]["curr_temp"], 250);
4272    }
4273
4274    // ── machine control: reboot ──
4275    #[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        // Reboot is fire-and-forget: a success is Unverified → 202.
4286        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    // ── machine control: steppers ──
4306    #[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    // WebSocket tests need the real HTTP transport (the mocked one can't upgrade).
4333    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        // First frame is the initial snapshot at 25 °C; a later frame must be hotter.
4368        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}