Skip to main content

bambu_rs/core/
status.rs

1//! A typed view over the merged report state.
2//!
3//! [`ReportState`](crate::core::report::ReportState) keeps the raw, untyped
4//! JSON; [`PrinterStatus`] extracts the fields an agent actually cares about.
5//! Every field is optional because a delta may not carry it and because we stay
6//! tolerant of fields the device omits.
7//!
8//! Field names and shapes here are taken from a **real A1 mini capture** (see
9//! `tests/fixtures/pushall-n1-idle.json`), not from spec guesses — e.g. fan
10//! speeds arrive as strings and are parsed here.
11
12use 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/// The fields of a printer `print` report that matter for monitoring.
19#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
20#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
21pub struct PrinterStatus {
22    /// Coarse job state, e.g. `IDLE`, `RUNNING`, `PAUSE`, `FINISH`, `FAILED`.
23    pub gcode_state: Option<String>,
24    /// `print_error` code (0 = none).
25    pub print_error: Option<i64>,
26    /// Typed view of a non-zero `print_error` (a device-level fault, distinct
27    /// from HMS — observed: a failing SD card surfaced `0x0500C010` here while
28    /// `hms` was empty). `None` when there is no active error.
29    pub error: Option<DeviceError>,
30    /// Progress percentage (`mc_percent`).
31    pub mc_percent: Option<i64>,
32    /// Current layer / total layers.
33    pub layer_num: Option<i64>,
34    pub total_layer_num: Option<i64>,
35    /// Remaining time in minutes (`mc_remaining_time`).
36    pub remaining_time_min: Option<i64>,
37    /// Current stage id (`stg_cur`). Read together with `gcode_state`: stage 0
38    /// is the no-special-stage default and appears while idle too.
39    pub stg_cur: Option<i64>,
40    /// Decoded name of `stg_cur` (e.g. `auto_bed_leveling`), or `None` for an
41    /// unknown/future stage id. See [`crate::core::stage`].
42    pub stage: Option<String>,
43    /// `home_flag` bitfield (per-axis homed state); it changes during a home/move,
44    /// so it's one of the few report signals that reflect ad-hoc motion.
45    pub home_flag: Option<i64>,
46    /// Nozzle / bed temperatures and their targets (°C).
47    pub nozzle_temper: Option<f64>,
48    pub nozzle_target: Option<f64>,
49    pub bed_temper: Option<f64>,
50    pub bed_target: Option<f64>,
51    /// **Raw** `chamber_temper` value as reported. On A1/P1 this is emitted but
52    /// is not a real sensor — call [`PrinterStatus::real_chamber_temperature`]
53    /// for a value only when the model actually has a chamber sensor.
54    pub chamber_temper_raw: Option<f64>,
55    /// Part-cooling fan speed (`cooling_fan_speed`; arrives as a string).
56    pub cooling_fan_speed: Option<i64>,
57    /// Active print-speed level (`spd_lvl`): 1 silent, 2 standard, 3 sport,
58    /// 4 ludicrous. How a `print_speed` command is verified.
59    pub spd_lvl: Option<i64>,
60    /// Name of the running subtask/job (empty when idle).
61    pub subtask_name: Option<String>,
62    /// The currently-loaded filament (the one the print uses), resolved from
63    /// `ams.tray_now` → the matching AMS tray or the external spool. `None` when
64    /// nothing is loaded or the report doesn't carry AMS data.
65    pub filament: Option<Filament>,
66    /// All `lights_report` entries (each `{node, mode}`), e.g.
67    /// `chamber_light=off`. This is the printer's *actual* light state — distinct
68    /// from a `ledctrl` ACK, which only confirms acceptance (observed: a faulty
69    /// unit ACKs `ledctrl` but `lights_report` stays `off`). Look a node up with
70    /// [`PrinterStatus::light_mode`].
71    pub lights: Vec<LightReport>,
72    /// Camera/timelapse settings from the `ipcam` report node. `None` when the
73    /// report carries no `ipcam` object.
74    pub ipcam: Option<Ipcam>,
75
76    // ── Enriched fields ───────────────────────────────────────────────────
77    // Mirror the device report's own shape: flat scalars where `print.*` is
78    // flat, nested structs only where the report nests an object. All optional
79    // and `skip_serializing_if`-elided so an idle frame stays compact.
80
81    // Fans (besides the part-cooling fan above). A fan reading 0 while RUNNING
82    // is a clog / heat-creep symptom worth surfacing.
83    /// Aux/part fan #1 (`big_fan1_speed`).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub big_fan1_speed: Option<i64>,
86    /// Chamber/second big fan (`big_fan2_speed`; 0 on the fanless-chamber A1).
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub big_fan2_speed: Option<i64>,
89    /// Hotend/heatbreak fan (`heatbreak_fan_speed`). Dead → heat creep / jams.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub heatbreak_fan_speed: Option<i64>,
92    /// Packed per-fan gear bitfield (`fan_gear`).
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub fan_gear: Option<i64>,
95
96    // Finer progress / print-phase detail.
97    /// Feed-rate override percent (`spd_mag`, 100 = nominal); distinct from the
98    /// named `spd_lvl` tier and what actually moves the ETA.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub spd_mag: Option<i64>,
101    /// Filename currently loaded/printing (`gcode_file`); `None` when idle.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub gcode_file: Option<String>,
104    /// Pre-print file preparation/download progress (`gcode_file_prepare_percent`).
105    /// Lets a viewer tell "preparing" from a stalled `mc_percent == 0`.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub gcode_file_prepare_percent: Option<i64>,
108    /// Coarse motion-controller phase (`mc_print_stage`), cross-checks `stg_cur`.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub mc_print_stage: Option<i64>,
111    /// Finer sub-phase within `mc_print_stage` (`mc_print_sub_stage`).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub mc_print_sub_stage: Option<i64>,
114    /// Current gcode line number (`mc_print_line_number`); increments within a
115    /// layer, so it is a fine-grained liveness/stall signal.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub mc_print_line_number: Option<i64>,
118    /// Job source/type (`print_type`), e.g. `idle` / `local` / `cloud`.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub print_type: Option<String>,
121    /// Queue of upcoming stage ids (`stg`); `stg_cur` is the current one.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub stg: Vec<i64>,
124
125    // Machine configuration / peripherals.
126    /// Installed nozzle diameter in mm (`nozzle_diameter`, e.g. `0.4`).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub nozzle_diameter: Option<String>,
129    /// Nozzle material (`nozzle_type`, e.g. `stainless_steel` / `hardened`).
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub nozzle_type: Option<String>,
132    /// Whether an SD card is present/mounted (`sdcard`).
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub sdcard: Option<bool>,
135    /// Top-level AMS state machine (`ams_status`): idle/loading/unloading code.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub ams_status: Option<i64>,
138    /// Top-level AMS RFID read state (`ams_rfid_status`).
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub ams_rfid_status: Option<i64>,
141    /// Hardware switch / filament-presence / door sensor bits (`hw_switch_state`).
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub hw_switch_state: Option<i64>,
144
145    // Connectivity. (`net`/`net.ip` is deliberately NOT surfaced — it exposes
146    // the device address; only the non-identifying RSSI is.)
147    /// Wi-Fi signal strength (`wifi_signal`, e.g. `-50dBm`). The capture's
148    /// `<redacted>` sentinel is filtered to `None`.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub wifi_signal: Option<String>,
151
152    // Job/device identity (for correlating live status with a queued job).
153    /// Cloud/print task id (`task_id`).
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub task_id: Option<String>,
156    /// Subtask id (`subtask_id`), pairs with `subtask_name`.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub subtask_id: Option<String>,
159    /// Project id (`project_id`).
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub project_id: Option<String>,
162    /// Slicing/print profile id (`profile_id`).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub profile_id: Option<String>,
165    /// The printer's own report counter (`sequence_id`); detects dropped/reordered
166    /// reports.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub sequence_id: Option<String>,
169    /// Device lifecycle/build channel (`lifecycle`, e.g. `product`).
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub lifecycle: Option<String>,
172
173    // Nested objects — these mirror objects the report itself nests.
174    /// Full AMS inventory (all units & trays), built from `ams` + `vt_tray`.
175    /// `filament` above remains a convenience pointer to the loaded tray.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub ams: Option<Ams>,
178    /// Firmware-update availability/progress (`upgrade_state`).
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub upgrade: Option<Upgrade>,
181    /// Peripheral online state (`online`: AHB / RFID bus presence).
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub online: Option<Online>,
184    /// In-progress file upload/transfer (`upload`).
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub upload: Option<Upload>,
187
188    /// Decoded HMS alerts (the device's primary fault/warning channel). Empty
189    /// when healthy; separate from `error`/`print_error`. See [`crate::core::hms`].
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub hms: Vec<HmsAlert>,
192}
193
194/// One `lights_report` entry: an LED node and its mode (`on`/`off`).
195#[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/// Camera/timelapse settings from the `ipcam` report node (A1/P1: a JPEG-stream
203/// camera). The `timelapse` field is the printer's *actual* timelapse setting,
204/// which is how an `ipcam_timelapse` command is verified (the ACK alone only
205/// says it was accepted — same caveat as the chamber light).
206#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
208pub struct Ipcam {
209    /// `timelapse` mode (`enable`/`disable`) — whether a timelapse is recorded
210    /// during prints.
211    pub timelapse: Option<String>,
212    /// `ipcam_record` mode (`enable`/`disable`).
213    pub record: Option<String>,
214    /// Stream resolution, e.g. `1080p`.
215    pub resolution: Option<String>,
216}
217
218/// The loaded filament a print draws from (resolved from `ams.tray_now`).
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
221pub struct Filament {
222    /// `ams0`..`amsN` for an AMS tray, or `external` for the external spool.
223    pub location: String,
224    /// Material, e.g. `PLA` (`tray_type`).
225    pub material: Option<String>,
226    /// Display name, e.g. `PLA Matte` (`tray_sub_brands`).
227    pub name: Option<String>,
228    /// Colour as reported (`tray_color`), e.g. `000000FF` (RGBA hex).
229    pub color: Option<String>,
230}
231
232/// A decoded HMS alert (the wire view of [`crate::core::hms::HmsEntry`]).
233/// `severity` is the **raw** bits — label conventions conflict across sources,
234/// so we expose the number and link to the wiki rather than bundling a table.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
237pub struct HmsAlert {
238    /// Canonical underscore form, e.g. `HMS_0300_0100_0001_0007`.
239    pub code: String,
240    /// Hyphen form Bambu shows on-screen, e.g. `0300-0100-0001-0007`.
241    pub code_hyphen: String,
242    /// Raw severity bits (`code >> 16`); not mapped to a label.
243    pub severity: u16,
244    /// Originating subsystem: `motion_controller` / `mainboard` / `ams` /
245    /// `toolhead` / `xcam` / `unknown:0xNN`.
246    pub module: String,
247    /// XCAM/micro-LiDAR code (only on X1-class hardware).
248    pub is_lidar: bool,
249    /// Deep link to Bambu's per-code troubleshooting page.
250    pub wiki: String,
251    /// Raw `attr` int (escape hatch for re-deriving fields).
252    pub attr: u32,
253    /// Raw `code` int.
254    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/// The full AMS picture: every unit and tray, the external spool, and the
273/// active/target/previous tray pointers (the live colour-swap signal — the tray
274/// *array* only arrives in full pushalls, the pointers in deltas).
275#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
277pub struct Ams {
278    /// Attached AMS units (`ams.ams[]`).
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub units: Vec<AmsUnit>,
281    /// The external/virtual spool (`vt_tray`), surfaced even when not loaded.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub external: Option<AmsTray>,
284    /// Active tray id (`ams.tray_now`): `255` none, `254` external, else a tray id.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub active_tray: Option<String>,
287    /// Target tray during a swap (`ams.tray_tar`); `!= active_tray` ⇒ swapping.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub target_tray: Option<String>,
290    /// Previous tray (`ams.tray_pre`); reconstructs swap timelines.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub previous_tray: Option<String>,
293    /// Hex bitfield of attached units (`ams.ams_exist_bits`).
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub ams_exist_bits: Option<String>,
296    /// Hex bitfield of occupied slots (`ams.tray_exist_bits`).
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub tray_exist_bits: Option<String>,
299    /// Hex bitfield of genuine-Bambu (RFID) trays (`ams.tray_is_bbl_bits`).
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub tray_is_bbl_bits: Option<String>,
302}
303
304/// One AMS unit (`ams.ams[]`).
305#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
307pub struct AmsUnit {
308    /// Physical unit id (`id`).
309    pub id: String,
310    /// Coarse dryness bucket 1–5 (`humidity`).
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub humidity: Option<i64>,
313    /// Finer raw humidity reading (`humidity_raw`).
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub humidity_raw: Option<i64>,
316    /// Internal temperature °C (`temp`, drying-capable units).
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub temp: Option<f64>,
319    /// Remaining/active drying time (`dry_time`).
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub dry_time: Option<i64>,
322    /// The unit's trays/slots (`tray[]`).
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub trays: Vec<AmsTray>,
325}
326
327/// One AMS tray/slot (`ams.ams[].tray[]` or `vt_tray`).
328#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
329#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
330pub struct AmsTray {
331    /// Tray id (`id`); what `tray_now`/`tray_pre`/`tray_tar` point at.
332    pub id: String,
333    /// Material (`tray_type`, e.g. `PLA`).
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub material: Option<String>,
336    /// Display name (`tray_sub_brands`, e.g. `PLA Matte`).
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub name: Option<String>,
339    /// Primary colour as RGBA hex (`tray_color`).
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub color: Option<String>,
342    /// All colour segments (`cols`); single-colour spools mirror `color`.
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub cols: Vec<String>,
345    /// Remaining filament percent (`remain`); `0`/`-1` mean unknown on the A1.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub remain: Option<i64>,
348    /// Per-tray RFID/load status code (`state`); raw (no verified label table).
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub state: Option<i64>,
351    /// Bambu filament-preset id (`tray_info_idx`, e.g. `GFA01`).
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub info_idx: Option<String>,
354    /// Short SKU/colour code (`tray_id_name`, e.g. `A01-R1`).
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub id_name: Option<String>,
357    /// Stable physical-spool id (`tray_uuid`); all-zero ⇒ `None`.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub uuid: Option<String>,
360    /// Recommended min nozzle temp (`nozzle_temp_min`).
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub nozzle_temp_min: Option<i64>,
363    /// Recommended max nozzle temp (`nozzle_temp_max`).
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub nozzle_temp_max: Option<i64>,
366    /// `true` when this tray is the active one (`id == tray_now`).
367    pub is_active: bool,
368    /// `true` when this tray is the swap target (`id == tray_tar`).
369    pub is_target: bool,
370}
371
372/// Firmware-update availability/progress (`upgrade_state`).
373#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
374#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
375pub struct Upgrade {
376    /// Whether an update is available/pending (`new_version_state`).
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub new_version_state: Option<i64>,
379    /// Non-zero ⇒ a flash is in progress (`cur_state_code`).
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub cur_state_code: Option<i64>,
382    /// Activity status (`status`, e.g. `IDLE`).
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub status: Option<String>,
385    /// Failed-update error code (`err_code`).
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub err_code: Option<i64>,
388}
389
390/// Peripheral online state (`online`).
391#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
392#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
393pub struct Online {
394    /// AHB bus present (`ahb`).
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub ahb: Option<bool>,
397    /// RFID reader present (`rfid`).
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub rfid: Option<bool>,
400    /// Reported version counter (`version`).
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub version: Option<i64>,
403}
404
405/// In-progress file upload/transfer (`upload`).
406#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
407#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
408pub struct Upload {
409    /// Transfer status (`status`, e.g. `idle`).
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub status: Option<String>,
412    /// Transfer progress percent (`progress`).
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub progress: Option<i64>,
415    /// Status message (`message`).
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub message: Option<String>,
418}
419
420impl PrinterStatus {
421    /// Extract a [`PrinterStatus`] from the merged report state (the object that
422    /// contains the `print` key). Missing fields become `None`.
423    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            // ── Enriched fields ───────────────────────────────────────────
469            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    /// The printer's current timelapse setting (`enable`/`disable`) from the
530    /// `ipcam` report node, if present. Used to verify an `ipcam_timelapse`
531    /// command took effect.
532    pub fn timelapse_mode(&self) -> Option<&str> {
533        self.ipcam.as_ref()?.timelapse.as_deref()
534    }
535
536    /// The mode (`on`/`off`) of a `lights_report` node (e.g. `chamber_light`),
537    /// if reported. Used to verify a `ledctrl` command took effect.
538    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    /// The chamber temperature **only if** the model has a real chamber sensor.
546    /// Models that merely echo a synthetic `chamber_temper` (A1 / P1) get `None`.
547    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    /// The parsed coarse job state, if a `gcode_state` was reported.
555    pub fn state(&self) -> Option<GcodeState> {
556        self.gcode_state.as_deref().map(GcodeState::parse)
557    }
558}
559
560/// A device-level fault decoded from `print_error` (0 = no error). This is a
561/// **separate channel from HMS** — on the A1 mini a failing SD card reported
562/// `print_error = 0x0500C010` while `hms` stayed empty, so a status view must
563/// surface `print_error` in its own right.
564///
565/// We don't bundle the full third-party code→text table (sources conflict; same
566/// rationale as [`crate::core::hms`]) — but we DO attach a plain-language
567/// [`message`](DeviceError::message) for the handful of codes verified on the
568/// real device, and always emit the hex + a lookup link.
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
571pub struct DeviceError {
572    /// Raw `print_error` value.
573    pub code: i64,
574    /// Conventional hex rendering, e.g. `0x0500C010`.
575    pub hex: String,
576    /// Plain-language cause — present ONLY for codes we've **verified on the real
577    /// device** (the printer's own on-screen message). Unverified codes leave
578    /// this `None` and rely on `lookup_url`, so we never surface a guessed cause.
579    pub message: Option<String>,
580    /// Link to Bambu's official error-code resolver for this code (we don't
581    /// bundle the full code→text table — sources conflict — so we point at the
582    /// authority instead, the same way [`crate::core::hms`] links to the wiki).
583    pub lookup_url: String,
584}
585
586/// On-screen text for the `print_error` codes we've confirmed on the real A1
587/// mini. Tiny and device-sourced on purpose; grow it only as codes are actually
588/// verified (the printer's own wording), never from guesswork.
589fn verified_error_message(code: i64) -> Option<&'static str> {
590    match code {
591        // All verified on the A1 mini screen (2026-06-16) during AMS filament
592        // operations (an end-of-print pullback, a tray-change) and a print
593        // (an under-extrusion pause at the first layer), respectively.
594        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    /// Build from a raw `print_error`; `None` when the code is 0 (no error).
603    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/// The coarse job state (`gcode_state`). The device sends uppercase tokens; an
617/// unrecognised token maps to [`GcodeState::Unknown`] for forward compatibility.
618#[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    /// Parse a `gcode_state` token (case-insensitive).
634    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    /// Whether the print has reached a terminal state (finished or failed).
650    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
659/// A string field, but `None` for empty (the device uses `""` for "unset").
660fn as_nonempty_string(v: &Value) -> Option<String> {
661    v.as_str().filter(|s| !s.is_empty()).map(str::to_owned)
662}
663
664/// Resolve the loaded filament from the `print` object: `ams.tray_now` names the
665/// active tray — `254` is the external spool (`vt_tray`), otherwise it matches a
666/// tray `id` inside `ams.ams[].tray[]`. Returns `None` when nothing is loaded
667/// (`tray_now == 255`) or the report carries no AMS data.
668fn 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
700/// Accept either a JSON number or a numeric string (the device sends some
701/// integer-valued fields, e.g. fan speeds, as strings).
702fn 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
710/// Like [`as_i64_loose`] but for floats — the device sends some real-valued
711/// fields (e.g. AMS unit temperature, `"0.0"`) as strings. Non-finite values
712/// (`NaN`/`inf`) are rejected so they can't poison JSON serialization.
713fn 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
722/// Wire string for an HMS [`Module`]; unknown ids keep their hex so nothing is
723/// silently mislabelled (the enum's Rust variant names stay off the wire).
724fn 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
735/// Build the full [`Ams`] view from the `print` object (`ams` + `vt_tray`).
736/// `None` only when the report carries neither.
737fn 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    // Keep the external spool only when it actually carries filament info.
755    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        // A `tray_uuid` of all-zeros means "no spool tag" → None.
806        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        // tray_now points at AMS tray 3 (PLA Matte black).
825        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); // empty sub_brands -> None
851        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        // No AMS data at all.
859        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        // The real SD-card fault value.
866        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        // Zero is "no error".
874        assert_eq!(DeviceError::from_code(0), None);
875    }
876
877    #[test]
878    fn device_error_attaches_a_message_only_for_device_verified_codes() {
879        // Verified on the real A1 mini screen (filament/toolhead faults).
880        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        // An unverified code carries no fabricated message — just hex + the link.
902        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        // Fan speed arrives as the string "0" and is parsed to a number.
931        assert_eq!(st.cooling_fan_speed, Some(0));
932        // Real float temperatures from the device.
933        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        // Raw chamber value is present (5.0) but the A1 mini has no real sensor.
937        assert_eq!(st.chamber_temper_raw, Some(5.0));
938
939        // ── Enriched scalars (all string "0"s parse to numbers) ──
940        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)); // string "1"
946        assert_eq!(st.print_type.as_deref(), Some("idle"));
947        assert_eq!(st.gcode_file, None); // empty "" -> None
948        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        // wifi_signal is "<redacted>" in the scrubbed fixture -> filtered to None.
954        assert_eq!(st.wifi_signal, None);
955        assert!(st.hms.is_empty()); // healthy idle device
956
957        // ── Nested objects ──
958        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); // empty -> None
968
969        // ── Full AMS inventory: 1 unit, 4 trays, idle (nothing loaded) ──
970        let ams = st.ams.as_ref().unwrap();
971        assert_eq!(ams.active_tray.as_deref(), Some("255")); // none loaded
972        assert_eq!(ams.tray_exist_bits.as_deref(), Some("f")); // all 4 slots full
973        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)); // string "5"
977        assert_eq!(unit.temp, Some(0.0)); // string "0.0" via as_f64_loose
978        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()); // real spool tag present
989        assert!(!t0.is_active); // nothing loaded (tray_now == 255)
990        // The PETG tray keeps its distinct temps.
991        assert_eq!(unit.trays[2].material.as_deref(), Some("PETG"));
992        assert_eq!(unit.trays[2].nozzle_temp_max, Some(260));
993        // External spool surfaced from vt_tray; empty sub_brands & all-zero uuid -> None.
994        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        // The single-filament convenience pointer is None while idle (tray_now 255).
1001        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); // synthetic -> hidden
1021        assert_eq!(st.real_chamber_temperature(&x1), Some(5.0)); // real sensor -> exposed
1022    }
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        // Unknown node -> None.
1033        assert_eq!(st.light_mode("logo_light"), None);
1034        // No lights_report -> empty, no panic.
1035        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        // No ipcam node -> None.
1051        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)); // from string
1081        assert_eq!(st.mc_percent, Some(42)); // from number
1082    }
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 } })); // delta
1106        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        // attr=0x03000100, code=0x00010007 -> the wiki's heatbed-temp-abnormal code.
1114        let st = PrinterStatus::from_state(&json!({ "print": { "hms": [
1115            { "attr": 50331904, "code": 65543 },
1116            { "attr": 0, "code": 0 } // zero padding -> skipped
1117        ]}}));
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); // 0x0001
1124        assert!(!a.is_lidar);
1125        assert!(a.wiki.contains("0300_0100_0001_0007"));
1126        assert_eq!(a.attr, 50331904);
1127        // Healthy device -> empty list, and it is elided from the JSON.
1128        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        // attr module byte 0x11 is community-only / unverified -> unknown:0x11.
1137        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        // tray_now=1, tray_tar=3 -> a colour swap from tray 1 to tray 3.
1146        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); // currently printing
1159        assert!(trays[2].is_target && !trays[2].is_active); // swapping to it
1160        assert!(!trays[0].is_active && !trays[0].is_target);
1161        // The convenience pointer still resolves the loaded tray.
1162        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        // A bare RUNNING status should not carry a wall of nulls for the new
1190        // optional fields (skip_serializing_if keeps WS frames compact).
1191        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        // `bambu --via-serve` deserializes a serve's /api/status response back into
1211        // PrinterStatus, so the type must survive its own serialization — including
1212        // the `skip_serializing_if`-elided fields (absent → default, not an error)
1213        // and the decoded `stage` string.
1214        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}