Skip to main content

bambu_rs/core/
command.rs

1//! MQTT command envelopes — the JSON published to `device/{serial}/request`.
2//!
3//! Each command renders to `{ "<category>": { "sequence_id": .., "command": ..,
4//! .. } }`. The printer echoes `sequence_id` back in its report, which is how we
5//! match a command to its effect (verify-by-reread).
6//!
7//! Shapes here are derived from the OpenBambuAPI spec and **must be confirmed
8//! against a real A1 mini**; where the device disagrees with the spec, the
9//! device wins.
10
11use serde_json::{Value, json};
12
13/// Monotonic allocator for the `sequence_id` field. Owned by the session/client
14/// — kept out of [`Command`] so commands stay pure, data-only values.
15#[derive(Debug, Default)]
16pub struct SequenceIds {
17    next: u64,
18}
19
20impl SequenceIds {
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Allocate the next sequence id. Bambu's `sequence_id` is a string.
26    pub fn next_id(&mut self) -> String {
27        let id = self.next;
28        self.next += 1;
29        id.to_string()
30    }
31}
32
33/// Basic AMS control action (`print.ams_control` `param`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AmsControl {
36    /// Resume after an AMS pause/error.
37    Resume,
38    /// Reset the AMS state.
39    Reset,
40    /// Pause the AMS.
41    Pause,
42}
43
44impl AmsControl {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            AmsControl::Resume => "resume",
48            AmsControl::Reset => "reset",
49            AmsControl::Pause => "pause",
50        }
51    }
52}
53
54/// Parameters for `print.ams_filament_setting` — set a tray's filament profile.
55/// Shapes are from the OpenBambuAPI spec. **[spec]**
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct AmsFilamentSetting {
58    /// Index of the AMS unit.
59    pub ams_id: u32,
60    /// Index of the tray within the unit.
61    pub tray_id: u32,
62    /// Filament profile id (e.g. `GFA00`); empty if unknown.
63    pub tray_info_idx: String,
64    /// Colour as hex `RRGGBBAA` (alpha usually `FF`).
65    pub tray_color: String,
66    /// Minimum/maximum nozzle temperature for the filament (°C).
67    pub nozzle_temp_min: i64,
68    pub nozzle_temp_max: i64,
69    /// Material, e.g. `PLA`, `PETG`.
70    pub tray_type: String,
71}
72
73/// Which LED a `system.ledctrl` command targets. The node name is what the
74/// printer matches in `lights_report`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum LedNode {
77    /// The chamber/logo light — present on the A1 mini. **[observed]**
78    ChamberLight,
79    /// A separate work light. **[spec]** — not present on every model (this A1
80    /// mini's `lights_report` only carries `chamber_light`), so it may ACK
81    /// without effect.
82    WorkLight,
83}
84
85impl LedNode {
86    /// The `led_node` token (also the `lights_report` node name).
87    pub fn as_str(self) -> &'static str {
88        match self {
89            LedNode::ChamberLight => "chamber_light",
90            LedNode::WorkLight => "work_light",
91        }
92    }
93}
94
95/// Whether to enable or disable the printer's per-print timelapse recording.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum TimelapseControl {
98    Enable,
99    Disable,
100}
101
102impl TimelapseControl {
103    /// The wire token the printer expects (`"enable"` / `"disable"`), which is
104    /// also exactly what the `ipcam.timelapse` report field reads back.
105    pub fn as_str(self) -> &'static str {
106        match self {
107            TimelapseControl::Enable => "enable",
108            TimelapseControl::Disable => "disable",
109        }
110    }
111}
112
113/// Print-speed profile. A1/P1 use four levels; the printer echoes the active
114/// one back as `spd_lvl` in its report.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum SpeedLevel {
117    Silent,
118    Standard,
119    Sport,
120    Ludicrous,
121}
122
123impl SpeedLevel {
124    /// The numeric level the printer expects (and reports as `spd_lvl`).
125    pub fn level(self) -> i64 {
126        match self {
127            SpeedLevel::Silent => 1,
128            SpeedLevel::Standard => 2,
129            SpeedLevel::Sport => 3,
130            SpeedLevel::Ludicrous => 4,
131        }
132    }
133
134    /// Map a numeric `spd_lvl` back to a level (`None` for an unknown value).
135    pub fn from_level(n: i64) -> Option<Self> {
136        match n {
137            1 => Some(SpeedLevel::Silent),
138            2 => Some(SpeedLevel::Standard),
139            3 => Some(SpeedLevel::Sport),
140            4 => Some(SpeedLevel::Ludicrous),
141            _ => None,
142        }
143    }
144
145    /// The lowercase name (`silent`/`standard`/`sport`/`ludicrous`).
146    pub fn as_str(self) -> &'static str {
147        match self {
148            SpeedLevel::Silent => "silent",
149            SpeedLevel::Standard => "standard",
150            SpeedLevel::Sport => "sport",
151            SpeedLevel::Ludicrous => "ludicrous",
152        }
153    }
154}
155
156/// A control/query command sent to the printer.
157///
158/// Pure and data-only: rendering to JSON ([`Command::to_payload`]) takes the
159/// caller-allocated `sequence_id`, so the value itself carries no mutable state.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Command {
162    /// Request a full state snapshot (`pushing.pushall`).
163    PushAll,
164    /// Request the module/firmware inventory (`info.get_version`). A read; the
165    /// response comes back under `/info` with a `module[]` array.
166    GetVersion,
167    /// Pause the current print.
168    Pause,
169    /// Resume a paused print.
170    Resume,
171    /// Stop (cancel) the current print — irreversible.
172    Stop,
173    /// Clear a print error / dismiss the error popup (`print.clean_print_error`).
174    /// This is what Bambu Studio sends to acknowledge an error popup. Command name
175    /// is from vendor docs; the A1 mini **ACKs it `success`** (observed). What it
176    /// does NOT do, observed on this unit: it did not move `gcode_state` out of
177    /// `FAILED` (tested with `print_error` already 0), and `ams_change_filament`
178    /// stayed rejected while `FAILED`. So treat it as "dismiss the error", not
179    /// "return to idle". Sending it *while an error is active* is still
180    /// uncharacterised. **[spec]**
181    CleanPrintError,
182    /// Send a single raw G-code line (`print.gcode_line`).
183    GcodeLine(String),
184    /// Print a raw G-code file already on the printer (`print.gcode_file`,
185    /// single-material — no AMS mapping). The value is the on-printer path.
186    GcodeFile(String),
187    /// Set the print-speed profile (`print.print_speed`). Effect is read back
188    /// from `spd_lvl`; can be sent mid-print.
189    PrintSpeed(SpeedLevel),
190    /// Start a print of a sliced 3MF on the printer (`print.project_file`).
191    ProjectFile(ProjectFile),
192    /// Turn an LED on/off (`system.ledctrl`). Effect is read back from
193    /// `lights_report` for the matching node, not the ACK alone.
194    Led { node: LedNode, on: bool },
195    /// Enable/disable the printer's per-print timelapse recording
196    /// (`camera.ipcam_timelapse`). Its effect is read back from the
197    /// `ipcam.timelapse` report field. **[spec]** — shape is from OpenBambuAPI;
198    /// not device-confirmed (this unit's camera is hardware-dead).
199    IpcamTimelapse(TimelapseControl),
200    /// Reboot the printer (`system.reboot`). Undocumented in the spec but
201    /// **accepted by the A1 mini** (observed). The connection drops and the
202    /// printer restarts, so there is no ACK — send it fire-and-forget.
203    Reboot,
204    /// Basic AMS control (`print.ams_control`): resume/reset/pause. **[spec]**
205    AmsControl(AmsControl),
206    /// Change the loaded filament via the AMS (`print.ams_change_filament`):
207    /// `target` tray, with the old (`curr_temp`) and new (`tar_temp`) nozzle
208    /// temps. Physically moves filament. **[spec]** — not device-confirmed.
209    AmsChangeFilament {
210        target: u32,
211        curr_temp: i64,
212        tar_temp: i64,
213    },
214    /// AMS RFID-read settings (`print.ams_user_setting`). **[spec]**
215    AmsUserSetting {
216        ams_id: u32,
217        /// Read RFID on startup.
218        startup_read: bool,
219        /// Read RFID on tray insertion.
220        tray_read: bool,
221    },
222    /// Set a tray's filament profile (`print.ams_filament_setting`). **[spec]**
223    AmsFilamentSetting(Box<AmsFilamentSetting>),
224    /// Run printer calibration (`print.calibration`, an `option` bitmask).
225    /// (Lidar — bit 0 — is X1-only and intentionally not exposed here.)
226    Calibration {
227        /// Bed leveling (bit 1 = 2).
228        bed_level: bool,
229        /// Vibration compensation (bit 2 = 4).
230        vibration: bool,
231        /// Motor-noise calibration (bit 3 = 8).
232        motor_noise: bool,
233    },
234}
235
236/// Parameters for `print.project_file` — start a sliced `.gcode.3mf` that is
237/// already on the printer's storage.
238///
239/// Field shapes are spec-derived (OpenBambuAPI) and must be confirmed on real
240/// hardware (the device is the source of truth — start the print and verify it
241/// reaches `RUNNING`). Calibration flags default to on, matching a normal slice.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ProjectFile {
244    /// URL of the file on the printer, e.g. `ftp:///cache/foo.gcode.3mf`.
245    pub url: String,
246    /// Plate number; the gcode is read from `Metadata/plate_{plate}.gcode`.
247    pub plate: u32,
248    /// Job name shown on the printer.
249    pub subtask_name: String,
250    /// Lowercase-hex md5 of the plate gcode (empty to skip the check).
251    pub md5: String,
252    /// Build-plate type (`auto`, or a specific plate name).
253    pub bed_type: String,
254    /// Use the AMS, with a per-filament tray mapping (`-1` = external spool).
255    pub use_ams: bool,
256    pub ams_mapping: Vec<i32>,
257    pub timelapse: bool,
258    pub flow_cali: bool,
259    pub bed_leveling: bool,
260    pub vibration_cali: bool,
261    pub layer_inspect: bool,
262}
263
264impl ProjectFile {
265    /// A minimal project print: no AMS, `auto` bed type, calibrations on.
266    pub fn new(url: impl Into<String>, plate: u32, subtask_name: impl Into<String>) -> Self {
267        Self {
268            url: url.into(),
269            plate,
270            subtask_name: subtask_name.into(),
271            md5: String::new(),
272            bed_type: "auto".to_string(),
273            use_ams: false,
274            ams_mapping: Vec::new(),
275            timelapse: false,
276            flow_cali: true,
277            bed_leveling: true,
278            vibration_cali: true,
279            layer_inspect: true,
280        }
281    }
282}
283
284impl Command {
285    /// The top-level message category — the single JSON key this command nests
286    /// under, and the key its ACK comes back under (`print` commands are ACKed
287    /// at `/print/...`, `system` commands at `/system/...`).
288    pub fn category(&self) -> &'static str {
289        match self {
290            Command::PushAll => "pushing",
291            Command::GetVersion => "info",
292            Command::Pause
293            | Command::Resume
294            | Command::Stop
295            | Command::CleanPrintError
296            | Command::GcodeLine(_)
297            | Command::GcodeFile(_)
298            | Command::PrintSpeed(_)
299            | Command::ProjectFile(_)
300            | Command::AmsControl(_)
301            | Command::AmsChangeFilament { .. }
302            | Command::AmsUserSetting { .. }
303            | Command::AmsFilamentSetting(_)
304            | Command::Calibration { .. } => "print",
305            Command::Led { .. } | Command::Reboot => "system",
306            Command::IpcamTimelapse(_) => "camera",
307        }
308    }
309
310    /// Render this command to its request-payload JSON, stamping `sequence_id`.
311    pub fn to_payload(&self, sequence_id: &str) -> Value {
312        match self {
313            Command::PushAll => json!({
314                "pushing": { "sequence_id": sequence_id, "command": "pushall" }
315            }),
316            Command::GetVersion => json!({
317                "info": { "sequence_id": sequence_id, "command": "get_version" }
318            }),
319            Command::Pause => print_command(sequence_id, "pause", ""),
320            Command::Resume => print_command(sequence_id, "resume", ""),
321            Command::Stop => print_command(sequence_id, "stop", ""),
322            Command::CleanPrintError => json!({
323                "print": {
324                    "sequence_id": sequence_id,
325                    "command": "clean_print_error",
326                    "subtask_id": "0",
327                }
328            }),
329            Command::GcodeLine(line) => print_command(sequence_id, "gcode_line", line),
330            Command::GcodeFile(path) => print_command(sequence_id, "gcode_file", path),
331            Command::PrintSpeed(level) => {
332                print_command(sequence_id, "print_speed", &level.level().to_string())
333            }
334            Command::AmsControl(action) => json!({
335                "print": {
336                    "sequence_id": sequence_id,
337                    "command": "ams_control",
338                    "param": action.as_str(),
339                }
340            }),
341            Command::AmsChangeFilament {
342                target,
343                curr_temp,
344                tar_temp,
345            } => json!({
346                "print": {
347                    "sequence_id": sequence_id,
348                    "command": "ams_change_filament",
349                    "target": target,
350                    "curr_temp": curr_temp,
351                    "tar_temp": tar_temp,
352                }
353            }),
354            Command::AmsUserSetting {
355                ams_id,
356                startup_read,
357                tray_read,
358            } => json!({
359                "print": {
360                    "sequence_id": sequence_id,
361                    "command": "ams_user_setting",
362                    "ams_id": ams_id,
363                    "startup_read_option": startup_read,
364                    "tray_read_option": tray_read,
365                }
366            }),
367            Command::AmsFilamentSetting(s) => json!({
368                "print": {
369                    "sequence_id": sequence_id,
370                    "command": "ams_filament_setting",
371                    "ams_id": s.ams_id,
372                    "tray_id": s.tray_id,
373                    "tray_info_idx": s.tray_info_idx,
374                    "tray_color": s.tray_color,
375                    "nozzle_temp_min": s.nozzle_temp_min,
376                    "nozzle_temp_max": s.nozzle_temp_max,
377                    "tray_type": s.tray_type,
378                }
379            }),
380            Command::ProjectFile(p) => json!({
381                "print": {
382                    "sequence_id": sequence_id,
383                    "command": "project_file",
384                    "param": format!("Metadata/plate_{}.gcode", p.plate),
385                    "url": p.url,
386                    "subtask_name": p.subtask_name,
387                    "md5": p.md5,
388                    "bed_type": p.bed_type,
389                    "timelapse": p.timelapse,
390                    "flow_cali": p.flow_cali,
391                    "bed_leveling": p.bed_leveling,
392                    "vibration_cali": p.vibration_cali,
393                    "layer_inspect": p.layer_inspect,
394                    "use_ams": p.use_ams,
395                    "ams_mapping": p.ams_mapping,
396                    "project_id": "0",
397                    "profile_id": "0",
398                    "task_id": "0",
399                    "subtask_id": "0",
400                }
401            }),
402            Command::Calibration {
403                bed_level,
404                vibration,
405                motor_noise,
406            } => {
407                let option = i64::from(*bed_level) * 2
408                    + i64::from(*vibration) * 4
409                    + i64::from(*motor_noise) * 8;
410                json!({
411                    "print": { "sequence_id": sequence_id, "command": "calibration", "option": option }
412                })
413            }
414            Command::Led { node, on } => json!({
415                "system": {
416                    "sequence_id": sequence_id,
417                    "command": "ledctrl",
418                    "led_node": node.as_str(),
419                    "led_mode": if *on { "on" } else { "off" },
420                    "led_on_time": 500,
421                    "led_off_time": 500,
422                    "loop_times": 0,
423                    "interval_time": 0,
424                }
425            }),
426            Command::Reboot => json!({
427                "system": { "sequence_id": sequence_id, "command": "reboot" }
428            }),
429            Command::IpcamTimelapse(control) => json!({
430                "camera": {
431                    "sequence_id": sequence_id,
432                    "command": "ipcam_timelapse",
433                    "control": control.as_str(),
434                }
435            }),
436        }
437    }
438}
439
440/// Build a `print.<command>` envelope carrying a `param` field.
441fn print_command(sequence_id: &str, command: &str, param: &str) -> Value {
442    json!({
443        "print": { "sequence_id": sequence_id, "command": command, "param": param }
444    })
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use serde_json::json;
451
452    #[test]
453    fn sequence_ids_are_monotonic_strings_from_zero() {
454        let mut ids = SequenceIds::new();
455        assert_eq!(ids.next_id(), "0");
456        assert_eq!(ids.next_id(), "1");
457        assert_eq!(ids.next_id(), "2");
458    }
459
460    #[test]
461    fn categories_match_the_envelope_key() {
462        assert_eq!(Command::PushAll.category(), "pushing");
463        assert_eq!(Command::Pause.category(), "print");
464        assert_eq!(Command::GcodeLine("G28".into()).category(), "print");
465        assert_eq!(Command::GcodeFile("/x".into()).category(), "print");
466        assert_eq!(
467            Command::ProjectFile(ProjectFile::new("u", 1, "n")).category(),
468            "print"
469        );
470        assert_eq!(
471            Command::Led {
472                node: LedNode::ChamberLight,
473                on: true
474            }
475            .category(),
476            "system"
477        );
478    }
479
480    #[test]
481    fn calibration_option_is_a_bitmask() {
482        let v = Command::Calibration {
483            bed_level: true,
484            vibration: true,
485            motor_noise: false,
486        }
487        .to_payload("1");
488        assert_eq!(v["print"]["command"], "calibration");
489        assert_eq!(v["print"]["option"], 6); // 2 (bed) | 4 (vibration)
490        assert_eq!(
491            Command::Calibration {
492                bed_level: false,
493                vibration: false,
494                motor_noise: true,
495            }
496            .to_payload("1")["print"]["option"],
497            8
498        );
499    }
500
501    #[test]
502    fn clean_print_error_payload() {
503        let v = Command::CleanPrintError.to_payload("3");
504        assert_eq!(v["print"]["command"], "clean_print_error");
505        assert_eq!(v["print"]["sequence_id"], "3");
506        assert_eq!(v["print"]["subtask_id"], "0");
507        assert_eq!(Command::CleanPrintError.category(), "print");
508    }
509
510    #[test]
511    fn gcode_file_payload() {
512        assert_eq!(
513            Command::GcodeFile("/cache/foo.gcode".into()).to_payload("2"),
514            json!({ "print": { "sequence_id": "2", "command": "gcode_file", "param": "/cache/foo.gcode" } })
515        );
516    }
517
518    #[test]
519    fn project_file_payload_has_plate_and_lan_ids() {
520        let pf = ProjectFile::new("ftp:///cache/x.gcode.3mf", 2, "x job");
521        let v = Command::ProjectFile(pf).to_payload("3");
522        let p = &v["print"];
523        assert_eq!(p["command"], "project_file");
524        assert_eq!(p["sequence_id"], "3");
525        assert_eq!(p["param"], "Metadata/plate_2.gcode");
526        assert_eq!(p["url"], "ftp:///cache/x.gcode.3mf");
527        assert_eq!(p["subtask_name"], "x job");
528        assert_eq!(p["use_ams"], false);
529        assert_eq!(p["task_id"], "0"); // LAN SD print uses "0" ids
530        assert!(p["ams_mapping"].is_array());
531    }
532
533    #[test]
534    fn get_version_is_an_info_read() {
535        assert_eq!(Command::GetVersion.category(), "info");
536        assert_eq!(
537            Command::GetVersion.to_payload("1"),
538            json!({ "info": { "sequence_id": "1", "command": "get_version" } })
539        );
540    }
541
542    #[test]
543    fn pushall_payload() {
544        assert_eq!(
545            Command::PushAll.to_payload("0"),
546            json!({ "pushing": { "sequence_id": "0", "command": "pushall" } })
547        );
548    }
549
550    #[test]
551    fn pause_resume_stop_payloads() {
552        assert_eq!(
553            Command::Pause.to_payload("3"),
554            json!({ "print": { "sequence_id": "3", "command": "pause", "param": "" } })
555        );
556        assert_eq!(
557            Command::Resume.to_payload("4"),
558            json!({ "print": { "sequence_id": "4", "command": "resume", "param": "" } })
559        );
560        assert_eq!(
561            Command::Stop.to_payload("5"),
562            json!({ "print": { "sequence_id": "5", "command": "stop", "param": "" } })
563        );
564    }
565
566    #[test]
567    fn gcode_line_payload_carries_the_line_in_param() {
568        assert_eq!(
569            Command::GcodeLine("M104 S210".to_string()).to_payload("7"),
570            json!({ "print": { "sequence_id": "7", "command": "gcode_line", "param": "M104 S210" } })
571        );
572    }
573
574    #[test]
575    fn ledctrl_on_and_off_payloads_carry_the_node() {
576        let on = Command::Led {
577            node: LedNode::ChamberLight,
578            on: true,
579        }
580        .to_payload("8");
581        assert_eq!(on["system"]["command"], "ledctrl");
582        assert_eq!(on["system"]["led_node"], "chamber_light");
583        assert_eq!(on["system"]["led_mode"], "on");
584        assert_eq!(on["system"]["sequence_id"], "8");
585
586        let off = Command::Led {
587            node: LedNode::ChamberLight,
588            on: false,
589        }
590        .to_payload("9");
591        assert_eq!(off["system"]["led_mode"], "off");
592
593        // work_light targets a different node (same envelope).
594        let work = Command::Led {
595            node: LedNode::WorkLight,
596            on: true,
597        }
598        .to_payload("1");
599        assert_eq!(work["system"]["led_node"], "work_light");
600    }
601
602    #[test]
603    fn print_speed_renders_the_level_as_a_print_param() {
604        let v = Command::PrintSpeed(SpeedLevel::Sport).to_payload("6");
605        assert_eq!(
606            v,
607            json!({ "print": { "sequence_id": "6", "command": "print_speed", "param": "3" } })
608        );
609        assert_eq!(Command::PrintSpeed(SpeedLevel::Silent).category(), "print");
610    }
611
612    #[test]
613    fn speed_level_maps_to_and_from_its_number() {
614        for (lvl, n) in [
615            (SpeedLevel::Silent, 1),
616            (SpeedLevel::Standard, 2),
617            (SpeedLevel::Sport, 3),
618            (SpeedLevel::Ludicrous, 4),
619        ] {
620            assert_eq!(lvl.level(), n);
621            assert_eq!(SpeedLevel::from_level(n), Some(lvl));
622        }
623        assert_eq!(SpeedLevel::from_level(0), None);
624        assert_eq!(SpeedLevel::from_level(5), None);
625    }
626
627    #[test]
628    fn ipcam_timelapse_is_a_camera_command() {
629        assert_eq!(
630            Command::IpcamTimelapse(TimelapseControl::Enable).category(),
631            "camera"
632        );
633        let on = Command::IpcamTimelapse(TimelapseControl::Enable).to_payload("4");
634        assert_eq!(on["camera"]["command"], "ipcam_timelapse");
635        assert_eq!(on["camera"]["control"], "enable");
636        assert_eq!(on["camera"]["sequence_id"], "4");
637        let off = Command::IpcamTimelapse(TimelapseControl::Disable).to_payload("5");
638        assert_eq!(off["camera"]["control"], "disable");
639    }
640
641    #[test]
642    fn ams_control_payload_matches_spec() {
643        let v = Command::AmsControl(AmsControl::Resume).to_payload("1");
644        assert_eq!(
645            v,
646            json!({ "print": { "sequence_id": "1", "command": "ams_control", "param": "resume" } })
647        );
648        assert_eq!(
649            Command::AmsControl(AmsControl::Reset).to_payload("1")["print"]["param"],
650            "reset"
651        );
652        assert_eq!(Command::AmsControl(AmsControl::Pause).category(), "print");
653    }
654
655    #[test]
656    fn ams_change_filament_payload_matches_spec() {
657        let v = Command::AmsChangeFilament {
658            target: 2,
659            curr_temp: 220,
660            tar_temp: 240,
661        }
662        .to_payload("1");
663        let p = &v["print"];
664        assert_eq!(p["command"], "ams_change_filament");
665        assert_eq!(p["target"], 2);
666        assert_eq!(p["curr_temp"], 220);
667        assert_eq!(p["tar_temp"], 240);
668    }
669
670    #[test]
671    fn ams_user_setting_payload_matches_spec() {
672        let v = Command::AmsUserSetting {
673            ams_id: 0,
674            startup_read: true,
675            tray_read: false,
676        }
677        .to_payload("1");
678        let p = &v["print"];
679        assert_eq!(p["command"], "ams_user_setting");
680        assert_eq!(p["ams_id"], 0);
681        assert_eq!(p["startup_read_option"], true);
682        assert_eq!(p["tray_read_option"], false);
683    }
684
685    #[test]
686    fn ams_filament_setting_payload_matches_spec() {
687        let v = Command::AmsFilamentSetting(Box::new(AmsFilamentSetting {
688            ams_id: 0,
689            tray_id: 1,
690            tray_info_idx: "GFA00".to_string(),
691            tray_color: "00112233".to_string(),
692            nozzle_temp_min: 190,
693            nozzle_temp_max: 230,
694            tray_type: "PLA".to_string(),
695        }))
696        .to_payload("1");
697        let p = &v["print"];
698        assert_eq!(p["command"], "ams_filament_setting");
699        assert_eq!(p["ams_id"], 0);
700        assert_eq!(p["tray_id"], 1);
701        assert_eq!(p["tray_info_idx"], "GFA00");
702        assert_eq!(p["tray_color"], "00112233");
703        assert_eq!(p["nozzle_temp_min"], 190);
704        assert_eq!(p["nozzle_temp_max"], 230);
705        assert_eq!(p["tray_type"], "PLA");
706    }
707
708    #[test]
709    fn reboot_is_a_system_command() {
710        assert_eq!(Command::Reboot.category(), "system");
711        assert_eq!(
712            Command::Reboot.to_payload("3"),
713            json!({ "system": { "sequence_id": "3", "command": "reboot" } })
714        );
715    }
716
717    #[test]
718    fn sequence_id_is_serialised_as_a_string_not_a_number() {
719        let payload = Command::PushAll.to_payload("42");
720        assert!(payload["pushing"]["sequence_id"].is_string());
721    }
722
723    #[test]
724    fn rendering_does_not_consume_or_mutate_the_command() {
725        let cmd = Command::GcodeLine("G28".to_string());
726        let _ = cmd.to_payload("0");
727        // Still usable / unchanged afterwards.
728        assert_eq!(cmd, Command::GcodeLine("G28".to_string()));
729    }
730}