Skip to main content

bambu_rs/core/
session.rs

1//! Verify-by-reread **orchestration**, I/O-free.
2//!
3//! [`VerifySession`] is the brain of `send_and_verify`, extracted from the MQTT
4//! client so it can be tested without a network: you publish a command, then feed
5//! it the printer's report messages one at a time via [`VerifySession::observe`];
6//! it returns a [`CommandOutcome`] the moment it can conclude, and
7//! [`VerifySession::timed_out`] gives the verdict when no conclusive message
8//! arrives in time. The real client is a thin async shell around this; tests
9//! drive it with a [`crate::core::fake::FakePrinter`] message sequence.
10//!
11//! The order of operations matters and is the reason this is per-message:
12//! - the ACK (echoed `sequence_id` + `result`/`reason` under the command's
13//!   category) must be matched *as its message merges*, before a later
14//!   `push_status` overwrites that category's `sequence_id` with the printer's
15//!   own counter (the two-kinds-of-`sequence_id` hazard);
16//! - the `print_error` baseline is captured from the first **full** snapshot so
17//!   only a *new* fault is blamed on the command.
18
19use serde::Serialize;
20use serde_json::Value;
21
22use crate::core::command::Command;
23use crate::core::report::{ReportState, is_full_snapshot_message};
24use crate::core::status::PrinterStatus;
25use crate::core::verify::{self, EffectStatus};
26
27/// Which stage of verification failed to confirm a command.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum VerifyStage {
31    /// No usable ACK arrived (the printer never echoed our `sequence_id`).
32    Ack,
33    /// The ACK was `success`, but the command's *effect* was never observed in
34    /// the report before the timeout (e.g. a print that never started).
35    Effect,
36}
37
38/// The result of verifying a control command.
39///
40/// The ACK (`result == "success"`) is necessary but **not sufficient**: for
41/// commands with an observable effect the effect is also confirmed from the
42/// report, and a new `print_error` after the command is treated as a rejection
43/// (observed: a failing SD card ACKed `project_file` then never printed).
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45#[serde(tag = "outcome", rename_all = "snake_case")]
46pub enum CommandOutcome {
47    /// ACKed `success` **and**, for effectful commands, the effect was observed.
48    Verified,
49    /// The printer rejected the command (ACK `result != success`) or a new
50    /// device error appeared right after it. `reason` is human-readable.
51    Rejected { reason: String },
52    /// Sent but not confirmed — never assume success. `stage` says whether we
53    /// never saw an ACK ([`VerifyStage::Ack`]) or saw the ACK but never the
54    /// effect ([`VerifyStage::Effect`]).
55    Unverified { stage: VerifyStage },
56}
57
58/// Drives verify-by-reread for one command over a stream of report messages.
59pub struct VerifySession {
60    cmd: Command,
61    seq: String,
62    state: ReportState,
63    acked: bool,
64    baseline_error: Option<i64>,
65}
66
67impl VerifySession {
68    /// Start verifying `cmd`, which was published with `seq` as its `sequence_id`.
69    pub fn new(cmd: Command, seq: impl Into<String>) -> Self {
70        Self {
71            cmd,
72            seq: seq.into(),
73            state: ReportState::new(),
74            acked: false,
75            baseline_error: None,
76        }
77    }
78
79    /// Feed one report message. Returns `Some(outcome)` once a verdict is
80    /// reached; `None` means keep waiting (feed the next message, or call
81    /// [`timed_out`](Self::timed_out) when the deadline passes).
82    pub fn observe(&mut self, message: Value) -> Option<CommandOutcome> {
83        let full = is_full_snapshot_message(&message);
84        self.state.apply(message);
85
86        // Baseline print_error from the first full snapshot, so we react only to
87        // a NEW fault, not a pre-existing one.
88        if self.baseline_error.is_none() && full {
89            self.baseline_error = Some(
90                PrinterStatus::from_state(self.state.get())
91                    .print_error
92                    .unwrap_or(0),
93            );
94        }
95
96        let cat = self.cmd.category();
97
98        // Phase 1 — the ACK echoes our sequence_id and carries result/reason.
99        if !self.acked {
100            let echoed = self
101                .state
102                .pointer(&format!("/{cat}/sequence_id"))
103                .and_then(|v| v.as_str())
104                == Some(self.seq.as_str());
105            if let (true, Some(result)) = (
106                echoed,
107                self.state
108                    .pointer(&format!("/{cat}/result"))
109                    .and_then(|v| v.as_str()),
110            ) {
111                if !result.eq_ignore_ascii_case("success") {
112                    let reason = self
113                        .state
114                        .pointer(&format!("/{cat}/reason"))
115                        .and_then(|v| v.as_str())
116                        .unwrap_or(result)
117                        .to_string();
118                    return Some(CommandOutcome::Rejected { reason });
119                }
120                self.acked = true;
121                // For commands with no readable effect, the ACK is the verdict.
122                if !verify::has_observable_effect(&self.cmd) {
123                    return Some(CommandOutcome::Verified);
124                }
125            }
126        }
127
128        // Phase 2 — confirm the effect actually happened (and no new fault).
129        if self.acked {
130            let status = PrinterStatus::from_state(self.state.get());
131            match verify::evaluate(&self.cmd, &status, self.baseline_error) {
132                EffectStatus::Observed => return Some(CommandOutcome::Verified),
133                EffectStatus::NewError(code) => {
134                    return Some(CommandOutcome::Rejected {
135                        reason: format!(
136                            "device reported error 0x{code:08X} after the command; \
137                             the effect was not observed (state unchanged)"
138                        ),
139                    });
140                }
141                EffectStatus::Pending => {}
142            }
143        }
144        None
145    }
146
147    /// The verdict when no conclusive message arrived before the timeout:
148    /// distinguishes "never ACKed" from "ACKed but the effect never showed".
149    pub fn timed_out(&self) -> CommandOutcome {
150        CommandOutcome::Unverified {
151            stage: if self.acked {
152                VerifyStage::Effect
153            } else {
154                VerifyStage::Ack
155            },
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::core::command::{Command, ProjectFile, SpeedLevel};
164    use crate::core::fake::FakePrinter;
165
166    fn project() -> Command {
167        Command::ProjectFile(ProjectFile::new("ftp:///model/x.gcode.3mf", 1, "x"))
168    }
169
170    /// Feed a whole message sequence; return the first concluded outcome (or the
171    /// timeout verdict if the sequence is exhausted without one).
172    fn run(cmd: Command, seq: &str, msgs: Vec<Value>) -> CommandOutcome {
173        let mut s = VerifySession::new(cmd, seq);
174        for m in msgs {
175            if let Some(o) = s.observe(m) {
176                return o;
177            }
178        }
179        s.timed_out()
180    }
181
182    #[test]
183    fn print_start_acked_and_running_is_verified() {
184        let mut p = FakePrinter::idle();
185        let msgs = vec![
186            p.snapshot(),
187            p.ack(&project(), "1", true, "success"),
188            p.effect_delta(&project()), // -> RUNNING
189        ];
190        assert_eq!(run(project(), "1", msgs), CommandOutcome::Verified);
191    }
192
193    #[test]
194    fn rejected_ack_is_rejected_with_reason() {
195        let p = FakePrinter::idle();
196        let msgs = vec![
197            p.snapshot(),
198            p.ack(&project(), "1", false, "print_id error"),
199        ];
200        assert_eq!(
201            run(project(), "1", msgs),
202            CommandOutcome::Rejected {
203                reason: "print_id error".to_string()
204            }
205        );
206    }
207
208    #[test]
209    fn acked_but_no_effect_times_out_as_unverified_effect() {
210        let p = FakePrinter::idle();
211        // ACK success, but the printer never leaves IDLE (the SD-card scenario).
212        let msgs = vec![p.snapshot(), p.ack(&project(), "1", true, "success")];
213        assert_eq!(
214            run(project(), "1", msgs),
215            CommandOutcome::Unverified {
216                stage: VerifyStage::Effect
217            }
218        );
219    }
220
221    #[test]
222    fn never_acked_times_out_as_unverified_ack() {
223        let p = FakePrinter::idle();
224        // Only the snapshot, no ACK ever.
225        let msgs = vec![p.snapshot()];
226        assert_eq!(
227            run(project(), "1", msgs),
228            CommandOutcome::Unverified {
229                stage: VerifyStage::Ack
230            }
231        );
232    }
233
234    #[test]
235    fn new_print_error_after_command_is_rejected() {
236        let mut p = FakePrinter::idle();
237        let msgs = vec![
238            p.snapshot(),
239            p.ack(&project(), "1", true, "success"),
240            p.new_error_delta(0x0500C010), // fault appears after the ACK
241        ];
242        assert!(matches!(
243            run(project(), "1", msgs),
244            CommandOutcome::Rejected { .. }
245        ));
246    }
247
248    #[test]
249    fn preexisting_error_is_not_blamed_on_the_command() {
250        // The fault is already present in the baseline snapshot; a no-effect ACK
251        // must read as Unverified(Effect), NOT Rejected (we didn't cause it).
252        let p = FakePrinter::with_error(0x0500C010);
253        let msgs = vec![p.snapshot(), p.ack(&project(), "1", true, "success")];
254        assert_eq!(
255            run(project(), "1", msgs),
256            CommandOutcome::Unverified {
257                stage: VerifyStage::Effect
258            }
259        );
260    }
261
262    #[test]
263    fn ack_only_command_is_verified_on_ack_without_effect() {
264        // gcode_line has no observable effect -> the success ACK is the verdict.
265        let cmd = Command::GcodeLine("G28".to_string());
266        let p = FakePrinter::idle();
267        let msgs = vec![p.snapshot(), p.ack(&cmd, "1", true, "success")];
268        assert_eq!(run(cmd, "1", msgs), CommandOutcome::Verified);
269    }
270
271    #[test]
272    fn effect_verified_via_spd_lvl_for_print_speed() {
273        let cmd = Command::PrintSpeed(SpeedLevel::Sport);
274        let mut p = FakePrinter::idle();
275        let msgs = vec![
276            p.snapshot(),
277            p.ack(&cmd, "1", true, "success"),
278            p.effect_delta(&cmd), // -> spd_lvl 3
279        ];
280        assert_eq!(run(cmd, "1", msgs), CommandOutcome::Verified);
281    }
282}