mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
An H2C ran its startup clean and bed levelling on one hotend, switched, and then printed several millimetres above the plate. The same job from Bambu Studio was fine. The H2C is the only model that mounts its nozzle from a rack of six, and a print command names that nozzle by physical rack position -- the firmware reports those as 16 to 21 -- not by the extruder index, 0 or 1, every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's pick and replays it (#1780). Anything queued from the library, an archive, the webhook or a slicer pipeline carried none, so the field was omitted and the firmware chose -- and its choice need not match what the file was sliced for. The scheduler now derives the per-slot extruder assignment from the file it is about to send, and the MQTT layer resolves it against the rack position the printer reports live. Both are needed: the file knows which side a slot prints from, only the printer knows which hotend is in the carriage, and it can be swapped from the touchscreen between queueing a job and printing it. Derived at dispatch rather than at creation because that is the first point knowing both the real printer and the real file -- an item can be created unassigned, reassigned later, or have its file swapped for a G-code-injected copy. One call therefore covers the print dialog, bulk library adds, the webhook and pipeline runs, and no column is needed. extract_nozzle_mapping_from_3mf is deliberately untouched. Its output feeds the AMS matcher, where nozzle_id is compared against a tray's extruder_id as a hard filter, and physical_extruder_map is what makes that comparison correct -- on an H2D it is [1, 0] and flips the two. Dropping the translation to suit the rack would send every dual-nozzle AMS match to the wrong extruder. The dense per-slot form is a separate function reusing the same output. Nothing here can fail a dispatch. The command is built and published with no exception handler above it, and the queue item is already committed as printing by then, so a bad input has to degrade to "firmware picks" rather than wedge the item. resolve_rack_nozzle_mapping validates every input and raises nothing; an unresolvable mapping, an unparseable value or an unknown rack position all omit the field, which is the behaviour that existed before. Slot IDs are bounded before the dense list is built: they come from the file, and one declaring filament id="50000000" would otherwise allocate a fifty-million-entry list on the dispatch path. Two things are not guessed. A job printing only from the fixed hotend is still left to the firmware, because that nozzle's physical ID is not confirmed by a known-good capture. And the rack is taken to feed extruder 0 from a single hardware observation -- if that is flipped, a one-sided job matches nothing and falls back to the old behaviour, so only a job using both nozzles at once could be harmed, which is what a second capture needs to confirm. Confined to the H2C throughout. Building the print command for 21 model spellings with and without the new argument changes exactly three of them -- H2C, O1C and O1C2. The other 18, including H2D and X2D, are identical. Reported by @tru3l3gend, who diagnosed it on real hardware against a working Bambu Studio dispatch, established the rack ID range and supplied a patch.
483 lines
16 KiB
Python
483 lines
16 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
|
|
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
|