Skip to main content

bambu_rs/core/
report.rs

1//! Printer report state and the pushall→delta merge engine.
2//!
3//! P1/A1-class printers (including the A1 mini) push only **deltas** after an
4//! initial `pushall` snapshot, so the client must cache the state and merge each
5//! incoming message into it. This module is the structural merge engine and the
6//! cached-state container; the **typed** accessors (gcode_state, temperatures,
7//! AMS, HMS, …) are added once we have real A1 mini captures to derive the exact
8//! field names from — until then we keep the state as untyped JSON.
9//!
10//! ## Merge semantics (clean-room — confirm against the device)
11//!
12//! - **Objects** merge recursively (this is what makes a delta a delta: keys the
13//!   delta doesn't mention are retained).
14//! - **Everything else** (scalars, **arrays**, `null`) in the delta **replaces**
15//!   the cached value. In particular `null` sets the value to `null`; it does
16//!   *not* delete the key (this differs from RFC 7386 JSON Merge Patch).
17//! - **Arrays are replaced wholesale**, not merged element-by-element.
18//!
19//! The wholesale-array rule was the least certain (some Bambu docs hint at partial
20//! AMS-tray updates), so we **observed it**: over a 180 s capture during which an
21//! AMS-Lite slot was physically pulled and reinserted, the A1 mini emitted **zero**
22//! autonomous `ams` deltas — `ams` (the complete `ams.ams[].tray[]` structure)
23//! appeared only in the full `pushall`. So on the A1 mini there are no partial
24//! tray-array deltas and wholesale replacement is correct; a per-path array-merge
25//! policy would only add a stale-entry risk for an unobserved case. Re-confirmed
26//! during a real 2-colour print: a red→black swap surfaced only via the scalar
27//! `tray_now`/`tray_pre`/`tray_tar` deltas; the `ams.ams[].tray[]` array still
28//! came only in full pushalls, never partially. (Other models: not verified.)
29//! The engine applies messages in **arrival order** (last-writer-wins); reordering
30//! / dropping stale deltas is the transport layer's responsibility.
31
32use serde_json::{Map, Value};
33
34/// Whether a **single raw report message** is the full `pushall` response.
35///
36/// Observed on the A1 mini: the full snapshot carries `print.msg == 0` (~64
37/// fields), while periodic deltas carry `print.msg == 1` (a few fields) — and
38/// **both** set `command == "push_status"`, so the command alone isn't a reliable
39/// full-vs-delta signal. Inspect the **raw incoming message** (not merged state):
40/// `msg` is per-message, so a merged delta would overwrite a snapshot's `msg == 0`.
41/// Missing `msg` falls back to the command check (older firmware may omit it).
42pub fn is_full_snapshot_message(message: &Value) -> bool {
43    let print = message.get("print");
44    let is_push_status = print
45        .and_then(|p| p.get("command"))
46        .and_then(|v| v.as_str())
47        == Some("push_status");
48    let msg = print.and_then(|p| p.get("msg")).and_then(|v| v.as_i64());
49    is_push_status && msg.is_none_or(|m| m == 0)
50}
51
52/// Recursively merge `delta` into `target` (see the module docs for semantics).
53pub fn merge_into(target: &mut Value, delta: &Value) {
54    if let (Value::Object(t), Value::Object(d)) = (&mut *target, delta) {
55        for (key, value) in d {
56            match t.get_mut(key) {
57                Some(existing) => merge_into(existing, value),
58                None => {
59                    t.insert(key.clone(), value.clone());
60                }
61            }
62        }
63    } else {
64        *target = delta.clone();
65    }
66}
67
68/// The fields that identify a print job (`task_id` / `subtask_id` / `gcode_file`)
69/// — stable for a job's life, so a change means a *different* print. Empty strings
70/// read as absent, matching the typed accessors.
71type PrintIdentity = (Option<String>, Option<String>, Option<String>);
72
73fn print_identity(state: &Value) -> PrintIdentity {
74    let field = |key: &str| {
75        state
76            .pointer(&format!("/print/{key}"))
77            .and_then(Value::as_str)
78            .filter(|s| !s.is_empty())
79            .map(str::to_owned)
80    };
81    (field("task_id"), field("subtask_id"), field("gcode_file"))
82}
83
84/// Whether an identity names an actual print (vs. the empty post-teardown state),
85/// so the progress reset fires when a new job *starts*, not when one ends.
86fn is_meaningful(id: &PrintIdentity) -> bool {
87    id.0.is_some() || id.1.is_some() || id.2.is_some()
88}
89
90/// The cached, merged printer state.
91///
92/// Seed it with the `pushall` snapshot and feed every subsequent report message
93/// through [`ReportState::apply`]; both go through the same merge so a snapshot
94/// is just a delta that happens to contain everything.
95#[derive(Debug, Clone)]
96pub struct ReportState {
97    state: Value,
98}
99
100impl Default for ReportState {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl ReportState {
107    /// A fresh, empty state.
108    pub fn new() -> Self {
109        Self {
110            state: Value::Object(Map::new()),
111        }
112    }
113
114    /// Merge one report message (snapshot or delta) into the cached state.
115    ///
116    /// A new print inherits the just-finished job's `mc_percent`/`layer_num`:
117    /// the printer reports the new `task_id` well before the first fresh percent,
118    /// so a job in preheat/calibration would read 100% / the old layer count.
119    /// When the merge reveals a new (different, non-empty) print identity, zero
120    /// those carried-over fields — but only the ones this very message didn't set,
121    /// so a report that brings its own fresh progress is trusted.
122    pub fn apply(&mut self, message: Value) {
123        let before = print_identity(&self.state);
124        merge_into(&mut self.state, &message);
125        let after = print_identity(&self.state);
126        if after != before
127            && is_meaningful(&after)
128            && let Some(print) = self.state.get_mut("print").and_then(Value::as_object_mut)
129        {
130            for field in ["mc_percent", "layer_num"] {
131                if message.pointer(&format!("/print/{field}")).is_none() {
132                    print.insert(field.to_string(), Value::from(0));
133                }
134            }
135        }
136    }
137
138    /// The full merged state as JSON.
139    pub fn get(&self) -> &Value {
140        &self.state
141    }
142
143    /// Look up a value by JSON Pointer (e.g. `"/print/gcode_state"`).
144    pub fn pointer(&self, pointer: &str) -> Option<&Value> {
145        self.state.pointer(pointer)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use proptest::prelude::*;
153    use serde_json::json;
154
155    #[test]
156    fn objects_merge_recursively_keeping_unmentioned_keys() {
157        let mut state = json!({ "print": { "a": 1, "b": 2, "nested": { "x": 1 } } });
158        merge_into(
159            &mut state,
160            &json!({ "print": { "b": 3, "c": 4, "nested": { "y": 2 } } }),
161        );
162        assert_eq!(
163            state,
164            json!({ "print": { "a": 1, "b": 3, "c": 4, "nested": { "x": 1, "y": 2 } } })
165        );
166    }
167
168    #[test]
169    fn scalars_are_replaced() {
170        let mut state = json!({ "temp": 200 });
171        merge_into(&mut state, &json!({ "temp": 215 }));
172        assert_eq!(state, json!({ "temp": 215 }));
173    }
174
175    #[test]
176    fn arrays_are_replaced_wholesale_not_element_merged() {
177        let mut state = json!({ "trays": [1, 2, 3] });
178        merge_into(&mut state, &json!({ "trays": [9] }));
179        assert_eq!(state, json!({ "trays": [9] }));
180    }
181
182    #[test]
183    fn null_replaces_value_but_keeps_the_key() {
184        let mut state = json!({ "k": 5 });
185        merge_into(&mut state, &json!({ "k": null }));
186        assert_eq!(state, json!({ "k": null }));
187        assert!(state.as_object().unwrap().contains_key("k"));
188    }
189
190    // ── new-print progress reset ──
191    // A new print inherits the finished job's mc_percent/layer_num until the
192    // printer pushes fresh values — it reports the new task_id well before the
193    // first percent — so a just-started job reads 100%. Detect the new print by
194    // its identity and zero the stale carryover.
195
196    #[test]
197    fn a_new_print_zeroes_progress_carried_over_from_the_last_job() {
198        let mut rs = ReportState::new();
199        rs.apply(json!({ "print": {
200            "task_id": "A", "gcode_file": "a.3mf", "mc_percent": 100, "layer_num": 60,
201        }}));
202        // New print B arrives (preheat) with the new identity but no progress yet.
203        rs.apply(json!({ "print": {
204            "task_id": "B", "gcode_file": "b.3mf", "gcode_state": "PREPARE",
205        }}));
206        let p = rs.pointer("/print").unwrap();
207        assert_eq!(
208            p.get("mc_percent"),
209            Some(&json!(0)),
210            "stale 100% must reset on a new print"
211        );
212        assert_eq!(p.get("layer_num"), Some(&json!(0)));
213    }
214
215    #[test]
216    fn a_progress_delta_within_the_same_print_is_kept() {
217        let mut rs = ReportState::new();
218        rs.apply(json!({ "print": { "task_id": "A", "mc_percent": 30, "layer_num": 5 } }));
219        rs.apply(json!({ "print": { "mc_percent": 31 } })); // same print, later delta
220        let p = rs.pointer("/print").unwrap();
221        assert_eq!(
222            p.get("mc_percent"),
223            Some(&json!(31)),
224            "same-print progress must not be zeroed"
225        );
226        assert_eq!(p.get("layer_num"), Some(&json!(5)));
227    }
228
229    #[test]
230    fn a_new_print_that_brings_its_own_percent_keeps_it() {
231        let mut rs = ReportState::new();
232        rs.apply(json!({ "print": { "task_id": "A", "mc_percent": 100 } }));
233        rs.apply(json!({ "print": { "task_id": "B", "mc_percent": 7 } }));
234        assert_eq!(
235            rs.pointer("/print/mc_percent"),
236            Some(&json!(7)),
237            "trust a fresh percent"
238        );
239    }
240
241    #[test]
242    fn finishing_a_print_keeps_its_final_progress() {
243        // Identity clears on teardown; the reset must only fire for a NEW print,
244        // so a finished job still reads 100%.
245        let mut rs = ReportState::new();
246        rs.apply(json!({ "print": { "task_id": "A", "mc_percent": 100, "layer_num": 60 } }));
247        rs.apply(json!({ "print": { "task_id": "", "gcode_state": "FINISH" } }));
248        assert_eq!(
249            rs.pointer("/print/mc_percent"),
250            Some(&json!(100)),
251            "a finished print keeps 100%"
252        );
253    }
254
255    #[test]
256    fn new_keys_are_added() {
257        let mut state = json!({ "a": 1 });
258        merge_into(&mut state, &json!({ "b": 2 }));
259        assert_eq!(state, json!({ "a": 1, "b": 2 }));
260    }
261
262    #[test]
263    fn last_writer_wins_so_a_stale_delta_reverts_overlapping_fields() {
264        // Documents that the engine has no staleness protection: applying an old
265        // delta after a newer one reverts the overlapping field. Ordering is the
266        // transport layer's job.
267        let mut state = json!({ "layer": 0 });
268        merge_into(&mut state, &json!({ "layer": 10 })); // newer
269        merge_into(&mut state, &json!({ "layer": 5 })); // stale, re-applied late
270        assert_eq!(state, json!({ "layer": 5 }));
271    }
272
273    #[test]
274    fn report_state_seeds_then_merges_deltas() {
275        let mut rs = ReportState::new();
276        rs.apply(json!({ "print": { "gcode_state": "RUNNING", "layer_num": 1 } }));
277        rs.apply(json!({ "print": { "layer_num": 2 } })); // delta
278        assert_eq!(rs.pointer("/print/gcode_state"), Some(&json!("RUNNING")));
279        assert_eq!(rs.pointer("/print/layer_num"), Some(&json!(2)));
280    }
281
282    #[test]
283    fn full_snapshot_is_msg_zero_not_just_push_status() {
284        // Full pushall response: push_status + msg 0.
285        assert!(is_full_snapshot_message(
286            &json!({ "print": { "command": "push_status", "msg": 0 } })
287        ));
288        // A delta also says push_status but msg == 1 -> NOT the full snapshot.
289        assert!(!is_full_snapshot_message(
290            &json!({ "print": { "command": "push_status", "msg": 1 } })
291        ));
292        // Older firmware without msg: fall back to the command check.
293        assert!(is_full_snapshot_message(
294            &json!({ "print": { "command": "push_status" } })
295        ));
296        // Not a push_status at all.
297        assert!(!is_full_snapshot_message(
298            &json!({ "print": { "command": "gcode_line" } })
299        ));
300    }
301
302    // --- property-based tests -------------------------------------------------
303
304    fn arb_json() -> impl Strategy<Value = Value> {
305        let leaf = prop_oneof![
306            Just(Value::Null),
307            any::<bool>().prop_map(Value::Bool),
308            any::<i64>().prop_map(|n| json!(n)),
309            "[a-z0-9]{0,5}".prop_map(Value::String),
310        ];
311        leaf.prop_recursive(4, 24, 6, |inner| {
312            prop_oneof![
313                prop::collection::vec(inner.clone(), 0..5).prop_map(Value::Array),
314                prop::collection::vec(("[a-z]{1,4}", inner), 0..5)
315                    .prop_map(|kvs| Value::Object(kvs.into_iter().collect())),
316            ]
317        })
318    }
319
320    fn arb_object() -> impl Strategy<Value = Value> {
321        prop::collection::vec(("[a-z]{1,4}", arb_json()), 0..6)
322            .prop_map(|kvs| Value::Object(kvs.into_iter().collect()))
323    }
324
325    fn prefix_keys(v: &Value, prefix: &str) -> Value {
326        match v {
327            Value::Object(m) => Value::Object(
328                m.iter()
329                    .map(|(k, val)| (format!("{prefix}{k}"), val.clone()))
330                    .collect(),
331            ),
332            other => other.clone(),
333        }
334    }
335
336    proptest! {
337        /// Re-applying the same delta never changes the result.
338        #[test]
339        fn merge_is_idempotent(base in arb_object(), delta in arb_object()) {
340            let mut once = base.clone();
341            merge_into(&mut once, &delta);
342            let mut twice = once.clone();
343            merge_into(&mut twice, &delta);
344            prop_assert_eq!(once, twice);
345        }
346
347        /// Keys present in the base but absent from the delta are retained.
348        #[test]
349        fn base_keys_absent_from_delta_are_retained(base in arb_object(), delta in arb_object()) {
350            let mut merged = base.clone();
351            merge_into(&mut merged, &delta);
352            if let (Value::Object(b), Value::Object(d), Value::Object(m)) = (&base, &delta, &merged) {
353                for (k, v) in b {
354                    if !d.contains_key(k) {
355                        prop_assert_eq!(m.get(k), Some(v));
356                    }
357                }
358            }
359        }
360
361        /// Two deltas touching disjoint top-level keys commute.
362        #[test]
363        fn disjoint_deltas_commute(base in arb_object(), a in arb_object(), b in arb_object()) {
364            let a = prefix_keys(&a, "a_");
365            let b = prefix_keys(&b, "b_");
366
367            let mut ab = base.clone();
368            merge_into(&mut ab, &a);
369            merge_into(&mut ab, &b);
370
371            let mut ba = base.clone();
372            merge_into(&mut ba, &b);
373            merge_into(&mut ba, &a);
374
375            prop_assert_eq!(ab, ba);
376        }
377    }
378}