Parcourir la source

Give an AMS slot a material type, not a product name (issue #2902)

Assigning a spool wrote its material straight into the slot's tray_type.
A slot that says "PLA+" satisfies nothing that asks for PLA: not
OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher,
which compares the type the printer reports to the one the 3MF declares
as plain equality. The reporter's slot was unusable for every PLA plate
he had.

Not only a label, either. The same string went into the generic-filament
lookup, which missed, so the slot went out with no tray_info_idx at all
-- the half-configured state #2604 documents the printer as reverting
from -- and took the 200/240 catch-all nozzle range instead of PLA's
190/230.

PLA+ is not a special case. Bambuddy's own colour catalogue supplies the
material dropdown, and around forty of its values are vendor product
lines rather than filament types: HTPLA, PolyTerra PLA, PLA Matte, ASA
Extrafill, Flexfill TPU 98A.

So the four routes that configure a slot reduce the material to a name
the printer knows before sending it, and the product name moves to
tray_sub_brands -- which is where Bambu Lab puts it too: their catalogue
carries a preset named "eSUN PLA+" whose type is PLA. A name the
reduction cannot place is sent exactly as before rather than guessed at,
so this can only repair a slot, never break a working one. The spool's
own wording still leads the id and temperature lookups with the reduced
type appended behind it, so "PETG HF" keeps its own generic preset
(GFG96) rather than being traded down to plain PETG's.

Two guards decide whether a candidate filament id is really a material
name -- the resolver's, which discards one, and slot reuse, which will
not carry one forward. Both saw only bare types, so "PLA+" passed as a
filament id. They share one answer now, which also refuses to read an
id-shaped value: "GFPLA" ends in a material name, and reducing it would
throw away the calibrated preset in the slot.

One thing had to move with it. on_ams_change auto-unlinks an assignment
whose slot stopped looking the way it did when the spool was assigned,
and the check that spares a slot Bambuddy itself reconfigured compared
the printer's reported type against the spool's raw material. With the
slot now carrying the reduced type, every spool this issue is about
would have been unlinked from the slot it had just been assigned to.
Both sides are reduced there -- the printer's too, so slots configured
by an older version, still reporting "PLA+", keep matching.

Something starts working as a result: a slot holding a calibrated preset
is reused when a same-material spool is assigned to it, which could not
happen for these spools while "PLA" and "PLA+" compared unequal.

Reverting any one of the behaviours above fails a distinct test -- the
reduction's four matching rules and its pass-through contract included,
since that contract is what makes the rest of it safe.
maziggy il y a 2 semaines
Parent
commit
88e8ca81c3

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
CHANGELOG.md


+ 22 - 5
backend/app/api/routes/inventory.py

@@ -67,6 +67,7 @@ from backend.app.utils.filament_ids import (
     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.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 logger = logging.getLogger(__name__)
@@ -114,7 +115,10 @@ async def apply_spool_to_slot_via_mqtt(
 
     state = printer_manager.get_status(printer_id)
 
-    tray_type = spool.material
+    # The slot carries the material type; the product line the material column
+    # may actually hold ("PLA+", "HTPLA") stays in tray_sub_brands below, which
+    # is where Bambu puts it too (issue #2902).
+    tray_type = printer_filament_type(spool.material)
     tray_sub_brands = (
         f"{spool.brand} {spool.material} {spool.subtype}".strip()
         if spool.brand
@@ -125,7 +129,6 @@ async def apply_spool_to_slot_via_mqtt(
     tray_color = spool.rgba or "FFFFFFFF"
 
     _generic_id_values = _GENERIC_ID_VALUES
-    _known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
 
     # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
     # the Spoolman-mode route via this helper (#1713). The helper handles
@@ -149,16 +152,25 @@ async def apply_spool_to_slot_via_mqtt(
             and current_tray_info_idx not in _generic_id_values
             and not current_tray_info_idx.startswith("PFUS")
             and not current_tray_info_idx.startswith("PFCN")
-            and current_tray_info_idx.upper() not in _known_materials
+            # Shares the resolver's reading of what counts as a material
+            # name, product lines included: a slot written by a Bambuddy from
+            # before #2902 can be holding "PLA+" in this field, and reusing
+            # that would carry the bad id forward instead of replacing it.
+            and not is_material_name(current_tray_info_idx)
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
         ):
             tray_info_idx = current_tray_info_idx
         elif tray_type:
-            material = tray_type.upper().strip()
+            # The spool's own wording is tried first and the reduced type only
+            # as a further fallback, so a material that already resolves keeps
+            # resolving to the same id: "PETG HF" has its own generic preset
+            # (GFG96) that reducing it to "PETG" would trade away for GFG99.
+            material = (spool.material or "").upper().strip()
             generic = (
                 GENERIC_FILAMENT_IDS.get(material)
                 or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""
             )
             if generic:
@@ -172,7 +184,12 @@ async def apply_spool_to_slot_via_mqtt(
     if tray_info_idx and not setting_id:
         setting_id = filament_id_to_setting_id(tray_info_idx)
 
-    temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
+    # 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)
+    )
     if spool.nozzle_temp_min is not None:
         temp_min = spool.nozzle_temp_min
     if spool.nozzle_temp_max is not None:

+ 18 - 1
backend/app/api/routes/printers.py

@@ -69,6 +69,7 @@ from backend.app.services.printer_manager import (
     uniform_tray_filament_hint,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
+from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
@@ -2557,6 +2558,17 @@ async def configure_ams_slot(
         f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
     )
 
+    # The modal derives tray_type from a preset name or a spool's material, so
+    # it can be a product line rather than a type ("PLA+", "PolyTerra PLA").
+    # A slot carrying one of those satisfies nothing that asks for PLA, so the
+    # slot gets the type and tray_sub_brands -- untouched here -- keeps the
+    # name (issue #2902). The requested wording is kept for the id lookup
+    # below, which knows some product lines the type table does not.
+    requested_tray_type = tray_type
+    tray_type = printer_filament_type(tray_type)
+    if tray_type != requested_tray_type:
+        logger.info("[configure_ams_slot] tray_type %r → %r", requested_tray_type, tray_type)
+
     # Get MQTT client for this printer
     client = printer_manager.get_client(printer_id)
     if not client:
@@ -2631,10 +2643,15 @@ async def configure_ams_slot(
             )
             effective_tray_info_idx = current_tray_info_idx
         elif tray_type:
-            material = tray_type.upper().strip()
+            # Requested wording first, reduced type only as a further fallback,
+            # so a material that already resolves keeps resolving to the same
+            # id: "PETG HF" has its own generic preset (GFG96) that reducing it
+            # to "PETG" would trade away for GFG99.
+            material = requested_tray_type.upper().strip()
             generic = (
                 _GENERIC_FILAMENT_IDS.get(material)
                 or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or _GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""
             )
             if generic:

+ 20 - 6
backend/app/api/routes/spoolman.py

@@ -35,6 +35,7 @@ from backend.app.utils.filament_ids import (
     MATERIAL_TEMPS,
     normalize_slicer_filament,
 )
+from backend.app.utils.filament_types import printer_filament_type
 
 logger = logging.getLogger(__name__)
 
@@ -904,28 +905,41 @@ async def link_spool(
 
             mqtt_client = printer_manager.get_client(p_id)
             if mqtt_client:
-                tray_type = mapped.get("material") or ""
+                # Spoolman's material is free text, so it arrives as whatever
+                # the user typed there -- "PLA+", "PolyTerra PLA". The sub-brand
+                # keeps that wording; the slot's type has to be one the printer
+                # and the slicer know (issue #2902).
+                material = mapped.get("material") or ""
+                tray_type = printer_filament_type(material)
                 brand = mapped.get("brand") or ""
                 subtype = mapped.get("subtype") or ""
                 if brand:
-                    tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
+                    tray_sub_brands = f"{brand} {material} {subtype}".strip()
                 elif subtype:
-                    tray_sub_brands = f"{tray_type} {subtype}".strip()
+                    tray_sub_brands = f"{material} {subtype}".strip()
                 else:
-                    tray_sub_brands = tray_type
+                    tray_sub_brands = material
 
                 tray_color = (mapped.get("rgba") or "808080FF").upper()
                 if len(tray_color) == 6:
                     tray_color = tray_color + "FF"
 
-                material_upper = tray_type.upper().strip()
+                # The spool's own wording is tried first and the reduced type
+                # only as a further fallback, so a material that already
+                # resolves keeps resolving to the same id: "PETG HF" has its
+                # own generic preset (GFG96) that reducing it to "PETG" would
+                # trade away for GFG99.
+                material_upper = material.upper().strip()
                 tray_info_idx = (
                     GENERIC_FILAMENT_IDS.get(material_upper)
                     or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                    or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                     or ""
                 )
                 setting_id = ""
-                temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
+                temp_defaults = (
+                    MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
+                )
                 temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
                 temp_max = temp_defaults[1]
 

+ 18 - 8
backend/app/api/routes/spoolman_inventory.py

@@ -65,6 +65,7 @@ from backend.app.utils.filament_ids import (
     filament_id_to_setting_id,
     normalize_slicer_filament,
 )
+from backend.app.utils.filament_types import printer_filament_type
 
 logger = logging.getLogger(__name__)
 
@@ -1477,15 +1478,20 @@ async def assign_spoolman_slot(
     try:
         mqtt_client = printer_manager.get_client(body.printer_id)
         if mqtt_client:
-            tray_type = mapped.get("material") or ""
+            # Spoolman's material is free text, so it arrives as whatever the
+            # user typed there -- "PLA+", "PolyTerra PLA". The sub-brand keeps
+            # that wording; the slot's type has to be one the printer and the
+            # slicer know (issue #2902).
+            material = mapped.get("material") or ""
+            tray_type = printer_filament_type(material)
             brand = mapped.get("brand") or ""
             subtype = mapped.get("subtype") or ""
             if brand:
-                tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
+                tray_sub_brands = f"{brand} {material} {subtype}".strip()
             elif subtype:
-                tray_sub_brands = f"{tray_type} {subtype}".strip()
+                tray_sub_brands = f"{material} {subtype}".strip()
             else:
-                tray_sub_brands = tray_type
+                tray_sub_brands = material
 
             tray_color = (mapped.get("rgba") or "808080FF").upper()
             if len(tray_color) == 6:
@@ -1504,19 +1510,23 @@ async def assign_spoolman_slot(
                 current_user=current_user,
                 slicer_filament=mapped.get("slicer_filament"),
                 slicer_filament_name=mapped.get("slicer_filament_name"),
-                material=tray_type,
+                material=material,
             )
             if sub_brand_override:
                 tray_sub_brands = sub_brand_override
 
-            material_upper = tray_type.upper().strip()
+            material_upper = material.upper().strip()
             # Fall back to generic-material id when slicer_filament is empty
             # or the resolver discarded an unresolvable value. Matches the
-            # internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
+            # internal-mode tail in inventory.py:_apply_spool_to_slot_inner,
+            # including the order: the spool's own wording first and the
+            # reduced type only after it, so "PETG HF" keeps its own generic
+            # preset (GFG96) rather than trading it for "PETG"'s GFG99.
             if not tray_info_idx:
                 tray_info_idx = (
                     GENERIC_FILAMENT_IDS.get(material_upper)
                     or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                    or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                     or ""
                 )
 
@@ -1529,7 +1539,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, (200, 240))
+            temp_defaults = MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
             temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
             temp_max = temp_defaults[1]
 

+ 14 - 2
backend/app/main.py

@@ -135,6 +135,7 @@ from backend.app.services.spoolman_tracking import (
 )
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
+from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import extruder_for_inlet, slot_extruder as resolve_slot_extruder
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.print_jobs import is_internal_printer_job
@@ -2102,11 +2103,22 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             continue
                         # Fingerprint mismatch — but check if tray now matches the
                         # assigned spool (e.g. auto-configure changed the tray).
+                        # Both sides are reduced to the type the slot can carry
+                        # before comparing: the assign path writes that rather
+                        # than the spool's raw material (#2902), so a spool whose
+                        # material is a product line — "PLA+", "HTPLA" — reports
+                        # back as "PLA" and would otherwise fail this check and
+                        # be auto-unlinked from the slot it was just assigned to.
+                        # Reducing the printer's side too keeps slots configured
+                        # by an older Bambuddy, still reporting "PLA+", matching.
                         spool = assignment.spool
                         if spool:
                             spool_color = (spool.rgba or "FFFFFFFF").upper()
-                            spool_type = (spool.material or "").upper()
-                            if _colors_similar(cur_color, spool_color) and cur_type.upper() == spool_type:
+                            spool_type = printer_filament_type(spool.material).upper()
+                            if (
+                                _colors_similar(cur_color, spool_color)
+                                and printer_filament_type(cur_type).upper() == spool_type
+                            ):
                                 logger.info(
                                     "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
                                     assignment.spool_id,

+ 11 - 7
backend/app/services/slicer_filament_resolver.py

@@ -42,15 +42,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.models.user import User
 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
 
 logger = logging.getLogger(__name__)
 
-_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
-
 
 async def resolve_slicer_filament(
     *,
@@ -153,6 +151,12 @@ async def resolve_slicer_filament(
                     tray_info_idx = lp_filament_id
                     setting_id = filament_id_to_setting_id(lp_filament_id)
                 else:
+                    # Deliberately not widened to cover product-line materials
+                    # ("PLA+", "HTPLA") the way the callers' own fallbacks were
+                    # (#2902). Returning an id here rather than "" would skip
+                    # the caller's whole no-id block, and with it the slot-reuse
+                    # branch that keeps a printer's calibrated preset -- so the
+                    # widening belongs there, after reuse has had its turn.
                     mat = (material or lp.filament_type or "").upper().strip()
                     tray_info_idx = (
                         GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
@@ -181,7 +185,9 @@ async def resolve_slicer_filament(
     # material fallback can rescue the slot:
     #   1. Literal material names ("PLA", "PETG-CF") that pass through
     #      normalize_slicer_filament unchanged when the spool's slicer_filament
-    #      is free-text rather than a real preset ID.
+    #      is free-text rather than a real preset ID. Product lines ("PLA+",
+    #      "HTPLA") count as material names too -- see is_material_name, which
+    #      is shared with the slot-reuse check that must agree with this.
     #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
     #      by the slicer as tray_info_idx (the printer's calibration table
     #      indexes by filament_id, and a PFUS isn't one). This normally gets
@@ -195,9 +201,7 @@ async def resolve_slicer_filament(
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
     # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     if tray_info_idx and (
-        tray_info_idx.upper() in _KNOWN_MATERIALS
-        or tray_info_idx.startswith("PFUS")
-        or tray_info_idx.startswith("PFCN")
+        is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
     ):
         tray_info_idx = ""
         # Preserve setting_id when it's still a valid slicer reference

+ 170 - 0
backend/app/utils/filament_types.py

@@ -15,8 +15,18 @@ which reduces a tray type to a drying-preset key. "Which drying profile?" is a
 genuinely different question with a different answer — PLA Silk dries like PLA
 but does not print like it — so folding those together would be the wrong kind
 of tidy.
+
+Two more readings of a filament type live here for the same reason. Writing one
+into an AMS slot needs the name the printer knows rather than the one on the
+spool (``printer_filament_type``), and two guards need to agree on when a value
+is a material name rather than a preset the printer can resolve
+(``is_material_name``). Both were open-coded and inconsistent before #2902.
 """
 
+import re
+
+from backend.app.utils.filament_ids import GENERIC_FILAMENT_IDS, MATERIAL_TEMPS
+
 # Types within a group are interchangeable on the printer side; Bambu Lab
 # firmware treats them as the same material. The first entry is canonical.
 #
@@ -60,3 +70,163 @@ def canonical_filament_type(ftype: str | None) -> str:
 def filament_types_compatible(a: str | None, b: str | None) -> bool:
     """Whether two filament types may stand in for one another."""
     return canonical_filament_type(a) == canonical_filament_type(b)
+
+
+# ---------------------------------------------------------------------------
+# Printer-side material names
+# ---------------------------------------------------------------------------
+
+# 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
+# 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.
+_PRINTER_TYPES: tuple[str, ...] = (
+    # Order within a length matters for ties: "PLA/PHA" must read as PLA.
+    "PAHT-CF",
+    "PA12-CF",
+    "PETG-CF",
+    "PPS-CF",
+    "PPA-CF",
+    "PPA-GF",
+    "PLA-CF",
+    "PA6-CF",
+    "PA6-GF",
+    "ABS-GF",
+    "ASA-CF",
+    "PET-CF",
+    "PC-ABS",
+    "PA-CF",
+    "PC-CF",
+    "PP-CF",
+    "PP-GF",
+    "PE-CF",
+    "PCTG",
+    "PETG",
+    "BVOH",
+    "HIPS",
+    "PEEK",
+    "PLA",
+    "PHA",
+    "ABS",
+    "ASA",
+    "TPU",
+    "PVA",
+    "PPS",
+    "EVA",
+    "PEI",
+    "PC",
+    "PA",
+    "PP",
+    "PE",
+)
+
+# Names that are a material by another name. ``GENERIC_FILAMENT_IDS`` already
+# points NYLON and PA at the same generic id, so they are the same thing to
+# everything downstream of here.
+_TYPE_ALIASES: dict[str, str] = {
+    "NYLON": "PA",
+}
+
+_PRINTER_TYPE_SET = frozenset(_PRINTER_TYPES)
+
+# Longest first, so "PLA-CF" is recognised before the "PLA" inside it. Sorted
+# stably, so the declaration order above still decides same-length ties.
+_TYPES_LONGEST_FIRST: tuple[str, ...] = tuple(sorted((*_PRINTER_TYPES, *_TYPE_ALIASES), key=len, reverse=True))
+
+# Splits a material name into words while keeping "-" and "+" inside them:
+# "PLA-CF" is one word, "PLA/PHA" is two, and the "+" of "PLA+" stays attached
+# so the prefix rule below can see a non-letter after the type.
+_WORDS = re.compile(r"[^A-Z0-9+\-]+")
+
+
+def _word_names_type(word: str, candidate: str) -> bool:
+    """Whether one word of a material name says "this is a ``candidate``"."""
+    if word == candidate:
+        return True
+    # Two-letter types are too short to recognise inside a longer word. "PA" is
+    # nylon and "Pastel" is not, and there is no reading of the rules below that
+    # separates them -- so PA, PC, PE and PP are taken only as a word of their
+    # own. A spool whose material really is bare nylon still says so.
+    if len(candidate) < 3:
+        return False
+    # A prefix counts only when a non-letter follows it -- "PLA+" and "PETG-HS"
+    # are the type, "PLASTIC" is a word that merely starts like one.
+    if word.startswith(candidate) and not word[len(candidate)].isalpha():
+        return True
+    # A suffix needs no such guard: "HTPLA" and "rPETG" are how vendors write
+    # their own PLA and PETG, and nothing else ends in a material name.
+    return word.endswith(candidate)
+
+
+def printer_filament_type(material: str | None) -> str:
+    """Reduce a spool's material to the name an AMS slot can carry.
+
+    Bambuddy lets a spool's material be anything -- typed by hand, synced from
+    Spoolman, or picked from the colour catalogue, whose material column is the
+    vendor's product line ("PLA+", "HTPLA", "PolyTerra PLA"). Every assignment
+    path then wrote that string into the slot's ``tray_type``, and a slot whose
+    type is "PLA+" satisfies nothing that asks for PLA: not the slicer, and not
+    Bambuddy's own dispatch matcher, which compares the printer's reported
+    ``tray_type`` to the 3MF's declared type as plain equality (issue #2902).
+
+    Returns the material name unchanged when it cannot be placed. That is the
+    important half of the contract -- an unrecognised material is left exactly
+    as it arrives rather than guessed at, so this can only ever fix a slot that
+    was already wrong.
+    """
+    text = (material or "").strip()
+    if not text:
+        return ""
+
+    upper = text.upper()
+    if upper in _PRINTER_TYPE_SET:
+        return upper
+    if upper in _TYPE_ALIASES:
+        return _TYPE_ALIASES[upper]
+
+    words = [w for w in _WORDS.split(upper) if w]
+    for candidate in _TYPES_LONGEST_FIRST:
+        if any(_word_names_type(w, candidate) for w in words):
+            return _TYPE_ALIASES.get(candidate, candidate)
+
+    return text
+
+
+# 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)
+
+# "GF" + letter + digits for Bambu's own presets, "P" + hex for local and cloud
+# ones -- the shapes ``slicer_filament_resolver`` documents. Matched loosely on
+# purpose: this only ever has to be sure a value is NOT a bare material name.
+_PRESET_ID_SHAPE = re.compile(r"^(?:GF|P)[A-Za-z0-9_]*$")
+
+
+def is_material_name(value: str | None) -> bool:
+    """Whether a candidate filament id is really just a material name.
+
+    Two places ask this, and have to answer it the same way: the slicer-filament
+    resolver, which throws such a value away so its caller's generic fallback
+    can rescue the slot, and the slot-reuse check, which will not carry one
+    forward. Both compared against the table above and so saw only bare types --
+    but "PLA+" is exactly as unusable a filament id as the "PLA" they already
+    rejected, and reaching the printer is how a product line ended up in the
+    field the calibration table is keyed by (issue #2902).
+
+    A value shaped like a preset id is never a material name, whatever letters
+    it happens to end in. Reading "GFPLA" as PLA would discard it, and what the
+    user loses when that happens is the calibrated preset in the slot.
+    """
+    text = (value or "").strip()
+    if not text:
+        return False
+    if text.upper() in _MATERIAL_NAMES:
+        return True
+    if _PRESET_ID_SHAPE.match(text):
+        return False
+    return printer_filament_type(text).upper() in _MATERIAL_NAMES

+ 625 - 0
backend/tests/integration/test_ams_slot_material_2902.py

@@ -0,0 +1,625 @@
+"""What reaches an AMS slot when a spool's material is a product line (#2902).
+
+The reporter assigned an eSUN PLA+ spool and the slot came out unusable: any
+plate sliced with a PLA profile refused it. Four routes configure a slot and
+all four wrote the spool's material straight into ``tray_type``, where "PLA+"
+matches nothing -- not the slicer, and not Bambuddy's own dispatch matcher,
+which compares the printer's reported ``tray_type`` to the 3MF's declared type
+as plain equality.
+
+Each test below asserts the whole slot, not just the type: an unrecognised
+material also missed the generic-filament-id lookup, so the slot went out with
+an empty ``tray_info_idx`` -- the half-configured state #2604 documents the
+printer as reverting from -- and took the 200/240 catch-all temperatures
+instead of PLA's.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+def _mqtt_mock():
+    client = MagicMock()
+    client.ams_set_filament_setting.return_value = True
+    client.extrusion_cali_sel.return_value = True
+    return client
+
+
+def _status(ams_data=None):
+    status = MagicMock()
+    status.raw_data = {"ams": {"ams": ams_data if ams_data is not None else []}}
+    status.nozzles = [MagicMock(nozzle_diameter="0.4")]
+    status.ams_extruder_map = None
+    status.kprofiles = []
+    return status
+
+
+def _spoolman_spool(material, spool_id=11, slicer_filament=None):
+    extra = {}
+    if slicer_filament is not None:
+        extra["bambu_slicer_filament"] = json.dumps(str(slicer_filament))
+    return {
+        "id": spool_id,
+        "filament": {
+            "id": 1,
+            "name": "Cool White",
+            "material": material,
+            "color_hex": "E1E9E9",
+            "weight": 1000,
+            "vendor": {"id": 1, "name": "eSUN"},
+        },
+        "remaining_weight": 800.0,
+        "used_weight": 200.0,
+        "archived": False,
+        "extra": extra,
+    }
+
+
+def _spoolman_client(spool):
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spool = AsyncMock(return_value=spool)
+    client.get_spools = AsyncMock(return_value=[spool])
+    client.merge_spool_extra = AsyncMock(return_value=spool)
+    return client
+
+
+class TestInternalInventoryAssign:
+    async def _assign(self, async_client, db_session, material, **spool_kwargs):
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="P1S",
+            serial_number=f"MAT2902{material[:4]}",
+            ip_address="192.168.1.77",
+            access_code="12345678",
+        )
+        db_session.add(printer)
+        spool = Spool(
+            material=material,
+            brand="eSUN",
+            color_name="Cool White",
+            rgba="E1E9E9FF",
+            label_weight=1000,
+            weight_used=0,
+            **spool_kwargs,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        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": 1},
+            )
+        assert response.status_code == 200
+        client.ams_set_filament_setting.assert_called_once()
+        return client.ams_set_filament_setting.call_args.kwargs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_pla_plus_spool_configures_the_slot_as_pla(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        sent = await self._assign(async_client, db_session, "PLA+")
+
+        assert sent["tray_type"] == "PLA"
+        # Not just the label: the id and its setting_id are what stop the
+        # printer treating the slot as half configured, and the temperatures
+        # are PLA's rather than the catch-all.
+        assert sent["tray_info_idx"] == "GFL99"
+        assert sent["setting_id"] == "GFSL99"
+        assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_product_name_is_not_lost_it_moves_to_the_sub_brand(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Which is where Bambu itself puts it -- their own catalogue has a
+        preset named "eSUN PLA+" (GFL03) whose type is PLA."""
+        sent = await self._assign(async_client, db_session, "PLA+")
+
+        assert "PLA+" in sent["tray_sub_brands"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_material_that_already_resolved_keeps_its_own_preset(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """ "PETG HF" has a generic preset of its own (GFG96, "Generic PETG HF").
+        Reducing the material before the id lookup rather than after it would
+        trade that away for plain PETG's GFG99 -- a quiet downgrade of slots
+        that work today."""
+        sent = await self._assign(async_client, db_session, "PETG HF")
+
+        assert sent["tray_info_idx"] == "GFG96"
+        assert sent["tray_type"] == "PETG"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_it_can_now_reuse_the_calibrated_preset_already_in_the_slot(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """A slot already holding a specific preset keeps it when the incoming
+        spool is the same material -- that is how a printer's calibration
+        context survives an assignment. The comparison is against the slot's
+        reported type, so a PLA+ spool could never match a PLA slot and the
+        reuse branch was dead for every spool this issue is about."""
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="Reuse P1S",
+            serial_number="MAT2902RU",
+            ip_address="192.168.1.81",
+            access_code="12345678",
+        )
+        db_session.add(printer)
+        spool = Spool(material="PLA+", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        await db_session.refresh(spool)
+
+        client = _mqtt_mock()
+        live_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        with patch("backend.app.services.printer_manager.printer_manager") as pm:
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status(live_slot)
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+        assert response.status_code == 200
+        sent = client.ams_set_filament_setting.call_args.kwargs
+        assert sent["tray_info_idx"] == "P4d64437"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_but_it_does_not_reuse_a_product_name_a_previous_version_left_there(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """A spool whose slicer_filament was free text could send that text to
+        the printer as the slot's filament id, and the printer reports it
+        straight back -- so an upgraded install can be looking at a slot that
+        says type PLA, id "PLA+". Reuse has always refused a bare material name
+        in that field; refusing a product line too is what stops the bad id
+        being carried forward on every assignment instead of replaced."""
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="Stale P1S",
+            serial_number="MAT2902ST",
+            ip_address="192.168.1.82",
+            access_code="12345678",
+        )
+        db_session.add(printer)
+        spool = Spool(material="PLA", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        await db_session.refresh(spool)
+
+        client = _mqtt_mock()
+        stale_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "PLA+", "tray_type": "PLA"}]}]
+        with patch("backend.app.services.printer_manager.printer_manager") as pm:
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status(stale_slot)
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+        assert response.status_code == 200
+        sent = client.ams_set_filament_setting.call_args.kwargs
+        assert sent["tray_info_idx"] == "GFL99"
+        assert sent["tray_type"] == "PLA"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_material_nothing_can_be_made_of_is_sent_unchanged(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """The catalogue ships a few names with no filament type in them at all.
+        Guessing at those would be worse than leaving them: this route behaved
+        exactly this way before #2902, and still does."""
+        sent = await self._assign(async_client, db_session, "CPE HG100")
+
+        assert sent["tray_type"] == "CPE HG100"
+        assert sent["tray_info_idx"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_free_text_slicer_filament_naming_a_product_is_not_a_filament_id(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """slicer_filament is free text on older spools, so "PLA+" can be sitting
+        in it. It is as unusable a tray_info_idx as the bare "PLA" the resolver
+        already discarded, and letting it through would put a product name in
+        the field the printer keys its calibration table by."""
+        sent = await self._assign(async_client, db_session, "PLA+", slicer_filament="PLA+")
+
+        assert sent["tray_info_idx"] == "GFL99"
+
+
+class TestSpoolmanInventoryAssign:
+    @pytest.fixture
+    async def settings(self, db_session):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+        await db_session.commit()
+
+    @pytest.fixture
+    async def printer(self, db_session):
+        from backend.app.models.printer import Printer
+
+        p = Printer(
+            name="Spoolman P1S",
+            serial_number="MAT2902SM",
+            ip_address="192.168.1.78",
+            access_code="12345678",
+        )
+        db_session.add(p)
+        await db_session.commit()
+        await db_session.refresh(p)
+        return p
+
+    async def _assign(self, async_client, printer, material, slicer_filament=None):
+        mqtt = _mqtt_mock()
+        spool = _spoolman_spool(material, slicer_filament=slicer_filament)
+        with (
+            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
+            patch(
+                "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
+                AsyncMock(return_value=_spoolman_client(spool)),
+            ),
+        ):
+            pm.get_client.return_value = mqtt
+            pm.get_status.return_value = _status()
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={"spoolman_spool_id": 11, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
+            )
+        assert response.status_code == 200
+        return mqtt.ams_set_filament_setting.call_args.kwargs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolmans_free_text_material_is_reduced_the_same_way(
+        self, async_client: AsyncClient, settings, printer
+    ):
+        """Spoolman's material field is free text too, so the same product names
+        arrive by this route -- and it is the route the reporter used."""
+        sent = await self._assign(async_client, printer, "PLA+")
+
+        assert sent["tray_type"] == "PLA"
+        assert sent["tray_info_idx"] == "GFL99"
+        # Exactly, not just "contains PLA+": the filament's own name is in this
+        # string too, so a substring check would pass even if the material had
+        # been reduced before it was built.
+        assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
+        self, async_client: AsyncClient, settings, printer
+    ):
+        sent = await self._assign(async_client, printer, "PETG HF")
+
+        assert sent["tray_info_idx"] == "GFG96"
+        assert sent["tray_type"] == "PETG"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_resolver_is_handed_the_spools_own_wording_not_the_type(
+        self, async_client: AsyncClient, settings, printer, db_session
+    ):
+        """This route also passes the material down to the slicer-filament
+        resolver. Handing that the reduced type instead would look harmless and
+        quietly downgrade GFG96 to GFG99 whenever the spool points at a local
+        preset with no filament_id of its own."""
+        from backend.app.models.local_preset import LocalPreset
+
+        lp = LocalPreset(name="Generic PETG HF", preset_type="filament", source="orcaslicer", setting="{}")
+        db_session.add(lp)
+        await db_session.commit()
+        await db_session.refresh(lp)
+
+        sent = await self._assign(async_client, printer, "PETG HF", slicer_filament=lp.id)
+
+        assert sent["tray_info_idx"] == "GFG96"
+
+
+class TestConfigureSlotModal:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_product_line_typed_into_the_modal_is_reduced_too(self, async_client: AsyncClient, printer_factory):
+        """The Configure Slot modal derives tray_type from a preset name or the
+        spool's material, so it can hand the backend a product line as readily
+        as the assignment routes can."""
+        printer = await printer_factory(model="P1S")
+
+        client = _mqtt_mock()
+        with patch("backend.app.api.routes.printers.printer_manager") as pm:
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/1/configure",
+                params={
+                    "tray_info_idx": "",
+                    "tray_type": "PLA+",
+                    "tray_sub_brands": "eSUN PLA+",
+                    "tray_color": "E1E9E9FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+        assert response.status_code == 200
+        sent = client.ams_set_filament_setting.call_args.kwargs
+        assert sent["tray_type"] == "PLA"
+        # The empty tray_info_idx the modal sent for a generic material is what
+        # the reduced type now rescues.
+        assert sent["tray_info_idx"] == "GFL99"
+        assert sent["tray_sub_brands"] == "eSUN PLA+"
+
+
+class TestSpoolmanLink:
+    """The fourth route that configures a slot: linking a Spoolman spool to a
+    slot's tag auto-configures it too, from the same free-text material."""
+
+    @pytest.fixture
+    async def settings(self, db_session):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+        await db_session.commit()
+
+    @pytest.fixture
+    async def printer(self, db_session):
+        from backend.app.models.printer import Printer
+
+        p = Printer(
+            name="Link P1S",
+            serial_number="MAT2902LK",
+            ip_address="192.168.1.79",
+            access_code="12345678",
+        )
+        db_session.add(p)
+        await db_session.commit()
+        await db_session.refresh(p)
+        return p
+
+    async def _link(self, async_client, printer, material):
+        client = _spoolman_client(_spoolman_spool(material, spool_id=12))
+        mqtt = _mqtt_mock()
+        with (
+            patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+            patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
+            patch("backend.app.api.routes.spoolman.printer_manager") as pm,
+        ):
+            pm.get_client.return_value = mqtt
+            pm.get_status.return_value = _status()
+            response = await async_client.post(
+                "/api/v1/spoolman/spools/12/link",
+                json={
+                    "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                    "printer_id": printer.id,
+                    "ams_id": 0,
+                    "tray_id": 3,
+                },
+            )
+        assert response.status_code == 200
+        return mqtt.ams_set_filament_setting.call_args.kwargs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_linking_a_pla_plus_spool_configures_the_slot_as_pla(
+        self, async_client: AsyncClient, settings, printer
+    ):
+        sent = await self._link(async_client, printer, "PLA+")
+
+        assert sent["tray_type"] == "PLA"
+        assert sent["tray_info_idx"] == "GFL99"
+        assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
+        assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
+        self, async_client: AsyncClient, settings, printer
+    ):
+        sent = await self._link(async_client, printer, "PETG HF")
+
+        assert sent["tray_info_idx"] == "GFG96"
+        assert sent["tray_type"] == "PETG"
+
+
+class TestALocalPresetThatNamesNoFilamentId:
+    """The one path where the material reaches the slicer-filament resolver
+    rather than the route's own fallback: a spool pointing at an imported local
+    preset whose setting JSON carries no filament_id. The resolver then has only
+    the material to go on, so it has to read it the same way -- and be handed
+    the spool's own wording, not the reduced type."""
+
+    @pytest.fixture
+    async def preset(self, db_session):
+        from backend.app.models.local_preset import LocalPreset
+
+        lp = LocalPreset(
+            name="eSUN PLA+ @BBL P1S",
+            preset_type="filament",
+            source="orcaslicer",
+            filament_type=None,
+            setting="{}",
+        )
+        db_session.add(lp)
+        await db_session.commit()
+        await db_session.refresh(lp)
+        return lp
+
+    @pytest.fixture
+    async def printer(self, db_session):
+        from backend.app.models.printer import Printer
+
+        p = Printer(
+            name="LP P1S",
+            serial_number="MAT2902LP",
+            ip_address="192.168.1.80",
+            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, preset, material):
+        spool = Spool(
+            material=material,
+            brand="eSUN",
+            rgba="E1E9E9FF",
+            label_weight=1000,
+            weight_used=0,
+            slicer_filament=str(preset.id),
+        )
+        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": 0},
+            )
+        assert response.status_code == 200
+        return client.ams_set_filament_setting.call_args.kwargs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_resolver_places_a_product_line_too(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
+    ):
+        sent = await self._assign(async_client, db_session, printer, preset, "PLA+")
+
+        assert sent["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_and_still_prefers_a_material_that_has_its_own_preset(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
+    ):
+        sent = await self._assign(async_client, db_session, printer, preset, "PETG HF")
+
+        assert sent["tray_info_idx"] == "GFG96"
+
+
+class TestTheAssignmentSurvivesTheSlotItJustConfigured:
+    """The other side of the same coin, and the one that bites hardest.
+
+    on_ams_change auto-unlinks an assignment whose slot no longer looks like it
+    did when the spool was assigned. The fingerprint is snapshotted *before* the
+    MQTT config goes out, so the very next AMS push after an assignment is a
+    mismatch by construction -- and what saves the assignment is a second check:
+    does the tray match the assigned spool now? That check read the spool's raw
+    material, which the slot no longer carries, so every spool this issue is
+    about would have been silently unlinked from the slot it had just been
+    assigned to. Correct in isolation, ruinous together.
+    """
+
+    async def _push(self, db_session, printer_factory, spool_material, reported_type, fingerprint_type="PETG"):
+        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)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+
+        assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=2,
+            fingerprint_color="E1E9E9FF",
+            fingerprint_type=fingerprint_type,
+        )
+        db_session.add(assignment)
+        await db_session.commit()
+        assignment_id = assignment.id
+
+        ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": reported_type, "tray_color": "E1E9E9FF", "state": 11}]}]
+        status = _status(ams_data)
+        status.state = "IDLE"
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main.mqtt_relay") as relay,
+            patch("backend.app.main.ws_manager") as ws,
+        ):
+            pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
+            pm.get_status.return_value = status
+            pm.get_model.return_value = "H2D"
+            relay.on_ams_change = AsyncMock()
+            ws.send_printer_status = AsyncMock()
+            ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        # on_ams_change commits through its own session.
+        db_session.expunge_all()
+        return await db_session.get(SpoolAssignment, assignment_id)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_pla_plus_spool_is_not_unlinked_from_the_slot_now_reporting_pla(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
+    ):
+        surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA")
+
+        assert surviving is not None, "the slot reports what we wrote to it -- that is a match, not a swap"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_nor_is_one_in_a_slot_an_older_version_configured(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
+    ):
+        """An install upgrading into this fix has slots still reporting "PLA+"
+        until something reconfigures them. Reducing only the spool's side would
+        break those the moment they were left alone."""
+        surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA+")
+
+        assert surviving is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_genuinely_different_filament_still_unlinks(
+        self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
+    ):
+        """The check still has to do its job: someone swapping PLA for ABS in
+        the slot must lose the assignment, or usage gets charged to the wrong
+        spool."""
+        surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
+
+        assert surviving is None

+ 191 - 0
backend/tests/unit/utils/test_printer_filament_type_2902.py

@@ -0,0 +1,191 @@
+"""What a spool's material becomes when it is written into an AMS slot (#2902).
+
+The reporter had eSUN PLA+ in his inventory. Bambuddy wrote "PLA+" into the
+slot's ``tray_type``, and neither slicer has such a type -- so a plate sliced
+with any PLA profile could not use that slot. The same string also went into
+the generic-filament-id lookup, which missed, so the slot went out with an
+empty ``tray_info_idx`` on top.
+
+"PLA+" is not a special case. The bundled colour catalogue's material column
+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 pytest
+
+from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
+from backend.app.utils.filament_ids import filament_id_to_setting_id
+from backend.app.utils.filament_types import (
+    _PRINTER_TYPES,
+    is_material_name,
+    printer_filament_type,
+)
+
+
+class TestTheReportedCase:
+    def test_pla_plus_is_pla(self):
+        assert printer_filament_type("PLA+") == "PLA"
+
+    def test_so_is_every_other_way_a_vendor_writes_pla(self):
+        # All four are shipped catalogue values, so all four could be sitting in
+        # someone's material field right now.
+        assert printer_filament_type("HTPLA") == "PLA"
+        assert printer_filament_type("PolyTerra PLA") == "PLA"
+        assert printer_filament_type("PLA Matte") == "PLA"
+        assert printer_filament_type("Pro PLA+") == "PLA"
+
+
+class TestItLeavesAlonePreciselyWhatItCannotPlace:
+    """The load-bearing half of the contract. Anything this cannot recognise has
+    to come back untouched, because that is what the four assignment paths sent
+    before -- so the change can only ever fix a slot, never break a working one.
+    """
+
+    @pytest.mark.parametrize("unknown", ["CPE HG100", "FiberSilk Metallic", "XT", "NylonX"])
+    def test_an_unrecognised_material_survives_verbatim(self, unknown):
+        assert printer_filament_type(unknown) == unknown
+
+    @pytest.mark.parametrize(
+        "preset_id",
+        [
+            "GFL99",  # Bambu generic
+            "GFSL99",  # its setting_id form
+            "GFA01",  # Bambu official
+            "P4d64437",  # local user preset
+            "PFUS9ac902733670a9",  # cloud user preset
+            "PFCN0123abcd",  # cloud shared preset
+        ],
+    )
+    def test_a_filament_id_is_never_mistaken_for_a_material(self, preset_id):
+        """slicer_filament_resolver runs candidate tray_info_idx values through
+        this to decide whether they are really a material name in disguise. A
+        preset id that came back as "PA" would be discarded as junk and the slot
+        would silently lose the user's calibrated profile."""
+        assert printer_filament_type(preset_id) == preset_id
+
+    def test_a_word_that_merely_starts_like_a_type_is_not_that_type(self):
+        assert printer_filament_type("PLASTIC") == "PLASTIC"
+
+    @pytest.mark.parametrize("name", ["Pastel", "Sparkle", "Pearl"])
+    def test_two_letter_types_are_not_hunted_inside_words(self, name):
+        """ "PA" is nylon and "Pastel" is not. There is no rule that separates
+        them by shape, so PA/PC/PE/PP are only ever read as a word of their own."""
+        assert printer_filament_type(name) == name
+
+    def test_but_a_spool_that_really_is_bare_nylon_still_says_so(self):
+        assert printer_filament_type("PA") == "PA"
+        assert printer_filament_type("PC") == "PC"
+        assert printer_filament_type("Nylon") == "PA"
+
+
+class TestHowItReadsAName:
+    def test_a_type_at_the_end_of_a_word_counts(self):
+        # How vendors write their own: Protopasta's HTPLA, Filamentum's rPETG.
+        assert printer_filament_type("HTPLA") == "PLA"
+        assert printer_filament_type("rPETG") == "PETG"
+        assert printer_filament_type("ReForm rPLA") == "PLA"
+
+    def test_a_type_at_the_start_counts_only_when_a_non_letter_follows(self):
+        assert printer_filament_type("PLA+") == "PLA"
+        assert printer_filament_type("PETG-HS") == "PETG"
+        assert printer_filament_type("PLA-ST") == "PLA"
+        assert printer_filament_type("PLASTIC") == "PLASTIC"
+
+    def test_the_longest_type_wins(self):
+        """Otherwise a carbon-filled spool would be configured as plain PLA and
+        print at the wrong temperature."""
+        assert printer_filament_type("Hyper PLA-CF") == "PLA-CF"
+        assert printer_filament_type("PLA-CF") == "PLA-CF"
+        assert printer_filament_type("PAHT-CF") == "PAHT-CF"
+
+    def test_a_blend_reads_as_its_first_named_type(self):
+        # PLA/PHA prints as PLA, and PLA is the half a slicer profile targets.
+        assert printer_filament_type("PLA/PHA") == "PLA"
+
+    def test_case_and_padding_do_not_matter(self):
+        assert printer_filament_type("pla") == "PLA"
+        assert printer_filament_type("  PLA  ") == "PLA"
+        assert printer_filament_type("pLa MaTtE") == "PLA"
+
+    @pytest.mark.parametrize("empty", [None, "", "   "])
+    def test_nothing_in_nothing_out(self, empty):
+        assert printer_filament_type(empty) == ""
+
+
+class TestTheShippedCatalogue:
+    """The catalogue is where the reporter's PLA+ came from, so it is also the
+    honest test set: every material Bambuddy itself puts in front of a user."""
+
+    def test_every_catalogue_material_lands_on_a_type_or_is_left_alone(self):
+        # Names that carry no filament type at all. Nothing can be made of them,
+        # so they must pass through -- exactly as they did before #2902.
+        unplaceable = {"CPE HG100", "FiberSilk Metallic", "XT", "NylonX", "NylonG"}
+        known = set(_PRINTER_TYPES)
+
+        for entry in DEFAULT_COLOR_CATALOG:
+            material = entry[3]
+            result = printer_filament_type(material)
+            if material in unplaceable:
+                assert result == material, f"{material!r} should have been left alone"
+            else:
+                assert result in known, f"{material!r} reduced to {result!r}, which is not a filament type"
+
+    def test_the_ones_that_cannot_be_placed_are_still_only_those_five(self):
+        """A guard on the list above: if a catalogue sync adds a sixth, this
+        fails and someone gets to decide what it is, rather than a slot quietly
+        going out with a product name in it again."""
+        known = set(_PRINTER_TYPES)
+        leftover = {e[3] for e in DEFAULT_COLOR_CATALOG if printer_filament_type(e[3]) not in known}
+        assert leftover == {"CPE HG100", "FiberSilk Metallic", "XT", "NylonX", "NylonG"}
+
+
+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."""
+
+    @pytest.mark.parametrize(
+        "name",
+        ["PLA", "PETG", "PA-CF", "PETG HF", "NYLON", "  PLA  ", "PLA+", "HTPLA", "PolyTerra PLA", "Pro PLA+"],
+    )
+    def test_a_material_or_a_product_line_is_one(self, name):
+        assert is_material_name(name) is True
+
+    @pytest.mark.parametrize(
+        "preset_id",
+        [
+            "GFL99",
+            "GFA01",
+            "GFSL05_07",
+            "GFNC0",
+            "P4d64437",
+            "PFUS9ac902733670a9",
+            "PFCN0123abcd",
+            # The trap this guard exists for. Bambu ids carry letters in these
+            # positions (GFNC0 does), so an id that happens to end in a material
+            # name is not far-fetched -- and reading it as one would throw the
+            # user's calibrated preset out of the slot.
+            "GFPLA",
+            "GFSABS",
+            "GFTPU",
+            "GFPVA",
+            "GFASA",
+        ],
+    )
+    def test_a_preset_id_never_is(self, preset_id):
+        assert is_material_name(preset_id) is False
+
+    def test_no_shipped_bambu_id_reads_as_a_material(self):
+        """The exhaustive version of the case above, over the whole catalogue
+        this repo knows, in both the filament_id and setting_id spellings."""
+        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+        for fid in _BUILTIN_FILAMENT_NAMES:
+            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])
+    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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff