Browse Source

fix(spool-tag-matcher): filter catalog lookup by material variant, not hex alone (issue #1227)

  Three Bambu Lab catalog rows share #FFFFFF — Jade White (PLA Basic),
  Ivory White (PLA Matte), White (PLA Silk). The catalog lookup in
  create_spool_from_tray filtered by manufacturer + hex only with no
  ORDER BY, so SQLite returned rows in rowid order and the first-inserted
  entry (Jade White) won every RFID-driven spool creation regardless of
  the actual material the AMS reported. Inserting an Ivory White PLA
  Matte roll always produced a spool named "Jade White".

  Same class of bug bites any other shared-hex pair across PLA Basic /
  Matte / Silk; the whites were just the most visible.

  Fix: add a material filter using tray_sub_brands (the printer-reported
  material variant — "PLA Matte" / "PLA Basic" / "PLA Silk"), which
  matches the catalog's `material` column directly. Use the raw
  tray_sub_brands value (captured before the gradient/dual/tri-color
  subtype upgrade) because the catalog stores "PLA Basic" for gradient
  rolls too — the upgraded subtype lives on the spool, not the catalog.

  Also add ORDER BY id to the query so the fallback path (empty
  tray_sub_brands — third-party spools / OpenTag tags) is deterministic
  across SQLite + PostgreSQL instead of DB-implementation-defined.

  Tests: 4 new in test_spool_tag_matcher.py — Ivory White PLA Matte
  resolves to Ivory not Jade (the regression pin), PLA Silk White
  resolves to White, Jade White PLA Basic still works with all three
  #FFFFFF entries seeded, and the empty-sub_brands fallback stays
  deterministic via the new ORDER BY.

  Existing spools already mis-named in the database don't auto-correct
  on next AMS read — the matcher only fires on new RFID-driven creation.
  Affected users need a manual rename in Inventory after upgrading.
maziggy 3 weeks ago
parent
commit
972e635233

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 13 - 2
backend/app/services/spool_tag_matcher.py

@@ -91,17 +91,28 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     # across material families (A17-R1 is PLA Translucent Cherry Pink; A01-R1 is
     # across material families (A17-R1 is PLA Translucent Cherry Pink; A01-R1 is
     # PLA Matte Scarlet Red), so a suffix-based fallback would pick the wrong name.
     # PLA Matte Scarlet Red), so a suffix-based fallback would pick the wrong name.
     # See #857.
     # See #857.
+    #
+    # Hex isn't unique either: #FFFFFF maps to "Jade White" (PLA Basic), "Ivory
+    # White" (PLA Matte), and "White" (PLA Silk) in the Bambu catalog. Filter by
+    # the printer-reported material variant (`tray_sub_brands`, e.g. "PLA Matte")
+    # so a new Ivory White roll doesn't get auto-named Jade White just because
+    # PLA Basic happens to come first in catalog insertion order. See #1227.
     rgba = tray_color if tray_color else None
     rgba = tray_color if tray_color else None
     color_name = None
     color_name = None
 
 
     if rgba and len(rgba) >= 6:
     if rgba and len(rgba) >= 6:
         hex_prefix = f"#{rgba[:6].upper()}"
         hex_prefix = f"#{rgba[:6].upper()}"
-        cat_result = await db.execute(
+        cat_query = (
             select(ColorCatalogEntry)
             select(ColorCatalogEntry)
             .where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
             .where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
             .where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB")
             .where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB")
-            .limit(1)
         )
         )
+        if tray_sub_brands:
+            cat_query = cat_query.where(func.upper(ColorCatalogEntry.material) == tray_sub_brands.upper())
+        # Deterministic tiebreak when the material filter can't disambiguate
+        # (e.g. third-party spools with empty tray_sub_brands).
+        cat_query = cat_query.order_by(ColorCatalogEntry.id).limit(1)
+        cat_result = await db.execute(cat_query)
         entry = cat_result.scalar_one_or_none()
         entry = cat_result.scalar_one_or_none()
         if entry:
         if entry:
             color_name = entry.color_name
             color_name = entry.color_name

+ 151 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -1028,6 +1028,157 @@ async def test_color_name_is_none_when_catalog_miss_and_code_unreadable(db_sessi
     assert spool.color_name is None
     assert spool.color_name is None
 
 
 
 
+@pytest.mark.asyncio
+async def test_ivory_white_pla_matte_resolves_to_ivory_not_jade(db_session):
+    """Regression for #1227 — #FFFFFF is shared by Jade White (PLA Basic),
+    Ivory White (PLA Matte), and White (PLA Silk) in the Bambu catalog. The
+    matcher must filter by `tray_sub_brands` so a new Ivory White PLA Matte
+    roll doesn't auto-name as Jade White just because PLA Basic was inserted
+    first.
+    """
+    # Seed in the order from catalog_defaults.py — PLA Basic first.
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Jade White",
+            hex_color="#FFFFFF",
+            material="PLA Basic",
+            is_default=True,
+        )
+    )
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Ivory White",
+            hex_color="#FFFFFF",
+            material="PLA Matte",
+            is_default=True,
+        )
+    )
+    await db_session.flush()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_type": "PLA",
+        "tray_sub_brands": "PLA Matte",
+        "tray_color": "FFFFFFFF",
+        "tray_id_name": "A01-W1",
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.color_name == "Ivory White", (
+        "PLA Matte White must resolve to 'Ivory White', not the PLA Basic 'Jade White' that shares the same hex"
+    )
+
+
+@pytest.mark.asyncio
+async def test_pla_silk_white_resolves_to_white_not_jade(db_session):
+    """Same shared-hex bug as #1227 but for the third collision: PLA Silk
+    White at #FFFFFF must not get the PLA Basic 'Jade White' name either.
+    """
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Jade White",
+            hex_color="#FFFFFF",
+            material="PLA Basic",
+            is_default=True,
+        )
+    )
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="White",
+            hex_color="#FFFFFF",
+            material="PLA Silk",
+            is_default=True,
+        )
+    )
+    await db_session.flush()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_type": "PLA",
+        "tray_sub_brands": "PLA Silk",
+        "tray_color": "FFFFFFFF",
+        "tray_id_name": "A05-W0",
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.color_name == "White"
+
+
+@pytest.mark.asyncio
+async def test_jade_white_pla_basic_still_resolves_correctly(db_session):
+    """Happy-path regression guard for #1227: the PLA Basic Jade White case
+    that worked before the fix must still work after it. Catalog has all
+    three #FFFFFF entries; the PLA Basic spool must still get 'Jade White'.
+    """
+    for color_name, material in [
+        ("Jade White", "PLA Basic"),
+        ("Ivory White", "PLA Matte"),
+        ("White", "PLA Silk"),
+    ]:
+        db_session.add(
+            ColorCatalogEntry(
+                manufacturer="Bambu Lab",
+                color_name=color_name,
+                hex_color="#FFFFFF",
+                material=material,
+                is_default=True,
+            )
+        )
+    await db_session.flush()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_type": "PLA",
+        "tray_sub_brands": "PLA Basic",
+        "tray_color": "FFFFFFFF",
+        "tray_id_name": "A00-W0",
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.color_name == "Jade White"
+
+
+@pytest.mark.asyncio
+async def test_unknown_material_falls_back_to_hex_only_lookup(db_session):
+    """When `tray_sub_brands` is empty (third-party spool / OpenTag tag without
+    a Bambu material variant), the material filter is dropped and the lookup
+    falls back to hex-only. The deterministic ORDER BY keeps the result
+    reproducible across SQLite/PostgreSQL.
+    """
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Jade White",
+            hex_color="#FFFFFF",
+            material="PLA Basic",
+            is_default=True,
+        )
+    )
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Ivory White",
+            hex_color="#FFFFFF",
+            material="PLA Matte",
+            is_default=True,
+        )
+    )
+    await db_session.flush()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_type": "PLA",
+        "tray_sub_brands": "",  # third-party tag, no material variant
+        "tray_color": "FFFFFFFF",
+        "tray_id_name": "",
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    # Either is acceptable so long as the result is deterministic; the first-
+    # inserted row (Jade White) wins via ORDER BY id.
+    assert spool.color_name == "Jade White"
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_color_name_falls_back_to_readable_tray_id_name(db_session):
 async def test_color_name_falls_back_to_readable_tray_id_name(db_session):
     """If tray_id_name is a human-readable label (no code pattern), use it when the
     """If tray_id_name is a human-readable label (no code pattern), use it when the

Some files were not shown because too many files changed in this diff