Skip to main content

bambu_rs/core/
hms.rs

1//! HMS (Health Management System) error decoding.
2//!
3//! Model-**independent**: the bit layout is identical across every Bambu model,
4//! so this is plain `core` with no model gating (the one model-specific aspect —
5//! XCAM/micro-LiDAR codes — is only *emitted* by X1-class hardware; callers can
6//! check [`HmsEntry::is_lidar`] / the model's `lidar` capability).
7//!
8//! The report carries `hms` as an array of `{attr, code}` 32-bit integers (our
9//! observed A1 mini reports `hms: []` = no active alerts). Each pair decodes to
10//! `HMS_AAAA_BBBB_CCCC_DDDD` where `AAAA = attr>>16`, `BBBB = attr&0xFFFF`,
11//! `CCCC = code>>16`, `DDDD = code&0xFFFF`.
12//!
13//! Provenance: the bit layout is reconstructed from protocol docs and
14//! cross-checked against the official Bambu wiki (the worked example
15//! `HMS_0300_0100_0001_0007` = "heatbed temperature abnormal" is confirmed). We
16//! deliberately do **not** hardcode a severity-label table (sources conflict)
17//! nor bundle any code→text mapping (that would copy pybambu's data); the code
18//! string + a wiki URL are emitted instead.
19
20use serde_json::Value;
21
22/// One decoded HMS alert.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct HmsEntry {
25    pub attr: u32,
26    pub code: u32,
27}
28
29/// The functional module an HMS code originates from — `(attr >> 24) & 0xFF`.
30/// Only the well-corroborated modules are named; the community-only ids
31/// (0x10/0x11/0x12/0x18) are left as [`Module::Unknown`] until verified.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Module {
34    /// 0x03 — motion controller.
35    MotionController,
36    /// 0x05 — mainboard / AP.
37    Mainboard,
38    /// 0x07 — AMS.
39    Ams,
40    /// 0x08 — toolhead.
41    Toolhead,
42    /// 0x0C — XCAM / micro-LiDAR (only on X1-class hardware).
43    Xcam,
44    Unknown(u8),
45}
46
47impl HmsEntry {
48    pub fn new(attr: u32, code: u32) -> Self {
49        Self { attr, code }
50    }
51
52    fn groups(&self) -> [u16; 4] {
53        [
54            (self.attr >> 16) as u16,
55            (self.attr & 0xFFFF) as u16,
56            (self.code >> 16) as u16,
57            (self.code & 0xFFFF) as u16,
58        ]
59    }
60
61    /// Canonical code string, underscore form: `HMS_0300_0100_0001_0007`.
62    pub fn code_string(&self) -> String {
63        let [a, b, c, d] = self.groups();
64        format!("HMS_{a:04X}_{b:04X}_{c:04X}_{d:04X}")
65    }
66
67    /// The four groups joined by hyphens (Bambu's on-screen form):
68    /// `0300-0100-0001-0007`.
69    pub fn code_hyphen(&self) -> String {
70        let [a, b, c, d] = self.groups();
71        format!("{a:04X}-{b:04X}-{c:04X}-{d:04X}")
72    }
73
74    /// Raw severity value = `code >> 16`. **Not** mapped to a label: sources
75    /// conflict (pybambu 1=fatal/2=serious/3=common/4=info vs the Bambu wiki
76    /// 1=Error/2=Warning/3=Info), so the bits are exposed and the label is left
77    /// to the caller.
78    pub fn severity_raw(&self) -> u16 {
79        (self.code >> 16) as u16
80    }
81
82    /// The originating module — `(attr >> 24) & 0xFF`.
83    pub fn module(&self) -> Module {
84        match ((self.attr >> 24) & 0xFF) as u8 {
85            0x03 => Module::MotionController,
86            0x05 => Module::Mainboard,
87            0x07 => Module::Ams,
88            0x08 => Module::Toolhead,
89            0x0C => Module::Xcam,
90            other => Module::Unknown(other),
91        }
92    }
93
94    /// Whether this is an XCAM/micro-LiDAR code (only emitted by X1-class).
95    pub fn is_lidar(&self) -> bool {
96        matches!(self.module(), Module::Xcam)
97    }
98
99    /// Best-effort link to Bambu's per-code troubleshooting page.
100    pub fn wiki_url(&self) -> String {
101        let [a, b, c, d] = self.groups();
102        format!(
103            "https://wiki.bambulab.com/en/x1/troubleshooting/hmscode/{a:04X}_{b:04X}_{c:04X}_{d:04X}"
104        )
105    }
106}
107
108/// Decode the `hms[]` array from a merged report state (`state["print"]["hms"]`).
109/// Entries with a zero `attr` or `code` are skipped (inactive / padding).
110pub fn decode_report_hms(state: &Value) -> Vec<HmsEntry> {
111    let Some(arr) = state.pointer("/print/hms").and_then(Value::as_array) else {
112        return Vec::new();
113    };
114    arr.iter()
115        .filter_map(|e| {
116            let attr = e.get("attr").and_then(Value::as_u64)?;
117            let code = e.get("code").and_then(Value::as_u64)?;
118            if attr == 0 || code == 0 {
119                return None;
120            }
121            Some(HmsEntry::new(attr as u32, code as u32))
122        })
123        .collect()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serde_json::json;
130
131    #[test]
132    fn worked_example_decodes_like_the_official_wiki() {
133        // attr=0x03000100, code=0x00010007 -> HMS_0300_0100_0001_0007
134        // ("heatbed temperature abnormal", confirmed against the Bambu wiki).
135        let e = HmsEntry::new(0x0300_0100, 0x0001_0007);
136        assert_eq!(e.code_string(), "HMS_0300_0100_0001_0007");
137        assert_eq!(e.code_hyphen(), "0300-0100-0001-0007");
138        assert_eq!(e.severity_raw(), 1);
139        assert_eq!(e.module(), Module::MotionController);
140        assert!(!e.is_lidar());
141        assert!(e.wiki_url().ends_with("/hmscode/0300_0100_0001_0007"));
142    }
143
144    #[test]
145    fn module_is_decoded_from_the_attr_high_byte() {
146        assert_eq!(HmsEntry::new(0x0500_0000, 1).module(), Module::Mainboard);
147        assert_eq!(HmsEntry::new(0x0700_0000, 1).module(), Module::Ams);
148        assert_eq!(HmsEntry::new(0x0800_0000, 1).module(), Module::Toolhead);
149        assert_eq!(
150            HmsEntry::new(0x0A00_0000, 1).module(),
151            Module::Unknown(0x0A)
152        );
153    }
154
155    #[test]
156    fn xcam_codes_are_flagged_as_lidar() {
157        let e = HmsEntry::new(0x0C00_0100, 0x0003_0001);
158        assert_eq!(e.module(), Module::Xcam);
159        assert!(e.is_lidar());
160    }
161
162    #[test]
163    fn observed_a1mini_fixture_has_no_active_alerts() {
164        let raw = include_str!("../../tests/fixtures/pushall-n1-idle.json");
165        let fixture: Value = serde_json::from_str(raw).unwrap();
166        // hms: [] was observed on the real device.
167        assert_eq!(decode_report_hms(&fixture["message"]), Vec::new());
168    }
169
170    #[test]
171    fn decode_skips_zero_padding_entries() {
172        let state = json!({ "print": { "hms": [
173            { "attr": 0, "code": 0 },
174            { "attr": 50331904, "code": 65543 }, // 0x03000100 / 0x00010007
175            { "attr": 123, "code": 0 },
176        ]}});
177        let decoded = decode_report_hms(&state);
178        assert_eq!(decoded, vec![HmsEntry::new(0x0300_0100, 0x0001_0007)]);
179    }
180
181    #[test]
182    fn missing_hms_field_decodes_to_empty() {
183        assert_eq!(decode_report_hms(&json!({ "print": {} })), Vec::new());
184        assert_eq!(decode_report_hms(&json!({})), Vec::new());
185    }
186}