Browse Source

Fix filament color name and subtype inconsistencies (#857)

maziggy 1 month ago
parent
commit
75fa935851

+ 1 - 0
CHANGELOG.md

@@ -20,6 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 
 ### Fixed
+- **Filament Color and Subtype Inconsistencies** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like "Dark Gray" instead of Bambu-specific names like "Titan Gray" because the fallback skipped the Bambu hex color database. (2) "Silk+" subtype was missing from the known variants list, so the Edit Spool dropdown showed "Silk" instead. Also added "Tough+". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as "Basic" and PLA Silk Dual Color as "Silk" because the firmware only sends the base material in `tray_sub_brands`. Now detects gradient/multi-color/tri-color variants from the `tray_id_name` color code pattern (M\*/T\* suffixes). Reported by @Frosty-Jackal.
 - **External Spool Print Fails on P1S/P1P Without AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854)) — Sending a print job to a printer with no AMS units and only an external spool (virtual tray 254) caused the printer to reject the command with "Failed to get AMS mapping table". The print command was sent with `use_ams: true` (the default), but firmware on printers without AMS hardware rejects that combination. Now automatically sets `use_ams: false` when all filament slots map to external spools or are unmapped. H2D-series printers are excluded since they use `use_ams` for nozzle routing. Reported by @UVCXanth.
 - **External Folder Scan 500 Error on 3MF Files** ([#846](https://github.com/maziggy/bambuddy/issues/846)) — Scanning an external folder containing .3mf files crashed with "Object of type bytes is not JSON serializable". The parsed 3MF metadata contained raw thumbnail bytes (`_thumbnail_data`) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent `parser.extract_thumbnail()` method — thumbnail data is already available in the parsed metadata. Now uses the same `clean_metadata()` pattern as upload and zip extraction. Reported by @SMAW.
 - **Archives Capped at 50 Items** ([#843](https://github.com/maziggy/bambuddy/issues/843)) — The archives page only showed the 50 most recent prints due to a hardcoded API limit. Users with more than 50 archives could not see or access older entries. Fixed by fetching all archives and adding client-side pagination with configurable page sizes (25, 50, 100, 200, or All). Page size preference is persisted. Reported by @dcbaldwin.

+ 29 - 0
backend/app/services/spool_tag_matcher.py

@@ -70,6 +70,22 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
         material = tray_sub_brands
 
+    # Upgrade subtype for gradient/multi-color variants based on tray_id_name color code.
+    # Firmware sends tray_sub_brands="PLA Basic" for gradients and "PLA Silk" for dual/tri-color,
+    # but the M*/T* suffix in tray_id_name distinguishes them:
+    #   A00-M* = PLA Basic Gradient, A05-M* = PLA Silk Dual Color, A05-T* = PLA Silk Tri Color
+    if tray_id_name and "-" in tray_id_name:
+        color_code = tray_id_name.split("-", 1)[1]
+        if color_code and color_code[0] == "M":
+            # M* = gradient for PLA Basic (A00), dual-color for PLA Silk (A05)
+            prefix = tray_id_name.split("-", 1)[0]
+            if prefix == "A05":
+                subtype = "Dual Color"
+            else:
+                subtype = "Gradient"
+        elif color_code and color_code[0] == "T":
+            subtype = "Tri Color"
+
     # Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
     from backend.app.core.bambu_colors import resolve_bambu_color_name
 
@@ -198,6 +214,19 @@ async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spo
     elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
         material = tray_sub_brands
 
+    # Upgrade subtype for gradient/multi-color variants (same logic as create_spool_from_tray)
+    tray_id_name = tray_data.get("tray_id_name", "")
+    if tray_id_name and "-" in tray_id_name:
+        color_code = tray_id_name.split("-", 1)[1]
+        if color_code and color_code[0] == "M":
+            prefix = tray_id_name.split("-", 1)[0]
+            if prefix == "A05":
+                subtype = "Dual Color"
+            else:
+                subtype = "Gradient"
+        elif color_code and color_code[0] == "T":
+            subtype = "Tri Color"
+
     # Build query: active spools with no tag, matching brand + material + color
     query = (
         select(Spool)

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

@@ -737,3 +737,118 @@ async def test_link_tag_preserves_existing_slicer_filament(db_session):
 
     assert spool.slicer_filament == "CUSTOM01"
     assert spool.slicer_filament_name == "My Custom PLA"
+
+
+# -- gradient / multi-color subtype detection --------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_spool_gradient_from_tray_id_name(db_session):
+    """PLA Basic with M* color code → subtype='Gradient'."""
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Basic",
+        "tray_id_name": "A00-M2",  # Ocean to Meadow
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.material == "PLA"
+    assert spool.subtype == "Gradient"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_dual_color_from_tray_id_name(db_session):
+    """PLA Silk with A05-M* color code → subtype='Dual Color'."""
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Silk",
+        "tray_id_name": "A05-M1",  # South Beach
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.material == "PLA"
+    assert spool.subtype == "Dual Color"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_tri_color_from_tray_id_name(db_session):
+    """PLA Silk with A05-T* color code → subtype='Tri Color'."""
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Silk",
+        "tray_id_name": "A05-T3",  # Neon City
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.material == "PLA"
+    assert spool.subtype == "Tri Color"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_silk_plus_subtype(db_session):
+    """PLA Silk+ preserves 'Silk+' subtype (no gradient override)."""
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Silk+",
+        "tray_id_name": "A06-D0",  # Titan Gray — D code, not M/T
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.material == "PLA"
+    assert spool.subtype == "Silk+"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_standard_not_affected(db_session):
+    """Standard filaments with D/K/etc codes are not affected."""
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Basic",
+        "tray_id_name": "A00-D3",  # Dark Gray
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.material == "PLA"
+    assert spool.subtype == "Basic"
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_gradient_spool(db_session):
+    """find_matching_untagged_spool matches gradient subtype from tray_id_name."""
+    spool = Spool(
+        material="PLA",
+        subtype="Gradient",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Basic",
+        "tray_id_name": "A00-M2",
+    }
+    found = await find_matching_untagged_spool(db_session, tray)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_gradient_no_match_basic(db_session):
+    """A 'Basic' spool does NOT match a Gradient tray (different subtype)."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_sub_brands": "PLA Basic",
+        "tray_id_name": "A00-M2",  # Gradient
+    }
+    found = await find_matching_untagged_spool(db_session, tray)
+    assert found is None

+ 64 - 0
frontend/src/__tests__/utils/colors.test.ts

@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest';
+import { hexToColorName, getColorName, resolveSpoolColorName } from '../../utils/colors';
+
+describe('hexToColorName', () => {
+  it('returns "Unknown" for null/empty input', () => {
+    expect(hexToColorName(null)).toBe('Unknown');
+    expect(hexToColorName('')).toBe('Unknown');
+    expect(hexToColorName(undefined)).toBe('Unknown');
+  });
+
+  it('classifies dark low-saturation colors as Dark Gray', () => {
+    // Titan Gray hex (5F6367) — low saturation, lightness < 0.4
+    expect(hexToColorName('5F6367')).toBe('Dark Gray');
+  });
+
+  it('classifies black hex as Black', () => {
+    expect(hexToColorName('000000')).toBe('Black');
+  });
+
+  it('classifies white hex as White', () => {
+    expect(hexToColorName('FFFFFF')).toBe('White');
+  });
+});
+
+describe('getColorName', () => {
+  it('looks up Bambu hex colors before HSL fallback', () => {
+    // 5f6367 is in BAMBU_HEX_COLORS as "Titan Gray"
+    expect(getColorName('5f6367')).toBe('Titan Gray');
+    // Also with uppercase
+    expect(getColorName('5F6367')).toBe('Titan Gray');
+  });
+
+  it('looks up alternative Titan Gray hex', () => {
+    // 565656 is also mapped to "Titan Gray" in BAMBU_HEX_COLORS
+    expect(getColorName('565656')).toBe('Titan Gray');
+  });
+
+  it('falls back to HSL for unknown hex colors', () => {
+    // A hex that is not in the Bambu database
+    expect(getColorName('123456')).toBe('Blue');
+  });
+
+  it('returns "Unknown" for empty string', () => {
+    expect(getColorName('')).toBe('Unknown');
+  });
+
+  it('handles hex with # prefix', () => {
+    expect(getColorName('#5f6367')).toBe('Titan Gray');
+  });
+});
+
+describe('resolveSpoolColorName', () => {
+  it('returns readable color name directly', () => {
+    expect(resolveSpoolColorName('Titan Gray', '5F6367FF')).toBe('Titan Gray');
+  });
+
+  it('looks up hex when color_name is a Bambu code', () => {
+    expect(resolveSpoolColorName('A06-D0', '5F6367FF')).toBe('Titan Gray');
+  });
+
+  it('returns null when color_name is a code and hex is unknown', () => {
+    expect(resolveSpoolColorName('A99-Z9', '12345600')).toBeNull();
+  });
+});

+ 1 - 1
frontend/src/components/spool-form/constants.ts

@@ -17,7 +17,7 @@ export const DEFAULT_BRANDS = [
 
 // Known filament variants/subtypes
 export const KNOWN_VARIANTS = [
-  'Basic', 'Matte', 'Silk', 'Tough', 'HF', 'High Flow', 'Engineering',
+  'Basic', 'Matte', 'Silk', 'Silk+', 'Tough', 'Tough+', 'HF', 'High Flow', 'Engineering',
   'Galaxy', 'Glow', 'Marble', 'Metal', 'Rainbow', 'Sparkle', 'Wood',
   'Translucent', 'Transparent', 'Clear', 'Lite', 'Pro', 'Plus', 'Max',
   'Super', 'Ultra', 'Flex', 'Soft', 'Hard', 'Strong', 'Impact',

+ 4 - 4
frontend/src/pages/PrintersPage.tsx

@@ -77,7 +77,7 @@ import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers';
 import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
-import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
+import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -3302,7 +3302,7 @@ function PrinterCard({
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   profile: slotPreset?.preset_name || cloudInfo?.name || inventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,
-                                  colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
+                                  colorName: getBambuColorName(tray.tray_id_name) || getColorName(tray.tray_color || ''),
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   fillLevel: effectiveFill,
@@ -3544,7 +3544,7 @@ function PrinterCard({
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           profile: slotPreset?.preset_name || cloudInfo?.name || htInventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,
-                          colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
+                          colorName: getBambuColorName(tray.tray_id_name) || getColorName(tray.tray_color || ''),
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           fillLevel: htEffectiveFill,
@@ -3888,7 +3888,7 @@ function PrinterCard({
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 profile: extSlotPreset?.preset_name || extCloudInfo?.name || extInventoryAssignment?.spool?.slicer_filament_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
-                                colorName: getBambuColorName(extTray.tray_id_name) || hexToColorName(extTray.tray_color),
+                                colorName: getBambuColorName(extTray.tray_id_name) || getColorName(extTray.tray_color || ''),
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),
                                 fillLevel: extEffectiveFill,

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DC3roes3.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DKoL3gto.js"></script>
+    <script type="module" crossorigin src="/assets/index-DC3roes3.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BGA3I7Jb.css">
   </head>
   <body>

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