1use serde_json::{Value, json};
12
13#[derive(Debug, Default)]
16pub struct SequenceIds {
17 next: u64,
18}
19
20impl SequenceIds {
21 pub fn new() -> Self {
22 Self::default()
23 }
24
25 pub fn next_id(&mut self) -> String {
27 let id = self.next;
28 self.next += 1;
29 id.to_string()
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AmsControl {
36 Resume,
38 Reset,
40 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#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct AmsFilamentSetting {
58 pub ams_id: u32,
60 pub tray_id: u32,
62 pub tray_info_idx: String,
64 pub tray_color: String,
66 pub nozzle_temp_min: i64,
68 pub nozzle_temp_max: i64,
69 pub tray_type: String,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum LedNode {
77 ChamberLight,
79 WorkLight,
83}
84
85impl LedNode {
86 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum TimelapseControl {
98 Enable,
99 Disable,
100}
101
102impl TimelapseControl {
103 pub fn as_str(self) -> &'static str {
106 match self {
107 TimelapseControl::Enable => "enable",
108 TimelapseControl::Disable => "disable",
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum SpeedLevel {
117 Silent,
118 Standard,
119 Sport,
120 Ludicrous,
121}
122
123impl SpeedLevel {
124 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Command {
162 PushAll,
164 GetVersion,
167 Pause,
169 Resume,
171 Stop,
173 CleanPrintError,
182 GcodeLine(String),
184 GcodeFile(String),
187 PrintSpeed(SpeedLevel),
190 ProjectFile(ProjectFile),
192 Led { node: LedNode, on: bool },
195 IpcamTimelapse(TimelapseControl),
200 Reboot,
204 AmsControl(AmsControl),
206 AmsChangeFilament {
210 target: u32,
211 curr_temp: i64,
212 tar_temp: i64,
213 },
214 AmsUserSetting {
216 ams_id: u32,
217 startup_read: bool,
219 tray_read: bool,
221 },
222 AmsFilamentSetting(Box<AmsFilamentSetting>),
224 Calibration {
227 bed_level: bool,
229 vibration: bool,
231 motor_noise: bool,
233 },
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ProjectFile {
244 pub url: String,
246 pub plate: u32,
248 pub subtask_name: String,
250 pub md5: String,
252 pub bed_type: String,
254 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 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 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 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
440fn 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); 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"); 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 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 assert_eq!(cmd, Command::GcodeLine("G28".to_string()));
729 }
730}