Skip to main content

bambu_rs/core/
model.rs

1//! Printer models and their canonical device codes.
2//!
3//! The model can be supplied by the user (`--model a1mini`) or resolved from a
4//! device-reported code (SSDP `DevModel.bambu.com`, the cloud `dev_model_name`,
5//! or the MQTT module `project_name` — for most models these share one code
6//! namespace).
7
8use std::fmt;
9
10/// A Bambu Lab printer model.
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum Model {
13    A1Mini,
14    A1,
15    P1P,
16    P1S,
17    X1,
18    X1Carbon,
19    X1E,
20    H2D,
21    /// A model name/code we don't recognise, kept verbatim.
22    Unknown(String),
23}
24
25impl Model {
26    /// Parse a user-facing config model name (case-insensitive, ignoring spaces,
27    /// hyphens and underscores), e.g. `"a1mini"`, `"A1 mini"`, `"x1c"`.
28    pub fn from_config_str(s: &str) -> Self {
29        let normalized: String = s
30            .chars()
31            .filter(|c| !matches!(c, ' ' | '-' | '_'))
32            .flat_map(char::to_lowercase)
33            .collect();
34        match normalized.as_str() {
35            "a1mini" => Model::A1Mini,
36            "a1" => Model::A1,
37            "p1p" => Model::P1P,
38            "p1s" => Model::P1S,
39            "x1" => Model::X1,
40            "x1c" | "x1carbon" => Model::X1Carbon,
41            "x1e" => Model::X1E,
42            "h2d" => Model::H2D,
43            _ => Model::Unknown(s.trim().to_string()),
44        }
45    }
46
47    /// The canonical config name (round-trips through [`Model::from_config_str`]).
48    pub fn as_str(&self) -> &str {
49        match self {
50            Model::A1Mini => "a1mini",
51            Model::A1 => "a1",
52            Model::P1P => "p1p",
53            Model::P1S => "p1s",
54            Model::X1 => "x1",
55            Model::X1Carbon => "x1c",
56            Model::X1E => "x1e",
57            Model::H2D => "h2d",
58            Model::Unknown(s) => s,
59        }
60    }
61
62    /// Whether this is a recognised model (not [`Model::Unknown`]).
63    pub fn is_known(&self) -> bool {
64        !matches!(self, Model::Unknown(_))
65    }
66
67    /// Resolve a device-reported model **code** to a [`Model`].
68    ///
69    /// These are the **vendor-canonical** codes from Bambu's own slicer machine
70    /// list (`BambuStudio/resources/printers/<code>.json` `model_id`), which for
71    /// the A1 family also match what the printer broadcasts over SSDP. We treat
72    /// the *mapping* as fact (vendor source); we do not copy the profile
73    /// contents. The A1 mini ↔ `"N1"` mapping is additionally **hardware-observed**
74    /// on a real unit (2026-06-13) — note that the once-"common" `N2S = A1 mini`
75    /// belief is inverted; `N1` is the A1 mini and `N2S` is the full-size A1.
76    ///
77    /// The legacy SSDP strings `"3DPrinter-X1-Carbon"` / `"3DPrinter-X1"` are
78    /// accepted and normalised to the modern `BL-P001` / `BL-P002` models.
79    pub fn from_device_code(code: &str) -> Self {
80        match code.trim() {
81            "N1" => Model::A1Mini,
82            "N2S" => Model::A1,
83            "C11" => Model::P1P,
84            "C12" => Model::P1S,
85            "C13" => Model::X1E,
86            "BL-P001" | "3DPrinter-X1-Carbon" => Model::X1Carbon,
87            "BL-P002" | "3DPrinter-X1" => Model::X1,
88            "O1D" => Model::H2D,
89            other => Model::Unknown(other.to_string()),
90        }
91    }
92
93    /// The canonical device code for this model (round-trips through
94    /// [`Model::from_device_code`]); `None` for [`Model::Unknown`].
95    pub fn device_code(&self) -> Option<&'static str> {
96        Some(match self {
97            Model::A1Mini => "N1",
98            Model::A1 => "N2S",
99            Model::P1P => "C11",
100            Model::P1S => "C12",
101            Model::X1 => "BL-P002",
102            Model::X1Carbon => "BL-P001",
103            Model::X1E => "C13",
104            Model::H2D => "O1D",
105            Model::Unknown(_) => return None,
106        })
107    }
108}
109
110impl fmt::Display for Model {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    const KNOWN: [Model; 8] = [
121        Model::A1Mini,
122        Model::A1,
123        Model::P1P,
124        Model::P1S,
125        Model::X1,
126        Model::X1Carbon,
127        Model::X1E,
128        Model::H2D,
129    ];
130
131    #[test]
132    fn parses_canonical_config_names() {
133        assert_eq!(Model::from_config_str("a1mini"), Model::A1Mini);
134        assert_eq!(Model::from_config_str("a1"), Model::A1);
135        assert_eq!(Model::from_config_str("p1s"), Model::P1S);
136        assert_eq!(Model::from_config_str("x1"), Model::X1);
137        assert_eq!(Model::from_config_str("x1c"), Model::X1Carbon);
138        assert_eq!(Model::from_config_str("h2d"), Model::H2D);
139    }
140
141    #[test]
142    fn config_parsing_is_lenient_about_case_and_separators() {
143        assert_eq!(Model::from_config_str("A1 mini"), Model::A1Mini);
144        assert_eq!(Model::from_config_str("A1-Mini"), Model::A1Mini);
145        assert_eq!(Model::from_config_str("a1_mini"), Model::A1Mini);
146        assert_eq!(Model::from_config_str("X1Carbon"), Model::X1Carbon);
147    }
148
149    #[test]
150    fn unknown_model_is_kept_verbatim() {
151        let m = Model::from_config_str("z9 ultra");
152        assert_eq!(m, Model::Unknown("z9 ultra".to_string()));
153        assert!(!m.is_known());
154    }
155
156    #[test]
157    fn resolves_vendor_canonical_device_codes() {
158        assert_eq!(Model::from_device_code("N1"), Model::A1Mini); // hardware-observed
159        assert_eq!(Model::from_device_code("N2S"), Model::A1); // NOT the A1 mini
160        assert_eq!(Model::from_device_code("C11"), Model::P1P);
161        assert_eq!(Model::from_device_code("C12"), Model::P1S);
162        assert_eq!(Model::from_device_code("C13"), Model::X1E);
163        assert_eq!(Model::from_device_code("BL-P001"), Model::X1Carbon);
164        assert_eq!(Model::from_device_code("BL-P002"), Model::X1);
165        assert_eq!(Model::from_device_code("O1D"), Model::H2D);
166    }
167
168    #[test]
169    fn x1_and_x1_carbon_are_distinct_codes() {
170        assert_ne!(Model::X1, Model::X1Carbon);
171        assert_eq!(Model::X1.device_code(), Some("BL-P002"));
172        assert_eq!(Model::X1Carbon.device_code(), Some("BL-P001"));
173    }
174
175    #[test]
176    fn legacy_ssdp_strings_normalise_to_modern_models() {
177        assert_eq!(
178            Model::from_device_code("3DPrinter-X1-Carbon"),
179            Model::X1Carbon
180        );
181        assert_eq!(Model::from_device_code("3DPrinter-X1"), Model::X1);
182    }
183
184    #[test]
185    fn unrecognised_device_code_is_unknown() {
186        assert_eq!(
187            Model::from_device_code("ZZ9"),
188            Model::Unknown("ZZ9".to_string())
189        );
190        assert_eq!(Model::Unknown("ZZ9".into()).device_code(), None);
191    }
192
193    #[test]
194    fn device_codes_round_trip_for_all_known_models() {
195        for m in KNOWN {
196            let code = m.device_code().expect("known model has a device code");
197            assert_eq!(Model::from_device_code(code), m, "round-trip for {m}");
198        }
199    }
200
201    #[test]
202    fn config_names_round_trip_for_all_known_models() {
203        for m in KNOWN {
204            assert!(m.is_known());
205            assert_eq!(Model::from_config_str(m.as_str()), m);
206        }
207    }
208}