Skip to main content

bambu_rs/core/
verify.rs

1//! Effect-verification predicates — "did the command actually take effect?"
2//!
3//! A command ACK (`result == "success"`) only says the printer *accepted* the
4//! request. A real fault often surfaces **after** the ACK: either the expected
5//! state transition never happens, or a new `print_error` appears. This was
6//! observed on a real A1 mini — a `project_file` ACKed `success` but the print
7//! never started (a failing SD card set `print_error = 0x0500C010` while
8//! `gcode_state` stayed `IDLE` and `hms` was empty). Trusting the ACK alone is a
9//! false positive.
10//!
11//! So for commands with an observable effect we read the report back and confirm
12//! the effect, while watching for a **new** `print_error` that appeared after we
13//! sent (the baseline error is captured before sending, so a pre-existing fault
14//! is not blamed on this command).
15//!
16//! Note: `subtask_name` is deliberately **not** used as a "print started"
17//! signal — in the SD-card failure it changed to the new job even though the
18//! print never began. Only `gcode_state` transitions are trusted.
19
20use crate::core::command::Command;
21use crate::core::status::{GcodeState, PrinterStatus};
22
23/// The observable effect status of a command, evaluated against one report.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EffectStatus {
26    /// The intended effect is visible in the report.
27    Observed,
28    /// A new device error (`print_error`) appeared after the command was sent.
29    NewError(i64),
30    /// No effect (and no new error) yet — the caller should keep waiting until
31    /// its timeout, after which "pending" means *unverified*.
32    Pending,
33}
34
35/// Whether a command produces an effect we can read back from the report. When
36/// `false`, the ACK is the only available signal and is the final verdict.
37pub fn has_observable_effect(cmd: &Command) -> bool {
38    matches!(
39        cmd,
40        Command::ProjectFile(_)
41            | Command::GcodeFile(_)
42            | Command::Calibration { .. }
43            | Command::Pause
44            | Command::Resume
45            | Command::Stop
46            | Command::Led { .. }
47            | Command::IpcamTimelapse(_)
48            | Command::PrintSpeed(_)
49    )
50}
51
52/// Evaluate a command's effect against the current `status`, given the
53/// `baseline_print_error` captured **before** the command was sent (so only a
54/// *new* error is reported, not a pre-existing one).
55pub fn evaluate(
56    cmd: &Command,
57    status: &PrinterStatus,
58    baseline_print_error: Option<i64>,
59) -> EffectStatus {
60    let state = status.state();
61
62    // Stop/abort is special: success == reaching a terminal state. Aborting a
63    // paused or already-errored job can transiently raise a `print_error` before
64    // it settles to FAILED, so a "new" error here must NOT mask the stop
65    // succeeding (observed: stop -> 0x0300400C -> then FAILED, print_error 0).
66    if matches!(cmd, Command::Stop) {
67        return if matches!(
68            state,
69            Some(GcodeState::Idle | GcodeState::Finish | GcodeState::Failed)
70        ) {
71            EffectStatus::Observed
72        } else {
73            EffectStatus::Pending
74        };
75    }
76
77    // For every other command, a new non-zero print_error means it did not
78    // cleanly take effect.
79    let current = status.print_error.unwrap_or(0);
80    let baseline = baseline_print_error.unwrap_or(0);
81    if current != 0 && current != baseline {
82        return EffectStatus::NewError(current);
83    }
84
85    let observed = match cmd {
86        // A print start has "taken effect" once the job is being prepared or
87        // run. (subtask_name is not trusted — see module docs.)
88        Command::ProjectFile(_) | Command::GcodeFile(_) | Command::Calibration { .. } => {
89            matches!(
90                state,
91                Some(GcodeState::Prepare | GcodeState::Running | GcodeState::Slicing)
92            )
93        }
94        Command::Pause => state == Some(GcodeState::Pause),
95        Command::Resume => state == Some(GcodeState::Running),
96        // The light is "set" only once `lights_report` actually shows the
97        // commanded mode for that node — the `ledctrl` ACK alone is not enough
98        // (a faulty unit ACKs but `lights_report` stays unchanged).
99        Command::Led { node, on } => {
100            let want = if *on { "on" } else { "off" };
101            status.light_mode(node.as_str()) == Some(want)
102        }
103        // The timelapse setting is "set" once `ipcam.timelapse` shows the
104        // commanded mode — the `ipcam_timelapse` ACK alone isn't enough (same
105        // caveat as the light; relevant since this unit's camera is faulty).
106        Command::IpcamTimelapse(control) => status.timelapse_mode() == Some(control.as_str()),
107        // The speed profile is applied once `spd_lvl` shows the commanded level.
108        Command::PrintSpeed(level) => status.spd_lvl == Some(level.level()),
109        // Stop is handled above (terminal-state check, error-tolerant).
110        Command::Stop => unreachable!("Stop handled before the new-error check"),
111        // No observable state effect — caller should not use evaluate() for
112        // these (ACK is the final verdict). The AMS commands are [spec] and
113        // ACK-verified: their physical effect is slow/unobserved, so we stand
114        // behind "the printer accepted it", not "it completed".
115        Command::PushAll
116        | Command::GetVersion
117        | Command::GcodeLine(_)
118        | Command::Reboot
119        | Command::AmsControl(_)
120        | Command::AmsChangeFilament { .. }
121        | Command::AmsUserSetting { .. }
122        | Command::AmsFilamentSetting(_)
123        // Ack-only for now: the success ACK is the verdict. Whether the clear also
124        // drops print_error to 0 / leaves FAILED is being confirmed on-device; once
125        // that's verified, this can graduate to an observable-effect predicate.
126        | Command::CleanPrintError => false,
127    };
128
129    if observed {
130        EffectStatus::Observed
131    } else {
132        EffectStatus::Pending
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::core::command::ProjectFile;
140
141    fn status(gcode_state: &str, print_error: i64) -> PrinterStatus {
142        PrinterStatus {
143            gcode_state: Some(gcode_state.to_string()),
144            print_error: Some(print_error),
145            ..Default::default()
146        }
147    }
148
149    fn project() -> Command {
150        Command::ProjectFile(ProjectFile::new("ftp:///model/x.gcode.3mf", 1, "x"))
151    }
152
153    #[test]
154    fn which_commands_have_an_observable_effect() {
155        assert!(has_observable_effect(&project()));
156        assert!(has_observable_effect(&Command::Pause));
157        assert!(has_observable_effect(&Command::Calibration {
158            bed_level: true,
159            vibration: false,
160            motor_noise: false
161        }));
162        // The light is effectful — verified via lights_report, not just the ACK.
163        assert!(has_observable_effect(&Command::Led {
164            node: crate::core::command::LedNode::ChamberLight,
165            on: true
166        }));
167        // ACK-only commands:
168        assert!(!has_observable_effect(&Command::GcodeLine("G28".into())));
169        assert!(!has_observable_effect(&Command::PushAll));
170        assert!(!has_observable_effect(&Command::Reboot));
171    }
172
173    #[test]
174    fn ipcam_timelapse_effect_reads_the_ipcam_node_not_the_ack() {
175        use crate::core::command::TimelapseControl;
176        use crate::core::status::Ipcam;
177        let with_timelapse = |mode: &str| PrinterStatus {
178            ipcam: Some(Ipcam {
179                timelapse: Some(mode.to_string()),
180                ..Default::default()
181            }),
182            ..Default::default()
183        };
184        // Enable observed only once ipcam.timelapse actually reads "enable".
185        assert_eq!(
186            evaluate(
187                &Command::IpcamTimelapse(TimelapseControl::Enable),
188                &with_timelapse("enable"),
189                Some(0)
190            ),
191            EffectStatus::Observed
192        );
193        // ACKed but ipcam.timelapse still "disable" -> pending (→ unverified on
194        // timeout), never a false "verified" (this unit's camera is faulty).
195        assert_eq!(
196            evaluate(
197                &Command::IpcamTimelapse(TimelapseControl::Enable),
198                &with_timelapse("disable"),
199                Some(0)
200            ),
201            EffectStatus::Pending
202        );
203        assert!(has_observable_effect(&Command::IpcamTimelapse(
204            TimelapseControl::Disable
205        )));
206    }
207
208    #[test]
209    fn print_speed_effect_reads_spd_lvl() {
210        use crate::core::command::SpeedLevel;
211        let at = |lvl: i64| PrinterStatus {
212            spd_lvl: Some(lvl),
213            ..Default::default()
214        };
215        // Observed once spd_lvl shows the commanded level.
216        assert_eq!(
217            evaluate(&Command::PrintSpeed(SpeedLevel::Sport), &at(3), Some(0)),
218            EffectStatus::Observed
219        );
220        // Still the old level -> pending (→ unverified on timeout).
221        assert_eq!(
222            evaluate(&Command::PrintSpeed(SpeedLevel::Sport), &at(2), Some(0)),
223            EffectStatus::Pending
224        );
225        assert!(has_observable_effect(&Command::PrintSpeed(
226            SpeedLevel::Silent
227        )));
228    }
229
230    #[test]
231    fn chamber_light_effect_reads_lights_report_not_the_ack() {
232        use crate::core::command::LedNode;
233        use crate::core::status::LightReport;
234        let chamber = |node: LedNode, on: bool| Command::Led { node, on };
235        let lit = |mode: &str| PrinterStatus {
236            lights: vec![LightReport {
237                node: "chamber_light".to_string(),
238                mode: mode.to_string(),
239            }],
240            ..Default::default()
241        };
242        // light on: observed only when lights_report actually shows "on".
243        assert_eq!(
244            evaluate(&chamber(LedNode::ChamberLight, true), &lit("on"), Some(0)),
245            EffectStatus::Observed
246        );
247        // Faulty unit: ledctrl ACKed but lights_report stays "off" -> pending
248        // (→ unverified on timeout), never a false "verified".
249        assert_eq!(
250            evaluate(&chamber(LedNode::ChamberLight, true), &lit("off"), Some(0)),
251            EffectStatus::Pending
252        );
253        assert_eq!(
254            evaluate(&chamber(LedNode::ChamberLight, false), &lit("off"), Some(0)),
255            EffectStatus::Observed
256        );
257        // work_light has no entry in lights_report -> pending (never false-verified).
258        assert_eq!(
259            evaluate(&chamber(LedNode::WorkLight, true), &lit("on"), Some(0)),
260            EffectStatus::Pending
261        );
262    }
263
264    #[test]
265    fn project_file_running_is_observed() {
266        assert_eq!(
267            evaluate(&project(), &status("RUNNING", 0), Some(0)),
268            EffectStatus::Observed
269        );
270        assert_eq!(
271            evaluate(&project(), &status("PREPARE", 0), Some(0)),
272            EffectStatus::Observed
273        );
274    }
275
276    #[test]
277    fn project_file_idle_with_no_error_is_pending_not_observed() {
278        // The exact SD-card failure window *before* print_error appears: ACK was
279        // success, but gcode_state is still IDLE — must NOT read as Observed.
280        assert_eq!(
281            evaluate(&project(), &status("IDLE", 0), Some(0)),
282            EffectStatus::Pending
283        );
284    }
285
286    #[test]
287    fn new_print_error_after_send_is_reported() {
288        // The real SD-card fault: print_error becomes 0x0500C010 while IDLE.
289        assert_eq!(
290            evaluate(&project(), &status("IDLE", 0x0500C010), Some(0)),
291            EffectStatus::NewError(0x0500C010)
292        );
293    }
294
295    #[test]
296    fn preexisting_error_is_not_blamed_on_this_command() {
297        // Same error present before sending (baseline) -> not a new fault; with
298        // no state transition yet it's simply pending.
299        assert_eq!(
300            evaluate(&project(), &status("IDLE", 0x0500C010), Some(0x0500C010)),
301            EffectStatus::Pending
302        );
303    }
304
305    #[test]
306    fn pause_resume_stop_effects() {
307        assert_eq!(
308            evaluate(&Command::Pause, &status("PAUSE", 0), Some(0)),
309            EffectStatus::Observed
310        );
311        assert_eq!(
312            evaluate(&Command::Resume, &status("RUNNING", 0), Some(0)),
313            EffectStatus::Observed
314        );
315        assert_eq!(
316            evaluate(&Command::Stop, &status("FINISH", 0), Some(0)),
317            EffectStatus::Observed
318        );
319        // Wrong state -> still pending.
320        assert_eq!(
321            evaluate(&Command::Pause, &status("RUNNING", 0), Some(0)),
322            EffectStatus::Pending
323        );
324    }
325
326    #[test]
327    fn stop_tolerates_a_transient_abort_error() {
328        // Aborting a paused/errored job transiently raises a new print_error
329        // (0x0300400C) before it settles to FAILED. Reaching a terminal state is
330        // success — the transient error must NOT be reported as a failure.
331        assert_eq!(
332            evaluate(&Command::Stop, &status("FAILED", 0x0300400C), Some(0)),
333            EffectStatus::Observed
334        );
335        // Not terminal yet -> pending (keep waiting), still not NewError.
336        assert_eq!(
337            evaluate(&Command::Stop, &status("PAUSE", 0x0300400C), Some(0)),
338            EffectStatus::Pending
339        );
340    }
341
342    #[test]
343    fn a_new_error_beats_an_otherwise_observed_effect() {
344        // Even if the state looks right, a fresh fault means it did not cleanly
345        // take effect.
346        assert_eq!(
347            evaluate(&Command::Resume, &status("RUNNING", 0x0500C010), Some(0)),
348            EffectStatus::NewError(0x0500C010)
349        );
350    }
351}