Ver Fonte

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.
maziggy há 2 semanas atrás
pai
commit
7b181b84f0

Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 0
CHANGELOG.md


+ 8 - 6
backend/app/api/routes/inventory.py

@@ -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
backend/app/api/routes/spoolman.py

@@ -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
backend/app/api/routes/spoolman_inventory.py

@@ -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
backend/app/main.py

@@ -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
backend/app/services/print_scheduler.py

@@ -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

+ 53 - 6
backend/app/services/slicer_filament_resolver.py

@@ -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
backend/app/utils/filament_types.py

@@ -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

+ 268 - 2
backend/tests/integration/test_ams_slot_material_2902.py

@@ -545,14 +545,29 @@ class TestTheAssignmentSurvivesTheSlotItJustConfigured:
     assigned to. Correct in isolation, ruinous together.
     """
 
-    async def _push(self, db_session, printer_factory, spool_material, reported_type, fingerprint_type="PETG"):
+    async def _push(
+        self,
+        db_session,
+        printer_factory,
+        spool_material,
+        reported_type,
+        fingerprint_type="PETG",
+        **spool_kwargs,
+    ):
         from unittest.mock import AsyncMock
 
         from backend.app.main import on_ams_change
         from backend.app.models.spool_assignment import SpoolAssignment
 
         printer = await printer_factory(name="H2D")
-        spool = Spool(material=spool_material, brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
+        spool = Spool(
+            material=spool_material,
+            brand="eSUN",
+            rgba="E1E9E9FF",
+            label_weight=1000,
+            weight_used=0,
+            **spool_kwargs,
+        )
         db_session.add(spool)
         await db_session.commit()
         await db_session.refresh(spool)
@@ -612,6 +627,55 @@ class TestTheAssignmentSurvivesTheSlotItJustConfigured:
 
         assert surviving is not None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_nor_is_one_whose_slot_took_its_presets_type_rather_than_its_material(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
+    ):
+        """The preset outranks the material column when the spool has one, so
+        the slot can legitimately carry a type the material never named. The
+        check has to recognise that as its own handiwork or it unlinks the
+        assignment on the very next AMS push."""
+        surviving = await self._push(
+            db_session,
+            printer_factory,
+            "PLA",
+            reported_type="PLA-AERO",
+            slicer_filament_name="Bambu PLA Aero @BBL H2D",
+        )
+
+        assert surviving is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_even_when_the_preset_name_was_never_stored(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
+    ):
+        """slicer_filament_name is optional. An imported local preset carries
+        its type outright, which is the value the assign path actually used."""
+        from backend.app.models.local_preset import LocalPreset
+
+        lp = LocalPreset(
+            name="Bambu PLA Aero @BBL H2D",
+            preset_type="filament",
+            source="orcaslicer",
+            filament_type="PLA-AERO",
+            setting="{}",
+        )
+        db_session.add(lp)
+        await db_session.commit()
+        await db_session.refresh(lp)
+
+        surviving = await self._push(
+            db_session,
+            printer_factory,
+            "PLA",
+            reported_type="PLA-AERO",
+            slicer_filament=str(lp.id),
+        )
+
+        assert surviving is not None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_a_genuinely_different_filament_still_unlinks(
@@ -623,3 +687,205 @@ class TestTheAssignmentSurvivesTheSlotItJustConfigured:
         surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
 
         assert surviving is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_and_a_preset_name_does_not_excuse_an_unrelated_slot(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
+    ):
+        """Widening the check to the preset only accepts the types the assign
+        path could actually have written. Anything else is still a swap."""
+        surviving = await self._push(
+            db_session,
+            printer_factory,
+            "PLA",
+            reported_type="ABS",
+            slicer_filament_name="Bambu PLA Aero @BBL H2D",
+        )
+
+        assert surviving is None
+
+
+class TestAFilledOrFoamedVariantIsATypeOfItsOwn:
+    """The first cut of this fix reduced PLA-AERO, PLA-GF, ASA-GF and PPS-GF
+    onto their base material, because the reduction table was assembled from
+    the cloud filament names and the frontend preset parser and never checked
+    against ``filament_fields.json`` -- the list Bambuddy itself offers when a
+    preset is created. @doncaruana caught PLA Aero on the issue.
+
+    That is worse than the bug it replaced. "PLA-AERO" matched nothing before,
+    which was useless but honest; "PLA" matches every plain PLA plate in the
+    queue, so the dispatcher would have sent one to foaming filament.
+    """
+
+    @pytest.fixture
+    async def printer(self, db_session):
+        from backend.app.models.printer import Printer
+
+        p = Printer(
+            name="Aero P1S",
+            serial_number="MAT2902AERO",
+            ip_address="192.168.1.81",
+            access_code="12345678",
+        )
+        db_session.add(p)
+        await db_session.commit()
+        await db_session.refresh(p)
+        return p
+
+    async def _assign(self, async_client, db_session, printer, material, tray_id, **spool_kwargs):
+        spool = Spool(
+            material=material,
+            brand="Bambu Lab",
+            rgba="E1E9E9FF",
+            label_weight=1000,
+            weight_used=0,
+            **spool_kwargs,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+
+        client = _mqtt_mock()
+        with patch("backend.app.services.printer_manager.printer_manager") as pm:
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
+            )
+        assert response.status_code == 200
+        return client.ams_set_filament_setting.call_args.kwargs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("material", "tray_id"),
+        [("PLA-AERO", 0), ("PLA-GF", 1), ("ASA-GF", 2), ("PPS-GF", 3)],
+    )
+    async def test_it_reaches_the_slot_intact(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer, material, tray_id
+    ):
+        sent = await self._assign(async_client, db_session, printer, material, tray_id)
+
+        assert sent["tray_type"] == material
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_written_with_a_space_it_still_reaches_the_slot_intact(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer
+    ):
+        """The table hyphenates because the slicers do; a spool says "PLA Aero"
+        and so does every Bambu preset name."""
+        sent = await self._assign(async_client, db_session, printer, "PLA Aero", 0)
+
+        assert sent["tray_type"] == "PLA-AERO"
+
+
+class TestThePresetOutranksTheMaterialColumn:
+    """#2902 again, from @doncaruana: a preset has to be picked from a list the
+    slicer defines, so it already knows its own type and nothing has to be read
+    out of a product name. When a spool points at one, that answer wins.
+
+    It cannot be the only answer. ``material`` is required on a spool and
+    ``slicer_filament`` is not -- the spool this issue was reported for had no
+    preset at all -- so the reduction stays as the fallback.
+    """
+
+    @pytest.fixture
+    async def printer(self, db_session):
+        from backend.app.models.printer import Printer
+
+        p = Printer(
+            name="Preset P1S",
+            serial_number="MAT2902PRE",
+            ip_address="192.168.1.82",
+            access_code="12345678",
+        )
+        db_session.add(p)
+        await db_session.commit()
+        await db_session.refresh(p)
+        return p
+
+    async def _preset(self, db_session, name, filament_type):
+        from backend.app.models.local_preset import LocalPreset
+
+        lp = LocalPreset(
+            name=name,
+            preset_type="filament",
+            source="orcaslicer",
+            filament_type=filament_type,
+            setting="{}",
+        )
+        db_session.add(lp)
+        await db_session.commit()
+        await db_session.refresh(lp)
+        return lp
+
+    async def _assign(self, async_client, db_session, printer, material, preset, tray_id):
+        spool = Spool(
+            material=material,
+            brand="Bambu Lab",
+            rgba="E1E9E9FF",
+            label_weight=1000,
+            weight_used=0,
+            slicer_filament=str(preset.id) if preset else None,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+
+        client = _mqtt_mock()
+        with patch("backend.app.services.printer_manager.printer_manager") as pm:
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
+            )
+        assert response.status_code == 200
+        return client.ams_set_filament_setting.call_args.kwargs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_slot_gets_the_presets_type_not_one_read_from_the_material(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer
+    ):
+        """The material column says "PLA", which the reduction would happily
+        accept. The preset says the spool is foaming PLA, and it is right."""
+        preset = await self._preset(db_session, "Bambu PLA Aero @BBL P1S", "PLA-AERO")
+        sent = await self._assign(async_client, db_session, printer, "PLA", preset, 0)
+
+        assert sent["tray_type"] == "PLA-AERO"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_preset_that_names_no_type_leaves_the_reduction_in_charge(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer
+    ):
+        preset = await self._preset(db_session, "eSUN PLA+ @BBL P1S", None)
+        sent = await self._assign(async_client, db_session, printer, "PLA+", preset, 1)
+
+        assert sent["tray_type"] == "PLA"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_and_a_spool_with_no_preset_at_all_still_gets_one(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer
+    ):
+        sent = await self._assign(async_client, db_session, printer, "PLA+", None, 2)
+
+        assert sent["tray_type"] == "PLA"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_hand_edited_preset_naming_a_product_line_is_still_reduced(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer
+    ):
+        """Preferring the preset does not mean trusting it blindly. A profile
+        whose filament_type is a product line puts that product line in the
+        slot, which is the exact failure this issue is about."""
+        preset = await self._preset(db_session, "My PLA+ @BBL P1S", "PLA+")
+        sent = await self._assign(async_client, db_session, printer, "PLA", preset, 3)
+
+        assert sent["tray_type"] == "PLA"

+ 125 - 5
backend/tests/unit/services/test_slicer_filament_resolver.py

@@ -35,7 +35,7 @@ async def test_pfus_cloud_unavailable_preserves_setting_id():
         "backend.app.api.routes.cloud.build_authenticated_cloud",
         AsyncMock(return_value=None),
     ):
-        tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
+        tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
             db=db,
             current_user=None,
             slicer_filament="PFUS990b6e19965353",
@@ -56,7 +56,7 @@ async def test_pfcn_cloud_unavailable_preserves_setting_id():
         "backend.app.api.routes.cloud.build_authenticated_cloud",
         AsyncMock(return_value=None),
     ):
-        tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
+        tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
             db=db,
             current_user=None,
             slicer_filament="PFCN1234567890",
@@ -82,7 +82,7 @@ async def test_pfus_cloud_resolves_filament_id_regression_guard():
         "backend.app.api.routes.cloud.build_authenticated_cloud",
         AsyncMock(return_value=cloud_mock),
     ):
-        tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
+        tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
             db=db,
             current_user=MagicMock(),
             slicer_filament="PFUS990b6e19965353",
@@ -106,7 +106,7 @@ async def test_gfs_cloud_unavailable_resolves_via_normalize():
         "backend.app.api.routes.cloud.build_authenticated_cloud",
         AsyncMock(return_value=None),
     ):
-        tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
+        tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
             db=db,
             current_user=None,
             slicer_filament="GFSG02",
@@ -130,7 +130,7 @@ async def test_literal_material_name_clears_both():
         "backend.app.api.routes.cloud.build_authenticated_cloud",
         AsyncMock(return_value=None),
     ):
-        tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
+        tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
             db=db,
             current_user=None,
             slicer_filament="PETG",
@@ -140,3 +140,123 @@ async def test_literal_material_name_clears_both():
     assert tray_info_idx == ""
     assert setting_id == ""
     assert sub_brand is None
+
+
+class TestThePresetsOwnType:
+    """#2902: a preset is chosen from a list the slicer defines, so its
+    ``filament_type`` is the slicer's own answer to what the material is --
+    no reading of a product name required. Raised by @doncaruana on the issue
+    after the first fix reduced "PLA Aero" to "PLA".
+
+    The resolver hands that answer back as the fourth element; the two assign
+    routes write it into ``tray_type`` in preference to reducing the spool's
+    material column. ``None`` means no preset said, and the reduction stands.
+    """
+
+    @pytest.mark.asyncio
+    async def test_a_local_presets_type_is_returned(self):
+        db = MagicMock()
+        lp = MagicMock()
+        lp.filament_type = "PLA-AERO"
+        lp.setting = None
+        lp.name = "Bambu PLA Aero @BBL X1C"
+        result = MagicMock()
+        result.scalar_one_or_none = MagicMock(return_value=lp)
+        db.execute = AsyncMock(return_value=result)
+
+        _idx, _sid, _brand, type_override = await resolve_slicer_filament(
+            db=db,
+            current_user=None,
+            slicer_filament="38",
+            slicer_filament_name=None,
+            material="PLA",
+        )
+        assert type_override == "PLA-AERO"
+
+    @pytest.mark.asyncio
+    async def test_a_cloud_presets_type_is_read_out_of_its_profile(self):
+        """Both slicers store it as a one-element array, and the preset JSON
+        sits under ``setting`` in the cloud envelope."""
+        db = MagicMock()
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(
+            return_value={
+                "filament_id": "GFA11",
+                "name": "Bambu PLA Aero @BBL X1C",
+                "setting": {"filament_type": ["PLA-AERO"]},
+            }
+        )
+        cloud.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            AsyncMock(return_value=cloud),
+        ):
+            idx, _sid, _brand, type_override = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament="GFSA11",
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == "GFA11"
+        assert type_override == "PLA-AERO"
+
+    @pytest.mark.asyncio
+    async def test_a_bare_string_filament_type_is_accepted_too(self):
+        """Hand-written and older profiles store it unwrapped. ``orca_profiles``
+        accepts both forms, so this has to as well."""
+        db = MagicMock()
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(
+            return_value={"filament_id": "GFG02", "setting": {"filament_type": "PETG"}}
+        )
+        cloud.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            AsyncMock(return_value=cloud),
+        ):
+            _idx, _sid, _brand, type_override = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament="GFSG02",
+                slicer_filament_name=None,
+                material="PETG",
+            )
+        assert type_override == "PETG"
+
+    @pytest.mark.asyncio
+    async def test_no_preset_means_no_answer(self):
+        """A spool with no slicer_filament -- the case this issue was reported
+        for. ``material`` is required on a spool and ``slicer_filament`` is
+        not, so the reduction has to stay as the fallback."""
+        db = MagicMock()
+        _idx, _sid, _brand, type_override = await resolve_slicer_filament(
+            db=db,
+            current_user=None,
+            slicer_filament=None,
+            slicer_filament_name=None,
+            material="PLA+",
+        )
+        assert type_override is None
+
+    @pytest.mark.asyncio
+    async def test_a_preset_that_does_not_say_gets_no_opinion(self):
+        db = MagicMock()
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(return_value={"filament_id": "GFG02", "setting": {}})
+        cloud.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            AsyncMock(return_value=cloud),
+        ):
+            _idx, _sid, _brand, type_override = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament="GFSG02",
+                slicer_filament_name=None,
+                material="PETG",
+            )
+        assert type_override is None

+ 59 - 9
backend/tests/unit/test_scheduler_preheat.py

@@ -500,20 +500,41 @@ async def test_normalize_filament_type_strips_at_space():
 
 
 @pytest.mark.asyncio
-async def test_get_preheat_filament_targets_defaults_when_missing(scheduler):
-    """Empty / null setting → bundled defaults are used. _get_preheat_filament_targets
-    upper-cases the keys, so the bundled `default` becomes `DEFAULT` on the
-    returned dict — keep both spellings synced."""
+@pytest.mark.parametrize("stored", [None, "", "not json at all", "[1, 2, 3]"])
+async def test_get_preheat_filament_targets_defaults_when_missing(scheduler, stored):
+    """Empty / null / malformed setting → bundled defaults are used.
+
+    Every path out of this function must honour the one contract its docstring
+    states: keys upper-cased, and DEFAULT present so the resolution loop can
+    index it unconditionally. The fallback paths used to hand the bundled
+    constant back as declared, with its lowercase `default`, so an install that
+    had never opened the setting returned a dict the loop could not read its
+    fallback out of. It happened to produce the right number only because that
+    default is 0 -- this test is what stops the constant changing and taking
+    every unconfigured install's chamber preheat down with it.
+    """
     db = AsyncMock()
-    with patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)):
+    with patch.object(scheduler, "_get_setting", AsyncMock(return_value=stored)):
         targets = await scheduler._get_preheat_filament_targets(db)
-    # The bundled defaults dict is kept as-is on the "no setting" path, so
-    # `default` (lowercase) is what callers see for that fallback.
     assert targets["PLA"] == 0
     assert targets["ABS"] == 45
     assert targets["PA-CF"] == 55
-    # Either casing must resolve to the fallback 0.
-    assert targets.get("default", targets.get("DEFAULT")) == 0
+    assert "DEFAULT" in targets, "the resolution loop looks the fallback up by this exact key"
+    assert targets["DEFAULT"] == PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS["default"]
+    assert all(key == key.upper() for key in targets), targets
+
+
+@pytest.mark.asyncio
+async def test_a_configured_map_reaches_the_loop_under_the_same_contract(scheduler):
+    """The editor writes the keys it displays, lowercase `default` included, so
+    the parsed path has always upper-cased. Both paths agree now."""
+    db = AsyncMock()
+    stored = '{"PLA": 0, "abs": 60, "default": 15}'
+    with patch.object(scheduler, "_get_setting", AsyncMock(return_value=stored)):
+        targets = await scheduler._get_preheat_filament_targets(db)
+    assert targets["ABS"] == 60
+    assert targets["DEFAULT"] == 15
+    assert all(key == key.upper() for key in targets), targets
 
 
 # ----------------------------------------------------------------------------
@@ -618,3 +639,32 @@ async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, arch
         await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
 
     client.set_airduct_mode.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_a_filled_variant_preheats_like_its_base_material(monkeypatch):
+    """#2902 widened the type an AMS slot carries, so a slot that used to say
+    "ASA" can now say "ASA-GF". The chamber map has no ASA-GF row, and an
+    unknown type preheats to nothing -- so an ASA-GF print would have gone out
+    with a cold chamber. The specific type is still tried first, so the rows
+    that do exist for a variant (PETG-CF, PA-CF) are not traded down."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    s = PrintScheduler()
+    targets = PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS
+
+    def target_for(tray_type: str) -> int:
+        normalised = s._normalize_filament_type(tray_type)
+        value = targets.get(normalised)
+        if value is None:
+            value = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
+        return value
+
+    assert target_for("ASA-GF") == targets["ASA"]
+    assert target_for("ASA-AERO") == targets["ASA"]
+    assert target_for("ABS-GF") == targets["ABS"]
+    # Variants listed in their own right keep their own row.
+    assert target_for("PETG-CF") == 40
+    assert target_for("PA-CF") == 55
+    # And a plain type is untouched.
+    assert target_for("PLA") == 0

+ 120 - 1
backend/tests/unit/utils/test_printer_filament_type_2902.py

@@ -11,6 +11,9 @@ is the vendor's product line, and roughly forty of its values are not filament
 types: HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A.
 """
 
+import json
+from pathlib import Path
+
 import pytest
 
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
@@ -18,10 +21,22 @@ from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_types import (
     _PRINTER_TYPES,
     is_material_name,
+    nozzle_temp_range,
     printer_filament_type,
 )
 
 
+@pytest.fixture(scope="module")
+def offered_types() -> set[str]:
+    """The ``filament_type`` options of the Profiles editor -- the same file
+    ``test_filament_fields_options`` pins for #1686."""
+    path = Path(__file__).resolve().parents[3] / "app" / "data" / "filament_fields.json"
+    with path.open() as f:
+        data = json.load(f)
+    field = next(f for f in data["fields"] if f["key"] == "filament_type")
+    return {opt["value"] for opt in field["options"]}
+
+
 class TestTheReportedCase:
     def test_pla_plus_is_pla(self):
         assert printer_filament_type("PLA+") == "PLA"
@@ -139,6 +154,59 @@ class TestTheShippedCatalogue:
         assert leftover == {"CPE HG100", "FiberSilk Metallic", "XT", "NylonX", "NylonG"}
 
 
+class TestTheMaterialsBambuddyOffers:
+    """``filament_fields.json`` is the material list the Profiles editor puts in
+    a dropdown, so every value in it is a type a user can legitimately end up
+    with on a spool -- and reducing one of those to something shorter is not a
+    repair, it is a slot lying about what is loaded.
+
+    Four were being reduced that way when #2902 first landed: PLA-AERO, PLA-GF,
+    ASA-GF and PPS-GF all collapsed onto their base material, so a plain PLA
+    plate would have dispatched onto foaming or glass-filled filament -- a match
+    that was impossible before the reduction existed. @doncaruana caught PLA
+    Aero in the issue thread.
+
+    The reduction table was assembled from the cloud filament names and the
+    frontend preset parser and never checked against this file, which is exactly
+    the drift this test exists to stop.
+    """
+
+    def test_every_type_the_dropdown_offers_reduces_to_itself(self, offered_types):
+        for material in sorted(offered_types):
+            assert printer_filament_type(material) == material, (
+                f"{material!r} is offered as a filament type but reduces to {printer_filament_type(material)!r}"
+            )
+
+    def test_and_is_a_type_the_reduction_knows(self, offered_types):
+        """The check above passes vacuously for a name the table cannot place at
+        all, since an unplaceable name is returned verbatim. This one does not."""
+        assert offered_types <= set(_PRINTER_TYPES)
+
+    @pytest.mark.parametrize(
+        ("written", "expected"),
+        [
+            ("PLA Aero", "PLA-AERO"),
+            ("Bambu PLA Aero", "PLA-AERO"),
+            ("ASA Aero", "ASA-AERO"),
+            ("PLA GF", "PLA-GF"),
+            ("PA6 GF", "PA6-GF"),
+        ],
+    )
+    def test_a_hyphenated_type_survives_being_written_with_a_space(self, written, expected):
+        """Bambu's own preset names are spaced ("Bambu PLA Aero") and so is what
+        a user types, while the table and the slicers hyphenate. Reading only
+        the "PLA" out of those is how the four above were being lost."""
+        assert printer_filament_type(written) == expected
+
+    def test_but_only_when_the_join_is_a_type_exactly(self):
+        """The prefix and suffix rules stay inside a single word. Applying them
+        across a space would make "Support for PLA" a type by its tail, and
+        every "<brand> PLA Basic" preset name a type by its head."""
+        assert printer_filament_type("PLA Basic") == "PLA"
+        assert printer_filament_type("Bambu PLA Basic") == "PLA"
+        assert printer_filament_type("PLA Silk") == "PLA"
+
+
 class TestIsMaterialName:
     """The question the slicer-filament resolver and the slot-reuse check both
     ask of a candidate filament id, and have to answer the same way."""
@@ -183,9 +251,60 @@ class TestIsMaterialName:
             assert not is_material_name(fid), fid
             assert not is_material_name(filament_id_to_setting_id(fid)), fid
 
-    @pytest.mark.parametrize("unknown", ["PCTG", "PPS-CF", "CPE HG100", "XT", "", None])
+    @pytest.mark.parametrize("unknown", ["PCTG", "PPS-CF", "PPS-GF", "PEEK", "PA6-CF", "CPE HG100", "XT", "", None])
     def test_anything_it_cannot_place_is_left_for_the_caller_to_use(self, unknown):
         """False means "keep it". A type the tables do not carry is not proof
         the value is junk, and discarding it would empty the slot's filament id
         for no reason."""
         assert is_material_name(unknown) is False
+
+    @pytest.mark.parametrize("variant", ["PLA-AERO", "PLA-GF", "ASA-GF", "ASA-AERO", "ABS-GF", "ASA-CF"])
+    def test_but_a_variant_the_caller_can_rescue_is_one(self, variant):
+        """The base decides, because the base is what the caller's fallback
+        looks up. Adding PLA-AERO to the type table must not quietly turn this
+        answer over: before the table had a row for it the value reduced to
+        "PLA" and read as a material name, and it is no less one now -- a slot
+        carrying it as a filament id still has to be replaced, not reused."""
+        assert is_material_name(variant) is True
+
+
+class TestTheNozzleRangeThatGoesWithIt:
+    """``MATERIAL_TEMPS`` carries eleven entries, and the reduction can now
+    return forty-one types. Without a fall back to the base material, widening
+    the table sent an ASA-GF spool out at the 200/240 catch-all -- too cold to
+    extrude ASA -- where before it correctly got ASA's own range.
+    """
+
+    @pytest.mark.parametrize(
+        ("material", "expected"),
+        [
+            # The ones the widened table would otherwise have cooled.
+            ("ASA-GF", (240, 270)),
+            ("ASA Aero", (240, 270)),
+            ("ASA-CF", (240, 270)),
+            ("ABS-GF", (240, 270)),
+            # Unchanged by the widening, and must stay that way.
+            ("PLA+", (190, 230)),
+            ("PLA-AERO", (190, 230)),
+            ("PLA-GF", (190, 230)),
+            ("PLA", (190, 230)),
+            ("PETG", (220, 260)),
+        ],
+    )
+    def test_a_variant_falls_back_to_its_base_material(self, material, expected):
+        assert nozzle_temp_range(material, printer_filament_type(material)) == expected
+
+    def test_but_a_variant_with_its_own_row_keeps_it(self):
+        """The base fallback is the last step, not the first: PLA-CF prints
+        hotter than PLA and PETG-CF hotter than PETG."""
+        assert nozzle_temp_range("PLA-CF", "PLA-CF") == (210, 240)
+        assert nozzle_temp_range("PETG CF", printer_filament_type("PETG CF")) == (240, 270)
+
+    def test_the_spools_own_wording_still_leads(self):
+        """Same order as the filament-id lookup: a material that resolves on
+        its own name keeps that answer."""
+        assert nozzle_temp_range("PA-CF", "PA-CF") == (270, 300)
+
+    def test_and_something_unplaceable_still_gets_the_catch_all(self):
+        assert nozzle_temp_range("CPE HG100", "CPE HG100") == (200, 240)
+        assert nozzle_temp_range(None, "") == (200, 240)

+ 6 - 1
frontend/src/utils/preheatFilamentTargets.ts

@@ -83,6 +83,11 @@ export function normalizePreheatFilamentType(trayType: string): string {
 // Pick the max chamber target across a list of loaded tray types, falling
 // back to `default` when a type isn't in the map. Returns 0 when nothing
 // is loaded, which short-circuits the chamber phase at dispatch.
+//
+// A filled or foamed variant takes its base material's row when it has none of
+// its own, matching `_derive_chamber_target`: ASA-GF is ASA and wants ASA's 45
+// degrees, not the 0 an unmapped type falls to. The full type is still tried
+// first, so PETG-CF and PA-CF keep their own hotter rows (#2902).
 export function deriveChamberTargetForTrays(
   trayTypes: readonly string[],
   map: Record<string, number>,
@@ -91,7 +96,7 @@ export function deriveChamberTargetForTrays(
   for (const raw of trayTypes) {
     const normalized = normalizePreheatFilamentType(raw);
     if (!normalized) continue;
-    const target = map[normalized] ?? map.default ?? 0;
+    const target = map[normalized] ?? map[normalized.split('-')[0]] ?? map.default ?? 0;
     if (target > best) best = target;
   }
   return best;

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff