printer_models.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. """Printer model normalization utilities.
  2. Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to
  3. normalized short names (e.g., "X1C") that match database storage.
  4. """
  5. # Map from 3MF printer_model strings to normalized short names
  6. PRINTER_MODEL_MAP = {
  7. "Bambu Lab X1 Carbon": "X1C",
  8. "Bambu Lab X1": "X1",
  9. "Bambu Lab X1E": "X1E",
  10. "Bambu Lab P1S": "P1S",
  11. "Bambu Lab P1P": "P1P",
  12. "Bambu Lab P2S": "P2S",
  13. "Bambu Lab A1": "A1",
  14. "Bambu Lab A1 Mini": "A1 Mini",
  15. "Bambu Lab A1 mini": "A1 Mini",
  16. # Bambu cloud rolled out a terse model-code rename mid-2026 (#1649);
  17. # 3MFs prepared with newer cloud presets may carry this short form.
  18. "Bambu Lab A1M": "A1 Mini",
  19. "Bambu Lab H2D": "H2D",
  20. "Bambu Lab H2D Pro": "H2D Pro",
  21. "Bambu Lab H2C": "H2C",
  22. "Bambu Lab H2S": "H2S",
  23. "Bambu Lab X2D": "X2D",
  24. "Bambu Lab A2L": "A2L",
  25. "Bambu Lab H3C": "H3C",
  26. }
  27. # Map from printer_model_id (internal codes in slice_info.config) to short names
  28. # These are the codes Bambu Studio uses internally, and the same codes the
  29. # printers announce over SSDP (DevModel), so Printer.model can hold them too.
  30. # A real P1P 3MF carries printer_model_id "C11" next to "Bambu Lab P1P"; the
  31. # X1 Carbon's code is "BL-P001", not a C-code.
  32. PRINTER_MODEL_ID_MAP = {
  33. # X1 series
  34. "BL-P001": "X1C",
  35. "BL-P002": "X1",
  36. "C13": "X1E",
  37. # P1 series
  38. "C11": "P1P",
  39. "C12": "P1S",
  40. "P1P": "P1P",
  41. "P1S": "P1S",
  42. # P2 series
  43. "N7": "P2S",
  44. "P2S": "P2S",
  45. # X2 series
  46. "N6": "X2D",
  47. # A2 series (A2L is single-FDM + integrated cutter/plotter — single nozzle)
  48. "N9": "A2L",
  49. # A1 series
  50. "A11": "A1",
  51. "A12": "A1 Mini",
  52. "N1": "A1 Mini",
  53. "N2S": "A1",
  54. "A04": "A1 Mini",
  55. # H2 series (Office/H series)
  56. "O1D": "H2D",
  57. "O1E": "H2D Pro", # Some devices report O1E
  58. "O2D": "H2D Pro", # Some devices report O2D
  59. "O1C": "H2C",
  60. "O1C2": "H2C",
  61. "O1S": "H2S",
  62. # H3 series ("Bambu Lab H3C.json" model_id). One extruder with nine
  63. # nozzles in Studio's profile, so not in DUAL_NOZZLE_MODELS or
  64. # NOZZLE_RACK_MODELS: the H2C's rack ids and left/right split don't fit it.
  65. "N10": "H3C",
  66. }
  67. # Rod/rail type classification for maintenance tasks.
  68. # Carbon rods: X1, P1 series (CoreXY with carbon fiber rods)
  69. # Steel rods: P2S, X2D series (hardened steel linear shafts)
  70. # Linear rails: A1, H2 series (linear rail motion system)
  71. # Values must be uppercase with spaces stripped for normalized comparison.
  72. CARBON_ROD_MODELS = frozenset(
  73. [
  74. # Display names (uppercase, no spaces)
  75. "X1",
  76. "X1C",
  77. "X1E",
  78. "P1P",
  79. "P1S",
  80. # Internal codes (dashes stripped, as the lookup strips them)
  81. "BLP001", # X1C (BL-P001)
  82. "BLP002", # X1 (BL-P002)
  83. "C13", # X1E
  84. "C11", # P1P
  85. "C12", # P1S
  86. ]
  87. )
  88. STEEL_ROD_MODELS = frozenset(
  89. [
  90. # Display names (uppercase, no spaces)
  91. "P2S",
  92. "X2D",
  93. # Internal codes
  94. "N7", # P2S
  95. "N6", # X2D
  96. ]
  97. )
  98. LINEAR_RAIL_MODELS = frozenset(
  99. [
  100. # Display names (uppercase, no spaces)
  101. "A1",
  102. "A1MINI",
  103. "A2L",
  104. "H2D",
  105. "H2DPRO",
  106. "H2C",
  107. "H2S",
  108. "H3C",
  109. # Internal codes
  110. "N1", # A1 Mini
  111. "N2S", # A1
  112. "N9", # A2L
  113. "A04", # A1 Mini (alternate)
  114. "A11", # A1
  115. "A12", # A1 Mini
  116. "O1D", # H2D
  117. "O1E", # H2D Pro
  118. "O2D", # H2D Pro (alternate)
  119. "O1C", # H2C
  120. "O1C2", # H2C (dual nozzle variant)
  121. "O1S", # H2S
  122. "N10", # H3C
  123. ]
  124. )
  125. # Models sold with a single nozzle flow variant, so a Standard / High Flow
  126. # choice on a K-profile is meaningless there. Derived from the slicer's own
  127. # rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
  128. # machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
  129. # single-nozzle and all carry two variants. Only the A-series has one.
  130. SINGLE_NOZZLE_FLOW_MODELS = frozenset(
  131. [
  132. # Display names (uppercase, no spaces)
  133. "A1",
  134. "A1MINI",
  135. "A2L",
  136. # Internal codes
  137. "N1", # A1 Mini
  138. "N2S", # A1
  139. "N9", # A2L
  140. "A04", # A1 Mini (alternate)
  141. "A11", # A1
  142. "A12", # A1 Mini
  143. ]
  144. )
  145. # Models without any external storage (MicroSD / SD card slot).
  146. # The A1 and A1 Mini ship with internal storage only — there is no
  147. # firmware-side "Store sent files on external storage" toggle and no
  148. # slicer-side equivalent surfaces one. The connection diagnostic's
  149. # external_storage check (printer_diagnostic.py) must skip on these
  150. # models instead of reporting fail from a 0-valued home_flag bit.
  151. NO_EXTERNAL_STORAGE_MODELS = frozenset(
  152. [
  153. # Display names (uppercase, no spaces)
  154. "A1",
  155. "A1MINI",
  156. # Internal codes
  157. "N1", # A1 Mini
  158. "N2S", # A1
  159. "A04", # A1 Mini (alternate)
  160. "A11", # A1
  161. "A12", # A1 Mini
  162. ]
  163. )
  164. # Models that HAVE a MicroSD slot but expose NO reachable control to enable
  165. # the "Store sent files on external storage" option. The toggle only renders
  166. # in Bambu Studio when the printer publishes the
  167. # `support_save_remote_print_file_to_storage` capability in its live status;
  168. # current P1-series firmware (through 01.10.00.00) never publishes it, and
  169. # the P1S/P1P have no on-printer screen, so `store_to_sdcard` (home_flag bit
  170. # 11) is stuck at False with no way for the user to change it. The
  171. # external_storage diagnostic must therefore skip (not fail) on these models
  172. # — a hard fail would be permanently unresolvable (#2524). If a future
  173. # firmware surfaces the capability, remove the model here and the check
  174. # reactivates. Bambu Lab's own storage-cache wiki lists P1 Series as "Not
  175. # Supported", corroborating this.
  176. NO_REMOTE_STORAGE_TOGGLE_MODELS = frozenset(
  177. [
  178. # Display names (uppercase, no spaces)
  179. "P1S",
  180. "P1P",
  181. # A2L (#3499): neither the printer (firmware 01.01.05.00) nor Bambu
  182. # Studio or OrcaSlicer offers the option, per the reporter.
  183. "A2L",
  184. # Internal codes
  185. "C11", # P1P
  186. "C12", # P1S
  187. "N9", # A2L
  188. ]
  189. )
  190. # Models with an ethernet port.
  191. # X1, P1P, A1, A1 Mini do NOT have ethernet.
  192. ETHERNET_MODELS = frozenset(
  193. [
  194. # Display names (uppercase, no spaces)
  195. "X1C",
  196. "X1E",
  197. "X2D",
  198. "P1S",
  199. "P2S",
  200. "H2D",
  201. "H2DPRO",
  202. "H2C",
  203. "H2S",
  204. # Internal codes (dashes stripped, as the lookup strips them)
  205. "BLP001", # X1C (BL-P001)
  206. "C13", # X1E
  207. "N6", # X2D
  208. "C12", # P1S
  209. "N7", # P2S
  210. "O1D", # H2D
  211. "O1E", # H2D Pro
  212. "O2D", # H2D Pro (alternate)
  213. "O1C", # H2C
  214. "O1C2", # H2C (dual nozzle variant)
  215. "O1S", # H2S
  216. ]
  217. )
  218. # Dual-nozzle (dual-extruder) printers. Single source of truth for nozzle
  219. # class — consumed by ``BambuMQTTClient.start_print``, the K-profile routes,
  220. # and the re-slice nozzle-class guard (previously an inline model tuple
  221. # duplicated across all three). Re-slicing a model laid out for a single-nozzle
  222. # printer onto one of these — or vice versa — is not yet supported: the source
  223. # 3MF's embedded single-nozzle filament/extruder layout is not a valid
  224. # dual-nozzle project and BambuStudio's multi-extruder validator rejects it.
  225. DUAL_NOZZLE_MODELS = frozenset(
  226. [
  227. # Display names (uppercase, no spaces)
  228. "H2D",
  229. "H2DPRO",
  230. "H2C",
  231. "X2D",
  232. # Internal codes
  233. "O1D", # H2D
  234. "O1E", # H2D Pro
  235. "O2D", # H2D Pro (alternate)
  236. "O1C", # H2C
  237. "O1C2", # H2C (dual nozzle variant)
  238. "N6", # X2D
  239. ]
  240. )
  241. # Printers with a swappable nozzle rack ("Vortek"): the H2C carries six
  242. # hotends in a rack and mounts one of them on its right extruder at a time.
  243. #
  244. # Why this needs its own set rather than reusing DUAL_NOZZLE_MODELS: on every
  245. # other dual-nozzle printer the dispatch `nozzle_mapping` values ARE the MQTT
  246. # extruder indices (0 = right, 1 = left). On a rack model the wire wants the
  247. # *physical* nozzle position for both carriages: the rack positions the
  248. # firmware reports as IDs 16-21 — see `device.nozzle.info` handling in
  249. # bambu_mqtt — and 1 for the fixed hotend, which is not its extruder index.
  250. # Note the H2C does not follow the 0 = right convention either: extruder
  251. # index 1 is the rack side, confirmed on hardware in #2800.
  252. # Sending an extruder index where a physical position is expected makes the
  253. # printer clean and level with one nozzle and then print with another, at the
  254. # wrong Z (#2800).
  255. NOZZLE_RACK_MODELS = frozenset(
  256. [
  257. # Display names (uppercase, no spaces)
  258. "H2C",
  259. # Internal codes
  260. "O1C", # H2C
  261. "O1C2", # H2C (dual nozzle variant)
  262. ]
  263. )
  264. # Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 /
  265. # airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's
  266. # touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it
  267. # is an add-on kit rather than built-in hardware. Other enclosed models
  268. # (X1 / P1S / H2 series) keep the "Chamber" naming.
  269. EXHAUST_FAN_LABEL_MODELS = frozenset(
  270. [
  271. # Display names (uppercase, no spaces)
  272. "P2S",
  273. "X2D",
  274. # Internal codes
  275. "N7", # P2S
  276. "N6", # X2D
  277. ]
  278. )
  279. def uses_exhaust_fan_label(model: str | None) -> bool:
  280. """Return True if this model calls the big_fan2 enclosure fan "Exhaust".
  281. P2S/X2D name that fan "Exhaust" in Bambu's firmware/UI; everything else
  282. enclosed calls it the chamber fan. Used so the UI badge and the API
  283. response message agree on what the user sees.
  284. """
  285. if not model:
  286. return False
  287. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  288. return normalized in EXHAUST_FAN_LABEL_MODELS
  289. # Models whose airduct part 10 is the RIGHT auxiliary fan and part 2 the left
  290. # one (#3373). Bambu Studio names the fans per model (resources/printers/*.json):
  291. # on the X2D (N6) "2" is Left(Aux) and "10" Right(Aux); on the P2S (N7) it is
  292. # the other way round.
  293. AUX_FANS_LEFT_FIRST_MODELS = frozenset(["X2D", "N6"])
  294. def aux_fan_sides(model: str | None) -> tuple[str, str]:
  295. """Which side the ``aux`` (part 2) and ``aux2`` (part 10) fans are on: ("left"|"right", ...)."""
  296. normalized = (model or "").strip().upper().replace(" ", "").replace("-", "")
  297. if normalized in AUX_FANS_LEFT_FIRST_MODELS:
  298. return "left", "right"
  299. return "right", "left"
  300. # Ceiling for every chamber-temperature target the UI and API accept (manual
  301. # M141, the preheat filament map, the per-item preheat override, the chamber
  302. # quick-select presets). The H2 series (H2C / H2D / H2D Pro / H2S) and X2D
  303. # heat the chamber to 65 °C; X1E tops out at 60. We validate against the
  304. # highest of those and let the firmware clamp on the lower-ceiling models —
  305. # the preheat filament map is global rather than per printer, so a per-model
  306. # maximum could not be expressed there anyway.
  307. MAX_CHAMBER_TEMP_C = 65
  308. def has_ethernet(model: str | None) -> bool:
  309. """Return True if the printer model has an ethernet port."""
  310. if not model:
  311. return False
  312. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  313. return normalized in ETHERNET_MODELS
  314. def has_external_storage(model: str | None) -> bool:
  315. """Return True if the printer model can have a MicroSD / external storage slot.
  316. Defaults to True when the model is unknown — the diagnostic only flips
  317. its check on for the explicit no-storage list. New models added to the
  318. Bambu lineup without a slot must be added to ``NO_EXTERNAL_STORAGE_MODELS``
  319. or the diagnostic will continue to evaluate ``store_to_sdcard`` against
  320. a hardware feature the printer doesn't have.
  321. """
  322. if not model:
  323. return True
  324. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  325. return normalized not in NO_EXTERNAL_STORAGE_MODELS
  326. def has_remote_storage_toggle(model: str | None) -> bool:
  327. """Return True if the model exposes a reachable control for the
  328. "Store sent files on external storage" option.
  329. False for P1-series (has an SD slot, but no on-printer screen and no
  330. published `support_save_remote_print_file_to_storage` capability, so the
  331. Bambu Studio toggle never renders), and for the A2L, which offers the
  332. option nowhere (#3499). The external_storage diagnostic uses
  333. this to skip rather than report an unresolvable fail (#2524). Defaults to
  334. True for unknown models so the check keeps working on anything not
  335. explicitly listed.
  336. """
  337. if not model:
  338. return True
  339. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  340. return normalized not in NO_REMOTE_STORAGE_TOGGLE_MODELS
  341. def is_dual_nozzle_model(model: str | None) -> bool:
  342. """Return True if the printer model has two nozzles (H2D family / X2D)."""
  343. if not model:
  344. return False
  345. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  346. return normalized in DUAL_NOZZLE_MODELS
  347. # Chamber heater runs only in the airduct's heating mode (in cooling mode the
  348. # H3C acks M141 and stays off; the H2 series heats in either).
  349. CHAMBER_HEATER_NEEDS_HEATING_MODE_MODELS = frozenset(["H3C", "N10"])
  350. # `mc_percent` counts the start sequence (an H3C is at ~20% on layer 0), so a
  351. # stopped print is charged by layers instead (usage_progress).
  352. PROGRESS_INCLUDES_START_SEQUENCE_MODELS = frozenset(["H3C", "N10"])
  353. AMS3H_MODELS = frozenset(["H3C", "N10"])
  354. def _model_key(model: str | None) -> str:
  355. return model.strip().upper().replace(" ", "").replace("-", "") if isinstance(model, str) else ""
  356. def progress_includes_start_sequence(model: str | None) -> bool:
  357. """True when the printer's progress percent counts its start sequence (H3C)."""
  358. return _model_key(model) in PROGRESS_INCLUDES_START_SEQUENCE_MODELS
  359. def has_ams3h(model: str | None) -> bool:
  360. """True for a model that comes with an AMS 3H (H3C)."""
  361. return _model_key(model) in AMS3H_MODELS
  362. def usage_progress(
  363. model: str | None,
  364. progress: float | int | None,
  365. layer_num: int | None,
  366. total_layers: int | None,
  367. ) -> float | int:
  368. """How much of a stopped print was printed, in percent, for charging its filament.
  369. The printer's own progress, except on a model whose progress counts the
  370. start sequence: there the share of layers printed (0 before layer 1).
  371. """
  372. if not progress_includes_start_sequence(model):
  373. return progress if progress is not None else 0.0
  374. if layer_num and layer_num > 0 and total_layers and total_layers > 0:
  375. return min(100.0, 100.0 * layer_num / total_layers)
  376. return 0.0
  377. def chamber_heater_needs_heating_mode(model: str | None) -> bool:
  378. """True when a chamber target only takes effect with the airduct in heating mode (H3C)."""
  379. return _model_key(model) in CHAMBER_HEATER_NEEDS_HEATING_MODE_MODELS
  380. def is_nozzle_rack_model(model: str | None) -> bool:
  381. """Return True if the model mounts its nozzles from a swappable rack (H2C).
  382. Accepts both the display name and the internal SSDP code, because
  383. ``BambuMQTTClient.model`` carries whichever the printer row happens to
  384. hold — the same reason the P2S dispatch tweak checks ``("P2S", "N7")``.
  385. """
  386. if not model:
  387. return False
  388. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  389. return normalized in NOZZLE_RACK_MODELS
  390. def supports_nozzle_flow_type(model: str | None) -> bool:
  391. """Return True if the model offers a Standard / High Flow nozzle choice.
  392. A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
  393. (Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
  394. profile's identity on any printer where both exist — and meaningless noise
  395. on one where only a single variant is sold.
  396. The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
  397. and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
  398. from the machine preset — ``support_nozzle_volume()`` is
  399. ``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
  400. Bambu profile evaluated against that formula puts only the A-series on the
  401. "one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
  402. upward at 2 or more per extruder).
  403. Defaults to True for unknown models: offering the choice on a printer that
  404. turns out to have one flow type costs the user a redundant dropdown, while
  405. hiding it on one that has two makes half its calibration table
  406. unreachable.
  407. """
  408. if not model:
  409. return True
  410. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  411. return normalized not in SINGLE_NOZZLE_FLOW_MODELS
  412. # Models whose firmware predates Bambu's newer MQTT protocol, so Bambu Studio
  413. # re-reads an AMS tag on them with the M620 R gcode rather than ams_get_rfid
  414. # (#3206). Short display names (uppercase, no spaces).
  415. LEGACY_RFID_REFRESH_MODELS = frozenset(["X1", "X1C", "X1E", "P1P", "P1S", "A1", "A1MINI"])
  416. def uses_legacy_rfid_refresh(model: str | None) -> bool:
  417. """True for models that may need M620 R instead of ams_get_rfid (#3206)."""
  418. if not model:
  419. return False
  420. short = PRINTER_MODEL_ID_MAP.get(model.strip(), model)
  421. return short.strip().upper().replace(" ", "") in LEGACY_RFID_REFRESH_MODELS
  422. def get_rod_type(model: str | None) -> str | None:
  423. """Return the rod/rail type for a printer model.
  424. Returns:
  425. "carbon" for X1/P1 series (carbon fiber rods),
  426. "steel_rod" for P2S/X2D series (hardened steel rods),
  427. "linear_rail" for A1/H2 series (linear rails),
  428. None for unknown models.
  429. """
  430. if not model:
  431. return None
  432. normalized = model.strip().upper().replace(" ", "").replace("-", "")
  433. if normalized in CARBON_ROD_MODELS:
  434. return "carbon"
  435. if normalized in STEEL_ROD_MODELS:
  436. return "steel_rod"
  437. if normalized in LINEAR_RAIL_MODELS:
  438. return "linear_rail"
  439. return None
  440. # G-code interchange families (#2578). A sliced 3MF may target a different
  441. # model ONLY within its family: same kinematics, build volume and G-code
  442. # dialect. X1/P1 series are the one proven-interchangeable group (256mm
  443. # CoreXY, single nozzle — mixed farms intentionally run X1-sliced jobs on
  444. # P1S/P1P). Everything else is exact-match only; extend deliberately, never
  445. # by assumption — a wrong entry here dispatches G-code onto hardware it was
  446. # not sliced for.
  447. # Short display names only (uppercase, no spaces) — is_gcode_compatible()
  448. # resolves internal codes (C11, O1D, ...) to short names before lookup.
  449. GCODE_COMPAT_FAMILIES = (frozenset(["X1", "X1C", "X1E", "P1P", "P1S"]),)
  450. def is_gcode_compatible(sliced_for_model: str | None, target_model: str | None) -> bool:
  451. """Return True when G-code sliced for one model may be dispatched to the other.
  452. Unknown/missing metadata on either side returns True — we can only
  453. validate what the 3MF declares, and legacy files without
  454. ``sliced_for_model`` must keep working.
  455. """
  456. if not sliced_for_model or not target_model:
  457. return True
  458. def _norm(model: str) -> str:
  459. # Internal codes (e.g. "C11") → short names first, so "C11" vs "X1C"
  460. # compares equal instead of leaning on family membership.
  461. resolved = PRINTER_MODEL_ID_MAP.get(model.strip(), model)
  462. return resolved.strip().upper().replace(" ", "").replace("-", "")
  463. a = _norm(sliced_for_model)
  464. b = _norm(target_model)
  465. if a == b:
  466. return True
  467. return any(a in family and b in family for family in GCODE_COMPAT_FAMILIES)
  468. def normalize_printer_model_id(model_id: str | None) -> str | None:
  469. """Convert printer_model_id (internal code) to normalized short name.
  470. Args:
  471. model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D")
  472. Returns:
  473. Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown.
  474. """
  475. if not model_id:
  476. return None
  477. # Check known mappings
  478. if model_id in PRINTER_MODEL_ID_MAP:
  479. return PRINTER_MODEL_ID_MAP[model_id]
  480. # Return original if unknown (might already be a short name)
  481. return model_id
  482. def normalize_printer_model(raw_model: str | None) -> str | None:
  483. """Convert 3MF printer_model to normalized short name.
  484. Args:
  485. raw_model: The printer_model string from 3MF metadata
  486. (e.g., "Bambu Lab X1 Carbon")
  487. Returns:
  488. Normalized short name (e.g., "X1C") or None if input is empty.
  489. Unknown models have "Bambu Lab " prefix stripped.
  490. """
  491. if not raw_model:
  492. return None
  493. # Check known mappings first
  494. if raw_model in PRINTER_MODEL_MAP:
  495. return PRINTER_MODEL_MAP[raw_model]
  496. # Strip "Bambu Lab " prefix for unknown models
  497. stripped = raw_model.replace("Bambu Lab ", "").strip()
  498. return stripped or None