mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
C11 is the P1P, C12 the P1S, C13 the X1E and N7 the P2S, as the virtual printer and a real P1P 3MF already say. The frontend map had them shifted, so discovery pre-filled a P1S as a P1P and an X1E as a P2S. The backend map read C11/C12 as X1C/X1 and lacked N7, the firmware check sent C13 to the P2S line, and the capability lists never matched BL-P001 because their lookup strips the dash. ----- Post work PR #3134
499 lines
17 KiB
Python
499 lines
17 KiB
Python
"""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, and the same codes the
|
|
# printers announce over SSDP (DevModel), so Printer.model can hold them too.
|
|
# A real P1P 3MF carries printer_model_id "C11" next to "Bambu Lab P1P"; the
|
|
# X1 Carbon's code is "BL-P001", not a C-code.
|
|
PRINTER_MODEL_ID_MAP = {
|
|
# X1 series
|
|
"BL-P001": "X1C",
|
|
"BL-P002": "X1",
|
|
"C13": "X1E",
|
|
# P1 series
|
|
"C11": "P1P",
|
|
"C12": "P1S",
|
|
"P1P": "P1P",
|
|
"P1S": "P1S",
|
|
# P2 series
|
|
"N7": "P2S",
|
|
"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 (dashes stripped, as the lookup strips them)
|
|
"BLP001", # X1C (BL-P001)
|
|
"BLP002", # X1 (BL-P002)
|
|
"C13", # X1E
|
|
"C11", # P1P
|
|
"C12", # P1S
|
|
]
|
|
)
|
|
|
|
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",
|
|
# Internal codes
|
|
"C11", # P1P
|
|
"C12", # P1S
|
|
]
|
|
)
|
|
|
|
|
|
# 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 (dashes stripped, as the lookup strips them)
|
|
"BLP001", # X1C (BL-P001)
|
|
"C13", # X1E
|
|
"N6", # X2D
|
|
"C12", # P1S
|
|
"N7", # P2S
|
|
"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 for both carriages: the rack positions the
|
|
# firmware reports as IDs 16-21 — see `device.nozzle.info` handling in
|
|
# bambu_mqtt — and 1 for the fixed hotend, which is not its extruder index.
|
|
# Note the H2C does not follow the 0 = right convention either: extruder
|
|
# index 1 is the rack side, confirmed on hardware in #2800.
|
|
# Sending an extruder index where a physical 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
|