Skip to main content

bambu_rs/core/
safety.rs

1//! Static safety checks for raw G-code before it is sent.
2//!
3//! `bambu gcode <line>` lets a caller (an AI agent included) send an arbitrary
4//! G-code line. This module is a **guard rail**, not a full parser: it blocks
5//! only what it *positively recognises* as unsafe (a clearly-dangerous command,
6//! or a temperature setpoint past a ceiling), and allows everything else. A
7//! caller can still override a block explicitly (`--force`).
8//!
9//! The point is to stop an agent from, say, commanding `M104 S999` (thermal
10//! runaway) or `M302` (cold extrusion) by accident — not to sandbox a
11//! determined operator.
12
13/// Temperature ceilings (°C) a raw G-code line may not exceed.
14#[derive(Debug, Clone, Copy)]
15pub struct TempLimits {
16    pub max_nozzle: f64,
17    pub max_bed: f64,
18}
19
20impl Default for TempLimits {
21    fn default() -> Self {
22        // Conservative caps: high enough to clear any real Bambu print, low
23        // enough to block absurd values (e.g. `M104 S999`). Tighter per-model
24        // limits can be sourced from the capability registry later.
25        Self {
26            max_nozzle: 300.0,
27            max_bed: 100.0,
28        }
29    }
30}
31
32/// The verdict of a static G-code safety check.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum GcodeVerdict {
35    /// Nothing recognised as unsafe — fine to send.
36    Allow,
37    /// Recognised as unsafe; `reason` explains why.
38    Block(String),
39}
40
41impl GcodeVerdict {
42    pub fn is_blocked(&self) -> bool {
43        matches!(self, GcodeVerdict::Block(_))
44    }
45}
46
47/// Below this nozzle temperature (°C), extruding is refused outright: pushing
48/// filament through a cold nozzle grinds the gears / strips the drive. This is a
49/// hard guard with **no** `--force` bypass — unlike the temperature ceiling.
50pub const MIN_EXTRUDE_TEMP_C: f64 = 170.0;
51
52/// The largest single relative jog (mm) a move may request, per axis.
53pub const MAX_JOG_MM: f64 = 50.0;
54
55/// The largest single relative extrude/retract (mm) a move may request.
56pub const MAX_EXTRUDE_MM: f64 = 50.0;
57
58/// Vet a relative jog of `delta_mm` (a single axis move).
59///
60/// Blocks a non-finite (`NaN`/`±inf`) or zero delta — a no-op move is almost
61/// always a mistake — and anything past [`MAX_JOG_MM`] in either direction.
62pub fn check_jog(delta_mm: f64) -> GcodeVerdict {
63    if !delta_mm.is_finite() {
64        return GcodeVerdict::Block("jog distance must be a finite number".to_string());
65    }
66    if delta_mm == 0.0 {
67        return GcodeVerdict::Block("jog distance must not be zero".to_string());
68    }
69    if delta_mm.abs() > MAX_JOG_MM {
70        return GcodeVerdict::Block(format!(
71            "jog distance {delta_mm} mm exceeds the {MAX_JOG_MM:.0} mm limit"
72        ));
73    }
74    GcodeVerdict::Allow
75}
76
77/// Vet a relative extrude/retract of `delta_mm`, given the current nozzle
78/// temperature `nozzle_temper` (°C).
79///
80/// Blocks a non-finite or zero delta, anything past [`MAX_EXTRUDE_MM`], and —
81/// the cold-extrusion guard — any extrude while the nozzle is below
82/// [`MIN_EXTRUDE_TEMP_C`]. A missing temperature (`None`) is treated as too
83/// cold. There is **no** `--force` bypass for the cold guard.
84pub fn check_extrude(delta_mm: f64, nozzle_temper: Option<f64>) -> GcodeVerdict {
85    if !delta_mm.is_finite() {
86        return GcodeVerdict::Block("extrude distance must be a finite number".to_string());
87    }
88    if delta_mm == 0.0 {
89        return GcodeVerdict::Block("extrude distance must not be zero".to_string());
90    }
91    if delta_mm.abs() > MAX_EXTRUDE_MM {
92        return GcodeVerdict::Block(format!(
93            "extrude distance {delta_mm} mm exceeds the {MAX_EXTRUDE_MM:.0} mm limit"
94        ));
95    }
96    // Cold-extrusion guard: refuse below the minimum (a missing reading counts
97    // as too cold). No force override here — see MIN_EXTRUDE_TEMP_C.
98    match nozzle_temper {
99        Some(t) if t >= MIN_EXTRUDE_TEMP_C => GcodeVerdict::Allow,
100        Some(t) => GcodeVerdict::Block(format!(
101            "nozzle is {t:.0}°C; must be at least {MIN_EXTRUDE_TEMP_C:.0}°C to extrude"
102        )),
103        None => GcodeVerdict::Block(format!(
104            "nozzle temperature unknown; must be at least {MIN_EXTRUDE_TEMP_C:.0}°C to extrude"
105        )),
106    }
107}
108
109/// Statically vet a raw G-code payload against `limits`.
110///
111/// A payload may contain more than one line (a `\n`/`\r` could otherwise hide a
112/// second command past the first), so every line is vetted and the first block
113/// wins.
114pub fn check_gcode(line: &str, limits: &TempLimits) -> GcodeVerdict {
115    line.split(['\n', '\r'])
116        .map(|l| check_one_line(l, limits))
117        .find(GcodeVerdict::is_blocked)
118        .unwrap_or(GcodeVerdict::Allow)
119}
120
121/// Vet a single G-code line. Tolerant of the forms a real sender might use —
122/// line numbers (`N5 …`), trailing comments (`… ; warmup`), no space between a
123/// word and its number (`M104S999`), and a space inside a parameter
124/// (`M104 S 999`) — so a dangerous command can't slip past on formatting.
125fn check_one_line(line: &str, limits: &TempLimits) -> GcodeVerdict {
126    // Drop a trailing comment and uppercase for case-insensitive matching.
127    let body = line.split(';').next().unwrap_or("").to_ascii_uppercase();
128    let Some(code) = command_code(&body) else {
129        return GcodeVerdict::Allow;
130    };
131    let (max, what) = match code.as_str() {
132        // Cold-extrusion enable: lets the extruder push unmelted filament, which
133        // can grind the gears / strip the drive.
134        "M302" => return GcodeVerdict::Block("M302 (cold extrusion) is blocked".to_string()),
135        "M104" | "M109" => (limits.max_nozzle, "nozzle"),
136        "M140" | "M190" => (limits.max_bed, "bed"),
137        _ => return GcodeVerdict::Allow,
138    };
139    // Both S (target) and R (set-and-wait target) carry the setpoint.
140    match max_setpoint(&body) {
141        Some(t) if t > max => GcodeVerdict::Block(format!(
142            "{what} target {t:.0}°C exceeds the {max:.0}°C safety limit (use --force to override)"
143        )),
144        _ => GcodeVerdict::Allow,
145    }
146}
147
148/// The command word (e.g. `M104`) of an uppercased line: the first
149/// letter+number word, skipping a leading `N<line-number>`.
150fn command_code(body: &str) -> Option<String> {
151    let chars: Vec<char> = body.chars().collect();
152    let read_word = |start: usize| -> Option<(String, usize)> {
153        let mut i = start;
154        while i < chars.len() && chars[i] == ' ' {
155            i += 1;
156        }
157        let letter = *chars.get(i)?;
158        if !letter.is_ascii_alphabetic() {
159            return None;
160        }
161        let mut j = i + 1;
162        while j < chars.len() && (chars[j].is_ascii_digit() || chars[j] == '.') {
163            j += 1;
164        }
165        let digits: String = chars[i + 1..j].iter().collect();
166        Some((format!("{letter}{digits}"), j))
167    };
168    let (first, next) = read_word(0)?;
169    if first.starts_with('N') {
170        // A line-number prefix; the real command is the next word.
171        read_word(next).map(|(w, _)| w)
172    } else {
173        Some(first)
174    }
175}
176
177/// The largest `S`/`R` numeric setpoint on the line, allowing a space between the
178/// letter and its number (`S 999`).
179fn max_setpoint(body: &str) -> Option<f64> {
180    let chars: Vec<char> = body.chars().collect();
181    let mut max: Option<f64> = None;
182    let mut i = 0;
183    while i < chars.len() {
184        if chars[i] == 'S' || chars[i] == 'R' {
185            let mut j = i + 1;
186            while j < chars.len() && chars[j] == ' ' {
187                j += 1;
188            }
189            let start = j;
190            while j < chars.len()
191                && (chars[j].is_ascii_digit() || matches!(chars[j], '.' | '-' | '+'))
192            {
193                j += 1;
194            }
195            if let Ok(v) = chars[start..j].iter().collect::<String>().parse::<f64>() {
196                max = Some(max.map_or(v, |m: f64| m.max(v)));
197            }
198            i = j.max(i + 1);
199        } else {
200            i += 1;
201        }
202    }
203    max
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn limits() -> TempLimits {
211        TempLimits::default()
212    }
213
214    #[test]
215    fn ordinary_gcode_is_allowed() {
216        assert_eq!(check_gcode("G28", &limits()), GcodeVerdict::Allow);
217        assert_eq!(check_gcode("M104 S210", &limits()), GcodeVerdict::Allow);
218        assert_eq!(check_gcode("M140 S60", &limits()), GcodeVerdict::Allow);
219        // Unknown command: not positively unsafe -> allowed.
220        assert_eq!(check_gcode("M9999 X1", &limits()), GcodeVerdict::Allow);
221    }
222
223    #[test]
224    fn over_limit_nozzle_temp_is_blocked() {
225        assert!(check_gcode("M104 S999", &limits()).is_blocked());
226        assert!(check_gcode("M109 S350", &limits()).is_blocked());
227        // At the limit is allowed; one over is blocked.
228        assert_eq!(check_gcode("M104 S300", &limits()), GcodeVerdict::Allow);
229        assert!(check_gcode("M104 S301", &limits()).is_blocked());
230    }
231
232    #[test]
233    fn over_limit_bed_temp_is_blocked() {
234        assert!(check_gcode("M140 S150", &limits()).is_blocked());
235        assert!(check_gcode("M190 S120", &limits()).is_blocked());
236        assert_eq!(check_gcode("M140 S100", &limits()), GcodeVerdict::Allow);
237    }
238
239    #[test]
240    fn cold_extrusion_is_blocked() {
241        assert!(check_gcode("M302", &limits()).is_blocked());
242        assert!(check_gcode("M302 P1", &limits()).is_blocked());
243        // Case/whitespace tolerant.
244        assert!(check_gcode("  m302  ", &limits()).is_blocked());
245    }
246
247    #[test]
248    fn temp_parsing_is_case_insensitive_and_handles_no_setpoint() {
249        assert!(check_gcode("m104 s999", &limits()).is_blocked());
250        // M104 with no S (a query / turn-off form) has no setpoint -> allowed.
251        assert_eq!(check_gcode("M104", &limits()), GcodeVerdict::Allow);
252    }
253
254    #[test]
255    fn parser_is_not_bypassed_by_formatting_variants() {
256        // No space between command and param.
257        assert!(check_gcode("M104S999", &limits()).is_blocked());
258        // Space *inside* the parameter.
259        assert!(check_gcode("M104 S 999", &limits()).is_blocked());
260        // R (set-and-wait) setpoint, not just S.
261        assert!(check_gcode("M109 R999", &limits()).is_blocked());
262        assert!(check_gcode("M190 R150", &limits()).is_blocked());
263        // Leading line number.
264        assert!(check_gcode("N5 M104 S999", &limits()).is_blocked());
265        assert!(check_gcode("N5 M302", &limits()).is_blocked());
266        // M302 with a stuck param.
267        assert!(check_gcode("M302P1", &limits()).is_blocked());
268        // Trailing comment doesn't hide the setpoint.
269        assert!(check_gcode("M104 S999 ; warmup", &limits()).is_blocked());
270        // A comment that merely mentions a high number is not a setpoint.
271        assert_eq!(
272            check_gcode("M104 S210 ; was S999", &limits()),
273            GcodeVerdict::Allow
274        );
275    }
276
277    #[test]
278    fn embedded_newline_cannot_smuggle_a_second_command() {
279        // Only the first line is "safe"; the hidden second line must still block.
280        assert!(check_gcode("G28\nM104 S999", &limits()).is_blocked());
281        assert!(check_gcode("G28\r\nM302", &limits()).is_blocked());
282    }
283
284    #[test]
285    fn jog_in_range_is_allowed() {
286        assert_eq!(check_jog(1.0), GcodeVerdict::Allow);
287        assert_eq!(check_jog(-10.0), GcodeVerdict::Allow);
288        // At the limit (either sign) is allowed; one past is not.
289        assert_eq!(check_jog(MAX_JOG_MM), GcodeVerdict::Allow);
290        assert_eq!(check_jog(-MAX_JOG_MM), GcodeVerdict::Allow);
291    }
292
293    #[test]
294    fn jog_zero_is_blocked() {
295        assert!(check_jog(0.0).is_blocked());
296        // -0.0 is still zero.
297        assert!(check_jog(-0.0).is_blocked());
298    }
299
300    #[test]
301    fn jog_over_bound_is_blocked() {
302        assert!(check_jog(MAX_JOG_MM + 0.1).is_blocked());
303        assert!(check_jog(-(MAX_JOG_MM + 0.1)).is_blocked());
304        assert!(check_jog(1000.0).is_blocked());
305    }
306
307    #[test]
308    fn jog_non_finite_is_blocked() {
309        assert!(check_jog(f64::NAN).is_blocked());
310        assert!(check_jog(f64::INFINITY).is_blocked());
311        assert!(check_jog(f64::NEG_INFINITY).is_blocked());
312    }
313
314    #[test]
315    fn extrude_when_hot_enough_is_allowed() {
316        // At the minimum is allowed (>=).
317        assert_eq!(
318            check_extrude(5.0, Some(MIN_EXTRUDE_TEMP_C)),
319            GcodeVerdict::Allow
320        );
321        assert_eq!(check_extrude(5.0, Some(220.0)), GcodeVerdict::Allow);
322        // Retract (negative) is fine too.
323        assert_eq!(check_extrude(-5.0, Some(220.0)), GcodeVerdict::Allow);
324        // At the distance limit is allowed.
325        assert_eq!(
326            check_extrude(MAX_EXTRUDE_MM, Some(220.0)),
327            GcodeVerdict::Allow
328        );
329        assert_eq!(
330            check_extrude(-MAX_EXTRUDE_MM, Some(220.0)),
331            GcodeVerdict::Allow
332        );
333    }
334
335    #[test]
336    fn extrude_cold_is_blocked() {
337        // Below the minimum.
338        assert!(check_extrude(5.0, Some(MIN_EXTRUDE_TEMP_C - 0.1)).is_blocked());
339        assert!(check_extrude(5.0, Some(25.0)).is_blocked());
340        // Unknown temperature is treated as too cold.
341        assert!(check_extrude(5.0, None).is_blocked());
342    }
343
344    #[test]
345    fn extrude_zero_is_blocked() {
346        // Blocked even when hot.
347        assert!(check_extrude(0.0, Some(220.0)).is_blocked());
348        assert!(check_extrude(-0.0, Some(220.0)).is_blocked());
349    }
350
351    #[test]
352    fn extrude_over_bound_is_blocked() {
353        // Blocked even when hot.
354        assert!(check_extrude(MAX_EXTRUDE_MM + 0.1, Some(220.0)).is_blocked());
355        assert!(check_extrude(-(MAX_EXTRUDE_MM + 0.1), Some(220.0)).is_blocked());
356        assert!(check_extrude(1000.0, Some(220.0)).is_blocked());
357    }
358
359    #[test]
360    fn extrude_non_finite_is_blocked() {
361        assert!(check_extrude(f64::NAN, Some(220.0)).is_blocked());
362        assert!(check_extrude(f64::INFINITY, Some(220.0)).is_blocked());
363        assert!(check_extrude(f64::NEG_INFINITY, Some(220.0)).is_blocked());
364    }
365
366    #[test]
367    fn extrude_distance_checked_before_temperature() {
368        // An over-bound (or non-finite) distance blocks regardless of temp —
369        // including when temp is None — so the bound error wins, not "too cold".
370        assert!(check_extrude(1000.0, None).is_blocked());
371        assert!(check_extrude(f64::NAN, None).is_blocked());
372    }
373}