Let a filled or foamed filament keep its own name (issue #2902)

The reduction that gave an AMS slot a material type read PLA-AERO,
    PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
    foaming or glass-filled filament went out saying plain PLA or ASA. That
    is worse than the bug it replaced. "PLA-AERO" matched nothing before,
    which was useless but honest; "PLA" matches every PLA plate in the
    queue, so the dispatcher would have sent one to filament that will not
    print it -- and the contract the first fix claimed, that it could only
    ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
    issue.

    All four are values Bambuddy itself offers: filament_fields.json is the
    material list the Profiles editor puts in a dropdown, and the reduction
    table was assembled from the cloud filament names and the frontend
    preset parser without ever being checked against it. It is checked now,
    so the next type added to one and not the other fails a test rather than
    a print. ASA-AERO joins them from the cloud catalogue (GFB02).

    The table hyphenates because the slicers do, while a spool says "PLA
    Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
    are joined and taken when the join is a type exactly -- exactly, because
    letting the prefix and suffix rules reach across a space would make
    "Support for PLA" a type by its tail.

    Also from @doncaruana, and the better half of his point: a preset is
    chosen from a list the slicer defines, so it already knows its own type
    and nothing has to be read out of a product name. The resolver now hands
    that answer back and both assign routes prefer it. It cannot be the only
    source -- material is required on a spool and slicer_filament is not, and
    the spool this issue was reported for had no preset at all -- so the
    reduction stays as the fallback for spools without one.

    Two things had to move with it. The auto-unlink guard compared the
    slot's reported type against the reduced material, so a spool whose
    preset outranked its material column would have been unlinked from the
    slot it had just been assigned to; it now accepts any type the assign
    path could have written. And two lookups keyed by material took the
    catch-all for a type they had no row for, which sent an ASA-GF spool out
    at 200/240 -- too cold to extrude -- and preheated its chamber to
    nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
    and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
    up ranges they had been missing all along.

    What counts as a material name is decided by the base for the same
    reason: saying yes throws the value away and rescues the slot from the
    generic-material fallback, so the answer has to be no when that fallback
    has nothing to offer. ABS-GF reduces to a generic ABS the printer can
    resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
    type to the table therefore cannot quietly change that answer, which is
    how these five slipped through in the first place.

    ------

    Hand the bundled chamber-preheat table back the way it is read

    Every lookup of the per-filament chamber map happens after the keys are
    upper-cased, and the parser documents exactly that: keys uppercased,
    DEFAULT always present so the resolution loop can index it
    unconditionally. The three fallback paths returned the bundled constant
    as declared, with the lowercase "default" row the Settings editor writes
    and displays, so an install that had never opened the setting got a dict
    the loop could not read its fallback out of and used a hardcoded 0 for
    any filament without a row of its own.

    It reported the right number only because that bundled default is 0.
    Raising it would have changed nothing for everyone who had not
    customised the map, with the map in Settings still showing the value
    that was not being used.

    The test that should have caught this asserted the fallback under either
    spelling, and its docstring contradicted itself between title and
    comment. It pins the contract now, over all four ways the parser can
    fall back.
This commit is contained in:
maziggy
2026-08-23 12:46:48 +02:00
13 changed files with 785 additions and 52 deletions
+8 -6
View File
@@ -63,11 +63,10 @@ from backend.app.services.spool_csv import (
from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
filament_id_to_setting_id,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import is_material_name, printer_filament_type
from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
logger = logging.getLogger(__name__)
@@ -136,7 +135,7 @@ async def apply_spool_to_slot_via_mqtt(
# the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
# sanitization. When it returns an empty tray_info_idx the local
# current-tray-state + generic-material fallback below rescues the slot.
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
db=db,
current_user=current_user,
slicer_filament=spool.slicer_filament,
@@ -145,6 +144,11 @@ async def apply_spool_to_slot_via_mqtt(
)
if sub_brand_override:
tray_sub_brands = sub_brand_override
# A preset says what its material is; the reduction above only infers it
# from whatever wording the spool's material column happens to carry. When
# the spool has a preset, its answer wins (issue #2902, @doncaruana).
if type_override:
tray_type = printer_filament_type(type_override)
if not tray_info_idx:
if (
@@ -187,9 +191,7 @@ async def apply_spool_to_slot_via_mqtt(
# Same order as the generic-id lookup above: the spool's own wording wins,
# the reduced type rescues what it does not cover. Without the second
# lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
temp_min, temp_max = (
MATERIAL_TEMPS.get((spool.material or "").upper()) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
)
temp_min, temp_max = nozzle_temp_range(spool.material, tray_type)
if spool.nozzle_temp_min is not None:
temp_min = spool.nozzle_temp_min
if spool.nozzle_temp_max is not None:
+2 -5
View File
@@ -32,10 +32,9 @@ from backend.app.services.spoolman import (
)
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import printer_filament_type
from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
logger = logging.getLogger(__name__)
@@ -970,9 +969,7 @@ async def link_spool(
or ""
)
setting_id = ""
temp_defaults = (
MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
)
temp_defaults = nozzle_temp_range(material, tray_type)
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
temp_max = temp_defaults[1]
+8 -4
View File
@@ -61,11 +61,10 @@ from backend.app.services.spoolman import (
from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
filament_id_to_setting_id,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import printer_filament_type
from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
logger = logging.getLogger(__name__)
@@ -1505,7 +1504,7 @@ async def assign_spoolman_slot(
# configured profile never reached the printer. Shared with the
# internal-mode route via the same helper so the two flows can't
# drift again.
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
db=db,
current_user=current_user,
slicer_filament=mapped.get("slicer_filament"),
@@ -1514,6 +1513,11 @@ async def assign_spoolman_slot(
)
if sub_brand_override:
tray_sub_brands = sub_brand_override
# A preset carries its own type; the reduction above only infers
# one from Spoolman's free-text material. The preset wins when the
# spool has one (issue #2902, @doncaruana).
if type_override:
tray_type = printer_filament_type(type_override)
material_upper = material.upper().strip()
# Fall back to generic-material id when slicer_filament is empty
@@ -1539,7 +1543,7 @@ async def assign_spoolman_slot(
if tray_info_idx and not setting_id:
setting_id = filament_id_to_setting_id(tray_info_idx)
temp_defaults = MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
temp_defaults = nozzle_temp_range(material, tray_type)
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
temp_max = temp_defaults[1]
+34 -2
View File
@@ -2114,10 +2114,42 @@ async def on_ams_change(printer_id: int, ams_data: list):
spool = assignment.spool
if spool:
spool_color = (spool.rgba or "FFFFFFFF").upper()
spool_type = printer_filament_type(spool.material).upper()
# Two ways the assign path can have arrived at the
# slot's type, so both count as "we wrote this".
# The material column is one; the spool's preset is
# the other, and it outranks the material when the
# spool has one -- a spool whose material says PLA
# and whose preset is "Bambu PLA Aero" puts
# PLA-AERO in the slot (#2902). Read from the stored
# preset name rather than resolving the preset,
# because this runs on every AMS push and a cloud
# lookup here would be both slow and unavailable on
# the unauthenticated replay path.
spool_types = {printer_filament_type(spool.material).upper()}
if spool.slicer_filament_name:
spool_types.add(printer_filament_type(spool.slicer_filament_name).upper())
# An imported local preset stores its type outright,
# which is what the assign path used -- and the name
# above may be unset. One keyed read, and only on a
# mismatch, which is rare.
#
# slicer_filament is free text up to fifty characters,
# so the digits have to be checked against the range
# of the integer primary key they are about to be
# compared with. Postgres raises on an out-of-range
# integer rather than simply not matching, and that
# would poison this session and abandon the rest of
# the cleanup pass.
lp_ref = (spool.slicer_filament or "").strip()
if lp_ref.isdigit() and int(lp_ref) <= 2147483647:
from backend.app.models.local_preset import LocalPreset as _LP
lp_type = await db.scalar(select(_LP.filament_type).where(_LP.id == int(lp_ref)))
if lp_type:
spool_types.add(printer_filament_type(lp_type).upper())
if (
_colors_similar(cur_color, spool_color)
and printer_filament_type(cur_type).upper() == spool_type
and printer_filament_type(cur_type).upper() in spool_types
):
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
+26 -3
View File
@@ -4476,6 +4476,22 @@ class PrintScheduler:
"default": 0,
}
@classmethod
def _bundled_preheat_targets(cls) -> dict[str, int]:
"""The bundled map under the same key casing a parsed one gets.
The constant is declared with a lowercase ``default`` because that is
the key the Settings editor writes and displays. Every read of the map
happens after ``str(key).upper()``, so handing the constant back as
declared broke the contract the parser documents: an install that had
never touched the setting returned a dict with no ``DEFAULT`` in it,
and the resolution loop's fallback silently found nothing. It read the
right number only because the bundled default happens to be 0 -- change
that constant and every unconfigured install would keep preheating to
zero with no way to tell why.
"""
return {key.upper(): value for key, value in cls.DEFAULT_PREHEAT_FILAMENT_TARGETS.items()}
async def _get_preheat_filament_targets(self, db: AsyncSession) -> dict[str, int]:
"""Parse the user-configured filament→chamber-target map, falling back
to DEFAULT_PREHEAT_FILAMENT_TARGETS on missing / malformed JSON. Keys
@@ -4483,14 +4499,14 @@ class PrintScheduler:
returned dict so the resolution loop can index it unconditionally."""
raw = await self._get_setting(db, "preheat_filament_targets")
if not raw:
return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
return self._bundled_preheat_targets()
try:
parsed = json.loads(raw)
if not isinstance(parsed, dict):
raise ValueError("not an object")
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("preheat_filament_targets unparseable, using defaults: %s", exc)
return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
return self._bundled_preheat_targets()
# Coerce values to int; drop unparseable rows so a stray string
# doesn't crash the loop.
out: dict[str, int] = {}
@@ -4539,7 +4555,14 @@ class PrintScheduler:
normalised = self._normalize_filament_type(tray.get("tray_type") or "")
if not normalised:
continue
target = targets.get(normalised, targets.get("DEFAULT", 0))
# A filled or foamed variant wants its base material's chamber
# when the map has no row of its own: ASA-GF is ASA and needs
# ASA's 45 degrees, not the 0 an unknown type falls to. The
# specific type is still tried first, so PETG-CF and PA-CF keep
# the hotter rows they are listed with (#2902).
target = targets.get(normalised)
if target is None:
target = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
if target > best:
best = target
return best
@@ -50,6 +50,28 @@ from backend.app.utils.filament_types import is_material_name
logger = logging.getLogger(__name__)
def _preset_filament_type(raw: object) -> str | None:
"""Read a slicer preset's ``filament_type`` field.
Bambu Studio and OrcaSlicer both store it as a one-element array
(``["PLA"]``); some hand-written and older profiles store a bare string.
``orca_profiles._extract_filament_fields`` accepts both and this has to
agree with it, since that is what fills ``LocalPreset.filament_type``.
The value is still run through ``printer_filament_type`` by the caller.
That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
pins exactly that -- and a pass-through for a type it does not, so nothing
the slicer says is discarded. It only bites on a hand-edited profile whose
``filament_type`` is a product line, which is the case this whole module
exists to keep out of an AMS slot.
"""
if isinstance(raw, list):
raw = raw[0] if raw else None
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None
async def resolve_slicer_filament(
*,
db: AsyncSession,
@@ -57,7 +79,7 @@ async def resolve_slicer_filament(
slicer_filament: str | None,
slicer_filament_name: str | None,
material: str | None,
) -> tuple[str, str, str | None]:
) -> tuple[str, str, str | None, str | None]:
"""Resolve a spool's slicer-preset reference to printer-side ids.
``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
@@ -72,18 +94,30 @@ async def resolve_slicer_filament(
``material``: spool material string for the local-preset fallback
branch when the LocalPreset's setting JSON doesn't carry a filament_id.
Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
when nothing resolved. ``sub_brand_override`` is non-None when a more
specific brand label is available (cloud detail name or local preset
Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
— all empty when nothing resolved. ``sub_brand_override`` is non-None when
a more specific brand label is available (cloud detail name or local preset
name); ``None`` means the caller should use its own default.
``type_override`` is the preset's own ``filament_type`` when the preset
carries one — the slicer's answer to what the material is, rather than one
parsed out of the spool's material column. It is what the caller should
write into ``tray_type``. ``None`` means no preset said, and the caller
falls back to reducing the spool's material (``printer_filament_type``).
Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
a list the slicer defines, so its type needs no interpreting. It cannot be
the only source, though — ``slicer_filament`` is nullable on a spool while
``material`` is required, and the spool this issue was reported for had no
preset at all.
"""
sf = (slicer_filament or "").strip()
if not sf:
return ("", "", None)
return ("", "", None, None)
tray_info_idx = ""
setting_id = ""
sub_brand_override: str | None = None
type_override: str | None = None
base_sf = sf.split("_")[0] if "_" in sf else sf
@@ -104,6 +138,15 @@ async def resolve_slicer_filament(
if cloud is not None and cloud.is_authenticated:
try:
detail = await cloud.get_setting_detail(base_sf)
# The preset's own type, straight from the slicer's own
# profile -- no parsing of a product name (#2902). The
# preset JSON is nested under ``setting``; some responses
# carry it at the top level instead, the same shape spread
# ``preset_resolver`` documents.
cloud_setting = detail.get("setting")
type_override = _preset_filament_type(
(cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
)
if detail.get("filament_id"):
tray_info_idx = detail["filament_id"]
cloud_name = detail.get("name", "")
@@ -134,6 +177,10 @@ async def resolve_slicer_filament(
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
lp = lp_result.scalar_one_or_none()
if lp:
# The slicer's own answer, extracted from the profile at import
# time by ``orca_profiles``. Preferred over anything parsed out
# of the spool's material column (#2902).
type_override = _preset_filament_type(lp.filament_type)
# Local preset's setting JSON carries the printer-recognized
# filament_id (e.g. "P4d64437") — use that directly so the
# slicer can resolve the specific preset. Falls through to
@@ -219,4 +266,4 @@ async def resolve_slicer_filament(
):
setting_id = ""
return (tray_info_idx, setting_id, sub_brand_override)
return (tray_info_idx, setting_id, sub_brand_override, type_override)
+74 -7
View File
@@ -77,27 +77,43 @@ def filament_types_compatible(a: str | None, b: str | None) -> bool:
# ---------------------------------------------------------------------------
# What a Bambu printer and the slicers accept in an AMS slot's ``tray_type``.
# Grounded in the catalogue this repo already carries: every "Generic X" entry
# in ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries
# add the composites, and the frontend's ``parsePresetName`` list contributes
# Grounded in the catalogues this repo already carries: ``filament_fields.json``
# is the list Bambuddy itself offers when a preset is created, so every value in
# it has to appear here -- ``TestTheMaterialsBambuddyOffers`` fails if one does
# not. On top of that, every "Generic X" entry in
# ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries add
# the composites, and the frontend's ``parsePresetName`` list contributes
# PEEK / PEI / PC-CF / PC-ABS.
#
# Product lines are deliberately absent. "PLA Matte", "PETG HF" and "eSUN PLA+"
# are things you buy, not types the firmware knows -- they belong in
# ``tray_sub_brands``, which is where Bambu itself puts them.
# A filled or foamed variant is a type of its own, not a flavour of the base
# material: PLA-AERO is a foaming PLA and PLA-GF a glass-filled one, and a slot
# that reduces either to "PLA" invites a plain PLA plate onto filament that
# will not print it. Four of the ones the dropdown offers were missing when
# #2902 first landed and were being reduced exactly that way, ASA-AERO -- which
# only the cloud catalogue names (GFB02) -- with them. See the issue thread,
# where @doncaruana caught PLA Aero.
#
# Product lines, by contrast, are deliberately absent. "PLA Matte", "PETG HF"
# and "eSUN PLA+" are things you buy, not types the firmware knows -- they
# belong in ``tray_sub_brands``, which is where Bambu itself puts them.
_PRINTER_TYPES: tuple[str, ...] = (
# Order within a length matters for ties: "PLA/PHA" must read as PLA.
"PLA-AERO",
"ASA-AERO",
"PAHT-CF",
"PA12-CF",
"PETG-CF",
"PPS-CF",
"PPS-GF",
"PPA-CF",
"PPA-GF",
"PLA-CF",
"PLA-GF",
"PA6-CF",
"PA6-GF",
"ABS-GF",
"ASA-CF",
"ASA-GF",
"PET-CF",
"PC-ABS",
"PA-CF",
@@ -190,6 +206,23 @@ def printer_filament_type(material: str | None) -> str:
return _TYPE_ALIASES[upper]
words = [w for w in _WORDS.split(upper) if w]
# A hyphenated type written with a space is still that type. The table
# spells it "PLA-AERO" because that is how the preset dropdown and the
# slicers spell it, while a spool says "PLA Aero" and a preset name says
# "Bambu PLA Aero" -- and the word rules below would find only the "PLA"
# in those and hand back a slot that lies about what is loaded.
#
# Adjacent words only, and only when the join is a type exactly. The prefix
# and suffix rules are deliberately not applied across a space: "Support
# for PLA" would otherwise start reading as a type by its tail.
for first, second in zip(words, words[1:], strict=False):
joined = f"{first}-{second}"
if joined in _PRINTER_TYPE_SET:
return joined
if joined in _TYPE_ALIASES:
return _TYPE_ALIASES[joined]
for candidate in _TYPES_LONGEST_FIRST:
if any(_word_names_type(w, candidate) for w in words):
return _TYPE_ALIASES.get(candidate, candidate)
@@ -197,6 +230,26 @@ def printer_filament_type(material: str | None) -> str:
return text
def nozzle_temp_range(material: str | None, tray_type: str | None) -> tuple[int, int]:
"""The nozzle range to send with a slot, given a spool's material and the
type the slot will carry.
The spool's own wording leads, as it does for the filament-id lookup, so a
material that has its own entry keeps it. The reduced type answers for
everything else -- and when the reduced type is a filled or foamed variant,
the base material answers for that, because ``MATERIAL_TEMPS`` carries
eleven entries and none of them is ASA-GF. Without that last step an
ASA-GF spool took the 200/240 catch-all and would not have extruded;
"ASA" gives it ASA's 240/270 (#2902).
"""
base = (tray_type or "").split("-")[0]
for key in (material, tray_type, base):
temps = MATERIAL_TEMPS.get((key or "").upper().strip())
if temps:
return temps
return (200, 240)
# Both tables are keyed by material, so their keys are exactly the set of names
# that are a material rather than a preset the printer can resolve.
_MATERIAL_NAMES = frozenset(MATERIAL_TEMPS) | frozenset(GENERIC_FILAMENT_IDS)
@@ -229,4 +282,18 @@ def is_material_name(value: str | None) -> bool:
return True
if _PRESET_ID_SHAPE.match(text):
return False
return printer_filament_type(text).upper() in _MATERIAL_NAMES
reduced = printer_filament_type(text).upper()
if reduced in _MATERIAL_NAMES:
return True
# A filled or foamed variant is its base material by another name, and the
# base is what decides. Saying yes means the caller throws this value away
# and rescues the slot from its generic-material fallback -- so the answer
# has to be no when that fallback has nothing to offer, or the slot goes out
# with no filament id at all, which is worse than the junk it replaced.
# ``ABS-GF`` reduces to a generic ABS the printer can resolve; ``PPS-CF``
# reduces to nothing, so it is left for the caller to send as it stands.
#
# This is also what keeps a type added to the table above from silently
# changing the answer: "PLA-AERO" read as a material name when the table
# had no row for it, and it still does (#2902).
return reduced.split("-")[0] in _MATERIAL_NAMES