1#[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 Self {
26 max_nozzle: 300.0,
27 max_bed: 100.0,
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum GcodeVerdict {
35 Allow,
37 Block(String),
39}
40
41impl GcodeVerdict {
42 pub fn is_blocked(&self) -> bool {
43 matches!(self, GcodeVerdict::Block(_))
44 }
45}
46
47pub const MIN_EXTRUDE_TEMP_C: f64 = 170.0;
51
52pub const MAX_JOG_MM: f64 = 50.0;
54
55pub const MAX_EXTRUDE_MM: f64 = 50.0;
57
58pub 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
77pub 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 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
109pub 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
121fn check_one_line(line: &str, limits: &TempLimits) -> GcodeVerdict {
126 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 "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 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
148fn 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 read_word(next).map(|(w, _)| w)
172 } else {
173 Some(first)
174 }
175}
176
177fn 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 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 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 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 assert_eq!(check_gcode("M104", &limits()), GcodeVerdict::Allow);
252 }
253
254 #[test]
255 fn parser_is_not_bypassed_by_formatting_variants() {
256 assert!(check_gcode("M104S999", &limits()).is_blocked());
258 assert!(check_gcode("M104 S 999", &limits()).is_blocked());
260 assert!(check_gcode("M109 R999", &limits()).is_blocked());
262 assert!(check_gcode("M190 R150", &limits()).is_blocked());
263 assert!(check_gcode("N5 M104 S999", &limits()).is_blocked());
265 assert!(check_gcode("N5 M302", &limits()).is_blocked());
266 assert!(check_gcode("M302P1", &limits()).is_blocked());
268 assert!(check_gcode("M104 S999 ; warmup", &limits()).is_blocked());
270 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 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 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 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 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 assert_eq!(check_extrude(-5.0, Some(220.0)), GcodeVerdict::Allow);
324 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 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 assert!(check_extrude(5.0, None).is_blocked());
342 }
343
344 #[test]
345 fn extrude_zero_is_blocked() {
346 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 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 assert!(check_extrude(1000.0, None).is_blocked());
371 assert!(check_extrude(f64::NAN, None).is_blocked());
372 }
373}