Skip to main content

bambu_rs/server/
start.rs

1//! Starting a print (the write side, separate from simple control because it
2//! carries file/plate/AMS parameters and needs a fresh MQTT connection). Behind
3//! a seam so the API is testable: tests/`--fake` use [`FakeStarter`]; live mode
4//! uses [`LiveStarter`] (`project_file`/`gcode_file` + verify).
5//!
6//! Safety lives in the HTTP handler (confirm gate, idle check, AMS-map range);
7//! this just builds the command and verifies it.
8
9use std::time::Duration;
10
11use super::control::ControlError;
12use crate::client::LanMqttClient;
13use crate::config::ResolvedTarget;
14use crate::core::command::Command as ProtoCommand;
15use crate::core::project::PlateInspection;
16use crate::core::session::CommandOutcome;
17use crate::core::start::{self, PrintStartParams};
18
19/// A resolved print-start request.
20pub struct StartRequest {
21    pub file: String,
22    pub plate: u32,
23    pub use_ams: bool,
24    pub ams_map: Vec<i32>,
25    pub bed_type: String,
26    /// Enable the printer-side timelapse flag. Beyond recording the built-in
27    /// camera, this arms the sliced timelapse gcode — on Smooth mode that's the
28    /// per-layer park with a spiral Z-hop + prime-tower wipe, which is skipped
29    /// entirely when the flag is off (and the head then scrapes the print).
30    pub timelapse: bool,
31    /// Plate inspection (when we have the file's bytes, e.g. an upload-then-start):
32    /// its plate-gcode md5 is stamped into the `project_file` so the printer
33    /// verifies the file. `None` for "file already on the printer" starts.
34    pub inspection: Option<PlateInspection>,
35}
36
37impl StartRequest {
38    /// Build the MQTT command via the shared [`core::start`](crate::core::start)
39    /// builder: `project_file` for a `.3mf`, `gcode_file` for raw `.gcode`, with
40    /// the plate-gcode md5 folded in when an inspection is present.
41    pub fn to_command(&self) -> ProtoCommand {
42        let params = PrintStartParams {
43            file: self.file.clone(),
44            plate: self.plate,
45            use_ams: self.use_ams,
46            ams_map: self.ams_map.clone(),
47            bed_type: self.bed_type.clone(),
48            timelapse: self.timelapse,
49        };
50        start::build_command(&params, self.inspection.as_ref())
51    }
52}
53
54/// Starts prints. Blocking — call from `spawn_blocking`.
55pub trait Starter: Send + Sync {
56    fn start(&self, req: &StartRequest) -> Result<CommandOutcome, ControlError>;
57}
58
59/// Drives a real printer over LAN MQTT.
60pub struct LiveStarter {
61    target: ResolvedTarget,
62}
63
64impl LiveStarter {
65    pub fn new(target: ResolvedTarget) -> Self {
66        Self { target }
67    }
68}
69
70impl Starter for LiveStarter {
71    fn start(&self, req: &StartRequest) -> Result<CommandOutcome, ControlError> {
72        LanMqttClient::new(self.target.clone())
73            .with_timeout(Duration::from_secs(30))
74            .send_and_verify(&req.to_command())
75            .map_err(|e| ControlError::Transport(e.to_string()))
76    }
77}
78
79/// A canned starter for `--fake` mode and tests.
80pub struct FakeStarter;
81
82impl Starter for FakeStarter {
83    fn start(&self, _req: &StartRequest) -> Result<CommandOutcome, ControlError> {
84        Ok(CommandOutcome::Verified)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn three_mf_builds_a_project_file_with_ftp_url_and_ams() {
94        let req = StartRequest {
95            file: "/cache/coin.gcode.3mf".to_string(),
96            plate: 2,
97            use_ams: true,
98            ams_map: vec![0, 3],
99            bed_type: "auto".to_string(),
100            timelapse: false,
101            inspection: None,
102        };
103        match req.to_command() {
104            ProtoCommand::ProjectFile(pf) => {
105                assert_eq!(pf.url, "ftp:///cache/coin.gcode.3mf");
106                assert_eq!(pf.plate, 2);
107                assert_eq!(pf.subtask_name, "coin.gcode.3mf");
108                assert!(pf.use_ams);
109                assert_eq!(pf.ams_mapping, vec![0, 3]);
110                assert!(!pf.timelapse, "timelapse off unless requested");
111            }
112            other => panic!("expected ProjectFile, got {other:?}"),
113        }
114    }
115
116    #[test]
117    fn timelapse_flag_flows_into_the_project_file() {
118        let req = StartRequest {
119            file: "/cube.gcode.3mf".to_string(),
120            plate: 1,
121            use_ams: false,
122            ams_map: vec![],
123            bed_type: "auto".to_string(),
124            timelapse: true,
125            inspection: None,
126        };
127        match req.to_command() {
128            ProtoCommand::ProjectFile(pf) => {
129                assert!(pf.timelapse, "arms the sliced timelapse gcode")
130            }
131            other => panic!("expected ProjectFile, got {other:?}"),
132        }
133    }
134
135    #[test]
136    fn raw_gcode_builds_a_gcode_file() {
137        let req = StartRequest {
138            file: "/test.gcode".to_string(),
139            plate: 1,
140            use_ams: false,
141            ams_map: vec![],
142            bed_type: "auto".to_string(),
143            timelapse: false,
144            inspection: None,
145        };
146        assert!(matches!(req.to_command(), ProtoCommand::GcodeFile(f) if f == "/test.gcode"));
147    }
148}