1use crate::core::capability::{ChamberTemperature, HardwareFeatures};
13use crate::core::hms::{HmsEntry, Module, decode_report_hms};
14use crate::core::stage::Stage;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
20#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
21pub struct PrinterStatus {
22 pub gcode_state: Option<String>,
24 pub print_error: Option<i64>,
26 pub error: Option<DeviceError>,
30 pub mc_percent: Option<i64>,
32 pub layer_num: Option<i64>,
34 pub total_layer_num: Option<i64>,
35 pub remaining_time_min: Option<i64>,
37 pub stg_cur: Option<i64>,
40 pub stage: Option<String>,
43 pub home_flag: Option<i64>,
46 pub nozzle_temper: Option<f64>,
48 pub nozzle_target: Option<f64>,
49 pub bed_temper: Option<f64>,
50 pub bed_target: Option<f64>,
51 pub chamber_temper_raw: Option<f64>,
55 pub cooling_fan_speed: Option<i64>,
57 pub spd_lvl: Option<i64>,
60 pub subtask_name: Option<String>,
62 pub filament: Option<Filament>,
66 pub lights: Vec<LightReport>,
72 pub ipcam: Option<Ipcam>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
85 pub big_fan1_speed: Option<i64>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub big_fan2_speed: Option<i64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub heatbreak_fan_speed: Option<i64>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub fan_gear: Option<i64>,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
100 pub spd_mag: Option<i64>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub gcode_file: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
107 pub gcode_file_prepare_percent: Option<i64>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub mc_print_stage: Option<i64>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub mc_print_sub_stage: Option<i64>,
114 #[serde(skip_serializing_if = "Option::is_none")]
117 pub mc_print_line_number: Option<i64>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub print_type: Option<String>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub stg: Vec<i64>,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
128 pub nozzle_diameter: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub nozzle_type: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub sdcard: Option<bool>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub ams_status: Option<i64>,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub ams_rfid_status: Option<i64>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub hw_switch_state: Option<i64>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
150 pub wifi_signal: Option<String>,
151
152 #[serde(skip_serializing_if = "Option::is_none")]
155 pub task_id: Option<String>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub subtask_id: Option<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub project_id: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub profile_id: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
168 pub sequence_id: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub lifecycle: Option<String>,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
177 pub ams: Option<Ams>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub upgrade: Option<Upgrade>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub online: Option<Online>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub upload: Option<Upload>,
187
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub hms: Vec<HmsAlert>,
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
197pub struct LightReport {
198 pub node: String,
199 pub mode: String,
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
208pub struct Ipcam {
209 pub timelapse: Option<String>,
212 pub record: Option<String>,
214 pub resolution: Option<String>,
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
221pub struct Filament {
222 pub location: String,
224 pub material: Option<String>,
226 pub name: Option<String>,
228 pub color: Option<String>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
237pub struct HmsAlert {
238 pub code: String,
240 pub code_hyphen: String,
242 pub severity: u16,
244 pub module: String,
247 pub is_lidar: bool,
249 pub wiki: String,
251 pub attr: u32,
253 pub raw_code: u32,
255}
256
257impl HmsAlert {
258 fn from_entry(e: HmsEntry) -> Self {
259 HmsAlert {
260 code: e.code_string(),
261 code_hyphen: e.code_hyphen(),
262 severity: e.severity_raw(),
263 module: hms_module_str(e.module()),
264 is_lidar: e.is_lidar(),
265 wiki: e.wiki_url(),
266 attr: e.attr,
267 raw_code: e.code,
268 }
269 }
270}
271
272#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
277pub struct Ams {
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub units: Vec<AmsUnit>,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub external: Option<AmsTray>,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub active_tray: Option<String>,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub target_tray: Option<String>,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub previous_tray: Option<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub ams_exist_bits: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub tray_exist_bits: Option<String>,
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub tray_is_bbl_bits: Option<String>,
302}
303
304#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
307pub struct AmsUnit {
308 pub id: String,
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub humidity: Option<i64>,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub humidity_raw: Option<i64>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub temp: Option<f64>,
319 #[serde(skip_serializing_if = "Option::is_none")]
321 pub dry_time: Option<i64>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub trays: Vec<AmsTray>,
325}
326
327#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
329#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
330pub struct AmsTray {
331 pub id: String,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub material: Option<String>,
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub name: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub color: Option<String>,
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub cols: Vec<String>,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub remain: Option<i64>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub state: Option<i64>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub info_idx: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub id_name: Option<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub uuid: Option<String>,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub nozzle_temp_min: Option<i64>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub nozzle_temp_max: Option<i64>,
366 pub is_active: bool,
368 pub is_target: bool,
370}
371
372#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
374#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
375pub struct Upgrade {
376 #[serde(skip_serializing_if = "Option::is_none")]
378 pub new_version_state: Option<i64>,
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub cur_state_code: Option<i64>,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub status: Option<String>,
385 #[serde(skip_serializing_if = "Option::is_none")]
387 pub err_code: Option<i64>,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
392#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
393pub struct Online {
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub ahb: Option<bool>,
397 #[serde(skip_serializing_if = "Option::is_none")]
399 pub rfid: Option<bool>,
400 #[serde(skip_serializing_if = "Option::is_none")]
402 pub version: Option<i64>,
403}
404
405#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
407#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
408pub struct Upload {
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub status: Option<String>,
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub progress: Option<i64>,
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub message: Option<String>,
418}
419
420impl PrinterStatus {
421 pub fn from_state(state: &Value) -> Self {
424 let print = state.get("print");
425 let get = |key: &str| print.and_then(|p| p.get(key));
426 let stg_cur = get("stg_cur").and_then(as_i64_loose);
427 let print_error = get("print_error").and_then(as_i64_loose);
428
429 PrinterStatus {
430 gcode_state: get("gcode_state").and_then(as_string),
431 print_error,
432 error: print_error.and_then(DeviceError::from_code),
433 mc_percent: get("mc_percent").and_then(as_i64_loose),
434 layer_num: get("layer_num").and_then(as_i64_loose),
435 total_layer_num: get("total_layer_num").and_then(as_i64_loose),
436 remaining_time_min: get("mc_remaining_time").and_then(as_i64_loose),
437 stg_cur,
438 stage: stg_cur.and_then(|id| Stage(id).name()).map(String::from),
439 home_flag: get("home_flag").and_then(as_i64_loose),
440 nozzle_temper: get("nozzle_temper").and_then(Value::as_f64),
441 nozzle_target: get("nozzle_target_temper").and_then(Value::as_f64),
442 bed_temper: get("bed_temper").and_then(Value::as_f64),
443 bed_target: get("bed_target_temper").and_then(Value::as_f64),
444 chamber_temper_raw: get("chamber_temper").and_then(Value::as_f64),
445 cooling_fan_speed: get("cooling_fan_speed").and_then(as_i64_loose),
446 spd_lvl: get("spd_lvl").and_then(as_i64_loose),
447 subtask_name: get("subtask_name").and_then(as_string),
448 filament: print.and_then(resolve_filament),
449 lights: get("lights_report")
450 .and_then(Value::as_array)
451 .map(|arr| {
452 arr.iter()
453 .filter_map(|e| {
454 Some(LightReport {
455 node: e.get("node").and_then(Value::as_str)?.to_string(),
456 mode: e.get("mode").and_then(Value::as_str)?.to_string(),
457 })
458 })
459 .collect()
460 })
461 .unwrap_or_default(),
462 ipcam: get("ipcam").map(|ic| Ipcam {
463 timelapse: ic.get("timelapse").and_then(as_string),
464 record: ic.get("ipcam_record").and_then(as_string),
465 resolution: ic.get("resolution").and_then(as_string),
466 }),
467
468 big_fan1_speed: get("big_fan1_speed").and_then(as_i64_loose),
470 big_fan2_speed: get("big_fan2_speed").and_then(as_i64_loose),
471 heatbreak_fan_speed: get("heatbreak_fan_speed").and_then(as_i64_loose),
472 fan_gear: get("fan_gear").and_then(as_i64_loose),
473
474 spd_mag: get("spd_mag").and_then(as_i64_loose),
475 gcode_file: get("gcode_file").and_then(as_nonempty_string),
476 gcode_file_prepare_percent: get("gcode_file_prepare_percent").and_then(as_i64_loose),
477 mc_print_stage: get("mc_print_stage").and_then(as_i64_loose),
478 mc_print_sub_stage: get("mc_print_sub_stage").and_then(as_i64_loose),
479 mc_print_line_number: get("mc_print_line_number").and_then(as_i64_loose),
480 print_type: get("print_type").and_then(as_string),
481 stg: get("stg")
482 .and_then(Value::as_array)
483 .map(|a| a.iter().filter_map(as_i64_loose).collect())
484 .unwrap_or_default(),
485
486 nozzle_diameter: get("nozzle_diameter").and_then(as_string),
487 nozzle_type: get("nozzle_type").and_then(as_string),
488 sdcard: get("sdcard").and_then(Value::as_bool),
489 ams_status: get("ams_status").and_then(as_i64_loose),
490 ams_rfid_status: get("ams_rfid_status").and_then(as_i64_loose),
491 hw_switch_state: get("hw_switch_state").and_then(as_i64_loose),
492
493 wifi_signal: get("wifi_signal")
494 .and_then(as_nonempty_string)
495 .filter(|s| s != "<redacted>"),
496
497 task_id: get("task_id").and_then(as_nonempty_string),
498 subtask_id: get("subtask_id").and_then(as_nonempty_string),
499 project_id: get("project_id").and_then(as_nonempty_string),
500 profile_id: get("profile_id").and_then(as_nonempty_string),
501 sequence_id: get("sequence_id").and_then(as_string),
502 lifecycle: get("lifecycle").and_then(as_string),
503
504 ams: print.and_then(build_ams),
505 upgrade: get("upgrade_state").map(|u| Upgrade {
506 new_version_state: u.get("new_version_state").and_then(as_i64_loose),
507 cur_state_code: u.get("cur_state_code").and_then(as_i64_loose),
508 status: u.get("status").and_then(as_string),
509 err_code: u.get("err_code").and_then(as_i64_loose),
510 }),
511 online: get("online").map(|o| Online {
512 ahb: o.get("ahb").and_then(Value::as_bool),
513 rfid: o.get("rfid").and_then(Value::as_bool),
514 version: o.get("version").and_then(as_i64_loose),
515 }),
516 upload: get("upload").map(|u| Upload {
517 status: u.get("status").and_then(as_string),
518 progress: u.get("progress").and_then(as_i64_loose),
519 message: u.get("message").and_then(as_nonempty_string),
520 }),
521
522 hms: decode_report_hms(state)
523 .into_iter()
524 .map(HmsAlert::from_entry)
525 .collect(),
526 }
527 }
528
529 pub fn timelapse_mode(&self) -> Option<&str> {
533 self.ipcam.as_ref()?.timelapse.as_deref()
534 }
535
536 pub fn light_mode(&self, node: &str) -> Option<&str> {
539 self.lights
540 .iter()
541 .find(|l| l.node == node)
542 .map(|l| l.mode.as_str())
543 }
544
545 pub fn real_chamber_temperature(&self, hardware: &HardwareFeatures) -> Option<f64> {
548 match hardware.chamber_temperature {
549 ChamberTemperature::RealSensor => self.chamber_temper_raw,
550 ChamberTemperature::ReportedSynthetic | ChamberTemperature::Unsupported => None,
551 }
552 }
553
554 pub fn state(&self) -> Option<GcodeState> {
556 self.gcode_state.as_deref().map(GcodeState::parse)
557 }
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
571pub struct DeviceError {
572 pub code: i64,
574 pub hex: String,
576 pub message: Option<String>,
580 pub lookup_url: String,
584}
585
586fn verified_error_message(code: i64) -> Option<&'static str> {
590 match code {
591 0x1200_8014 => Some("couldn't find the filament position in the toolhead"),
595 0x1200_8015 => Some("couldn't pull the filament out of the toolhead"),
596 0x1200_8016 => Some("the extruder isn't pushing filament out properly"),
597 _ => None,
598 }
599}
600
601impl DeviceError {
602 pub fn from_code(code: i64) -> Option<Self> {
604 (code != 0).then(|| DeviceError {
605 code,
606 hex: format!("0x{:08X}", code as u32),
607 message: verified_error_message(code).map(str::to_string),
608 lookup_url: format!(
609 "https://e.bambulab.com/query.php?lang=en&e={:08X}",
610 code as u32
611 ),
612 })
613 }
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619pub enum GcodeState {
620 Idle,
621 Prepare,
622 Running,
623 Pause,
624 Finish,
625 Failed,
626 Slicing,
627 Init,
628 Offline,
629 Unknown,
630}
631
632impl GcodeState {
633 pub fn parse(s: &str) -> Self {
635 match s.trim().to_ascii_uppercase().as_str() {
636 "IDLE" => GcodeState::Idle,
637 "PREPARE" => GcodeState::Prepare,
638 "RUNNING" => GcodeState::Running,
639 "PAUSE" => GcodeState::Pause,
640 "FINISH" => GcodeState::Finish,
641 "FAILED" => GcodeState::Failed,
642 "SLICING" => GcodeState::Slicing,
643 "INIT" => GcodeState::Init,
644 "OFFLINE" => GcodeState::Offline,
645 _ => GcodeState::Unknown,
646 }
647 }
648
649 pub fn is_terminal(&self) -> bool {
651 matches!(self, GcodeState::Finish | GcodeState::Failed)
652 }
653}
654
655fn as_string(v: &Value) -> Option<String> {
656 v.as_str().map(str::to_owned)
657}
658
659fn as_nonempty_string(v: &Value) -> Option<String> {
661 v.as_str().filter(|s| !s.is_empty()).map(str::to_owned)
662}
663
664fn resolve_filament(print: &Value) -> Option<Filament> {
669 let ams = print.get("ams")?;
670 let tray_now = ams.get("tray_now").and_then(Value::as_str)?;
671
672 if tray_now == "254" {
673 let vt = print.get("vt_tray")?;
674 return Some(Filament {
675 location: "external".to_string(),
676 material: vt.get("tray_type").and_then(as_nonempty_string),
677 name: vt.get("tray_sub_brands").and_then(as_nonempty_string),
678 color: vt.get("tray_color").and_then(as_nonempty_string),
679 });
680 }
681
682 for unit in ams.get("ams").and_then(Value::as_array)?.iter() {
683 let Some(trays) = unit.get("tray").and_then(Value::as_array) else {
684 continue;
685 };
686 for tray in trays {
687 if tray.get("id").and_then(Value::as_str) == Some(tray_now) {
688 return Some(Filament {
689 location: format!("ams{tray_now}"),
690 material: tray.get("tray_type").and_then(as_nonempty_string),
691 name: tray.get("tray_sub_brands").and_then(as_nonempty_string),
692 color: tray.get("tray_color").and_then(as_nonempty_string),
693 });
694 }
695 }
696 }
697 None
698}
699
700fn as_i64_loose(v: &Value) -> Option<i64> {
703 match v {
704 Value::Number(n) => n.as_i64(),
705 Value::String(s) => s.trim().parse::<i64>().ok(),
706 _ => None,
707 }
708}
709
710fn as_f64_loose(v: &Value) -> Option<f64> {
714 let n = match v {
715 Value::Number(n) => n.as_f64(),
716 Value::String(s) => s.trim().parse::<f64>().ok(),
717 _ => None,
718 }?;
719 n.is_finite().then_some(n)
720}
721
722fn hms_module_str(m: Module) -> String {
725 match m {
726 Module::MotionController => "motion_controller".to_string(),
727 Module::Mainboard => "mainboard".to_string(),
728 Module::Ams => "ams".to_string(),
729 Module::Toolhead => "toolhead".to_string(),
730 Module::Xcam => "xcam".to_string(),
731 Module::Unknown(n) => format!("unknown:0x{n:02X}"),
732 }
733}
734
735fn build_ams(print: &Value) -> Option<Ams> {
738 let ams = print.get("ams");
739 let vt = print.get("vt_tray");
740 if ams.is_none() && vt.is_none() {
741 return None;
742 }
743 let active = ams.and_then(|a| a.get("tray_now")).and_then(as_string);
744 let target = ams.and_then(|a| a.get("tray_tar")).and_then(as_string);
745 let previous = ams.and_then(|a| a.get("tray_pre")).and_then(as_string);
746 let (act, tar) = (active.as_deref(), target.as_deref());
747
748 let units = ams
749 .and_then(|a| a.get("ams"))
750 .and_then(Value::as_array)
751 .map(|arr| arr.iter().map(|u| build_unit(u, act, tar)).collect())
752 .unwrap_or_default();
753
754 let external = vt.map(|v| build_tray(v, act, tar)).filter(|t| {
756 t.material.is_some() || t.color.is_some() || t.name.is_some() || !t.cols.is_empty()
757 });
758
759 let bits = |k: &str| ams.and_then(|a| a.get(k)).and_then(as_nonempty_string);
760 Some(Ams {
761 units,
762 external,
763 active_tray: active,
764 target_tray: target,
765 previous_tray: previous,
766 ams_exist_bits: bits("ams_exist_bits"),
767 tray_exist_bits: bits("tray_exist_bits"),
768 tray_is_bbl_bits: bits("tray_is_bbl_bits"),
769 })
770}
771
772fn build_unit(u: &Value, active: Option<&str>, target: Option<&str>) -> AmsUnit {
773 AmsUnit {
774 id: u.get("id").and_then(as_string).unwrap_or_default(),
775 humidity: u.get("humidity").and_then(as_i64_loose),
776 humidity_raw: u.get("humidity_raw").and_then(as_i64_loose),
777 temp: u.get("temp").and_then(as_f64_loose),
778 dry_time: u.get("dry_time").and_then(as_i64_loose),
779 trays: u
780 .get("tray")
781 .and_then(Value::as_array)
782 .map(|arr| arr.iter().map(|t| build_tray(t, active, target)).collect())
783 .unwrap_or_default(),
784 }
785}
786
787fn build_tray(t: &Value, active: Option<&str>, target: Option<&str>) -> AmsTray {
788 let id = t.get("id").and_then(as_string).unwrap_or_default();
789 let matches = |p: Option<&str>| !id.is_empty() && p == Some(id.as_str());
790 AmsTray {
791 is_active: matches(active),
792 is_target: matches(target),
793 material: t.get("tray_type").and_then(as_nonempty_string),
794 name: t.get("tray_sub_brands").and_then(as_nonempty_string),
795 color: t.get("tray_color").and_then(as_nonempty_string),
796 cols: t
797 .get("cols")
798 .and_then(Value::as_array)
799 .map(|a| a.iter().filter_map(as_nonempty_string).collect())
800 .unwrap_or_default(),
801 remain: t.get("remain").and_then(as_i64_loose),
802 state: t.get("state").and_then(as_i64_loose),
803 info_idx: t.get("tray_info_idx").and_then(as_nonempty_string),
804 id_name: t.get("tray_id_name").and_then(as_nonempty_string),
805 uuid: t
807 .get("tray_uuid")
808 .and_then(as_nonempty_string)
809 .filter(|s| s.bytes().any(|b| b != b'0')),
810 nozzle_temp_min: t.get("nozzle_temp_min").and_then(as_i64_loose),
811 nozzle_temp_max: t.get("nozzle_temp_max").and_then(as_i64_loose),
812 id,
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use crate::core::report::ReportState;
820 use serde_json::json;
821
822 #[test]
823 fn filament_resolves_from_ams_tray_now() {
824 let state = json!({ "print": {
826 "ams": {
827 "tray_now": "3",
828 "ams": [{ "id": "0", "tray": [
829 { "id": "0", "tray_type": "PLA", "tray_sub_brands": "PLA Matte", "tray_color": "DE4343FF" },
830 { "id": "3", "tray_type": "PLA", "tray_sub_brands": "PLA Matte", "tray_color": "000000FF" }
831 ]}]
832 }
833 }});
834 let f = PrinterStatus::from_state(&state).filament.unwrap();
835 assert_eq!(f.location, "ams3");
836 assert_eq!(f.material.as_deref(), Some("PLA"));
837 assert_eq!(f.name.as_deref(), Some("PLA Matte"));
838 assert_eq!(f.color.as_deref(), Some("000000FF"));
839 }
840
841 #[test]
842 fn filament_resolves_external_spool() {
843 let state = json!({ "print": {
844 "ams": { "tray_now": "254", "ams": [] },
845 "vt_tray": { "tray_type": "PLA", "tray_sub_brands": "", "tray_color": "161616FF" }
846 }});
847 let f = PrinterStatus::from_state(&state).filament.unwrap();
848 assert_eq!(f.location, "external");
849 assert_eq!(f.material.as_deref(), Some("PLA"));
850 assert_eq!(f.name, None); assert_eq!(f.color.as_deref(), Some("161616FF"));
852 }
853
854 #[test]
855 fn filament_none_when_nothing_loaded() {
856 let state = json!({ "print": { "ams": { "tray_now": "255", "ams": [] } } });
857 assert_eq!(PrinterStatus::from_state(&state).filament, None);
858 let bare = json!({ "print": { "gcode_state": "IDLE" } });
860 assert_eq!(PrinterStatus::from_state(&bare).filament, None);
861 }
862
863 #[test]
864 fn device_error_decodes_nonzero_print_error_to_hex() {
865 let e = DeviceError::from_code(0x0500C010).unwrap();
867 assert_eq!(e.code, 0x0500C010);
868 assert_eq!(e.hex, "0x0500C010");
869 assert_eq!(
870 e.lookup_url,
871 "https://e.bambulab.com/query.php?lang=en&e=0500C010"
872 );
873 assert_eq!(DeviceError::from_code(0), None);
875 }
876
877 #[test]
878 fn device_error_attaches_a_message_only_for_device_verified_codes() {
879 assert_eq!(
881 DeviceError::from_code(0x1200_8015)
882 .unwrap()
883 .message
884 .as_deref(),
885 Some("couldn't pull the filament out of the toolhead")
886 );
887 assert_eq!(
888 DeviceError::from_code(0x1200_8014)
889 .unwrap()
890 .message
891 .as_deref(),
892 Some("couldn't find the filament position in the toolhead")
893 );
894 assert_eq!(
895 DeviceError::from_code(0x1200_8016)
896 .unwrap()
897 .message
898 .as_deref(),
899 Some("the extruder isn't pushing filament out properly")
900 );
901 let u = DeviceError::from_code(0x0500_C010).unwrap();
903 assert!(u.message.is_none());
904 assert_eq!(u.hex, "0x0500C010");
905 }
906
907 #[test]
908 fn status_surfaces_a_nonzero_print_error_as_a_typed_error() {
909 let state = json!({ "print": { "print_error": 83935248, "gcode_state": "IDLE" } });
910 let st = PrinterStatus::from_state(&state);
911 assert_eq!(st.print_error, Some(83935248));
912 assert_eq!(st.error.as_ref().unwrap().hex, "0x0500C010");
913 }
914
915 #[test]
916 fn parses_the_real_a1mini_idle_pushall_fixture() {
917 let raw = include_str!("../../tests/fixtures/pushall-n1-idle.json");
918 let fixture: Value = serde_json::from_str(raw).expect("valid fixture json");
919 let mut rs = ReportState::new();
920 rs.apply(fixture["message"].clone());
921
922 let st = PrinterStatus::from_state(rs.get());
923 assert_eq!(st.gcode_state.as_deref(), Some("IDLE"));
924 assert_eq!(st.print_error, Some(0));
925 assert_eq!(st.mc_percent, Some(0));
926 assert_eq!(st.layer_num, Some(0));
927 assert_eq!(st.total_layer_num, Some(0));
928 assert_eq!(st.stg_cur, Some(0));
929 assert_eq!(st.subtask_name.as_deref(), Some(""));
930 assert_eq!(st.cooling_fan_speed, Some(0));
932 assert!((st.bed_temper.unwrap() - 26.53125).abs() < 1e-9);
934 assert!((st.nozzle_temper.unwrap() - 27.21875).abs() < 1e-9);
935 assert_eq!(st.bed_target, Some(0.0));
936 assert_eq!(st.chamber_temper_raw, Some(5.0));
938
939 assert_eq!(st.big_fan1_speed, Some(0));
941 assert_eq!(st.big_fan2_speed, Some(0));
942 assert_eq!(st.heatbreak_fan_speed, Some(0));
943 assert_eq!(st.fan_gear, Some(0));
944 assert_eq!(st.spd_mag, Some(100));
945 assert_eq!(st.mc_print_stage, Some(1)); assert_eq!(st.print_type.as_deref(), Some("idle"));
947 assert_eq!(st.gcode_file, None); assert_eq!(st.nozzle_diameter.as_deref(), Some("0.4"));
949 assert_eq!(st.nozzle_type.as_deref(), Some("stainless_steel"));
950 assert_eq!(st.sdcard, Some(true));
951 assert_eq!(st.sequence_id.as_deref(), Some("5"));
952 assert_eq!(st.lifecycle.as_deref(), Some("product"));
953 assert_eq!(st.wifi_signal, None);
955 assert!(st.hms.is_empty()); let online = st.online.as_ref().unwrap();
959 assert_eq!(online.ahb, Some(false));
960 assert_eq!(online.rfid, Some(false));
961 assert_eq!(online.version, Some(816539411));
962 let up = st.upgrade.as_ref().unwrap();
963 assert_eq!(up.new_version_state, Some(2));
964 assert_eq!(up.status.as_deref(), Some("IDLE"));
965 let upload = st.upload.as_ref().unwrap();
966 assert_eq!(upload.status.as_deref(), Some("idle"));
967 assert_eq!(upload.message, None); let ams = st.ams.as_ref().unwrap();
971 assert_eq!(ams.active_tray.as_deref(), Some("255")); assert_eq!(ams.tray_exist_bits.as_deref(), Some("f")); assert_eq!(ams.units.len(), 1);
974 let unit = &ams.units[0];
975 assert_eq!(unit.id, "0");
976 assert_eq!(unit.humidity, Some(5)); assert_eq!(unit.temp, Some(0.0)); assert_eq!(unit.trays.len(), 4);
979 let t0 = &unit.trays[0];
980 assert_eq!(t0.id, "0");
981 assert_eq!(t0.material.as_deref(), Some("PLA"));
982 assert_eq!(t0.name.as_deref(), Some("PLA Matte"));
983 assert_eq!(t0.color.as_deref(), Some("DE4343FF"));
984 assert_eq!(t0.cols, vec!["DE4343FF".to_string()]);
985 assert_eq!(t0.info_idx.as_deref(), Some("GFA01"));
986 assert_eq!(t0.nozzle_temp_min, Some(190));
987 assert_eq!(t0.nozzle_temp_max, Some(230));
988 assert!(t0.uuid.is_some()); assert!(!t0.is_active); assert_eq!(unit.trays[2].material.as_deref(), Some("PETG"));
992 assert_eq!(unit.trays[2].nozzle_temp_max, Some(260));
993 let ext = ams.external.as_ref().unwrap();
995 assert_eq!(ext.id, "254");
996 assert_eq!(ext.material.as_deref(), Some("PLA"));
997 assert_eq!(ext.name, None);
998 assert_eq!(ext.uuid, None);
999
1000 assert_eq!(st.filament, None);
1002 }
1003
1004 #[test]
1005 fn real_chamber_temperature_respects_hardware() {
1006 use crate::core::capability::{ChamberTemperature, HardwareFeatures};
1007 let st = PrinterStatus::from_state(&json!({ "print": { "chamber_temper": 5.0 } }));
1008 let a1 = HardwareFeatures {
1009 lidar: false,
1010 chamber_temperature: ChamberTemperature::ReportedSynthetic,
1011 aux_fan: false,
1012 chamber_fan: false,
1013 };
1014 let x1 = HardwareFeatures {
1015 lidar: true,
1016 chamber_temperature: ChamberTemperature::RealSensor,
1017 aux_fan: true,
1018 chamber_fan: true,
1019 };
1020 assert_eq!(st.real_chamber_temperature(&a1), None); assert_eq!(st.real_chamber_temperature(&x1), Some(5.0)); }
1023
1024 #[test]
1025 fn lights_report_parses_and_is_looked_up_by_node() {
1026 let st = PrinterStatus::from_state(&json!({ "print": { "lights_report": [
1027 { "node": "chamber_light", "mode": "off" },
1028 { "node": "work_light", "mode": "on" }
1029 ]}}));
1030 assert_eq!(st.light_mode("chamber_light"), Some("off"));
1031 assert_eq!(st.light_mode("work_light"), Some("on"));
1032 assert_eq!(st.light_mode("logo_light"), None);
1034 let bare = PrinterStatus::from_state(&json!({ "print": {} }));
1036 assert!(bare.lights.is_empty());
1037 assert_eq!(bare.light_mode("chamber_light"), None);
1038 }
1039
1040 #[test]
1041 fn ipcam_node_parses_timelapse_and_record() {
1042 let st = PrinterStatus::from_state(&json!({ "print": { "ipcam": {
1043 "timelapse": "disable", "ipcam_record": "enable", "resolution": "1080p"
1044 }}}));
1045 let ic = st.ipcam.as_ref().unwrap();
1046 assert_eq!(ic.timelapse.as_deref(), Some("disable"));
1047 assert_eq!(ic.record.as_deref(), Some("enable"));
1048 assert_eq!(ic.resolution.as_deref(), Some("1080p"));
1049 assert_eq!(st.timelapse_mode(), Some("disable"));
1050 assert_eq!(
1052 PrinterStatus::from_state(&json!({ "print": {} })).ipcam,
1053 None
1054 );
1055 assert_eq!(
1056 PrinterStatus::from_state(&json!({ "print": {} })).timelapse_mode(),
1057 None
1058 );
1059 }
1060
1061 #[test]
1062 fn missing_fields_become_none() {
1063 let st = PrinterStatus::from_state(&json!({ "print": { "gcode_state": "RUNNING" } }));
1064 assert_eq!(st.gcode_state.as_deref(), Some("RUNNING"));
1065 assert_eq!(st.bed_temper, None);
1066 assert_eq!(st.mc_percent, None);
1067 }
1068
1069 #[test]
1070 fn empty_or_unrelated_state_is_all_none() {
1071 let st = PrinterStatus::from_state(&json!({}));
1072 assert_eq!(st, PrinterStatus::default());
1073 }
1074
1075 #[test]
1076 fn numeric_strings_and_numbers_both_parse() {
1077 let st = PrinterStatus::from_state(&json!({
1078 "print": { "cooling_fan_speed": "85", "mc_percent": 42 }
1079 }));
1080 assert_eq!(st.cooling_fan_speed, Some(85)); assert_eq!(st.mc_percent, Some(42)); }
1083
1084 #[test]
1085 fn gcode_state_parses_and_classifies_terminality() {
1086 assert_eq!(GcodeState::parse("IDLE"), GcodeState::Idle);
1087 assert_eq!(GcodeState::parse("running"), GcodeState::Running);
1088 assert_eq!(GcodeState::parse("WAT"), GcodeState::Unknown);
1089 assert!(GcodeState::parse("FINISH").is_terminal());
1090 assert!(GcodeState::parse("FAILED").is_terminal());
1091 assert!(!GcodeState::parse("RUNNING").is_terminal());
1092 }
1093
1094 #[test]
1095 fn printer_status_exposes_typed_state() {
1096 let st = PrinterStatus::from_state(&json!({ "print": { "gcode_state": "RUNNING" } }));
1097 assert_eq!(st.state(), Some(GcodeState::Running));
1098 assert_eq!(PrinterStatus::from_state(&json!({})).state(), None);
1099 }
1100
1101 #[test]
1102 fn status_reflects_merged_deltas() {
1103 let mut rs = ReportState::new();
1104 rs.apply(json!({ "print": { "gcode_state": "RUNNING", "mc_percent": 10 } }));
1105 rs.apply(json!({ "print": { "mc_percent": 55 } })); let st = PrinterStatus::from_state(rs.get());
1107 assert_eq!(st.gcode_state.as_deref(), Some("RUNNING"));
1108 assert_eq!(st.mc_percent, Some(55));
1109 }
1110
1111 #[test]
1112 fn hms_alerts_are_decoded_into_the_status() {
1113 let st = PrinterStatus::from_state(&json!({ "print": { "hms": [
1115 { "attr": 50331904, "code": 65543 },
1116 { "attr": 0, "code": 0 } ]}}));
1118 assert_eq!(st.hms.len(), 1);
1119 let a = &st.hms[0];
1120 assert_eq!(a.code, "HMS_0300_0100_0001_0007");
1121 assert_eq!(a.code_hyphen, "0300-0100-0001-0007");
1122 assert_eq!(a.module, "motion_controller");
1123 assert_eq!(a.severity, 1); assert!(!a.is_lidar);
1125 assert!(a.wiki.contains("0300_0100_0001_0007"));
1126 assert_eq!(a.attr, 50331904);
1127 let healthy = PrinterStatus::from_state(&json!({ "print": { "hms": [] } }));
1129 assert!(healthy.hms.is_empty());
1130 let v = serde_json::to_value(&healthy).unwrap();
1131 assert!(v.get("hms").is_none(), "empty hms should be skipped");
1132 }
1133
1134 #[test]
1135 fn hms_unknown_module_keeps_its_hex() {
1136 let st = PrinterStatus::from_state(&json!({ "print": { "hms": [
1138 { "attr": 0x1100_0000u32, "code": 0x0002_0001u32 }
1139 ]}}));
1140 assert_eq!(st.hms[0].module, "unknown:0x11");
1141 }
1142
1143 #[test]
1144 fn ams_derives_active_and_target_during_a_swap() {
1145 let st = PrinterStatus::from_state(&json!({ "print": { "ams": {
1147 "tray_now": "1", "tray_tar": "3", "tray_pre": "1",
1148 "ams": [{ "id": "0", "tray": [
1149 { "id": "0", "tray_type": "PLA", "tray_color": "DE4343FF" },
1150 { "id": "1", "tray_type": "PLA", "tray_color": "000000FF" },
1151 { "id": "3", "tray_type": "PETG", "tray_color": "D6ABFF80" }
1152 ]}]
1153 }}}));
1154 let ams = st.ams.as_ref().unwrap();
1155 assert_eq!(ams.active_tray.as_deref(), Some("1"));
1156 assert_eq!(ams.target_tray.as_deref(), Some("3"));
1157 let trays = &ams.units[0].trays;
1158 assert!(trays[1].is_active && !trays[1].is_target); assert!(trays[2].is_target && !trays[2].is_active); assert!(!trays[0].is_active && !trays[0].is_target);
1161 assert_eq!(st.filament.as_ref().unwrap().location, "ams1");
1163 }
1164
1165 #[test]
1166 fn ams_is_none_without_ams_or_vt_tray() {
1167 let st = PrinterStatus::from_state(&json!({ "print": { "gcode_state": "IDLE" } }));
1168 assert_eq!(st.ams, None);
1169 }
1170
1171 #[test]
1172 fn wifi_signal_real_value_passes_through_but_sentinel_is_dropped() {
1173 let real = PrinterStatus::from_state(&json!({ "print": { "wifi_signal": "-50dBm" } }));
1174 assert_eq!(real.wifi_signal.as_deref(), Some("-50dBm"));
1175 let scrubbed =
1176 PrinterStatus::from_state(&json!({ "print": { "wifi_signal": "<redacted>" } }));
1177 assert_eq!(scrubbed.wifi_signal, None);
1178 }
1179
1180 #[test]
1181 fn loose_float_parsing_accepts_string_and_number() {
1182 assert_eq!(as_f64_loose(&json!("0.0")), Some(0.0));
1183 assert_eq!(as_f64_loose(&json!(28.5)), Some(28.5));
1184 assert_eq!(as_f64_loose(&json!(true)), None);
1185 }
1186
1187 #[test]
1188 fn enriched_fields_are_elided_from_an_idle_payload() {
1189 let st = PrinterStatus::from_state(&json!({ "print": { "gcode_state": "RUNNING" } }));
1192 let v = serde_json::to_value(&st).unwrap();
1193 for absent in [
1194 "ams",
1195 "upgrade",
1196 "online",
1197 "upload",
1198 "wifi_signal",
1199 "big_fan1_speed",
1200 ] {
1201 assert!(
1202 v.get(absent).is_none(),
1203 "{absent} should be skipped when absent"
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn round_trips_through_json() {
1210 let st = PrinterStatus::from_state(&json!({ "print": {
1215 "gcode_state": "RUNNING", "stg_cur": 2, "mc_percent": 42,
1216 "nozzle_temper": 215.0, "stg": [2, 0],
1217 "lights_report": [{ "node": "chamber_light", "mode": "on" }],
1218 }}));
1219 assert_eq!(st.stage.as_deref(), Some("heatbed_preheating"));
1220 let json = serde_json::to_string(&st).unwrap();
1221 let back: PrinterStatus = serde_json::from_str(&json).unwrap();
1222 assert_eq!(st, back);
1223 }
1224}