Skip to main content

bambu_rs/core/
timelapse.rs

1//! Pure per-layer timelapse capture state machine, shared by the CLI
2//! (`bambu timelapse capture`) and the server's serve-internal runner.
3//!
4//! Given a stream of [`PrinterStatus`] snapshots it decides — per snapshot —
5//! whether to grab a frame (and its number + layer) or to end the watch. All
6//! I/O (actually grabbing the frame, writing files, the MQTT/ws transport) is
7//! the caller's; this stays free of it so the decision logic is exhaustively
8//! unit-testable and identical across both call sites.
9
10use crate::core::status::{GcodeState, PrinterStatus};
11
12/// What the caller should do for one observed status snapshot.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CaptureAction {
15    /// Keep watching; nothing to do this tick.
16    Continue,
17    /// The watched print reached a terminal/abnormal state (or, without
18    /// `wait`, was never active) — end the capture.
19    Stop,
20    /// A new layer crossed the `every` filter — grab frame `frame_no` (1-based).
21    Capture { frame_no: u64, layer: i64 },
22}
23
24/// A print's identity. A changed identity means a *new* print, so a stale
25/// `layer_num` carried over (e.g. across a reconnect, or one print ending and
26/// another starting) can't suppress or mislabel the next print's first frame.
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28struct PrintIdentity {
29    task_id: Option<String>,
30    subtask_id: Option<String>,
31    gcode_file: Option<String>,
32}
33
34impl PrintIdentity {
35    fn of(s: &PrinterStatus) -> Self {
36        Self {
37            task_id: s.task_id.clone(),
38            subtask_id: s.subtask_id.clone(),
39            gcode_file: s.gcode_file.clone(),
40        }
41    }
42    /// An idle printer reports blank/`"0"` ids; those carry no signal, so don't
43    /// treat the idle→idle transition as a "new print".
44    fn is_meaningful(&self) -> bool {
45        [&self.task_id, &self.subtask_id, &self.gcode_file]
46            .into_iter()
47            .any(|f| f.as_deref().is_some_and(|v| !v.is_empty() && v != "0"))
48    }
49}
50
51/// Per-print capture state. One session captures one print: it watches for the
52/// print to be active (optionally [`wait`](CaptureSession::new)ing for it),
53/// emits a [`CaptureAction::Capture`] on each new layer that passes the `every`
54/// filter, and [`Stop`](CaptureAction::Stop)s when the print reaches a terminal
55/// state or errors.
56pub struct CaptureSession {
57    every: u64,
58    wait: bool,
59    last_layer: Option<i64>,
60    frame_no: u64,
61    seen_active: bool,
62    identity: Option<PrintIdentity>,
63}
64
65impl CaptureSession {
66    /// `every` = capture every Nth layer (clamped to >= 1). `wait` = sit through
67    /// idle/finished/stale-error states until a print becomes active (so the
68    /// session can be started before the print), rather than stopping at once.
69    pub fn new(every: u64, wait: bool) -> Self {
70        Self {
71            every: every.max(1),
72            wait,
73            last_layer: None,
74            frame_no: 0,
75            seen_active: false,
76            identity: None,
77        }
78    }
79
80    /// Frames emitted so far (the last `Capture`'s `frame_no`).
81    pub fn frames(&self) -> u64 {
82        self.frame_no
83    }
84
85    /// Feed one status snapshot; get the action to take.
86    pub fn observe(&mut self, s: &PrinterStatus) -> CaptureAction {
87        let state = s.state();
88        let active = is_active(state);
89        if active {
90            self.seen_active = true;
91            // A new (meaningful) print identity resets per-print layer tracking,
92            // so its first layer isn't suppressed by the previous print's.
93            let id = PrintIdentity::of(s);
94            if id.is_meaningful() && self.identity.as_ref() != Some(&id) {
95                self.identity = Some(id);
96                self.last_layer = None;
97            }
98        }
99
100        // Stop takes priority over a coincident capture so termination is always
101        // clean — the final layer was already captured on the last active tick.
102        if self.should_stop(state, s.error.is_some()) {
103            return CaptureAction::Stop;
104        }
105
106        if active
107            && let Some(layer) = s.layer_num
108            && self.last_layer != Some(layer)
109        {
110            self.last_layer = Some(layer);
111            // Capture every Nth layer (layer 0 = the first reported layer).
112            if layer >= 0 && (layer as u64).is_multiple_of(self.every) {
113                self.frame_no += 1;
114                return CaptureAction::Capture {
115                    frame_no: self.frame_no,
116                    layer,
117                };
118            }
119        }
120        CaptureAction::Continue
121    }
122
123    fn should_stop(&self, state: Option<GcodeState>, has_error: bool) -> bool {
124        should_stop(self.wait, self.seen_active, state, has_error)
125    }
126}
127
128/// A print is "active" (and so its `layer_num` is meaningful) only in these
129/// states; an idle printer's stale `layer_num` must not trigger a frame.
130fn is_active(state: Option<GcodeState>) -> bool {
131    matches!(
132        state,
133        Some(GcodeState::Running | GcodeState::Prepare | GcodeState::Pause)
134    )
135}
136
137/// Whether a watch should end: shared by the layer-driven [`CaptureSession`] and
138/// the time-driven [`PrintActivitySession`]. While `wait`ing for a print to start
139/// (not yet `seen_active`) nothing ends it; an error ends it; otherwise a terminal
140/// state (finish/failed/idle) does.
141fn should_stop(wait: bool, seen_active: bool, state: Option<GcodeState>, has_error: bool) -> bool {
142    if wait && !seen_active {
143        return false;
144    }
145    if has_error {
146        return true;
147    }
148    matches!(
149        state,
150        Some(GcodeState::Finish | GcodeState::Failed | GcodeState::Idle)
151    )
152}
153
154/// What a time-sampled ("plain") runner should do on a tick — capture only while
155/// the print is active, end when it finishes/errors.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum ActivityAction {
158    /// The print is active — grab a frame now.
159    Capture,
160    /// Not active yet (idle/preparing-to-start while `wait`ing) — skip this tick.
161    Idle,
162    /// The print ended (or errored) — stop the runner.
163    Stop,
164}
165
166/// Tracks only a print's *lifecycle* (active → ended), no layer logic — for a
167/// runner that samples frames on its own clock (a wall-time interval) rather than
168/// per layer. Shares the active/stop rules with [`CaptureSession`] so both agree
169/// on when a print starts and ends.
170pub struct PrintActivitySession {
171    wait: bool,
172    seen_active: bool,
173}
174
175impl PrintActivitySession {
176    /// `wait` = sit through idle/finished until a print becomes active (so the
177    /// runner can be armed before the print starts), rather than stopping at once.
178    pub fn new(wait: bool) -> Self {
179        Self {
180            wait,
181            seen_active: false,
182        }
183    }
184
185    /// Feed the current status (call on each sampling tick). `Stop` takes priority
186    /// over `Capture` so a finishing print ends cleanly instead of grabbing once
187    /// more.
188    pub fn observe(&mut self, s: &PrinterStatus) -> ActivityAction {
189        let state = s.state();
190        let active = is_active(state);
191        if active {
192            self.seen_active = true;
193        }
194        if should_stop(self.wait, self.seen_active, state, s.error.is_some()) {
195            return ActivityAction::Stop;
196        }
197        if active {
198            ActivityAction::Capture
199        } else {
200            ActivityAction::Idle
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::core::status::DeviceError;
209
210    fn st(state: &str, layer: Option<i64>) -> PrinterStatus {
211        PrinterStatus {
212            gcode_state: Some(state.to_string()),
213            layer_num: layer,
214            ..Default::default()
215        }
216    }
217
218    fn cap(a: CaptureAction) -> Option<(u64, i64)> {
219        match a {
220            CaptureAction::Capture { frame_no, layer } => Some((frame_no, layer)),
221            _ => None,
222        }
223    }
224
225    #[test]
226    fn idle_does_not_capture_a_stale_layer() {
227        let mut s = CaptureSession::new(1, true);
228        // Idle with a leftover layer_num must not fire (and with --wait, must not stop).
229        assert_eq!(s.observe(&st("IDLE", Some(42))), CaptureAction::Continue);
230        assert_eq!(s.frames(), 0);
231    }
232
233    #[test]
234    fn first_active_layer_captures_once_then_no_recapture_on_same_layer() {
235        let mut s = CaptureSession::new(1, false);
236        assert_eq!(cap(s.observe(&st("RUNNING", Some(1)))), Some((1, 1)));
237        // Same layer again -> no recapture.
238        assert_eq!(s.observe(&st("RUNNING", Some(1))), CaptureAction::Continue);
239        // Next layer -> capture.
240        assert_eq!(cap(s.observe(&st("RUNNING", Some(2)))), Some((2, 2)));
241    }
242
243    #[test]
244    fn every_n_filters_layers_including_layer_zero() {
245        let mut s = CaptureSession::new(2, false);
246        assert_eq!(cap(s.observe(&st("RUNNING", Some(0)))), Some((1, 0))); // 0 % 2 == 0
247        assert_eq!(s.observe(&st("RUNNING", Some(1))), CaptureAction::Continue); // odd skipped
248        assert_eq!(cap(s.observe(&st("RUNNING", Some(2)))), Some((2, 2)));
249        assert_eq!(s.observe(&st("RUNNING", Some(3))), CaptureAction::Continue);
250    }
251
252    #[test]
253    fn negative_or_missing_layer_does_nothing() {
254        let mut s = CaptureSession::new(1, false);
255        assert_eq!(s.observe(&st("RUNNING", Some(-1))), CaptureAction::Continue);
256        assert_eq!(s.observe(&st("RUNNING", None)), CaptureAction::Continue);
257        assert_eq!(s.frames(), 0);
258    }
259
260    #[test]
261    fn without_wait_idle_or_finish_stops_immediately() {
262        assert_eq!(
263            CaptureSession::new(1, false).observe(&st("IDLE", None)),
264            CaptureAction::Stop
265        );
266        assert_eq!(
267            CaptureSession::new(1, false).observe(&st("FINISH", Some(100))),
268            CaptureAction::Stop
269        );
270    }
271
272    #[test]
273    fn with_wait_sits_through_idle_finish_and_stale_error_until_active() {
274        let mut s = CaptureSession::new(1, true);
275        assert_eq!(s.observe(&st("IDLE", None)), CaptureAction::Continue);
276        assert_eq!(s.observe(&st("FINISH", Some(100))), CaptureAction::Continue);
277        // A stale print_error from the previous print is ignored while waiting.
278        let mut errd = st("FINISH", Some(100));
279        errd.error = DeviceError::from_code(0x05004003);
280        assert_eq!(s.observe(&errd), CaptureAction::Continue);
281        // Then the new print starts and captures.
282        assert_eq!(cap(s.observe(&st("RUNNING", Some(1)))), Some((1, 1)));
283    }
284
285    #[test]
286    fn after_active_a_terminal_or_error_stops() {
287        let mut s = CaptureSession::new(1, true);
288        assert!(cap(s.observe(&st("RUNNING", Some(1)))).is_some());
289        assert_eq!(s.observe(&st("FINISH", Some(1))), CaptureAction::Stop);
290
291        let mut s = CaptureSession::new(1, true);
292        assert!(cap(s.observe(&st("RUNNING", Some(1)))).is_some());
293        let mut errd = st("RUNNING", Some(1));
294        errd.error = DeviceError::from_code(0x1234);
295        assert_eq!(s.observe(&errd), CaptureAction::Stop);
296    }
297
298    #[test]
299    fn pause_counts_as_active() {
300        let mut s = CaptureSession::new(1, false);
301        assert_eq!(cap(s.observe(&st("PAUSE", Some(5)))), Some((1, 5)));
302    }
303
304    #[test]
305    fn a_new_print_identity_resets_layer_tracking() {
306        let mut s = CaptureSession::new(1, true);
307        let mut a = st("RUNNING", Some(10));
308        a.task_id = Some("task-A".into());
309        assert!(cap(s.observe(&a)).is_some()); // captures layer 10 of print A
310        // Print B starts; its layer_num restarts at 0 < 10. Without the identity
311        // reset, last_layer=10 would let 0 through anyway (10 != 0), but a print
312        // that begins again at the SAME layer must still fire — assert the reset
313        // makes a repeated layer number of a new print capture.
314        let mut b = st("RUNNING", Some(10));
315        b.task_id = Some("task-B".into());
316        assert!(
317            cap(s.observe(&b)).is_some(),
318            "a new print at the same layer number must capture (identity reset)"
319        );
320    }
321
322    // ── PrintActivitySession (time-sampled "plain" lifecycle) ──
323
324    #[test]
325    fn plain_waits_through_idle_then_captures_while_active() {
326        let mut a = PrintActivitySession::new(true);
327        assert_eq!(a.observe(&st("IDLE", None)), ActivityAction::Idle); // armed before print
328        assert_eq!(a.observe(&st("RUNNING", None)), ActivityAction::Capture);
329        assert_eq!(a.observe(&st("PAUSE", None)), ActivityAction::Capture); // pause still active
330    }
331
332    #[test]
333    fn plain_stops_when_the_print_finishes() {
334        let mut a = PrintActivitySession::new(true);
335        assert_eq!(a.observe(&st("RUNNING", None)), ActivityAction::Capture);
336        assert_eq!(a.observe(&st("FINISH", None)), ActivityAction::Stop);
337    }
338
339    #[test]
340    fn plain_stops_on_a_device_error() {
341        let mut a = PrintActivitySession::new(true);
342        a.observe(&st("RUNNING", None));
343        let mut e = st("RUNNING", None);
344        e.error = Some(DeviceError::from_code(0x1200_8016).unwrap());
345        assert_eq!(a.observe(&e), ActivityAction::Stop);
346    }
347
348    #[test]
349    fn plain_without_wait_stops_if_never_active() {
350        let mut a = PrintActivitySession::new(false);
351        assert_eq!(a.observe(&st("IDLE", None)), ActivityAction::Stop);
352    }
353}