Skip to main content

bambu_rs/server/
control.rs

1//! Control actions (the write side of the API) and the controller seam.
2//!
3//! [`Controller`] keeps the API testable without a printer: tests and `--fake`
4//! use [`FakeController`]; live mode uses [`LiveController`], which drives the
5//! real device with `send_and_verify` (a second MQTT connection alongside the
6//! monitor — they coexist, see `docs/protocol.md`).
7
8use std::time::Duration;
9
10use crate::client::LanMqttClient;
11use crate::config::ResolvedTarget;
12use crate::core::command::{AmsControl, Command as ProtoCommand, LedNode, SpeedLevel};
13use crate::core::session::{CommandOutcome, VerifyStage};
14
15/// Which axes a homing move targets (`G28` with no arg homes all).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum HomeAxes {
18    All,
19    X,
20    Y,
21    Z,
22}
23
24/// A single jog axis.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Axis {
27    X,
28    Y,
29    Z,
30}
31
32impl Axis {
33    /// The G-code axis letter.
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Axis::X => "X",
37            Axis::Y => "Y",
38            Axis::Z => "Z",
39        }
40    }
41}
42
43/// Which heater a temperature target applies to.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TempPart {
46    Nozzle,
47    Bed,
48}
49
50/// The `M104`/`M140` line that sets `part` to `celsius` (`0` = cooldown).
51pub fn temp_line(part: TempPart, celsius: u32) -> String {
52    match part {
53        TempPart::Nozzle => format!("M104 S{celsius}"),
54        TempPart::Bed => format!("M140 S{celsius}"),
55    }
56}
57
58/// A control action the API can perform.
59#[derive(Debug, Clone)]
60pub enum ControlAction {
61    Pause,
62    Resume,
63    Stop,
64    /// Clear a print error so the printer leaves `FAILED` without a reboot
65    /// (`clean_print_error`) — re-enables writes the error was blocking.
66    ClearError,
67    Light {
68        node: LedNode,
69        on: bool,
70    },
71    Speed(SpeedLevel),
72    Gcode(String),
73    Home(HomeAxes),
74    Move {
75        axis: Axis,
76        delta: f64,
77        feedrate: u32,
78    },
79    Extrude {
80        delta: f64,
81        feedrate: u32,
82    },
83    SetTemp {
84        part: TempPart,
85        celsius: u32,
86    },
87    Calibrate {
88        bed_level: bool,
89        vibration: bool,
90        motor_noise: bool,
91    },
92    Ams(AmsControl),
93    /// Change the loaded filament via the AMS (`ams_change_filament`). `target`
94    /// is a tray (0..3), `254` (external spool), or `255` (unload). Physically
95    /// moves filament, so the API gates it behind confirm + idle.
96    AmsChange {
97        target: u32,
98        curr_temp: i64,
99        tar_temp: i64,
100    },
101    Reboot,
102    DisableSteppers,
103}
104
105impl ControlAction {
106    fn into_command(self) -> ProtoCommand {
107        match self {
108            ControlAction::Pause => ProtoCommand::Pause,
109            ControlAction::Resume => ProtoCommand::Resume,
110            ControlAction::Stop => ProtoCommand::Stop,
111            ControlAction::ClearError => ProtoCommand::CleanPrintError,
112            ControlAction::Light { node, on } => ProtoCommand::Led { node, on },
113            ControlAction::Speed(level) => ProtoCommand::PrintSpeed(level),
114            ControlAction::Gcode(line) => ProtoCommand::GcodeLine(line),
115            ControlAction::Home(axes) => ProtoCommand::GcodeLine(
116                match axes {
117                    HomeAxes::All => "G28",
118                    HomeAxes::X => "G28 X",
119                    HomeAxes::Y => "G28 Y",
120                    HomeAxes::Z => "G28 Z",
121                }
122                .to_string(),
123            ),
124            // Relative move (G91), then back to absolute (G90) so a jog never
125            // shifts the coordinate frame.
126            ControlAction::Move {
127                axis,
128                delta,
129                feedrate,
130            } => ProtoCommand::GcodeLine(format!(
131                "G91\nG1 {}{delta} F{feedrate}\nG90",
132                axis.as_str()
133            )),
134            // Relative extrusion (M83), restoring absolute mode (M82) after.
135            ControlAction::Extrude { delta, feedrate } => {
136                ProtoCommand::GcodeLine(format!("M83\nG1 E{delta} F{feedrate}\nM82"))
137            }
138            ControlAction::SetTemp { part, celsius } => {
139                ProtoCommand::GcodeLine(temp_line(part, celsius))
140            }
141            ControlAction::Calibrate {
142                bed_level,
143                vibration,
144                motor_noise,
145            } => ProtoCommand::Calibration {
146                bed_level,
147                vibration,
148                motor_noise,
149            },
150            ControlAction::Ams(action) => ProtoCommand::AmsControl(action),
151            ControlAction::AmsChange {
152                target,
153                curr_temp,
154                tar_temp,
155            } => ProtoCommand::AmsChangeFilament {
156                target,
157                curr_temp,
158                tar_temp,
159            },
160            ControlAction::Reboot => ProtoCommand::Reboot,
161            ControlAction::DisableSteppers => ProtoCommand::GcodeLine("M84".to_string()),
162        }
163    }
164
165    /// Whether this action's effect can be read back. Reboot tears down the
166    /// connection (no ACK), so it is sent fire-and-forget instead.
167    fn needs_verify(&self) -> bool {
168        !matches!(self, ControlAction::Reboot)
169    }
170}
171
172/// Why a control action couldn't be carried out — distinct from the printer
173/// *rejecting* it (that is a [`CommandOutcome::Rejected`]).
174#[derive(Debug)]
175pub enum ControlError {
176    /// Couldn't reach or talk to the printer.
177    Transport(String),
178}
179
180pub type ControlResult = Result<CommandOutcome, ControlError>;
181
182/// Executes control actions. Blocking — call from `spawn_blocking`.
183pub trait Controller: Send + Sync {
184    fn execute(&self, action: ControlAction) -> ControlResult;
185}
186
187/// Drives a real printer over LAN MQTT.
188pub struct LiveController {
189    target: ResolvedTarget,
190    timeout: Duration,
191}
192
193impl LiveController {
194    pub fn new(target: ResolvedTarget) -> Self {
195        Self {
196            target,
197            timeout: Duration::from_secs(15),
198        }
199    }
200}
201
202impl Controller for LiveController {
203    fn execute(&self, action: ControlAction) -> ControlResult {
204        let client = LanMqttClient::new(self.target.clone()).with_timeout(self.timeout);
205        let needs_verify = action.needs_verify();
206        let cmd = action.into_command();
207        if needs_verify {
208            client
209                .send_and_verify(&cmd)
210                .map_err(|e| ControlError::Transport(e.to_string()))
211        } else {
212            // Reboot: no ACK to await — report Unverified, never a false success.
213            client
214                .send_fire(&cmd)
215                .map(|()| CommandOutcome::Unverified {
216                    stage: VerifyStage::Ack,
217                })
218                .map_err(|e| ControlError::Transport(e.to_string()))
219        }
220    }
221}
222
223/// A canned controller for `--fake` mode and tests.
224pub struct FakeController {
225    outcome: Result<CommandOutcome, String>,
226}
227
228impl FakeController {
229    /// Always reports the command verified (the `--fake` default).
230    pub fn verified() -> Self {
231        Self {
232            outcome: Ok(CommandOutcome::Verified),
233        }
234    }
235
236    #[cfg(test)]
237    pub fn returning(outcome: CommandOutcome) -> Self {
238        Self {
239            outcome: Ok(outcome),
240        }
241    }
242
243    #[cfg(test)]
244    pub fn failing() -> Self {
245        Self {
246            outcome: Err("fake transport failure".to_string()),
247        }
248    }
249}
250
251impl Controller for FakeController {
252    fn execute(&self, _action: ControlAction) -> ControlResult {
253        match &self.outcome {
254            Ok(o) => Ok(o.clone()),
255            Err(e) => Err(ControlError::Transport(e.clone())),
256        }
257    }
258}