#[derive(Debug, Clone, Copy)]
pub struct TempLimits {
pub max_nozzle: f64,
pub max_bed: f64,
}
impl Default for TempLimits {
fn default() -> Self {
Self {
max_nozzle: 300.0,
max_bed: 100.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GcodeVerdict {
Allow,
Block(String),
}
impl GcodeVerdict {
pub fn is_blocked(&self) -> bool {
matches!(self, GcodeVerdict::Block(_))
}
}
pub const MIN_EXTRUDE_TEMP_C: f64 = 170.0;
pub const MAX_JOG_MM: f64 = 50.0;
pub const MAX_EXTRUDE_MM: f64 = 50.0;
pub fn check_jog(delta_mm: f64) -> GcodeVerdict {
if !delta_mm.is_finite() {
return GcodeVerdict::Block("jog distance must be a finite number".to_string());
}
if delta_mm == 0.0 {
return GcodeVerdict::Block("jog distance must not be zero".to_string());
}
if delta_mm.abs() > MAX_JOG_MM {
return GcodeVerdict::Block(format!(
"jog distance {delta_mm} mm exceeds the {MAX_JOG_MM:.0} mm limit"
));
}
GcodeVerdict::Allow
}
pub fn check_extrude(delta_mm: f64, nozzle_temper: Option<f64>) -> GcodeVerdict {
if !delta_mm.is_finite() {
return GcodeVerdict::Block("extrude distance must be a finite number".to_string());
}
if delta_mm == 0.0 {
return GcodeVerdict::Block("extrude distance must not be zero".to_string());
}
if delta_mm.abs() > MAX_EXTRUDE_MM {
return GcodeVerdict::Block(format!(
"extrude distance {delta_mm} mm exceeds the {MAX_EXTRUDE_MM:.0} mm limit"
));
}
match nozzle_temper {
Some(t) if t >= MIN_EXTRUDE_TEMP_C => GcodeVerdict::Allow,
Some(t) => GcodeVerdict::Block(format!(
"nozzle is {t:.0}°C; must be at least {MIN_EXTRUDE_TEMP_C:.0}°C to extrude"
)),
None => GcodeVerdict::Block(format!(
"nozzle temperature unknown; must be at least {MIN_EXTRUDE_TEMP_C:.0}°C to extrude"
)),
}
}
pub fn check_gcode(line: &str, limits: &TempLimits) -> GcodeVerdict {
line.split(['\n', '\r'])
.map(|l| check_one_line(l, limits))
.find(GcodeVerdict::is_blocked)
.unwrap_or(GcodeVerdict::Allow)
}
fn check_one_line(line: &str, limits: &TempLimits) -> GcodeVerdict {
let body = line.split(';').next().unwrap_or("").to_ascii_uppercase();
let Some(code) = command_code(&body) else {
return GcodeVerdict::Allow;
};
let (max, what) = match code.as_str() {
"M302" => return GcodeVerdict::Block("M302 (cold extrusion) is blocked".to_string()),
"M104" | "M109" => (limits.max_nozzle, "nozzle"),
"M140" | "M190" => (limits.max_bed, "bed"),
_ => return GcodeVerdict::Allow,
};
match max_setpoint(&body) {
Some(t) if t > max => GcodeVerdict::Block(format!(
"{what} target {t:.0}°C exceeds the {max:.0}°C safety limit (use --force to override)"
)),
_ => GcodeVerdict::Allow,
}
}
fn command_code(body: &str) -> Option<String> {
let chars: Vec<char> = body.chars().collect();
let read_word = |start: usize| -> Option<(String, usize)> {
let mut i = start;
while i < chars.len() && chars[i] == ' ' {
i += 1;
}
let letter = *chars.get(i)?;
if !letter.is_ascii_alphabetic() {
return None;
}
let mut j = i + 1;
while j < chars.len() && (chars[j].is_ascii_digit() || chars[j] == '.') {
j += 1;
}
let digits: String = chars[i + 1..j].iter().collect();
Some((format!("{letter}{digits}"), j))
};
let (first, next) = read_word(0)?;
if first.starts_with('N') {
read_word(next).map(|(w, _)| w)
} else {
Some(first)
}
}
fn max_setpoint(body: &str) -> Option<f64> {
let chars: Vec<char> = body.chars().collect();
let mut max: Option<f64> = None;
let mut i = 0;
while i < chars.len() {
if chars[i] == 'S' || chars[i] == 'R' {
let mut j = i + 1;
while j < chars.len() && chars[j] == ' ' {
j += 1;
}
let start = j;
while j < chars.len()
&& (chars[j].is_ascii_digit() || matches!(chars[j], '.' | '-' | '+'))
{
j += 1;
}
if let Ok(v) = chars[start..j].iter().collect::<String>().parse::<f64>() {
max = Some(max.map_or(v, |m: f64| m.max(v)));
}
i = j.max(i + 1);
} else {
i += 1;
}
}
max
}
#[cfg(test)]
mod tests {
use super::*;
fn limits() -> TempLimits {
TempLimits::default()
}
#[test]
fn ordinary_gcode_is_allowed() {
assert_eq!(check_gcode("G28", &limits()), GcodeVerdict::Allow);
assert_eq!(check_gcode("M104 S210", &limits()), GcodeVerdict::Allow);
assert_eq!(check_gcode("M140 S60", &limits()), GcodeVerdict::Allow);
assert_eq!(check_gcode("M9999 X1", &limits()), GcodeVerdict::Allow);
}
#[test]
fn over_limit_nozzle_temp_is_blocked() {
assert!(check_gcode("M104 S999", &limits()).is_blocked());
assert!(check_gcode("M109 S350", &limits()).is_blocked());
assert_eq!(check_gcode("M104 S300", &limits()), GcodeVerdict::Allow);
assert!(check_gcode("M104 S301", &limits()).is_blocked());
}
#[test]
fn over_limit_bed_temp_is_blocked() {
assert!(check_gcode("M140 S150", &limits()).is_blocked());
assert!(check_gcode("M190 S120", &limits()).is_blocked());
assert_eq!(check_gcode("M140 S100", &limits()), GcodeVerdict::Allow);
}
#[test]
fn cold_extrusion_is_blocked() {
assert!(check_gcode("M302", &limits()).is_blocked());
assert!(check_gcode("M302 P1", &limits()).is_blocked());
assert!(check_gcode(" m302 ", &limits()).is_blocked());
}
#[test]
fn temp_parsing_is_case_insensitive_and_handles_no_setpoint() {
assert!(check_gcode("m104 s999", &limits()).is_blocked());
assert_eq!(check_gcode("M104", &limits()), GcodeVerdict::Allow);
}
#[test]
fn parser_is_not_bypassed_by_formatting_variants() {
assert!(check_gcode("M104S999", &limits()).is_blocked());
assert!(check_gcode("M104 S 999", &limits()).is_blocked());
assert!(check_gcode("M109 R999", &limits()).is_blocked());
assert!(check_gcode("M190 R150", &limits()).is_blocked());
assert!(check_gcode("N5 M104 S999", &limits()).is_blocked());
assert!(check_gcode("N5 M302", &limits()).is_blocked());
assert!(check_gcode("M302P1", &limits()).is_blocked());
assert!(check_gcode("M104 S999 ; warmup", &limits()).is_blocked());
assert_eq!(
check_gcode("M104 S210 ; was S999", &limits()),
GcodeVerdict::Allow
);
}
#[test]
fn embedded_newline_cannot_smuggle_a_second_command() {
assert!(check_gcode("G28\nM104 S999", &limits()).is_blocked());
assert!(check_gcode("G28\r\nM302", &limits()).is_blocked());
}
#[test]
fn jog_in_range_is_allowed() {
assert_eq!(check_jog(1.0), GcodeVerdict::Allow);
assert_eq!(check_jog(-10.0), GcodeVerdict::Allow);
assert_eq!(check_jog(MAX_JOG_MM), GcodeVerdict::Allow);
assert_eq!(check_jog(-MAX_JOG_MM), GcodeVerdict::Allow);
}
#[test]
fn jog_zero_is_blocked() {
assert!(check_jog(0.0).is_blocked());
assert!(check_jog(-0.0).is_blocked());
}
#[test]
fn jog_over_bound_is_blocked() {
assert!(check_jog(MAX_JOG_MM + 0.1).is_blocked());
assert!(check_jog(-(MAX_JOG_MM + 0.1)).is_blocked());
assert!(check_jog(1000.0).is_blocked());
}
#[test]
fn jog_non_finite_is_blocked() {
assert!(check_jog(f64::NAN).is_blocked());
assert!(check_jog(f64::INFINITY).is_blocked());
assert!(check_jog(f64::NEG_INFINITY).is_blocked());
}
#[test]
fn extrude_when_hot_enough_is_allowed() {
assert_eq!(
check_extrude(5.0, Some(MIN_EXTRUDE_TEMP_C)),
GcodeVerdict::Allow
);
assert_eq!(check_extrude(5.0, Some(220.0)), GcodeVerdict::Allow);
assert_eq!(check_extrude(-5.0, Some(220.0)), GcodeVerdict::Allow);
assert_eq!(
check_extrude(MAX_EXTRUDE_MM, Some(220.0)),
GcodeVerdict::Allow
);
assert_eq!(
check_extrude(-MAX_EXTRUDE_MM, Some(220.0)),
GcodeVerdict::Allow
);
}
#[test]
fn extrude_cold_is_blocked() {
assert!(check_extrude(5.0, Some(MIN_EXTRUDE_TEMP_C - 0.1)).is_blocked());
assert!(check_extrude(5.0, Some(25.0)).is_blocked());
assert!(check_extrude(5.0, None).is_blocked());
}
#[test]
fn extrude_zero_is_blocked() {
assert!(check_extrude(0.0, Some(220.0)).is_blocked());
assert!(check_extrude(-0.0, Some(220.0)).is_blocked());
}
#[test]
fn extrude_over_bound_is_blocked() {
assert!(check_extrude(MAX_EXTRUDE_MM + 0.1, Some(220.0)).is_blocked());
assert!(check_extrude(-(MAX_EXTRUDE_MM + 0.1), Some(220.0)).is_blocked());
assert!(check_extrude(1000.0, Some(220.0)).is_blocked());
}
#[test]
fn extrude_non_finite_is_blocked() {
assert!(check_extrude(f64::NAN, Some(220.0)).is_blocked());
assert!(check_extrude(f64::INFINITY, Some(220.0)).is_blocked());
assert!(check_extrude(f64::NEG_INFINITY, Some(220.0)).is_blocked());
}
#[test]
fn extrude_distance_checked_before_temperature() {
assert!(check_extrude(1000.0, None).is_blocked());
assert!(check_extrude(f64::NAN, None).is_blocked());
}
}