use std::io::Read;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
use axum::http::{
StatusCode,
header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE},
};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get, post};
use axum::{Json, Router};
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::json;
use tokio::io::AsyncWriteExt;
use tokio::sync::watch;
#[cfg(feature = "dashboard")]
use super::assets::static_handler;
#[cfg(test)]
use super::camera::NoCamera;
use super::camera::{CameraSource, ExternalCamera, open_mjpeg_stream, url_stream_opener};
#[cfg(test)]
use super::control::FakeController;
use super::control::{
Axis, ControlAction, ControlError, Controller, HomeAxes, TempPart, temp_line,
};
#[cfg(test)]
use super::files::FakeFiles;
use super::files::FileStore;
#[cfg(test)]
use super::start::FakeStarter;
use super::start::{StartRequest, Starter};
use super::timelapse::{
DEFAULT_SMOOTH_BURST_MS, FrameGrab, PlainCapture, TimelapseManager, real_park_spawn,
real_segment_spawn,
};
use crate::core::command::{AmsControl, LedNode, SpeedLevel};
use crate::core::park::ParkTuning;
use crate::core::safety::{GcodeVerdict, TempLimits, check_extrude, check_gcode, check_jog};
use crate::core::session::CommandOutcome;
use crate::core::status::{Ams, AmsTray, AmsUnit, Filament, LightReport, Online, PrinterStatus};
use crate::park::{ParkCapture, SegmentCapture};
pub trait PrinterSource: Send + Sync {
fn current(&self) -> PrinterStatus;
fn subscribe(&self) -> watch::Receiver<PrinterStatus>;
}
pub struct FakeSource {
tx: watch::Sender<PrinterStatus>,
_keepalive: watch::Receiver<PrinterStatus>,
}
impl FakeSource {
pub fn idle() -> Self {
let (tx, rx) = watch::channel(PrinterStatus {
gcode_state: Some("IDLE".to_string()),
print_error: Some(0),
..Default::default()
});
Self { tx, _keepalive: rx }
}
pub fn ramping(interval: Duration) -> Self {
let initial = PrinterStatus {
gcode_state: Some("RUNNING".to_string()),
print_error: Some(0),
subtask_name: Some("benchy_2c.3mf".to_string()),
gcode_file: Some("benchy_2c.3mf".to_string()),
print_type: Some("local".to_string()),
nozzle_target: Some(220.0),
bed_target: Some(60.0),
nozzle_temper: Some(25.0),
bed_temper: Some(25.0),
mc_percent: Some(0),
layer_num: Some(0),
total_layer_num: Some(200),
remaining_time_min: Some(72),
spd_lvl: Some(2),
spd_mag: Some(100),
cooling_fan_speed: Some(0),
big_fan1_speed: Some(0),
heatbreak_fan_speed: Some(7000),
nozzle_diameter: Some("0.4".to_string()),
nozzle_type: Some("stainless_steel".to_string()),
sdcard: Some(true),
wifi_signal: Some("-58dBm".to_string()),
online: Some(Online {
ahb: Some(false),
rfid: Some(false),
version: Some(1),
}),
filament: Some(Filament {
location: "ams0".to_string(),
material: Some("PLA".to_string()),
name: Some("PLA Matte".to_string()),
color: Some("DE4343FF".to_string()),
}),
ams: Some(fake_ams()),
lights: vec![LightReport {
node: "chamber_light".to_string(),
mode: "off".to_string(),
}],
..Default::default()
};
let (tx, rx) = watch::channel(initial.clone());
let task_tx = tx.clone();
tokio::spawn(async move {
let mut s = initial;
let mut tick: i64 = 0;
const PRINT: i64 = 100;
const CYCLE: i64 = 115;
loop {
tokio::time::sleep(interval).await;
tick += 1;
let p = tick % CYCLE;
if p == 1 {
s.nozzle_temper = Some(25.0);
s.bed_temper = Some(25.0);
}
if (1..=PRINT).contains(&p) {
s.gcode_state = Some("RUNNING".to_string());
s.nozzle_temper = Some(approach(s.nozzle_temper.unwrap_or(25.0), 220.0, 8.0));
s.bed_temper = Some(approach(s.bed_temper.unwrap_or(25.0), 60.0, 4.0));
let hot = s.nozzle_temper.unwrap_or(0.0) >= 200.0;
s.cooling_fan_speed = Some(if hot { 100 } else { 0 });
s.mc_percent = Some(p);
s.layer_num = Some(p * 2); s.remaining_time_min = Some((PRINT - p) * 72 / 100);
} else {
s.gcode_state = Some("FINISH".to_string());
s.mc_percent = Some(100);
s.layer_num = Some(200);
s.remaining_time_min = Some(0);
s.cooling_fan_speed = Some(0);
s.nozzle_temper = Some(approach(s.nozzle_temper.unwrap_or(220.0), 30.0, 12.0));
s.bed_temper = Some(approach(s.bed_temper.unwrap_or(60.0), 30.0, 6.0));
}
if task_tx.send(s.clone()).is_err() {
break; }
}
});
Self { tx, _keepalive: rx }
}
}
fn fake_ams() -> Ams {
let tray = |id: &str, material: &str, name: &str, color: &str, active: bool| AmsTray {
id: id.to_string(),
material: Some(material.to_string()),
name: Some(name.to_string()),
color: Some(color.to_string()),
cols: vec![color.to_string()],
remain: Some(-1), state: Some(3),
id_name: Some(format!("A01-R{id}")),
uuid: Some(format!("FACADE0000000000000000000000000{id}")),
nozzle_temp_min: Some(if material == "PETG" { 230 } else { 190 }),
nozzle_temp_max: Some(if material == "PETG" { 260 } else { 230 }),
is_active: active,
is_target: active,
..Default::default()
};
Ams {
units: vec![AmsUnit {
id: "0".to_string(),
humidity: Some(5),
humidity_raw: Some(28),
temp: Some(0.0),
dry_time: None,
trays: vec![
tray("0", "PLA", "PLA Matte Red", "DE4343FF", true),
tray("1", "PLA", "PLA Basic Black", "000000FF", false),
tray("2", "PETG", "PETG Translucent", "D6ABFF80", false),
tray("3", "PLA", "PLA Wood", "918669FF", false),
],
}],
external: None,
active_tray: Some("0".to_string()),
target_tray: Some("0".to_string()),
previous_tray: Some("255".to_string()),
ams_exist_bits: Some("1".to_string()),
tray_exist_bits: Some("f".to_string()),
tray_is_bbl_bits: Some("f".to_string()),
}
}
fn approach(current: f64, target: f64, step: f64) -> f64 {
if current < target {
(current + step).min(target)
} else {
(current - step).max(target)
}
}
impl PrinterSource for FakeSource {
fn current(&self) -> PrinterStatus {
self.tx.borrow().clone()
}
fn subscribe(&self) -> watch::Receiver<PrinterStatus> {
self.tx.subscribe()
}
}
#[derive(Clone)]
pub struct AppState {
pub source: Arc<dyn PrinterSource>,
pub controller: Arc<dyn Controller>,
pub files: Arc<dyn FileStore>,
pub starter: Arc<dyn Starter>,
pub password: Option<String>,
pub start_lock: Arc<tokio::sync::Mutex<()>>,
pub external_cameras: Arc<RwLock<Vec<ExternalCamera>>>,
pub internal_camera: Arc<dyn CameraSource>,
pub timelapse: Arc<TimelapseManager>,
}
fn is_safe_remote_path(p: &str) -> bool {
p.starts_with('/')
&& p.len() > 1
&& !p.contains("..")
&& !p.contains("//")
&& !p.contains('\\')
&& !p.contains(':')
}
impl AppState {
#[cfg(test)]
pub fn fake() -> Self {
Self {
source: Arc::new(FakeSource::idle()),
controller: Arc::new(FakeController::verified()),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
}
}
}
pub fn router(state: AppState) -> Router {
let reads = Router::new()
.route("/api/status", get(status))
.route("/api/ws", get(status_ws))
.route("/api/file", get(list_files))
.route("/api/file/thumbnail", get(file_thumbnail))
.route("/api/file/raw", get(file_raw))
.route("/api/file/gcode", get(file_gcode))
.route("/api/file/inspect", get(file_inspect))
.route("/api/file/mesh", get(file_mesh))
.route("/api/camera", get(cameras_list))
.route("/api/camera/{id}/snapshot", get(camera_snapshot))
.route("/api/camera/{id}/stream", get(camera_stream))
.route("/api/camera/{id}/park", get(park_index))
.route("/api/camera/{id}/park/{n}", get(camera_park_frame))
.route("/api/timelapse", get(timelapse_status))
.route("/api/capture", get(captures_list))
.route("/api/capture/{run}/{cam}/video.mp4", get(capture_video))
.route("/api/capture/{run}/{cam}/thumb.jpg", get(capture_thumb));
let writes = Router::new()
.route("/api/job/pause", post(job_pause))
.route("/api/job/resume", post(job_resume))
.route("/api/job/stop", post(job_stop))
.route("/api/job/clear-error", post(job_clear_error))
.route("/api/job/start", post(job_start))
.route("/api/light", post(light))
.route("/api/speed", post(speed))
.route("/api/gcode", post(gcode))
.route("/api/home", post(home))
.route("/api/move", post(move_axis))
.route("/api/extrude", post(extrude))
.route("/api/temp", post(temp))
.route("/api/calibrate", post(calibrate))
.route("/api/ams", post(ams))
.route("/api/ams/change", post(ams_change))
.route("/api/reboot", post(reboot))
.route("/api/steppers", post(steppers))
.route(
"/api/camera/config",
get(cameras_config_get).post(cameras_config_set),
)
.route("/api/timelapse/start", post(timelapse_start))
.route("/api/timelapse/stop", post(timelapse_stop))
.route(
"/api/file/upload",
post(upload_file).layer(DefaultBodyLimit::max(512 * 1024 * 1024)),
)
.route(
"/api/job/upload-start",
post(job_upload_start).layer(DefaultBodyLimit::max(512 * 1024 * 1024)),
)
.layer(middleware::from_fn_with_state(
state.clone(),
require_password,
));
let app = reads
.merge(writes)
.route("/api/{*rest}", any(api_not_found));
#[cfg(feature = "dashboard")]
let app = app.fallback(static_handler);
app.with_state(state)
}
async fn api_not_found() -> Response {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "unknown API endpoint" })),
)
.into_response()
}
async fn status(State(st): State<AppState>) -> Json<PrinterStatus> {
Json(st.source.current())
}
#[derive(Deserialize, Default)]
struct ConfirmBody {
#[serde(default)]
confirm: bool,
}
#[derive(Deserialize)]
struct LightBody {
node: String,
on: bool,
}
#[derive(Deserialize)]
struct SpeedBody {
level: String,
}
async fn job_pause(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
run_confirmed(st, ControlAction::Pause, body).await
}
async fn job_resume(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
run_confirmed(st, ControlAction::Resume, body).await
}
async fn job_stop(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
run_confirmed(st, ControlAction::Stop, body).await
}
async fn job_clear_error(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
run_confirmed(st, ControlAction::ClearError, body).await
}
async fn light(State(st): State<AppState>, Json(b): Json<LightBody>) -> Response {
let node = match b.node.as_str() {
"chamber" => LedNode::ChamberLight,
"work" => LedNode::WorkLight,
other => return bad_request(format!("unknown light node {other:?}")),
};
execute(st, ControlAction::Light { node, on: b.on }).await
}
async fn speed(State(st): State<AppState>, Json(b): Json<SpeedBody>) -> Response {
let level = match b.level.as_str() {
"silent" => SpeedLevel::Silent,
"standard" => SpeedLevel::Standard,
"sport" => SpeedLevel::Sport,
"ludicrous" => SpeedLevel::Ludicrous,
other => return bad_request(format!("unknown speed level {other:?}")),
};
execute(st, ControlAction::Speed(level)).await
}
#[derive(Deserialize)]
struct GcodeBody {
line: String,
#[serde(default)]
confirm: bool,
#[serde(default)]
force: bool,
}
async fn gcode(State(st): State<AppState>, Json(b): Json<GcodeBody>) -> Response {
if b.line.trim().is_empty() {
return bad_request("empty gcode line".to_string());
}
if !b.confirm {
return (
StatusCode::PRECONDITION_REQUIRED,
Json(json!({ "error": "confirm required: POST {\"confirm\": true}" })),
)
.into_response();
}
if !b.force
&& let GcodeVerdict::Block(reason) = check_gcode(&b.line, &TempLimits::default())
{
return bad_request(format!("unsafe gcode (use force to override): {reason}"));
}
execute(st, ControlAction::Gcode(b.line)).await
}
async fn run_confirmed(
st: AppState,
action: ControlAction,
body: Option<Json<ConfirmBody>>,
) -> Response {
if !body.map(|b| b.confirm).unwrap_or(false) {
return (
StatusCode::PRECONDITION_REQUIRED,
Json(json!({ "error": "confirm required: POST {\"confirm\": true}" })),
)
.into_response();
}
execute(st, action).await
}
async fn execute(st: AppState, action: ControlAction) -> Response {
let controller = st.controller.clone();
let res = tokio::task::spawn_blocking(move || controller.execute(action)).await;
verify_response(res)
}
type VerifyJoin = Result<Result<CommandOutcome, ControlError>, tokio::task::JoinError>;
fn verify_response(res: VerifyJoin) -> Response {
match res {
Ok(Ok(outcome)) => {
let code = match &outcome {
CommandOutcome::Verified => StatusCode::OK,
CommandOutcome::Unverified { .. } => StatusCode::ACCEPTED,
CommandOutcome::Rejected { .. } => StatusCode::CONFLICT,
};
(code, Json(outcome)).into_response()
}
Ok(Err(ControlError::Transport(e))) => {
(StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response()
}
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "control task failed" })),
)
.into_response(),
}
}
fn bad_request(msg: String) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
fn require_idle(st: &AppState) -> Option<Response> {
let state = st
.source
.current()
.gcode_state
.unwrap_or_default()
.to_ascii_uppercase();
if matches!(state.as_str(), "RUNNING" | "PAUSE" | "PREPARE" | "SLICING") {
return Some(
(
StatusCode::CONFLICT,
Json(json!({ "error": format!("printer is busy ({state}); operation refused") })),
)
.into_response(),
);
}
None
}
fn need_confirm(confirm: bool) -> Option<Response> {
if confirm {
return None;
}
Some(
(
StatusCode::PRECONDITION_REQUIRED,
Json(json!({ "error": "confirm required: POST {\"confirm\": true}" })),
)
.into_response(),
)
}
#[derive(Deserialize)]
struct HomeBody {
#[serde(default = "default_axes")]
axes: String,
}
fn default_axes() -> String {
"all".to_string()
}
async fn home(State(st): State<AppState>, Json(b): Json<HomeBody>) -> Response {
let axes = match b.axes.as_str() {
"all" => HomeAxes::All,
"x" => HomeAxes::X,
"y" => HomeAxes::Y,
"z" => HomeAxes::Z,
other => return bad_request(format!("unknown axes {other:?}")),
};
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(st, ControlAction::Home(axes)).await
}
#[derive(Deserialize)]
struct MoveBody {
axis: String,
delta: f64,
#[serde(default = "default_move_feedrate")]
feedrate: u32,
}
fn default_move_feedrate() -> u32 {
3000
}
async fn move_axis(State(st): State<AppState>, Json(b): Json<MoveBody>) -> Response {
let axis = match b.axis.as_str() {
"x" => Axis::X,
"y" => Axis::Y,
"z" => Axis::Z,
other => return bad_request(format!("unknown axis {other:?}")),
};
if let GcodeVerdict::Block(reason) = check_jog(b.delta) {
return bad_request(reason);
}
if !(60..=6000).contains(&b.feedrate) {
return bad_request(format!("feedrate {} out of range (60..=6000)", b.feedrate));
}
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(
st,
ControlAction::Move {
axis,
delta: b.delta,
feedrate: b.feedrate,
},
)
.await
}
#[derive(Deserialize)]
struct ExtrudeBody {
delta: f64,
#[serde(default = "default_extrude_feedrate")]
feedrate: u32,
}
fn default_extrude_feedrate() -> u32 {
300
}
async fn extrude(State(st): State<AppState>, Json(b): Json<ExtrudeBody>) -> Response {
let nozzle_temper = st.source.current().nozzle_temper;
if let GcodeVerdict::Block(reason) = check_extrude(b.delta, nozzle_temper) {
return bad_request(reason);
}
if !(60..=6000).contains(&b.feedrate) {
return bad_request(format!("feedrate {} out of range (60..=6000)", b.feedrate));
}
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(
st,
ControlAction::Extrude {
delta: b.delta,
feedrate: b.feedrate,
},
)
.await
}
#[derive(Deserialize)]
struct TempBody {
part: String,
celsius: u32,
#[serde(default)]
confirm: bool,
#[serde(default)]
force: bool,
}
async fn temp(State(st): State<AppState>, Json(b): Json<TempBody>) -> Response {
let part = match b.part.as_str() {
"nozzle" => TempPart::Nozzle,
"bed" => TempPart::Bed,
other => return bad_request(format!("unknown part {other:?}")),
};
let line = temp_line(part, b.celsius);
if !b.force
&& let GcodeVerdict::Block(reason) = check_gcode(&line, &TempLimits::default())
{
return bad_request(format!(
"unsafe temperature (use force to override): {reason}"
));
}
if b.celsius > 0
&& let Some(unconfirmed) = need_confirm(b.confirm)
{
return unconfirmed;
}
execute(
st,
ControlAction::SetTemp {
part,
celsius: b.celsius,
},
)
.await
}
#[derive(Deserialize)]
struct CalibrateBody {
#[serde(default)]
bed_level: bool,
#[serde(default)]
vibration: bool,
#[serde(default)]
motor_noise: bool,
#[serde(default)]
confirm: bool,
}
async fn calibrate(State(st): State<AppState>, Json(b): Json<CalibrateBody>) -> Response {
if !(b.bed_level || b.vibration || b.motor_noise) {
return bad_request(
"select at least one calibration (bed_level/vibration/motor_noise)".to_string(),
);
}
if let Some(unconfirmed) = need_confirm(b.confirm) {
return unconfirmed;
}
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(
st,
ControlAction::Calibrate {
bed_level: b.bed_level,
vibration: b.vibration,
motor_noise: b.motor_noise,
},
)
.await
}
#[derive(Deserialize)]
struct AmsBody {
action: String,
#[serde(default)]
confirm: bool,
}
async fn ams(State(st): State<AppState>, Json(b): Json<AmsBody>) -> Response {
let action = match b.action.as_str() {
"resume" => AmsControl::Resume,
"reset" => AmsControl::Reset,
"pause" => AmsControl::Pause,
other => return bad_request(format!("unknown ams action {other:?}")),
};
if !matches!(action, AmsControl::Resume) {
if let Some(unconfirmed) = need_confirm(b.confirm) {
return unconfirmed;
}
if let Some(busy) = require_idle(&st) {
return busy;
}
}
execute(st, ControlAction::Ams(action)).await
}
#[derive(Deserialize)]
struct AmsChangeBody {
target: u32,
tar_temp: i64,
curr_temp: Option<i64>,
#[serde(default)]
confirm: bool,
#[serde(default)]
dry_run: bool,
}
async fn ams_change(State(st): State<AppState>, Json(b): Json<AmsChangeBody>) -> Response {
if !matches!(b.target, 0..=3 | 254 | 255) {
return bad_request(format!(
"target {} invalid (trays 0..3, 254 external spool, or 255 unload)",
b.target
));
}
let curr = b.curr_temp.unwrap_or(b.tar_temp);
let max = TempLimits::default().max_nozzle as i64;
for (label, t) in [("tar_temp", b.tar_temp), ("curr_temp", curr)] {
if !(0..=max).contains(&t) {
return bad_request(format!("{label} {t}°C is out of range (0..={max})"));
}
}
if b.dry_run {
return Json(json!({ "plan": {
"command": "ams_change_filament",
"target": b.target,
"curr_temp": curr,
"tar_temp": b.tar_temp,
}}))
.into_response();
}
if let Some(unconfirmed) = need_confirm(b.confirm) {
return unconfirmed;
}
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(
st,
ControlAction::AmsChange {
target: b.target,
curr_temp: curr,
tar_temp: b.tar_temp,
},
)
.await
}
async fn reboot(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
if let Some(unconfirmed) = need_confirm(body.map(|b| b.confirm).unwrap_or(false)) {
return unconfirmed;
}
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(st, ControlAction::Reboot).await
}
async fn steppers(State(st): State<AppState>, body: Option<Json<ConfirmBody>>) -> Response {
if let Some(unconfirmed) = need_confirm(body.map(|b| b.confirm).unwrap_or(false)) {
return unconfirmed;
}
if let Some(busy) = require_idle(&st) {
return busy;
}
execute(st, ControlAction::DisableSteppers).await
}
#[derive(Deserialize)]
struct StartBody {
file: String,
#[serde(default = "default_plate")]
plate: u32,
#[serde(default)]
confirm: bool,
#[serde(default)]
use_ams: bool,
#[serde(default)]
ams_map: Vec<i32>,
bed_type: Option<String>,
#[serde(default)]
timelapse: bool,
#[serde(default)]
dry_run: bool,
}
fn default_plate() -> u32 {
1
}
async fn job_start(State(st): State<AppState>, Json(b): Json<StartBody>) -> Response {
let lower = b.file.to_ascii_lowercase();
if !is_safe_remote_path(&b.file) {
return bad_request(format!(
"file must be an absolute printer path: {:?}",
b.file
));
}
if !(lower.ends_with(".3mf") || lower.ends_with(".gcode")) {
return bad_request("file must be a .3mf or .gcode".to_string());
}
if b.use_ams {
for (i, v) in b.ams_map.iter().enumerate() {
if !(-1..=3).contains(v) {
return bad_request(format!(
"ams_map[{i}]={v} out of range (trays 0..3, or -1 external)"
));
}
}
}
let req = StartRequest {
file: b.file.clone(),
plate: b.plate,
use_ams: b.use_ams,
ams_map: b.ams_map.clone(),
bed_type: b.bed_type.clone().unwrap_or_else(|| "auto".to_string()),
timelapse: b.timelapse,
inspection: None,
};
if b.dry_run {
let has_timelapse_blocks = if req.file.to_ascii_lowercase().ends_with(".3mf") {
let (files, file, plate) = (st.files.clone(), req.file.clone(), req.plate);
match tokio::task::spawn_blocking(move || {
files.fetch(&file).and_then(|bytes| {
crate::core::project::inspect_plate(&bytes, plate).map_err(|e| e.to_string())
})
})
.await
{
Ok(Ok(insp)) => Some(insp.has_timelapse_blocks),
_ => None,
}
} else {
None
};
return Json(json!({ "plan": {
"file": req.file,
"plate": req.plate,
"use_ams": req.use_ams,
"ams_map": req.ams_map,
"bed_type": req.bed_type,
"timelapse": req.timelapse,
"has_timelapse_blocks": has_timelapse_blocks,
}}))
.into_response();
}
if !b.confirm {
return (
StatusCode::PRECONDITION_REQUIRED,
Json(json!({ "error": "confirm required: POST {\"confirm\": true} (try dry_run first)" })),
)
.into_response();
}
let Ok(_guard) = st.start_lock.try_lock() else {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "a print start is already in progress" })),
)
.into_response();
};
if let Some(busy) = require_idle(&st) {
return busy;
}
let starter = st.starter.clone();
let res = tokio::task::spawn_blocking(move || starter.start(&req)).await;
verify_response(res)
}
#[derive(Deserialize)]
struct ListQuery {
dir: Option<String>,
}
async fn list_files(State(st): State<AppState>, Query(q): Query<ListQuery>) -> Response {
let dir = q.dir.unwrap_or_else(|| "/".to_string());
let files = st.files.clone();
match tokio::task::spawn_blocking(move || files.list(&dir)).await {
Ok(Ok(names)) => Json(json!({ "files": names })).into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "file task failed" })),
)
.into_response(),
}
}
#[derive(Deserialize)]
struct ThumbQuery {
name: String,
#[serde(default = "default_plate")]
plate: u32,
}
async fn file_thumbnail(State(st): State<AppState>, Query(q): Query<ThumbQuery>) -> Response {
let remote = if q.name.starts_with('/') {
q.name.clone()
} else {
format!("/{}", q.name)
};
if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
return bad_request(format!("thumbnail needs a .3mf printer path: {:?}", q.name));
}
if !(1..=64).contains(&q.plate) {
return bad_request("plate out of range (1..64)".to_string());
}
let files = st.files.clone();
let plate = q.plate;
match tokio::task::spawn_blocking(move || files.thumbnail(&remote, plate)).await {
Ok(Ok(Some(png))) => ([(CONTENT_TYPE, "image/png")], png).into_response(),
Ok(Ok(None)) => StatusCode::NOT_FOUND.into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "thumbnail task failed" })),
)
.into_response(),
}
}
#[derive(Deserialize)]
struct RawQuery {
name: String,
}
async fn file_raw(State(st): State<AppState>, Query(q): Query<RawQuery>) -> Response {
let remote = if q.name.starts_with('/') {
q.name.clone()
} else {
format!("/{}", q.name)
};
let lower = remote.to_ascii_lowercase();
if !is_safe_remote_path(&remote) || !(lower.ends_with(".3mf") || lower.ends_with(".gcode")) {
return bad_request(format!("viewer needs a .3mf/.gcode path: {:?}", q.name));
}
let ctype = if lower.ends_with(".gcode") {
"text/plain; charset=utf-8"
} else {
"application/octet-stream"
};
let files = st.files.clone();
match tokio::task::spawn_blocking(move || files.fetch(&remote)).await {
Ok(Ok(bytes)) => ([(CONTENT_TYPE, ctype)], bytes).into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => server_error("fetch task failed".to_string()),
}
}
#[derive(Deserialize)]
struct GcodeFileQuery {
name: String,
#[serde(default = "default_plate")]
plate: u32,
}
async fn file_gcode(State(st): State<AppState>, Query(q): Query<GcodeFileQuery>) -> Response {
let remote = if q.name.starts_with('/') {
q.name.clone()
} else {
format!("/{}", q.name)
};
if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
return bad_request(format!("gcode needs a .3mf printer path: {:?}", q.name));
}
if !(1..=64).contains(&q.plate) {
return bad_request("plate out of range (1..64)".to_string());
}
let files = st.files.clone();
let plate = q.plate;
match tokio::task::spawn_blocking(move || files.gcode(&remote, plate)).await {
Ok(Ok(Some(gcode))) => {
([(CONTENT_TYPE, "text/plain; charset=utf-8")], gcode).into_response()
}
Ok(Ok(None)) => StatusCode::NOT_FOUND.into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => server_error("gcode task failed".to_string()),
}
}
#[derive(Deserialize)]
struct InspectQuery {
name: String,
#[serde(default = "default_plate")]
plate: u32,
}
async fn file_inspect(State(st): State<AppState>, Query(q): Query<InspectQuery>) -> Response {
let remote = if q.name.starts_with('/') {
q.name.clone()
} else {
format!("/{}", q.name)
};
if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
return Json(json!({ "inspected": false, "error": "not a .3mf printer path" }))
.into_response();
}
if !(1..=64).contains(&q.plate) {
return bad_request("plate out of range (1..64)".to_string());
}
let (files, plate) = (st.files.clone(), q.plate);
match tokio::task::spawn_blocking(move || {
files
.fetch(&remote)
.and_then(|b| crate::core::project::inspect_plate(&b, plate).map_err(|e| e.to_string()))
})
.await
{
Ok(Ok(i)) => Json(json!({
"inspected": true,
"plate": i.plate,
"has_timelapse_blocks": i.has_timelapse_blocks,
"gcode_md5": i.gcode_md5,
"bed_type": i.bed_type,
"filament_colors": i.filament_colors,
}))
.into_response(),
Ok(Err(e)) => Json(json!({ "inspected": false, "error": e })).into_response(),
Err(_) => server_error("inspect task failed".to_string()),
}
}
#[derive(Deserialize)]
struct MeshQuery {
name: String,
}
async fn file_mesh(State(st): State<AppState>, Query(q): Query<MeshQuery>) -> Response {
let remote = if q.name.starts_with('/') {
q.name.clone()
} else {
format!("/{}", q.name)
};
if !is_safe_remote_path(&remote) || !remote.to_ascii_lowercase().ends_with(".3mf") {
return bad_request(format!("mesh needs a .3mf printer path: {:?}", q.name));
}
let files = st.files.clone();
match tokio::task::spawn_blocking(move || files.models(&remote)).await {
Ok(Ok(models)) => Json(json!({ "models": models })).into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => server_error("mesh task failed".to_string()),
}
}
const CAMERA_MAX_BYTES: u64 = 32 * 1024 * 1024;
const CAMERA_TIMEOUT: Duration = Duration::from_secs(8);
async fn cameras_list(State(st): State<AppState>) -> Json<serde_json::Value> {
let mut cameras = Vec::new();
if st.internal_camera.configured() {
cameras.push(json!({ "id": "internal", "kind": "internal", "label": "built-in camera" }));
}
for (i, c) in st.external_cameras.read().unwrap().iter().enumerate() {
cameras.push(json!({
"id": format!("ext-{i}"),
"kind": "external",
"label": c.label,
"stream": c.stream_url.is_some(),
"park": c.stream_url.is_some() && c.park_tuning.is_some(),
"segment": c.stream_url.is_some()
&& c.park_tuning.is_some()
&& c.select_tuning.is_some(),
}));
}
Json(json!({ "cameras": cameras }))
}
async fn camera_snapshot(State(st): State<AppState>, Path(id): Path<String>) -> Response {
if id == "internal" {
if !st.internal_camera.configured() {
return StatusCode::NOT_FOUND.into_response();
}
let cam = st.internal_camera.clone();
return match tokio::task::spawn_blocking(move || cam.snapshot()).await {
Ok(Ok(bytes)) => (
[
(CONTENT_TYPE, "image/jpeg".to_string()),
(CACHE_CONTROL, "no-store".to_string()),
],
bytes,
)
.into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => server_error("camera task failed".to_string()),
};
}
let url = id
.strip_prefix("ext-")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|i| {
st.external_cameras
.read()
.unwrap()
.get(i)
.map(|c| c.url.clone())
});
let Some(url) = url else {
return StatusCode::NOT_FOUND.into_response();
};
match tokio::task::spawn_blocking(move || fetch_camera_frame(&url)).await {
Ok(Ok((ctype, bytes))) => (
[
(CONTENT_TYPE, ctype),
(CACHE_CONTROL, "no-store".to_string()),
],
bytes,
)
.into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => server_error("camera task failed".to_string()),
}
}
fn resolve_stream_url(id: &str, externals: &[ExternalCamera]) -> Option<String> {
id.strip_prefix("ext-")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|i| externals.get(i))
.and_then(|c| c.stream_url.clone())
}
async fn camera_stream(State(st): State<AppState>, Path(id): Path<String>) -> Response {
let Some(url) = resolve_stream_url(&id, &st.external_cameras.read().unwrap()) else {
return StatusCode::NOT_FOUND.into_response();
};
let opened = tokio::task::spawn_blocking(move || open_mjpeg_stream(&url)).await;
let (ctype, reader) = match opened {
Ok(Ok(s)) => (s.content_type, s.reader),
Ok(Err(e)) => {
return (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response();
}
Err(_) => return server_error("camera stream task failed".to_string()),
};
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(8);
tokio::task::spawn_blocking(move || {
let mut reader = reader;
let mut buf = vec![0u8; 32 * 1024];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx
.blocking_send(Ok(Bytes::copy_from_slice(&buf[..n])))
.is_err()
{
break;
}
}
Err(e) => {
let _ = tx.blocking_send(Err(e));
break;
}
}
}
});
let body = Body::from_stream(futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
}));
Response::builder()
.header(CONTENT_TYPE, ctype)
.header(CACHE_CONTROL, "no-store")
.body(body)
.unwrap()
}
fn parse_parks_index(contents: &str) -> Vec<serde_json::Value> {
let mut by_n: std::collections::BTreeMap<u64, serde_json::Value> =
std::collections::BTreeMap::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(n) = v.get("n").and_then(serde_json::Value::as_u64) else {
continue;
};
by_n.insert(
n,
json!({
"n": n,
"t": v.get("t").and_then(serde_json::Value::as_f64),
"confidence": v.get("confidence").and_then(serde_json::Value::as_f64),
}),
);
}
by_n.into_values().collect()
}
fn live_park_source(st: &AppState) -> Option<(String, Vec<String>, bool)> {
let sources = [
st.timelapse.status_segment(),
st.timelapse.status_park(),
st.timelapse.status_smooth(),
];
if let Some(s) = sources.iter().find(|s| s.running && s.out_dir.is_some()) {
return Some((s.out_dir.clone().unwrap(), s.cameras.clone(), s.running));
}
sources
.into_iter()
.filter(|s| s.out_dir.is_some())
.max_by_key(|s| run_dir_epoch(s.out_dir.as_deref().unwrap_or("")))
.map(|s| (s.out_dir.unwrap(), s.cameras, s.running))
}
fn run_dir_epoch(dir: &str) -> u64 {
std::path::Path::new(dir)
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.split('_').next())
.and_then(|e| e.parse::<u64>().ok())
.unwrap_or(0)
}
async fn park_index(State(st): State<AppState>, Path(id): Path<String>) -> Response {
let Some((dir, cameras, running)) = live_park_source(&st) else {
return StatusCode::NOT_FOUND.into_response();
};
if !cameras.iter().any(|c| c == &id) {
return StatusCode::NOT_FOUND.into_response();
}
let jsonl = std::path::Path::new(&dir).join(&id).join("parks.jsonl");
let parks = match tokio::fs::read_to_string(&jsonl).await {
Ok(s) => parse_parks_index(&s),
Err(_) => Vec::new(), };
Json(json!({ "running": running, "count": parks.len(), "parks": parks })).into_response()
}
async fn camera_park_frame(
State(st): State<AppState>,
Path((id, n)): Path<(String, u64)>,
) -> Response {
let Some((dir, cameras, _)) = live_park_source(&st) else {
return StatusCode::NOT_FOUND.into_response();
};
if !cameras.iter().any(|c| c == &id) {
return StatusCode::NOT_FOUND.into_response();
}
let path = std::path::Path::new(&dir)
.join(&id)
.join(format!("park_{n:06}.jpg"));
match tokio::fs::read(&path).await {
Ok(bytes) => (
[
(CONTENT_TYPE, "image/jpeg".to_string()),
(CACHE_CONTROL, "no-store".to_string()),
],
bytes,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
fn tuning_json(c: &ExternalCamera) -> serde_json::Value {
let Some(park) = &c.park_tuning else {
return serde_json::Value::Null;
};
let mut v = serde_json::to_value(park).unwrap_or_else(|_| json!({}));
if let (Some(obj), Some(sel)) = (v.as_object_mut(), c.select_tuning)
&& let Ok(serde_json::Value::Object(sobj)) = serde_json::to_value(sel)
{
for (k, val) in sobj {
obj.entry(k).or_insert(val);
}
}
v
}
fn external_json(st: &AppState) -> Vec<serde_json::Value> {
st.external_cameras
.read()
.unwrap()
.iter()
.enumerate()
.map(|(i, c)| {
json!({ "id": format!("ext-{i}"), "label": c.label, "url": c.url,
"stream_url": c.stream_url, "park_tuning": tuning_json(c) })
})
.collect()
}
async fn cameras_config_get(State(st): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "external": external_json(&st) }))
}
#[derive(Deserialize)]
struct ExternalCameraInput {
label: Option<String>,
url: String,
#[serde(default)]
stream_url: Option<String>,
#[serde(default)]
park_tuning: Option<serde_json::Value>,
}
fn is_http_url(u: &str) -> bool {
u.starts_with("http://")
}
#[derive(Deserialize)]
struct CamerasConfigBody {
external: Vec<ExternalCameraInput>,
}
async fn cameras_config_set(
State(st): State<AppState>,
Json(b): Json<CamerasConfigBody>,
) -> Response {
let mut next = Vec::with_capacity(b.external.len());
for (i, e) in b.external.into_iter().enumerate() {
let url = e.url.trim().to_string();
if !is_http_url(&url) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "camera URL must start with http:// (the proxy is plain-HTTP; no TLS)" })),
)
.into_response();
}
let stream_url = e
.stream_url
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(s) = &stream_url
&& !is_http_url(s)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "camera stream URL must start with http:// (the proxy is plain-HTTP; no TLS)" })),
)
.into_response();
}
let (park, select) = match e.park_tuning {
Some(v) => {
let park: ParkTuning = match serde_json::from_value(v.clone()) {
Ok(p) => p,
Err(err) => return bad_request(format!("invalid park_tuning: {err}")),
};
(Some(park), serde_json::from_value(v).ok())
}
None => (None, None),
};
next.push(
ExternalCamera::new(e.label, url, stream_url, i)
.with_park_tuning(park)
.with_select_tuning(select),
);
}
*st.external_cameras.write().unwrap() = next;
Json(json!({ "external": external_json(&st) })).into_response()
}
fn fetch_camera_frame(url: &str) -> Result<(String, Vec<u8>), String> {
let agent = ureq::AgentBuilder::new()
.timeout(CAMERA_TIMEOUT)
.redirects(0)
.build();
let resp = agent.get(url).call().map_err(|e| e.to_string())?;
let ctype = resp
.header("content-type")
.map(str::to_string)
.unwrap_or_else(|| "image/jpeg".to_string());
let mut bytes = Vec::new();
resp.into_reader()
.take(CAMERA_MAX_BYTES)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("camera returned an empty body".to_string());
}
Ok((ctype, bytes))
}
#[derive(Deserialize)]
struct TimelapseStartBody {
#[serde(default)]
camera: Option<String>,
#[serde(default)]
cameras: Vec<String>,
#[serde(default)]
mode: Option<String>,
#[serde(default = "default_every")]
every: u64,
#[serde(default)]
interval_ms: Option<u64>,
#[serde(default)]
burst_offsets_ms: Option<Vec<u64>>,
#[serde(default)]
window_ms: Option<u64>,
}
fn default_every() -> u64 {
1
}
#[derive(Deserialize, Default)]
struct TimelapseStopBody {
#[serde(default)]
mode: Option<String>,
}
fn timelapse_status_json(st: &AppState) -> serde_json::Value {
let smooth = st.timelapse.status_smooth();
let plain = st.timelapse.status_plain();
let park = st.timelapse.status_park();
let segment = st.timelapse.status_segment();
let mut out = smooth.to_json();
if let Some(o) = out.as_object_mut() {
o.insert(
"running".to_string(),
json!(smooth.running || plain.running || park.running || segment.running),
);
o.insert("smooth".to_string(), smooth.to_json());
o.insert("plain".to_string(), plain.to_json());
o.insert("park".to_string(), park.to_json());
o.insert("segment".to_string(), segment.to_json());
}
out
}
fn resolve_grab(st: &AppState, camera: &str) -> Option<(String, FrameGrab)> {
if camera == "internal" {
if !st.internal_camera.configured() {
return None;
}
let cam = st.internal_camera.clone();
return Some((camera.to_string(), Arc::new(move || cam.snapshot())));
}
let idx = camera.strip_prefix("ext-")?.parse::<usize>().ok()?;
let url = st.external_cameras.read().unwrap().get(idx)?.url.clone();
Some((
camera.to_string(),
Arc::new(move || fetch_camera_frame(&url).map(|(_, bytes)| bytes)),
))
}
fn sanitize_hint(s: &str) -> String {
let cleaned: String = s
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(40)
.collect();
let trimmed = cleaned.trim_matches('_');
if trimmed.is_empty() {
"print".to_string()
} else {
trimmed.to_string()
}
}
fn captures_root() -> std::path::PathBuf {
std::path::PathBuf::from("captures")
}
fn run_out_dir(st: &AppState, mode: &str) -> std::path::PathBuf {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let hint = sanitize_hint(
st.source
.current()
.subtask_name
.as_deref()
.unwrap_or("print"),
);
captures_root().join(format!("{epoch}_{hint}_{mode}"))
}
async fn captures_list(State(_st): State<AppState>) -> Response {
let runs = tokio::task::spawn_blocking(|| crate::captures::list_captures(&captures_root()))
.await
.unwrap_or_default();
Json(json!({ "captures": runs })).into_response()
}
fn is_safe_segment(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& !s.starts_with('.')
&& !s.contains('/')
&& !s.contains('\\')
&& s != ".."
}
#[derive(Deserialize)]
struct CaptureVideoQuery {
#[serde(default = "default_fps")]
fps: u32,
}
fn default_fps() -> u32 {
10
}
async fn capture_video(
State(st): State<AppState>,
Path((run, cam)): Path<(String, String)>,
Query(q): Query<CaptureVideoQuery>,
) -> Response {
if !is_safe_segment(&run) || !is_safe_segment(&cam) {
return bad_request("invalid capture path".to_string());
}
let fps = q.fps.clamp(1, 60);
let select_tuning = cam
.strip_prefix("ext-")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|i| {
st.external_cameras
.read()
.unwrap()
.get(i)
.and_then(|c| c.select_tuning)
});
let run_dir = captures_root().join(&run);
let sub = run_dir.join(&cam);
let cam_dir = if sub.is_dir() { sub } else { run_dir };
let path = tokio::task::spawn_blocking(move || -> Result<std::path::PathBuf, String> {
use crate::captures::{CaptureKind, assemble_mp4, classify};
let files: Vec<String> = std::fs::read_dir(&cam_dir)
.map_err(|e| e.to_string())?
.flatten()
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
match classify(&files).ok_or("no recording")?.kind {
CaptureKind::Video => {
let mp4 = cam_dir.join("plain.mp4");
if mp4.is_file() {
return Ok(mp4);
}
let mjpeg = cam_dir.join("plain.mjpeg");
if mjpeg.is_file() {
crate::captures::transcode_mp4(&mjpeg, &mp4)?;
return Ok(mp4);
}
Err("video not available".to_string())
}
CaptureKind::Smooth => {
let out = cam_dir.join("timelapse.mp4");
let selected = select_tuning.is_some_and(|sel| {
crate::captures::assemble_smooth_selected_mp4(&cam_dir, &sel, &out, fps).is_ok()
});
if !selected {
assemble_mp4(&cam_dir, CaptureKind::Smooth, &out, fps)?;
}
Ok(out)
}
kind => {
let out = cam_dir.join("timelapse.mp4");
assemble_mp4(&cam_dir, kind, &out, fps)?;
Ok(out)
}
}
})
.await;
let p = match path {
Ok(Ok(p)) => p,
Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(),
Err(_) => return server_error("assemble task failed".to_string()),
};
let Ok(file) = tokio::fs::File::open(&p).await else {
return StatusCode::NOT_FOUND.into_response();
};
let stream = futures_util::stream::unfold(Some(file), |st| async move {
let mut f = st?;
let mut buf = vec![0u8; 64 * 1024];
match tokio::io::AsyncReadExt::read(&mut f, &mut buf).await {
Ok(0) => None,
Ok(n) => {
buf.truncate(n);
Some((Ok::<Bytes, std::io::Error>(Bytes::from(buf)), Some(f)))
}
Err(e) => Some((Err(e), None)),
}
});
(
[
(CONTENT_TYPE, "video/mp4".to_string()),
(CACHE_CONTROL, "no-store".to_string()),
],
Body::from_stream(stream),
)
.into_response()
}
async fn capture_thumb(Path((run, cam)): Path<(String, String)>) -> Response {
if !is_safe_segment(&run) || !is_safe_segment(&cam) {
return bad_request("invalid capture path".to_string());
}
let run_dir = captures_root().join(&run);
let sub = run_dir.join(&cam);
let cam_dir = if sub.is_dir() { sub } else { run_dir };
let path = tokio::task::spawn_blocking(move || -> Result<std::path::PathBuf, String> {
use crate::captures::{CaptureKind, classify, extract_video_thumb, thumb_frame};
let files: Vec<String> = std::fs::read_dir(&cam_dir)
.map_err(|e| e.to_string())?
.flatten()
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
let kind = classify(&files).ok_or("no recording")?.kind;
match kind {
CaptureKind::Video => {
let thumb = cam_dir.join("thumb.jpg");
if !thumb.is_file() {
let mp4 = cam_dir.join("plain.mp4");
let src = if mp4.is_file() {
mp4
} else {
cam_dir.join("plain.mjpeg")
};
if !src.is_file() {
return Err("no video".to_string());
}
extract_video_thumb(&src, &thumb)?;
}
Ok(thumb)
}
kind => Ok(cam_dir.join(thumb_frame(&files, kind).ok_or("no frame")?)),
}
})
.await;
let p = match path {
Ok(Ok(p)) => p,
Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(),
Err(_) => return server_error("thumb task failed".to_string()),
};
match tokio::fs::read(&p).await {
Ok(bytes) => (
[
(CONTENT_TYPE, "image/jpeg".to_string()),
(CACHE_CONTROL, "no-store".to_string()),
],
bytes,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
fn start_park_run(
st: &AppState,
ids: &[String],
out_dir: std::path::PathBuf,
rx: watch::Receiver<PrinterStatus>,
) -> Response {
let externals = st.external_cameras.read().unwrap();
let mut caps = Vec::new();
let mut skipped = Vec::new();
for id in ids {
let cap = id
.strip_prefix("ext-")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|i| externals.get(i))
.and_then(|c| match (&c.stream_url, &c.park_tuning) {
(Some(url), Some(t)) => Some(ParkCapture {
id: id.clone(),
stream_url: url.clone(),
tuning: t.clone(),
}),
_ => None,
});
match cap {
Some(c) => caps.push(c),
None => skipped.push(id.clone()),
}
}
drop(externals);
if caps.is_empty() {
return bad_request(format!(
"no park-capable cameras among {ids:?} — each needs a stream_url and a park_tuning"
));
}
match st
.timelapse
.start_park(caps, rx, out_dir, real_park_spawn())
{
Ok(()) => {
let mut body = timelapse_status_json(st);
if let Some(o) = body.as_object_mut() {
o.insert("skipped".to_string(), json!(skipped));
}
Json(body).into_response()
}
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
fn start_segment_run(
st: &AppState,
ids: &[String],
window_ms: u64,
out_dir: std::path::PathBuf,
rx: watch::Receiver<PrinterStatus>,
) -> Response {
let externals = st.external_cameras.read().unwrap();
let mut caps = Vec::new();
let mut skipped = Vec::new();
for id in ids {
let cap = id
.strip_prefix("ext-")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|i| externals.get(i))
.and_then(|c| match (&c.stream_url, &c.park_tuning, c.select_tuning) {
(Some(url), Some(park), Some(select)) => Some(SegmentCapture {
id: id.clone(),
stream_url: url.clone(),
fps: park.fps,
window_ms,
select_tuning: select,
}),
_ => None,
});
match cap {
Some(c) => caps.push(c),
None => skipped.push(id.clone()),
}
}
drop(externals);
if caps.is_empty() {
return bad_request(format!(
"no segment-capable cameras among {ids:?} — each needs a stream_url, a park_tuning, and a select_tuning"
));
}
match st
.timelapse
.start_segment(caps, rx, out_dir, real_segment_spawn())
{
Ok(()) => {
let mut body = timelapse_status_json(st);
if let Some(o) = body.as_object_mut() {
o.insert("skipped".to_string(), json!(skipped));
}
Json(body).into_response()
}
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
async fn timelapse_start(
State(st): State<AppState>,
Json(b): Json<TimelapseStartBody>,
) -> Response {
let mode = b.mode.as_deref().unwrap_or("smooth");
let interval_ms = b.interval_ms.unwrap_or(3000);
let window_ms = b.window_ms.unwrap_or(120_000);
let burst_offsets = b
.burst_offsets_ms
.clone()
.unwrap_or_else(|| DEFAULT_SMOOTH_BURST_MS.to_vec());
match mode {
"smooth" => {
if b.every < 1 {
return bad_request("every must be >= 1".to_string());
}
if burst_offsets.is_empty() {
return bad_request("burst_offsets_ms must have at least one offset".to_string());
}
if burst_offsets.len() > 16 {
return bad_request("burst_offsets_ms: at most 16 offsets".to_string());
}
if let Some(&o) = burst_offsets.iter().find(|&&o| o > 10_000) {
return bad_request(format!("burst_offsets_ms: {o} ms exceeds the 10000 ms cap"));
}
}
"plain" => {
if interval_ms < 100 {
return bad_request("interval_ms must be >= 100".to_string());
}
}
"park" => {}
"segment" => {
if !(5_000..=600_000).contains(&window_ms) {
return bad_request("window_ms must be between 5000 and 600000".to_string());
}
}
other => {
return bad_request(format!(
"unknown mode {other:?} (use smooth, plain, park, or segment)"
));
}
}
let mut ids: Vec<String> = if !b.cameras.is_empty() {
b.cameras.clone()
} else {
b.camera.clone().into_iter().collect()
};
ids.dedup();
if ids.is_empty() {
return bad_request("specify a camera or cameras to capture".to_string());
}
if mode == "park" {
let out_dir = run_out_dir(&st, mode);
let rx = st.source.subscribe();
return start_park_run(&st, &ids, out_dir, rx);
}
if mode == "segment" {
let out_dir = run_out_dir(&st, mode);
let rx = st.source.subscribe();
return start_segment_run(&st, &ids, window_ms, out_dir, rx);
}
let mut grabs = Vec::with_capacity(ids.len());
for id in &ids {
let Some(resolved) = resolve_grab(&st, id) else {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("unknown or unconfigured camera: {id}") })),
)
.into_response();
};
grabs.push(resolved);
}
let out_dir = run_out_dir(&st, mode);
let rx = st.source.subscribe();
let res = match mode {
"plain" => {
let externals = st.external_cameras.read().unwrap();
let caps: Vec<PlainCapture> = ids
.iter()
.zip(grabs)
.map(
|(id, (gid, grab))| match resolve_stream_url(id, &externals) {
Some(url) => PlainCapture::Stream {
id: gid,
open: url_stream_opener(url),
},
None => PlainCapture::Sample { id: gid, grab },
},
)
.collect();
drop(externals);
st.timelapse.start_plain(caps, interval_ms, rx, out_dir)
}
_ => {
let externals = st.external_cameras.read().unwrap();
let selects: Vec<Option<crate::core::park::SelectTuning>> = ids
.iter()
.map(|id| {
id.strip_prefix("ext-")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|i| externals.get(i))
.and_then(|c| c.select_tuning)
})
.collect();
drop(externals);
st.timelapse.start_smooth_with_select(
grabs,
b.every,
burst_offsets,
rx,
out_dir,
selects,
)
}
};
match res {
Ok(()) => Json(timelapse_status_json(&st)).into_response(),
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
async fn timelapse_stop(
State(st): State<AppState>,
body: Option<Json<TimelapseStopBody>>,
) -> Response {
let mode = body
.and_then(|b| b.0.mode)
.unwrap_or_else(|| "all".to_string());
match mode.as_str() {
"smooth" => {
st.timelapse.stop_smooth();
}
"plain" => {
st.timelapse.stop_plain();
}
"park" => {
st.timelapse.stop_park();
}
"segment" => {
st.timelapse.stop_segment();
}
"all" => {
st.timelapse.stop_smooth();
st.timelapse.stop_plain();
st.timelapse.stop_park();
st.timelapse.stop_segment();
}
other => {
return bad_request(format!(
"unknown mode {other:?} (use smooth, plain, park, segment, or all)"
));
}
}
Json(timelapse_status_json(&st)).into_response()
}
async fn timelapse_status(State(st): State<AppState>) -> Json<serde_json::Value> {
Json(timelapse_status_json(&st))
}
#[derive(Deserialize)]
struct UploadQuery {
dir: Option<String>,
name: String,
}
async fn upload_file(
State(st): State<AppState>,
Query(q): Query<UploadQuery>,
body: Body,
) -> Response {
if q.name.is_empty() || q.name.contains('/') || q.name.contains('\\') || q.name.contains("..") {
return bad_request(format!("invalid filename {:?}", q.name));
}
let dir = q.dir.unwrap_or_else(|| "/".to_string());
if dir != "/" && !is_safe_remote_path(&dir) {
return bad_request(format!("invalid dir {dir:?}"));
}
let remote = format!("{}/{}", dir.trim_end_matches('/'), q.name);
let tmp = match tempfile::Builder::new().prefix("bambu-upload-").tempfile() {
Ok(t) => t,
Err(e) => return server_error(e.to_string()),
};
{
let mut file = match tokio::fs::File::create(tmp.path()).await {
Ok(f) => f,
Err(e) => return server_error(e.to_string()),
};
let mut stream = body.into_data_stream();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(_) => return bad_request("upload stream error".to_string()),
};
if file.write_all(&chunk).await.is_err() {
return server_error("writing upload".to_string());
}
}
if file.flush().await.is_err() {
return server_error("flushing upload".to_string());
}
}
let name = q.name.clone();
let path = tmp.path().to_path_buf();
let files = st.files.clone();
let res = tokio::task::spawn_blocking(move || files.upload(&remote, &path)).await;
drop(tmp); match res {
Ok(Ok(())) => Json(json!({ "uploaded": name })).into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
Err(_) => server_error("upload task failed".to_string()),
}
}
fn server_error(msg: String) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": msg })),
)
.into_response()
}
const MAX_UPLOAD_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Deserialize)]
struct UploadStartQuery {
name: String,
dir: Option<String>,
#[serde(default = "default_plate")]
plate: u32,
#[serde(default)]
timelapse: bool,
bed_type: Option<String>,
#[serde(default)]
confirm: bool,
#[serde(default)]
dry_run: bool,
#[serde(default)]
overwrite: bool,
}
async fn job_upload_start(
State(st): State<AppState>,
Query(q): Query<UploadStartQuery>,
body: Body,
) -> Response {
if q.name.is_empty() || q.name.contains('/') || q.name.contains('\\') || q.name.contains("..") {
return bad_request(format!("invalid filename {:?}", q.name));
}
let dir = q.dir.clone().unwrap_or_else(|| "/".to_string());
if dir != "/" && !is_safe_remote_path(&dir) {
return bad_request(format!("invalid dir {dir:?}"));
}
let remote = format!("{}/{}", dir.trim_end_matches('/'), q.name);
let is_3mf = q.name.to_ascii_lowercase().ends_with(".3mf");
if !q.confirm && !q.dry_run {
return (
StatusCode::PRECONDITION_REQUIRED,
Json(
json!({ "error": "confirm required: add &confirm=true (try &dry_run=true first)" }),
),
)
.into_response();
}
let tmp = match tempfile::Builder::new().prefix("bambu-upload-").tempfile() {
Ok(t) => t,
Err(e) => return server_error(e.to_string()),
};
{
let mut file = match tokio::fs::File::create(tmp.path()).await {
Ok(f) => f,
Err(e) => return server_error(e.to_string()),
};
let mut stream = body.into_data_stream();
let mut written: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(_) => return bad_request("upload stream error".to_string()),
};
written += chunk.len() as u64;
if written > MAX_UPLOAD_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({ "error": "upload exceeds the 512 MiB limit" })),
)
.into_response();
}
if file.write_all(&chunk).await.is_err() {
return server_error("writing upload".to_string());
}
}
if file.flush().await.is_err() {
return server_error("flushing upload".to_string());
}
}
let inspection = if is_3mf {
match std::fs::read(tmp.path())
.map_err(|e| e.to_string())
.and_then(|b| {
crate::core::project::inspect_plate(&b, q.plate).map_err(|e| e.to_string())
}) {
Ok(insp) => Some(insp),
Err(e) => return bad_request(format!("3mf inspection: {e}")),
}
} else {
None
};
let bed_type = q.bed_type.clone().unwrap_or_else(|| "auto".to_string());
let md5 = inspection.as_ref().map(|i| i.gcode_md5.clone());
if q.dry_run {
return Json(json!({ "plan": {
"file": remote,
"plate": q.plate,
"use_ams": false,
"bed_type": bed_type,
"timelapse": q.timelapse,
"md5": md5,
"has_timelapse_blocks": inspection.as_ref().map(|i| i.has_timelapse_blocks),
"overwrite": q.overwrite,
}}))
.into_response();
}
let Ok(_guard) = st.start_lock.try_lock() else {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "a print start is already in progress" })),
)
.into_response();
};
if let Some(busy) = require_idle(&st) {
return busy;
}
if !q.overwrite {
let files = st.files.clone();
let dir_for_check = dir.clone();
let name = q.name.clone();
if let Ok(Ok(entries)) =
tokio::task::spawn_blocking(move || files.list(&dir_for_check)).await
&& entries.iter().any(|e| e.name == name)
{
return (
StatusCode::CONFLICT,
Json(json!({ "error": format!("{remote} already exists (add &overwrite=true to replace it)") })),
)
.into_response();
}
}
let files = st.files.clone();
let path = tmp.path().to_path_buf();
let remote_for_upload = remote.clone();
let up = tokio::task::spawn_blocking(move || files.upload(&remote_for_upload, &path)).await;
drop(tmp); match up {
Ok(Ok(())) => {}
Ok(Err(e)) => {
return (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response();
}
Err(_) => return server_error("upload task failed".to_string()),
}
let req = StartRequest {
file: remote,
plate: q.plate,
use_ams: false,
ams_map: Vec::new(),
bed_type,
timelapse: q.timelapse,
inspection,
};
let starter = st.starter.clone();
let res = tokio::task::spawn_blocking(move || starter.start(&req)).await;
verify_response(res)
}
async fn status_ws(State(st): State<AppState>, ws: WebSocketUpgrade) -> Response {
eprintln!("ws: client upgrade accepted");
ws.on_upgrade(move |socket| async move {
stream_status(socket, st.source.clone()).await;
eprintln!("ws: client disconnected");
})
}
async fn stream_status(mut socket: WebSocket, source: Arc<dyn PrinterSource>) {
let mut rx = source.subscribe();
loop {
let snapshot = rx.borrow_and_update().clone();
let Ok(json) = serde_json::to_string(&snapshot) else {
break;
};
if socket.send(Message::Text(json.into())).await.is_err() {
break; }
if rx.changed().await.is_err() {
break; }
}
}
async fn require_password(State(st): State<AppState>, req: Request, next: Next) -> Response {
let Some(pw) = st.password.as_deref() else {
return next.run(req).await; };
let given = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split_once(' '))
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
.map(|(_, tok)| tok.trim());
if given.is_some_and(|tok| constant_time_eq(tok.as_bytes(), pw.as_bytes())) {
next.run(req).await
} else {
eprintln!("auth: rejected write {} {}", req.method(), req.uri().path());
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "password required" })),
)
.into_response()
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::VerifyStage;
use axum_test::TestServer;
fn app(password: Option<&str>, controller: impl Controller + 'static) -> TestServer {
let state = AppState {
source: Arc::new(FakeSource::idle()),
controller: Arc::new(controller),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: password.map(str::to_owned),
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
TestServer::new(router(state))
}
#[tokio::test]
async fn status_is_open_and_returns_printer_status_json() {
let res = app(None, FakeController::verified())
.get("/api/status")
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["gcode_state"], "IDLE");
assert_eq!(body["print_error"], 0);
}
#[tokio::test]
async fn status_is_open_even_when_a_password_is_set() {
app(Some("secret"), FakeController::verified())
.get("/api/status")
.await
.assert_status_ok();
}
#[tokio::test]
async fn job_stop_needs_confirmation() {
app(None, FakeController::verified())
.post("/api/job/stop")
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn job_pause_confirmed_returns_verified() {
let res = app(None, FakeController::verified())
.post("/api/job/pause")
.json(&json!({ "confirm": true }))
.await;
res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["outcome"], "verified");
}
#[tokio::test]
async fn job_clear_error_needs_confirmation() {
app(None, FakeController::verified())
.post("/api/job/clear-error")
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn job_clear_error_confirmed_returns_verified() {
let res = app(None, FakeController::verified())
.post("/api/job/clear-error")
.json(&json!({ "confirm": true }))
.await;
res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["outcome"], "verified");
}
#[tokio::test]
async fn upload_start_needs_confirmation() {
app(None, FakeController::verified())
.post("/api/job/upload-start?name=x.gcode")
.bytes(b"G28\n".to_vec().into())
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn upload_start_confirmed_uploads_then_starts() {
let res = app(None, FakeController::verified())
.post("/api/job/upload-start?name=x.gcode&confirm=true")
.bytes(b"G28\n".to_vec().into())
.await;
res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["outcome"], "verified");
}
#[tokio::test]
async fn upload_start_dry_run_plans_without_starting() {
let res = app(None, FakeController::verified())
.post("/api/job/upload-start?name=x.gcode&dry_run=true")
.bytes(b"G28\n".to_vec().into())
.await;
res.assert_status_ok();
let v = res.json::<serde_json::Value>();
assert_eq!(v["plan"]["file"], "/x.gcode");
}
#[tokio::test]
async fn upload_start_rejects_a_traversal_name() {
app(None, FakeController::verified())
.post("/api/job/upload-start?name=../evil.gcode&confirm=true")
.bytes(b"x".to_vec().into())
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn upload_start_is_gated_by_password() {
app(Some("hunter2"), FakeController::verified())
.post("/api/job/upload-start?name=x.gcode&confirm=true")
.bytes(b"G28\n".to_vec().into())
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejected_outcome_maps_to_409() {
let c = FakeController::returning(CommandOutcome::Rejected {
reason: "busy".into(),
});
app(None, c)
.post("/api/job/stop")
.json(&json!({ "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn unverified_outcome_maps_to_202() {
let c = FakeController::returning(CommandOutcome::Unverified {
stage: VerifyStage::Effect,
});
app(None, c)
.post("/api/light")
.json(&json!({ "node": "chamber", "on": true }))
.await
.assert_status(StatusCode::ACCEPTED);
}
#[tokio::test]
async fn transport_failure_maps_to_502() {
app(None, FakeController::failing())
.post("/api/light")
.json(&json!({ "node": "chamber", "on": false }))
.await
.assert_status(StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn unknown_light_node_is_400() {
app(None, FakeController::verified())
.post("/api/light")
.json(&json!({ "node": "kitchen", "on": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn speed_level_sets_ok() {
app(None, FakeController::verified())
.post("/api/speed")
.json(&json!({ "level": "standard" }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn write_without_password_is_401_when_one_is_set() {
app(Some("secret"), FakeController::verified())
.post("/api/light")
.json(&json!({ "node": "chamber", "on": true }))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn write_with_correct_password_is_allowed() {
app(Some("secret"), FakeController::verified())
.post("/api/light")
.authorization_bearer("secret")
.json(&json!({ "node": "chamber", "on": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn gcode_needs_confirmation() {
app(None, FakeController::verified())
.post("/api/gcode")
.json(&json!({ "line": "G28" }))
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn gcode_safe_line_runs() {
app(None, FakeController::verified())
.post("/api/gcode")
.json(&json!({ "line": "G28", "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn gcode_unsafe_line_is_blocked_unless_forced() {
let s = app(None, FakeController::verified());
s.post("/api/gcode")
.json(&json!({ "line": "M104 S999", "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
s.post("/api/gcode")
.json(&json!({ "line": "M104 S999", "confirm": true, "force": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn list_files_is_open() {
let res = app(Some("secret"), FakeController::verified())
.get("/api/file")
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
let files = body["files"].as_array().unwrap();
assert!(
files
.iter()
.any(|f| f["name"] == "coin2c.gcode.3mf" && f["is_dir"] == false)
);
assert!(
files
.iter()
.any(|f| f["name"] == "cache" && f["is_dir"] == true)
);
}
#[tokio::test]
async fn thumbnail_returns_png() {
let res = app(None, FakeController::verified())
.get("/api/file/thumbnail?name=coin2c.gcode.3mf")
.await;
res.assert_status_ok();
assert_eq!(res.header("content-type"), "image/png");
}
#[tokio::test]
async fn raw_serves_3mf_bytes() {
let res = app(None, FakeController::verified())
.get("/api/file/raw?name=/cache/coin.gcode.3mf")
.await;
res.assert_status_ok();
assert_eq!(res.header("content-type"), "application/octet-stream");
}
#[tokio::test]
async fn raw_rejects_other_extensions() {
app(None, FakeController::verified())
.get("/api/file/raw?name=/secret.txt")
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn gcode_file_serves_plate_toolpath() {
let res = app(None, FakeController::verified())
.get("/api/file/gcode?name=/coin2c.gcode.3mf&plate=1")
.await;
res.assert_status_ok();
assert!(
res.header("content-type")
.to_str()
.unwrap()
.starts_with("text/plain")
);
assert!(res.text().contains("G1"));
}
#[tokio::test]
async fn gcode_file_rejects_non_3mf() {
app(None, FakeController::verified())
.get("/api/file/gcode?name=/raw.gcode")
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn mesh_file_serves_object_models() {
let res = app(None, FakeController::verified())
.get("/api/file/mesh?name=/coin2c.gcode.3mf")
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
let models = body["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
assert!(models[0].as_str().unwrap().contains("<triangle "));
}
#[tokio::test]
async fn mesh_file_rejects_non_3mf() {
app(None, FakeController::verified())
.get("/api/file/mesh?name=/raw.gcode")
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn cameras_list_is_empty_without_built_in_or_external() {
let res = app(None, FakeController::verified())
.get("/api/camera")
.await;
res.assert_status_ok();
assert_eq!(
res.json::<serde_json::Value>()["cameras"]
.as_array()
.unwrap()
.len(),
0
);
}
#[tokio::test]
async fn camera_snapshot_is_404_for_unknown_id() {
let server = app(None, FakeController::verified());
for id in ["internal", "ext-0", "bogus"] {
server
.get(&format!("/api/camera/{id}/snapshot"))
.await
.assert_status(StatusCode::NOT_FOUND);
}
}
#[tokio::test]
async fn external_cameras_can_be_set_then_listed_and_cleared() {
let server = app(None, FakeController::verified());
let res = server
.post("/api/camera/config")
.json(&json!({
"external": [
{ "label": "front", "url": "http://cam.local/a.jpg" },
{ "url": "http://cam.local/b.jpg" }
]
}))
.await;
res.assert_status_ok();
let list = server.get("/api/camera").await.json::<serde_json::Value>();
let cams = list["cameras"].as_array().unwrap();
assert_eq!(cams.len(), 2);
assert_eq!(cams[0]["id"], "ext-0");
assert_eq!(cams[0]["label"], "front");
assert_eq!(cams[0]["kind"], "external");
assert_eq!(cams[1]["label"], "external 2"); assert!(cams[0].get("url").is_none()); let cfg = server
.get("/api/camera/config")
.await
.json::<serde_json::Value>();
assert_eq!(cfg["external"][0]["url"], "http://cam.local/a.jpg");
server
.post("/api/camera/config")
.json(&json!({ "external": [] }))
.await
.assert_status_ok();
let list = server.get("/api/camera").await.json::<serde_json::Value>();
assert_eq!(list["cameras"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn camera_config_rejects_non_http_url() {
let server = app(None, FakeController::verified());
server
.post("/api/camera/config")
.json(&json!({ "external": [{ "url": "file:///etc/passwd" }] }))
.await
.assert_status(StatusCode::BAD_REQUEST);
server
.post("/api/camera/config")
.json(&json!({ "external": [{ "url": "https://cam.local/a.jpg" }] }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn external_camera_stream_url_round_trips_and_flags_the_list() {
let server = app(None, FakeController::verified());
server
.post("/api/camera/config")
.json(&json!({
"external": [
{ "label": "front", "url": "http://cam.local/snapshot",
"stream_url": "http://cam.local/stream" },
{ "url": "http://cam.local/b.jpg" }
]
}))
.await
.assert_status_ok();
let list = server.get("/api/camera").await.json::<serde_json::Value>();
let cams = list["cameras"].as_array().unwrap();
assert_eq!(cams[0]["stream"], true);
assert_eq!(cams[1]["stream"], false);
assert!(cams[0].get("url").is_none());
let cfg = server
.get("/api/camera/config")
.await
.json::<serde_json::Value>();
assert_eq!(cfg["external"][0]["stream_url"], "http://cam.local/stream");
assert!(cfg["external"][1]["stream_url"].is_null());
}
#[tokio::test]
async fn park_tuning_round_trips_and_flags_capability() {
let server = app(None, FakeController::verified());
let tuning = json!({ "fps": 4, "left_frac": 0.33, "ema_seconds": 30, "abs_floor": 1500,
"mad_k": 6, "merge_gap_s": 1.2, "max_island_s": 3, "min_sep_s": 3,
"candidate_frac": 0.75, "warmup_s": 4, "baseline_s": 90 });
server
.post("/api/camera/config")
.json(&json!({ "external": [
{ "label": "front", "url": "http://cam.local/snap",
"stream_url": "http://cam.local/stream", "park_tuning": tuning },
{ "url": "http://cam.local/b.jpg", "stream_url": "http://cam.local/bstream" },
]}))
.await
.assert_status_ok();
let list = server.get("/api/camera").await.json::<serde_json::Value>();
let cams = list["cameras"].as_array().unwrap();
assert_eq!(cams[0]["park"], true);
assert_eq!(
cams[1]["park"], false,
"stream but no tuning → not park-capable"
);
let cfg = server
.get("/api/camera/config")
.await
.json::<serde_json::Value>();
assert!(cfg["external"][0]["park_tuning"].is_object());
assert_eq!(cfg["external"][0]["park_tuning"]["fps"], json!(4.0));
assert!(cfg["external"][1]["park_tuning"].is_null());
}
#[tokio::test]
async fn select_tuning_round_trips_and_flags_segment_capability() {
let server = app(None, FakeController::verified());
let full = json!({ "fps": 15, "left_frac": 0.33, "ema_seconds": 30, "abs_floor": 150,
"mad_k": 3, "merge_gap_s": 1.2, "max_island_s": 3, "min_sep_s": 3,
"candidate_frac": 0.75, "warmup_s": 4, "baseline_s": 90,
"min_outlier": 2.5, "min_left_density": 3.0, "min_confidence": 0.4,
"select_candidate_frac": 0.6 });
let park_only = json!({ "fps": 4, "left_frac": 0.33, "ema_seconds": 30, "abs_floor": 1500,
"mad_k": 6, "merge_gap_s": 1.2, "max_island_s": 3, "min_sep_s": 3,
"candidate_frac": 0.75, "warmup_s": 4, "baseline_s": 90 });
server
.post("/api/camera/config")
.json(&json!({ "external": [
{ "url": "http://cam.local/s", "stream_url": "http://cam.local/stream", "park_tuning": full },
{ "url": "http://cam.local/b", "stream_url": "http://cam.local/bstream", "park_tuning": park_only },
]}))
.await
.assert_status_ok();
let list = server.get("/api/camera").await.json::<serde_json::Value>();
let cams = list["cameras"].as_array().unwrap();
assert_eq!(
cams[0]["segment"], true,
"stream + park + select → segment-capable"
);
assert_eq!(cams[0]["park"], true);
assert_eq!(
cams[1]["segment"], false,
"no select knobs → not segment-capable"
);
assert_eq!(cams[1]["park"], true, "still park-capable");
let cfg = server
.get("/api/camera/config")
.await
.json::<serde_json::Value>();
assert_eq!(cfg["external"][0]["park_tuning"]["min_outlier"], json!(2.5));
assert_eq!(cfg["external"][0]["park_tuning"]["fps"], json!(15.0));
assert!(
cfg["external"][1]["park_tuning"]
.get("min_outlier")
.is_none(),
"park-only camera's echo carries no select knobs"
);
}
#[tokio::test]
async fn camera_config_rejects_a_partial_park_tuning() {
let server = app(None, FakeController::verified());
let res = server
.post("/api/camera/config")
.json(&json!({ "external": [
{ "url": "http://cam.local/a.jpg", "stream_url": "http://cam.local/s",
"park_tuning": { "fps": 4, "left_frac": 0.33 } },
]}))
.await;
assert!(
!res.status_code().is_success(),
"partial tuning must be rejected"
);
}
#[tokio::test]
async fn timelapse_start_park_rejects_without_a_capable_camera() {
let server = app(None, FakeController::verified());
server
.post("/api/camera/config")
.json(&json!({ "external": [ { "url": "http://cam.local/a.jpg" } ] }))
.await
.assert_status_ok();
server
.post("/api/timelapse/start")
.json(&json!({ "mode": "park", "camera": "ext-0" }))
.await
.assert_status_bad_request();
}
#[tokio::test]
async fn timelapse_start_segment_rejects_without_a_capable_camera() {
let server = app(None, FakeController::verified());
server
.post("/api/camera/config")
.json(&json!({ "external": [
{ "url": "http://cam.local/a.jpg", "stream_url": "http://cam.local/s" },
]}))
.await
.assert_status_ok();
server
.post("/api/timelapse/start")
.json(&json!({ "mode": "segment", "camera": "ext-0" }))
.await
.assert_status_bad_request();
}
#[test]
fn run_dir_epoch_orders_runs_by_recency() {
assert_eq!(
super::run_dir_epoch("captures/1718900000_benchy_segment"),
1718900000
);
assert!(
super::run_dir_epoch("captures/1718900500_x_park")
> super::run_dir_epoch("captures/1718900000_x_segment"),
"a later epoch is more recent regardless of slot"
);
assert_eq!(
super::run_dir_epoch("captures/not-a-run"),
0,
"unparseable sorts oldest"
);
}
#[tokio::test]
async fn timelapse_start_segment_rejects_a_bad_window() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "mode": "segment", "camera": "ext-0", "window_ms": 50 }))
.await
.assert_status_bad_request();
}
#[test]
fn parse_parks_index_dedupes_by_n_keeps_the_replace_and_sorts() {
let jsonl = concat!(
"{\"n\":1,\"idx\":20,\"t\":5.0,\"confidence\":0.70,\"replace\":false}\n",
"{\"n\":0,\"idx\":10,\"t\":2.5,\"confidence\":0.80,\"replace\":false}\n",
"{\"n\":1,\"idx\":22,\"t\":5.6,\"confidence\":0.95,\"replace\":true}\n",
"not json\n",
"\n",
);
let idx = parse_parks_index(jsonl);
assert_eq!(idx.len(), 2, "two distinct frames: {idx:?}");
assert_eq!(idx[0]["n"], 0, "sorted by n");
assert_eq!(idx[1]["n"], 1);
assert_eq!(
idx[1]["confidence"], 0.95,
"the replace's stronger metadata wins"
);
assert_eq!(idx[1]["t"], 5.6);
}
fn app_with_timelapse(
controller: impl Controller + 'static,
) -> (TestServer, Arc<TimelapseManager>) {
let tl: Arc<TimelapseManager> = Default::default();
let state = AppState {
source: Arc::new(FakeSource::idle()),
controller: Arc::new(controller),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: tl.clone(),
};
(TestServer::new(router(state)), tl)
}
fn test_tuning() -> ParkTuning {
ParkTuning {
fps: 4.0,
left_frac: 0.33,
ema_seconds: 6.0,
abs_floor: 1500.0,
mad_k: 6.0,
merge_gap_s: 1.2,
max_island_s: 3.0,
min_sep_s: 3.0,
candidate_frac: 0.75,
warmup_s: 0.5,
baseline_s: 20.0,
}
}
fn install_park_run(
tl: &Arc<TimelapseManager>,
out: &std::path::Path,
id: &str,
) -> watch::Sender<PrinterStatus> {
let (tx, rx) = watch::channel(PrinterStatus::default());
let noop: crate::server::timelapse::ParkSpawn =
Arc::new(|_, _, _, _| tokio::task::spawn_blocking(|| {}));
tl.start_park(
vec![ParkCapture {
id: id.to_string(),
stream_url: "http://cam/stream".into(),
tuning: test_tuning(),
}],
rx,
out.to_path_buf(),
noop,
)
.unwrap();
tx
}
#[tokio::test]
async fn parks_index_and_indexed_frames_serve_during_and_after_a_run() {
let dir = std::env::temp_dir().join(format!("bambu-api-parks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let cam = dir.join("ext-0");
std::fs::create_dir_all(&cam).unwrap();
let (server, tl) = app_with_timelapse(FakeController::verified());
let _tx = install_park_run(&tl, &dir, "ext-0");
std::fs::write(cam.join("park_000000.jpg"), b"FRAME0").unwrap();
std::fs::write(cam.join("park_000001.jpg"), b"FRAME1").unwrap();
std::fs::write(
cam.join("parks.jsonl"),
"{\"n\":0,\"t\":1.0,\"confidence\":0.8}\n{\"n\":1,\"t\":2.0,\"confidence\":0.9}\n",
)
.unwrap();
let res = server.get("/api/camera/ext-0/park").await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["count"], 2);
assert_eq!(body["parks"][0]["n"], 0);
assert_eq!(body["parks"][1]["n"], 1);
let f1 = server.get("/api/camera/ext-0/park/1").await;
f1.assert_status_ok();
assert_eq!(f1.as_bytes().as_ref(), b"FRAME1");
server
.get("/api/camera/ext-0/park/9")
.await
.assert_status_not_found();
server
.get("/api/camera/ext-1/park")
.await
.assert_status_not_found();
server
.get("/api/camera/ext-1/park/0")
.await
.assert_status_not_found();
tl.stop_park();
let after = server.get("/api/camera/ext-0/park").await;
after.assert_status_ok();
assert_eq!(after.json::<serde_json::Value>()["count"], 2);
server
.get("/api/camera/ext-0/park/0")
.await
.assert_status_ok();
let _ = std::fs::remove_dir_all(&dir);
}
struct OneFile(Vec<u8>);
impl crate::server::files::FileStore for OneFile {
fn list(&self, _: &str) -> Result<Vec<crate::ftp::FileEntry>, String> {
Ok(vec![])
}
fn upload(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
Ok(())
}
fn thumbnail(&self, _: &str, _: u32) -> Result<Option<Vec<u8>>, String> {
Ok(None)
}
fn fetch(&self, _: &str) -> Result<Vec<u8>, String> {
Ok(self.0.clone())
}
fn gcode(&self, _: &str, _: u32) -> Result<Option<Vec<u8>>, String> {
Ok(None)
}
fn models(&self, _: &str) -> Result<Vec<String>, String> {
Ok(vec![])
}
}
fn three_mf_with_timelapse(markers: usize) -> Vec<u8> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut gcode = String::from("; time_lapse_gcode = ;SKIPTYPE: timelapse template\n");
for i in 0..markers {
gcode.push_str(&format!("G1 Z{i}\n; SKIPTYPE: timelapse\nM1004 S5 P1\n"));
}
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
zip.start_file("Metadata/plate_1.gcode", SimpleFileOptions::default())
.unwrap();
zip.write_all(gcode.as_bytes()).unwrap();
zip.finish().unwrap();
}
buf
}
#[tokio::test]
async fn file_inspect_reports_timelapse_capability_open() {
let state = AppState {
source: Arc::new(FakeSource::idle()),
controller: Arc::new(FakeController::verified()),
files: Arc::new(OneFile(three_mf_with_timelapse(3))),
starter: Arc::new(FakeStarter),
password: Some("secret".to_string()),
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
let server = TestServer::new(router(state));
let res = server
.get("/api/file/inspect?name=/cube.gcode.3mf&plate=1")
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["inspected"], true);
assert_eq!(body["has_timelapse_blocks"], true);
}
#[tokio::test]
async fn file_inspect_degrades_to_not_inspected() {
let res = app(None, FakeController::verified())
.get("/api/file/inspect?name=/x.gcode.3mf&plate=1")
.await;
res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["inspected"], false);
let res2 = app(None, FakeController::verified())
.get("/api/file/inspect?name=/notes.txt")
.await;
res2.assert_status_ok();
assert_eq!(res2.json::<serde_json::Value>()["inspected"], false);
}
#[tokio::test]
async fn job_start_dry_run_reports_timelapse_block_capability() {
let state = AppState {
source: Arc::new(FakeSource::idle()),
controller: Arc::new(FakeController::verified()),
files: Arc::new(OneFile(three_mf_with_timelapse(3))),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
let server = TestServer::new(router(state));
let res = server
.post("/api/job/start")
.json(&json!({ "file": "/cube.gcode.3mf", "plate": 1, "dry_run": true, "timelapse": true }))
.await;
res.assert_status_ok();
assert_eq!(
res.json::<serde_json::Value>()["plan"]["has_timelapse_blocks"],
true
);
}
#[tokio::test]
async fn job_start_dry_run_timelapse_blocks_null_when_uninspectable() {
let res = app(None, FakeController::verified())
.post("/api/job/start")
.json(&json!({ "file": "/x.gcode.3mf", "plate": 1, "dry_run": true }))
.await;
res.assert_status_ok();
assert!(
res.json::<serde_json::Value>()["plan"]["has_timelapse_blocks"].is_null(),
"uninspectable file → unknown (null), gracefully"
);
}
#[tokio::test]
async fn captures_list_is_open_and_returns_an_array() {
let res = app(Some("secret"), FakeController::verified())
.get("/api/capture")
.await;
res.assert_status_ok();
assert!(res.json::<serde_json::Value>()["captures"].is_array());
}
#[tokio::test]
async fn unknown_api_path_404s_as_json() {
let server = app(None, FakeController::verified());
let res = server.get("/api/nope").await;
res.assert_status_not_found();
assert!(res.json::<serde_json::Value>()["error"].is_string());
server
.get("/api/camera/x/bogus")
.await
.assert_status_not_found();
server.get("/api/status").await.assert_status_ok();
}
#[test]
fn is_safe_segment_blocks_traversal() {
assert!(is_safe_segment("1781634785_cube_smooth"));
assert!(is_safe_segment("ext-0"));
assert!(is_safe_segment("default"));
assert!(!is_safe_segment(""));
assert!(!is_safe_segment(".."));
assert!(!is_safe_segment(".hidden"));
assert!(!is_safe_segment("a/b"));
assert!(!is_safe_segment("a\\b"));
}
#[tokio::test]
async fn capture_video_rejects_unsafe_and_unknown() {
let server = app(None, FakeController::verified());
server
.get("/api/capture/.evil/cam/video.mp4")
.await
.assert_status_bad_request();
server
.get("/api/capture/no_such_run_zzz/cam/video.mp4")
.await
.assert_status_not_found();
}
#[tokio::test]
async fn capture_thumb_rejects_unsafe_and_unknown() {
let server = app(None, FakeController::verified());
server
.get("/api/capture/.evil/cam/thumb.jpg")
.await
.assert_status_bad_request();
server
.get("/api/capture/no_such_run_zzz/cam/thumb.jpg")
.await
.assert_status_not_found();
}
#[tokio::test]
async fn parks_index_and_frame_are_404_without_a_run() {
let server = app(None, FakeController::verified());
server
.get("/api/camera/ext-0/park")
.await
.assert_status_not_found();
server
.get("/api/camera/ext-0/park/0")
.await
.assert_status_not_found();
}
#[tokio::test]
async fn camera_config_rejects_non_http_stream_url() {
let server = app(None, FakeController::verified());
server
.post("/api/camera/config")
.json(&json!({
"external": [
{ "url": "http://cam.local/a.jpg", "stream_url": "file:///etc/passwd" }
]
}))
.await
.assert_status(StatusCode::BAD_REQUEST);
server
.post("/api/camera/config")
.json(&json!({
"external": [
{ "url": "http://cam.local/a.jpg", "stream_url": "https://cam.local/stream" }
]
}))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[test]
fn resolve_stream_url_only_for_ext_with_a_stream() {
use super::{ExternalCamera, resolve_stream_url};
let cams = vec![
ExternalCamera::new(
Some("a".into()),
"http://x/snap".into(),
Some("http://x/stream".into()),
0,
),
ExternalCamera::new(None, "http://y/snap".into(), None, 1),
];
assert_eq!(
resolve_stream_url("ext-0", &cams).as_deref(),
Some("http://x/stream")
);
assert_eq!(resolve_stream_url("ext-1", &cams), None); assert_eq!(resolve_stream_url("ext-9", &cams), None); assert_eq!(resolve_stream_url("internal", &cams), None);
assert_eq!(resolve_stream_url("bogus", &cams), None);
}
#[tokio::test]
async fn camera_stream_relays_the_upstream_multipart_body() {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let upstream = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf); let mut body = Vec::new();
body.extend_from_slice(b"--FRAME\r\nContent-Type: image/jpeg\r\n\r\n");
body.extend_from_slice(&[0xff, 0xd8, 0xff, b'D', b'A', b'T', b'A']);
body.extend_from_slice(b"\r\n--FRAME--\r\n");
let head = "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; \
boundary=FRAME\r\nConnection: close\r\n\r\n";
let _ = sock.write_all(head.as_bytes());
let _ = sock.write_all(&body);
}
});
let server = app(None, FakeController::verified());
server
.post("/api/camera/config")
.json(&json!({ "external": [
{ "url": format!("http://{addr}/snap"),
"stream_url": format!("http://{addr}/stream") }
] }))
.await
.assert_status_ok();
let res = server.get("/api/camera/ext-0/stream").await;
res.assert_status_ok();
assert!(
res.header("content-type")
.to_str()
.unwrap()
.starts_with("multipart/x-mixed-replace")
);
let bytes = res.as_bytes();
assert!(bytes.windows(7).any(|w| w == b"--FRAME"));
assert!(bytes.windows(4).any(|w| w == b"DATA"));
upstream.join().unwrap();
}
#[tokio::test]
async fn timelapse_status_is_open_and_initially_idle() {
let res = app(None, FakeController::verified())
.get("/api/timelapse")
.await;
res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["running"], false);
}
#[tokio::test]
async fn timelapse_start_rejects_unknown_camera() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-9" }))
.await
.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn timelapse_start_rejects_every_zero() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-0", "every": 0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn timelapse_start_rejects_unknown_mode() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-0", "mode": "fancy" }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn timelapse_plain_rejects_too_fast_interval() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-0", "mode": "plain", "interval_ms": 10 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn timelapse_smooth_rejects_an_out_of_range_burst_offset() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-0", "burst_offsets_ms": [800, 99999] }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn timelapse_smooth_rejects_an_empty_burst() {
app(None, FakeController::verified())
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-0", "burst_offsets_ms": [] }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn timelapse_stop_rejects_unknown_mode() {
app(None, FakeController::verified())
.post("/api/timelapse/stop")
.json(&json!({ "mode": "plian" }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn timelapse_stop_without_a_mode_is_ok() {
app(None, FakeController::verified())
.post("/api/timelapse/stop")
.await
.assert_status_ok();
}
#[tokio::test]
async fn timelapse_start_stop_are_gated_by_password() {
let server = app(Some("hunter2"), FakeController::verified());
server
.post("/api/timelapse/start")
.json(&json!({ "camera": "ext-0" }))
.await
.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/api/timelapse/stop")
.await
.assert_status(StatusCode::UNAUTHORIZED);
server.get("/api/timelapse").await.assert_status_ok();
}
#[tokio::test]
async fn camera_config_is_gated_by_password() {
app(Some("hunter2"), FakeController::verified())
.post("/api/camera/config")
.json(&json!({ "external": [{ "url": "http://cam.local/a.jpg" }] }))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn thumbnail_rejects_non_3mf() {
app(None, FakeController::verified())
.get("/api/file/thumbnail?name=/timelapse/video.mp4")
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn start_rejects_relative_path() {
app(None, FakeController::verified())
.post("/api/job/start")
.json(&json!({ "file": "host/evil.3mf", "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn upload_rejects_traversal_dir() {
app(None, FakeController::verified())
.post("/api/file/upload?dir=../etc&name=a.3mf")
.bytes(b"data".to_vec().into())
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn upload_open_when_no_password() {
app(None, FakeController::verified())
.post("/api/file/upload?name=part.gcode.3mf")
.bytes(b"PK\x03\x04 fake 3mf".to_vec().into())
.await
.assert_status_ok();
}
#[tokio::test]
async fn upload_needs_password_when_set() {
app(Some("secret"), FakeController::verified())
.post("/api/file/upload?name=part.gcode.3mf")
.bytes(b"data".to_vec().into())
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn upload_rejects_path_traversal() {
app(None, FakeController::verified())
.post("/api/file/upload?name=../etc/passwd")
.bytes(b"data".to_vec().into())
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn start_dry_run_returns_plan_without_confirm() {
let res = app(None, FakeController::verified())
.post("/api/job/start")
.json(&json!({ "file": "/coin.gcode.3mf", "plate": 2, "dry_run": true }))
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["plan"]["plate"], 2);
assert_eq!(body["plan"]["bed_type"], "auto");
}
#[tokio::test]
async fn start_needs_confirmation() {
app(None, FakeController::verified())
.post("/api/job/start")
.json(&json!({ "file": "/coin.gcode.3mf" }))
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn start_confirmed_on_idle_printer_verifies() {
app(None, FakeController::verified())
.post("/api/job/start")
.json(&json!({ "file": "/coin.gcode.3mf", "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn start_rejects_bad_filetype_and_traversal() {
let s = app(None, FakeController::verified());
s.post("/api/job/start")
.json(&json!({ "file": "/notes.txt", "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
s.post("/api/job/start")
.json(&json!({ "file": "../secret.3mf", "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn start_rejects_out_of_range_ams_map() {
app(None, FakeController::verified())
.post("/api/job/start")
.json(&json!({ "file": "/c.3mf", "confirm": true, "use_ams": true, "ams_map": [0, 9] }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn start_on_busy_printer_is_409() {
let state = AppState {
source: Arc::new(FakeSource::ramping(Duration::from_millis(50))),
controller: Arc::new(FakeController::verified()),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
TestServer::new(router(state))
.post("/api/job/start")
.json(&json!({ "file": "/c.3mf", "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
fn busy_app(controller: impl Controller + 'static) -> TestServer {
let state = AppState {
source: Arc::new(FakeSource::ramping(Duration::from_millis(50))),
controller: Arc::new(controller),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
TestServer::new(router(state))
}
struct HotSource(watch::Sender<PrinterStatus>);
impl HotSource {
fn new() -> Self {
let (tx, _rx) = watch::channel(PrinterStatus {
gcode_state: Some("IDLE".to_string()),
print_error: Some(0),
nozzle_temper: Some(220.0),
..Default::default()
});
Self(tx)
}
}
impl PrinterSource for HotSource {
fn current(&self) -> PrinterStatus {
self.0.borrow().clone()
}
fn subscribe(&self) -> watch::Receiver<PrinterStatus> {
self.0.subscribe()
}
}
fn hot_app(controller: impl Controller + 'static) -> TestServer {
let state = AppState {
source: Arc::new(HotSource::new()),
controller: Arc::new(controller),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
TestServer::new(router(state))
}
#[tokio::test]
async fn home_all_on_idle_runs() {
app(None, FakeController::verified())
.post("/api/home")
.json(&json!({}))
.await
.assert_status_ok();
}
#[tokio::test]
async fn home_does_not_require_confirm() {
app(None, FakeController::verified())
.post("/api/home")
.json(&json!({ "axes": "z" }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn home_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/home")
.json(&json!({ "axes": "all" }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn home_unknown_axes_is_400() {
app(None, FakeController::verified())
.post("/api/home")
.json(&json!({ "axes": "w" }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn move_in_range_on_idle_runs_without_confirm() {
app(None, FakeController::verified())
.post("/api/move")
.json(&json!({ "axis": "x", "delta": 10.0 }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn move_over_bound_is_400() {
app(None, FakeController::verified())
.post("/api/move")
.json(&json!({ "axis": "x", "delta": 999.0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn move_zero_delta_is_400() {
app(None, FakeController::verified())
.post("/api/move")
.json(&json!({ "axis": "y", "delta": 0.0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn move_out_of_range_feedrate_is_400() {
app(None, FakeController::verified())
.post("/api/move")
.json(&json!({ "axis": "x", "delta": 5.0, "feedrate": 1 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn move_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/move")
.json(&json!({ "axis": "x", "delta": 5.0 }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn move_unknown_axis_is_400() {
app(None, FakeController::verified())
.post("/api/move")
.json(&json!({ "axis": "w", "delta": 5.0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn extrude_on_cold_nozzle_is_400() {
app(None, FakeController::verified())
.post("/api/extrude")
.json(&json!({ "delta": 5.0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn extrude_cold_guard_has_no_force_bypass() {
app(None, FakeController::verified())
.post("/api/extrude")
.json(&json!({ "delta": 5.0, "force": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn extrude_on_hot_idle_nozzle_runs() {
hot_app(FakeController::verified())
.post("/api/extrude")
.json(&json!({ "delta": 5.0 }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn extrude_over_bound_is_400() {
hot_app(FakeController::verified())
.post("/api/extrude")
.json(&json!({ "delta": 999.0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn extrude_zero_delta_is_400() {
hot_app(FakeController::verified())
.post("/api/extrude")
.json(&json!({ "delta": 0.0 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn temp_setpoint_needs_confirm() {
app(None, FakeController::verified())
.post("/api/temp")
.json(&json!({ "part": "nozzle", "celsius": 210 }))
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn temp_setpoint_confirmed_runs() {
app(None, FakeController::verified())
.post("/api/temp")
.json(&json!({ "part": "nozzle", "celsius": 210, "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn temp_cooldown_is_allowed_without_confirm() {
busy_app(FakeController::verified())
.post("/api/temp")
.json(&json!({ "part": "nozzle", "celsius": 0 }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn temp_over_limit_is_400_unless_forced() {
let s = app(None, FakeController::verified());
s.post("/api/temp")
.json(&json!({ "part": "nozzle", "celsius": 999, "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
s.post("/api/temp")
.json(&json!({ "part": "nozzle", "celsius": 999, "confirm": true, "force": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn temp_unknown_part_is_400() {
app(None, FakeController::verified())
.post("/api/temp")
.json(&json!({ "part": "chamber", "celsius": 50 }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn temp_is_not_idle_gated_for_a_setpoint() {
busy_app(FakeController::verified())
.post("/api/temp")
.json(&json!({ "part": "bed", "celsius": 60, "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn calibrate_needs_confirm() {
app(None, FakeController::verified())
.post("/api/calibrate")
.json(&json!({ "bed_level": true }))
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn calibrate_with_no_flags_is_400() {
app(None, FakeController::verified())
.post("/api/calibrate")
.json(&json!({ "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn calibrate_confirmed_on_idle_runs() {
app(None, FakeController::verified())
.post("/api/calibrate")
.json(&json!({ "bed_level": true, "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn calibrate_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/calibrate")
.json(&json!({ "vibration": true, "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn ams_reset_needs_confirm() {
app(None, FakeController::verified())
.post("/api/ams")
.json(&json!({ "action": "reset" }))
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn ams_reset_confirmed_on_idle_runs() {
app(None, FakeController::verified())
.post("/api/ams")
.json(&json!({ "action": "reset", "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn ams_reset_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/ams")
.json(&json!({ "action": "reset", "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn ams_resume_is_allowed_without_confirm_even_when_busy() {
busy_app(FakeController::verified())
.post("/api/ams")
.json(&json!({ "action": "resume" }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn ams_unknown_action_is_400() {
app(None, FakeController::verified())
.post("/api/ams")
.json(&json!({ "action": "eject" }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ams_change_needs_confirm() {
app(None, FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 255, "tar_temp": 220 }))
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn ams_change_confirmed_on_idle_runs() {
app(None, FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 1, "tar_temp": 220, "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn ams_unload_target_255_confirmed_runs() {
app(None, FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 255, "tar_temp": 250, "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn ams_change_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 0, "tar_temp": 220, "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn ams_change_over_limit_temp_is_400() {
app(None, FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 1, "tar_temp": 999, "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ams_change_curr_temp_is_also_clamped() {
app(None, FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 1, "tar_temp": 220, "curr_temp": 999, "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ams_change_unknown_target_is_400() {
app(None, FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 7, "tar_temp": 220, "confirm": true }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ams_change_dry_run_previews_without_confirm_or_idle() {
let res = busy_app(FakeController::verified())
.post("/api/ams/change")
.json(&json!({ "target": 255, "tar_temp": 250, "dry_run": true }))
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["plan"]["command"], "ams_change_filament");
assert_eq!(body["plan"]["target"], 255);
assert_eq!(body["plan"]["tar_temp"], 250);
assert_eq!(body["plan"]["curr_temp"], 250);
}
#[tokio::test]
async fn reboot_needs_confirm() {
app(None, FakeController::verified())
.post("/api/reboot")
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn reboot_confirmed_on_idle_is_202() {
let c = FakeController::returning(CommandOutcome::Unverified {
stage: VerifyStage::Ack,
});
app(None, c)
.post("/api/reboot")
.json(&json!({ "confirm": true }))
.await
.assert_status(StatusCode::ACCEPTED);
}
#[tokio::test]
async fn reboot_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/reboot")
.json(&json!({ "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
#[tokio::test]
async fn steppers_needs_confirm() {
app(None, FakeController::verified())
.post("/api/steppers")
.await
.assert_status(StatusCode::PRECONDITION_REQUIRED);
}
#[tokio::test]
async fn steppers_confirmed_on_idle_runs() {
app(None, FakeController::verified())
.post("/api/steppers")
.json(&json!({ "confirm": true }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn steppers_on_busy_printer_is_409() {
busy_app(FakeController::verified())
.post("/api/steppers")
.json(&json!({ "confirm": true }))
.await
.assert_status(StatusCode::CONFLICT);
}
fn ws_server(state: AppState) -> TestServer {
TestServer::builder().http_transport().build(router(state))
}
#[tokio::test]
async fn ws_is_open_and_pushes_initial_status() {
let mut ws = ws_server(AppState::fake())
.get_websocket("/api/ws")
.await
.into_websocket()
.await;
let msg: serde_json::Value = ws.receive_json().await;
assert_eq!(msg["gcode_state"], "IDLE");
assert_eq!(msg["print_error"], 0);
}
#[tokio::test]
async fn ws_streams_subsequent_updates_from_a_ramping_source() {
let state = AppState {
source: Arc::new(FakeSource::ramping(Duration::from_millis(5))),
controller: Arc::new(FakeController::verified()),
files: Arc::new(FakeFiles),
starter: Arc::new(FakeStarter),
password: None,
start_lock: Arc::new(tokio::sync::Mutex::new(())),
external_cameras: Arc::new(RwLock::new(Vec::new())),
internal_camera: Arc::new(NoCamera),
timelapse: Default::default(),
};
let mut ws = ws_server(state)
.get_websocket("/api/ws")
.await
.into_websocket()
.await;
let first: serde_json::Value = ws.receive_json().await;
assert_eq!(first["gcode_state"], "RUNNING");
let start = first["nozzle_temper"].as_f64().unwrap_or(0.0);
let mut hotter = false;
for _ in 0..5 {
let next: serde_json::Value = ws.receive_json().await;
if next["nozzle_temper"].as_f64().unwrap_or(0.0) > start {
hotter = true;
break;
}
}
assert!(hotter, "ramping source should push rising nozzle temps");
}
}