"""Printer model normalization utilities. Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to normalized short names (e.g., "X1C") that match database storage. """ # Map from 3MF printer_model strings to normalized short names PRINTER_MODEL_MAP = { "Bambu Lab X1 Carbon": "X1C", "Bambu Lab X1": "X1", "Bambu Lab X1E": "X1E", "Bambu Lab P1S": "P1S", "Bambu Lab P1P": "P1P", "Bambu Lab P2S": "P2S", "Bambu Lab A1": "A1", "Bambu Lab A1 Mini": "A1 Mini", "Bambu Lab A1 mini": "A1 Mini", # Bambu cloud rolled out a terse model-code rename mid-2026 (#1649); # 3MFs prepared with newer cloud presets may carry this short form. "Bambu Lab A1M": "A1 Mini", "Bambu Lab H2D": "H2D", "Bambu Lab H2D Pro": "H2D Pro", "Bambu Lab H2C": "H2C", "Bambu Lab H2S": "H2S", "Bambu Lab X2D": "X2D", "Bambu Lab A2L": "A2L", } # Map from printer_model_id (internal codes in slice_info.config) to short names # These are the codes Bambu Studio uses internally PRINTER_MODEL_ID_MAP = { # X1 series "C11": "X1C", "C12": "X1", "C13": "X1E", # P1 series "P1P": "P1P", "P1S": "P1S", # P2 series "P2S": "P2S", # X2 series "N6": "X2D", # A2 series (A2L is single-FDM + integrated cutter/plotter — single nozzle) "N9": "A2L", # A1 series "A11": "A1", "A12": "A1 Mini", "N1": "A1 Mini", "N2S": "A1", "A04": "A1 Mini", # H2 series (Office/H series) "O1D": "H2D", "O1E": "H2D Pro", # Some devices report O1E "O2D": "H2D Pro", # Some devices report O2D "O1C": "H2C", "O1C2": "H2C", "O1S": "H2S", } # Rod/rail type classification for maintenance tasks. # Carbon rods: X1, P1 series (CoreXY with carbon fiber rods) # Steel rods: P2S, X2D series (hardened steel linear shafts) # Linear rails: A1, H2 series (linear rail motion system) # Values must be uppercase with spaces stripped for normalized comparison. CARBON_ROD_MODELS = frozenset( [ # Display names (uppercase, no spaces) "X1", "X1C", "X1E", "P1P", "P1S", # Internal codes "C11", # X1C "C12", # X1 "C13", # X1E ] ) STEEL_ROD_MODELS = frozenset( [ # Display names (uppercase, no spaces) "P2S", "X2D", # Internal codes "N7", # P2S "N6", # X2D ] ) LINEAR_RAIL_MODELS = frozenset( [ # Display names (uppercase, no spaces) "A1", "A1MINI", "A2L", "H2D", "H2DPRO", "H2C", "H2S", # Internal codes "N1", # A1 Mini "N2S", # A1 "N9", # A2L "A04", # A1 Mini (alternate) "A11", # A1 "A12", # A1 Mini "O1D", # H2D "O1E", # H2D Pro "O2D", # H2D Pro (alternate) "O1C", # H2C "O1C2", # H2C (dual nozzle variant) "O1S", # H2S ] ) # Models sold with a single nozzle flow variant, so a Standard / High Flow # choice on a K-profile is meaningless there. Derived from the slicer's own # rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu # machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are # single-nozzle and all carry two variants. Only the A-series has one. SINGLE_NOZZLE_FLOW_MODELS = frozenset( [ # Display names (uppercase, no spaces) "A1", "A1MINI", "A2L", # Internal codes "N1", # A1 Mini "N2S", # A1 "N9", # A2L "A04", # A1 Mini (alternate) "A11", # A1 "A12", # A1 Mini ] ) # Models without any external storage (MicroSD / SD card slot). # The A1 and A1 Mini ship with internal storage only — there is no # firmware-side "Store sent files on external storage" toggle and no # slicer-side equivalent surfaces one. The connection diagnostic's # external_storage check (printer_diagnostic.py) must skip on these # models instead of reporting fail from a 0-valued home_flag bit. NO_EXTERNAL_STORAGE_MODELS = frozenset( [ # Display names (uppercase, no spaces) "A1", "A1MINI", # Internal codes "N1", # A1 Mini "N2S", # A1 "A04", # A1 Mini (alternate) "A11", # A1 "A12", # A1 Mini ] ) # Models that HAVE a MicroSD slot but expose NO reachable control to enable # the "Store sent files on external storage" option. The toggle only renders # in Bambu Studio when the printer publishes the # `support_save_remote_print_file_to_storage` capability in its live status; # current P1-series firmware (through 01.10.00.00) never publishes it, and # the P1S/P1P have no on-printer screen, so `store_to_sdcard` (home_flag bit # 11) is stuck at False with no way for the user to change it. The # external_storage diagnostic must therefore skip (not fail) on these models # — a hard fail would be permanently unresolvable (#2524). If a future # firmware surfaces the capability, remove the model here and the check # reactivates. Bambu Lab's own storage-cache wiki lists P1 Series as "Not # Supported", corroborating this. NO_REMOTE_STORAGE_TOGGLE_MODELS = frozenset( [ # Display names (uppercase, no spaces) "P1S", "P1P", ] ) # Models with an ethernet port. # X1, P1P, A1, A1 Mini do NOT have ethernet. ETHERNET_MODELS = frozenset( [ # Display names (uppercase, no spaces) "X1C", "X1E", "X2D", "P1S", "P2S", "H2D", "H2DPRO", "H2C", "H2S", # Internal codes "C11", # X1C "C13", # X1E "N6", # X2D "P1S", # P1S "O1D", # H2D "O1E", # H2D Pro "O2D", # H2D Pro (alternate) "O1C", # H2C "O1C2", # H2C (dual nozzle variant) "O1S", # H2S ] ) # Dual-nozzle (dual-extruder) printers. Single source of truth for nozzle # class — consumed by ``BambuMQTTClient.start_print``, the K-profile routes, # and the re-slice nozzle-class guard (previously an inline model tuple # duplicated across all three). Re-slicing a model laid out for a single-nozzle # printer onto one of these — or vice versa — is not yet supported: the source # 3MF's embedded single-nozzle filament/extruder layout is not a valid # dual-nozzle project and BambuStudio's multi-extruder validator rejects it. DUAL_NOZZLE_MODELS = frozenset( [ # Display names (uppercase, no spaces) "H2D", "H2DPRO", "H2C", "X2D", # Internal codes "O1D", # H2D "O1E", # H2D Pro "O2D", # H2D Pro (alternate) "O1C", # H2C "O1C2", # H2C (dual nozzle variant) "N6", # X2D ] ) # Printers with a swappable nozzle rack ("Vortek"): the H2C carries six # hotends in a rack and mounts one of them on its right extruder at a time. # # Why this needs its own set rather than reusing DUAL_NOZZLE_MODELS: on every # other dual-nozzle printer the dispatch `nozzle_mapping` values ARE the MQTT # extruder indices (0 = right, 1 = left). On a rack model the wire wants the # *physical* nozzle position, and the rack positions are reported by the # firmware as IDs 16-21 — see `device.nozzle.info` handling in bambu_mqtt. # Sending an extruder index where a rack position is expected makes the # printer clean and level with one nozzle and then print with another, at the # wrong Z (#2800). NOZZLE_RACK_MODELS = frozenset( [ # Display names (uppercase, no spaces) "H2C", # Internal codes "O1C", # H2C "O1C2", # H2C (dual nozzle variant) ] ) # Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 / # airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's # touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it # is an add-on kit rather than built-in hardware. Other enclosed models # (X1 / P1S / H2 series) keep the "Chamber" naming. EXHAUST_FAN_LABEL_MODELS = frozenset( [ # Display names (uppercase, no spaces) "P2S", "X2D", # Internal codes "N7", # P2S "N6", # X2D ] ) def uses_exhaust_fan_label(model: str | None) -> bool: """Return True if this model calls the big_fan2 enclosure fan "Exhaust". P2S/X2D name that fan "Exhaust" in Bambu's firmware/UI; everything else enclosed calls it the chamber fan. Used so the UI badge and the API response message agree on what the user sees. """ if not model: return False normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized in EXHAUST_FAN_LABEL_MODELS # Ceiling for every chamber-temperature target the UI and API accept (manual # M141, the preheat filament map, the per-item preheat override, the chamber # quick-select presets). The H2 series (H2C / H2D / H2D Pro / H2S) and X2D # heat the chamber to 65 °C; X1E tops out at 60. We validate against the # highest of those and let the firmware clamp on the lower-ceiling models — # the preheat filament map is global rather than per printer, so a per-model # maximum could not be expressed there anyway. MAX_CHAMBER_TEMP_C = 65 def has_ethernet(model: str | None) -> bool: """Return True if the printer model has an ethernet port.""" if not model: return False normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized in ETHERNET_MODELS def has_external_storage(model: str | None) -> bool: """Return True if the printer model can have a MicroSD / external storage slot. Defaults to True when the model is unknown — the diagnostic only flips its check on for the explicit no-storage list. New models added to the Bambu lineup without a slot must be added to ``NO_EXTERNAL_STORAGE_MODELS`` or the diagnostic will continue to evaluate ``store_to_sdcard`` against a hardware feature the printer doesn't have. """ if not model: return True normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized not in NO_EXTERNAL_STORAGE_MODELS def has_remote_storage_toggle(model: str | None) -> bool: """Return True if the model exposes a reachable control for the "Store sent files on external storage" option. False for P1-series (has an SD slot, but no on-printer screen and no published `support_save_remote_print_file_to_storage` capability, so the Bambu Studio toggle never renders). The external_storage diagnostic uses this to skip rather than report an unresolvable fail (#2524). Defaults to True for unknown models so the check keeps working on anything not explicitly listed. """ if not model: return True normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized not in NO_REMOTE_STORAGE_TOGGLE_MODELS def is_dual_nozzle_model(model: str | None) -> bool: """Return True if the printer model has two nozzles (H2D family / X2D).""" if not model: return False normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized in DUAL_NOZZLE_MODELS def is_nozzle_rack_model(model: str | None) -> bool: """Return True if the model mounts its nozzles from a swappable rack (H2C). Accepts both the display name and the internal SSDP code, because ``BambuMQTTClient.model`` carries whichever the printer row happens to hold — the same reason the P2S dispatch tweak checks ``("P2S", "N7")``. """ if not model: return False normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized in NOZZLE_RACK_MODELS def supports_nozzle_flow_type(model: str | None) -> bool: """Return True if the model offers a Standard / High Flow nozzle choice. A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4`` (Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the profile's identity on any printer where both exist — and meaningless noise on one where only a single variant is sold. The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle and all offer both flows. BambuStudio/OrcaSlicer derive the same capability from the machine preset — ``support_nozzle_volume()`` is ``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled Bambu profile evaluated against that formula puts only the A-series on the "one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P upward at 2 or more per extruder). Defaults to True for unknown models: offering the choice on a printer that turns out to have one flow type costs the user a redundant dropdown, while hiding it on one that has two makes half its calibration table unreachable. """ if not model: return True normalized = model.strip().upper().replace(" ", "").replace("-", "") return normalized not in SINGLE_NOZZLE_FLOW_MODELS def get_rod_type(model: str | None) -> str | None: """Return the rod/rail type for a printer model. Returns: "carbon" for X1/P1 series (carbon fiber rods), "steel_rod" for P2S/X2D series (hardened steel rods), "linear_rail" for A1/H2 series (linear rails), None for unknown models. """ if not model: return None normalized = model.strip().upper().replace(" ", "").replace("-", "") if normalized in CARBON_ROD_MODELS: return "carbon" if normalized in STEEL_ROD_MODELS: return "steel_rod" if normalized in LINEAR_RAIL_MODELS: return "linear_rail" return None # G-code interchange families (#2578). A sliced 3MF may target a different # model ONLY within its family: same kinematics, build volume and G-code # dialect. X1/P1 series are the one proven-interchangeable group (256mm # CoreXY, single nozzle — mixed farms intentionally run X1-sliced jobs on # P1S/P1P). Everything else is exact-match only; extend deliberately, never # by assumption — a wrong entry here dispatches G-code onto hardware it was # not sliced for. # Short display names only (uppercase, no spaces) — is_gcode_compatible() # resolves internal codes (C11, O1D, ...) to short names before lookup. GCODE_COMPAT_FAMILIES = (frozenset(["X1", "X1C", "X1E", "P1P", "P1S"]),) def is_gcode_compatible(sliced_for_model: str | None, target_model: str | None) -> bool: """Return True when G-code sliced for one model may be dispatched to the other. Unknown/missing metadata on either side returns True — we can only validate what the 3MF declares, and legacy files without ``sliced_for_model`` must keep working. """ if not sliced_for_model or not target_model: return True def _norm(model: str) -> str: # Internal codes (e.g. "C11") → short names first, so "C11" vs "X1C" # compares equal instead of leaning on family membership. resolved = PRINTER_MODEL_ID_MAP.get(model.strip(), model) return resolved.strip().upper().replace(" ", "").replace("-", "") a = _norm(sliced_for_model) b = _norm(target_model) if a == b: return True return any(a in family and b in family for family in GCODE_COMPAT_FAMILIES) def normalize_printer_model_id(model_id: str | None) -> str | None: """Convert printer_model_id (internal code) to normalized short name. Args: model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D") Returns: Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown. """ if not model_id: return None # Check known mappings if model_id in PRINTER_MODEL_ID_MAP: return PRINTER_MODEL_ID_MAP[model_id] # Return original if unknown (might already be a short name) return model_id def normalize_printer_model(raw_model: str | None) -> str | None: """Convert 3MF printer_model to normalized short name. Args: raw_model: The printer_model string from 3MF metadata (e.g., "Bambu Lab X1 Carbon") Returns: Normalized short name (e.g., "X1C") or None if input is empty. Unknown models have "Bambu Lab " prefix stripped. """ if not raw_model: return None # Check known mappings first if raw_model in PRINTER_MODEL_MAP: return PRINTER_MODEL_MAP[raw_model] # Strip "Bambu Lab " prefix for unknown models stripped = raw_model.replace("Bambu Lab ", "").strip() return stripped or None