Browse Source

Show filament subtypes in all mapping dropdowns (#624)

  All filament mapping dropdowns (single-printer, multi-printer, and
  "Print to Any" model-based) showed only the base material type (e.g.
  "PLA") without distinguishing subtypes like "PLA Basic" vs "PLA Matte",
  making entries with different subtypes but same color look identical.

  Backend: Add tray_sub_brands to available-filaments response and include
  it in the dedup key so different subtypes of the same color appear as
  separate entries.

  Frontend: Show tray_sub_brands in FilamentMapping, FilamentOverride, and
  PrinterSelector dropdowns, falling back to base type when unset. Add
  traySubBrands field to LoadedFilament interface.
maziggy 2 months ago
parent
commit
940810cba3

+ 1 - 0
CHANGELOG.md

@@ -17,6 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
 - **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
 - **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
 - **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
 - **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
 - **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
+- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.
 
 
 ## [0.2.2b1] - 2026-03-03
 ## [0.2.2b1] - 2026-03-03
 
 

+ 11 - 13
backend/app/api/routes/printers.py

@@ -122,8 +122,8 @@ async def get_available_filaments(
         return []
         return []
 
 
     # Collect filaments from all matching printers
     # Collect filaments from all matching printers
-    # Dedup key includes extruder_id so same color on different nozzles appears separately
-    seen: set[tuple[str, str, int | None]] = set()  # (type_upper, color_normalized, extruder_id)
+    # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately
+    seen: set[tuple[str, str, str, int | None]] = set()  # (type_upper, color_normalized, sub_brands_upper, extruder_id)
     filaments = []
     filaments = []
 
 
     for printer in printers_list:
     for printer in printers_list:
@@ -147,8 +147,9 @@ async def get_available_filaments(
                 hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
                 hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
                 color = f"#{hex_color}"
                 color = f"#{hex_color}"
                 tray_info_idx = tray.get("tray_info_idx", "")
                 tray_info_idx = tray.get("tray_info_idx", "")
+                tray_sub_brands = tray.get("tray_sub_brands", "") or ""
 
 
-                key = (tray_type.upper(), hex_color.lower(), extruder_id)
+                key = (tray_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
                 if key not in seen:
                 if key not in seen:
                     seen.add(key)
                     seen.add(key)
                     filaments.append(
                     filaments.append(
@@ -156,6 +157,7 @@ async def get_available_filaments(
                             "type": tray_type,
                             "type": tray_type,
                             "color": color,
                             "color": color,
                             "tray_info_idx": tray_info_idx,
                             "tray_info_idx": tray_info_idx,
+                            "tray_sub_brands": tray_sub_brands,
                             "extruder_id": extruder_id,
                             "extruder_id": extruder_id,
                         }
                         }
                     )
                     )
@@ -169,10 +171,11 @@ async def get_available_filaments(
             hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
             hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
             color = f"#{hex_color}"
             color = f"#{hex_color}"
             tray_info_idx = vt.get("tray_info_idx", "")
             tray_info_idx = vt.get("tray_info_idx", "")
+            tray_sub_brands = vt.get("tray_sub_brands", "") or ""
             vt_id = int(vt.get("id", 254))
             vt_id = int(vt.get("id", 254))
             extruder_id = (255 - vt_id) if ams_extruder_map else None
             extruder_id = (255 - vt_id) if ams_extruder_map else None
 
 
-            key = (vt_type.upper(), hex_color.lower(), extruder_id)
+            key = (vt_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
             if key not in seen:
             if key not in seen:
                 seen.add(key)
                 seen.add(key)
                 filaments.append(
                 filaments.append(
@@ -180,6 +183,7 @@ async def get_available_filaments(
                         "type": vt_type,
                         "type": vt_type,
                         "color": color,
                         "color": color,
                         "tray_info_idx": tray_info_idx,
                         "tray_info_idx": tray_info_idx,
+                        "tray_sub_brands": tray_sub_brands,
                         "extruder_id": extruder_id,
                         "extruder_id": extruder_id,
                     }
                     }
                 )
                 )
@@ -1989,9 +1993,7 @@ async def get_ams_labels(
     # Fetch labels for all known serials
     # Fetch labels for all known serials
     labels: dict[int, str] = {}
     labels: dict[int, str] = {}
     if serials_to_query:
     if serials_to_query:
-        result = await db.execute(
-            select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query))
-        )
+        result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))
         for lbl in result.scalars().all():
         for lbl in result.scalars().all():
             aid = serial_to_ams_id.get(lbl.ams_serial_number)
             aid = serial_to_ams_id.get(lbl.ams_serial_number)
             if aid is not None:
             if aid is not None:
@@ -2041,9 +2043,7 @@ async def save_ams_label(
     stripped = body.ams_serial.strip() if body.ams_serial else ""
     stripped = body.ams_serial.strip() if body.ams_serial else ""
     serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
     serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
 
 
-    result = await db.execute(
-        select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key)
-    )
+    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
     existing = result.scalar_one_or_none()
     existing = result.scalar_one_or_none()
 
 
     if existing:
     if existing:
@@ -2068,9 +2068,7 @@ async def delete_ams_label(
     stripped = ams_serial.strip() if ams_serial else ""
     stripped = ams_serial.strip() if ams_serial else ""
     serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
     serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
 
 
-    result = await db.execute(
-        select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key)
-    )
+    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
     existing = result.scalar_one_or_none()
     existing = result.scalar_one_or_none()
 
 
     if existing:
     if existing:

+ 219 - 0
backend/tests/integration/test_available_filaments.py

@@ -0,0 +1,219 @@
+"""Integration tests for GET /api/v1/printers/available-filaments endpoint.
+
+Tests that the endpoint returns deduplicated filaments with tray_sub_brands,
+correctly distinguishing subtypes like "PLA Basic" vs "PLA Matte".
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _make_mock_status(ams_data: list, vt_tray: list | None = None, ams_extruder_map: dict | None = None) -> MagicMock:
+    """Create a mock printer status with raw_data containing AMS info."""
+    status = MagicMock()
+    raw = {"ams": ams_data}
+    if vt_tray is not None:
+        raw["vt_tray"] = vt_tray
+    if ams_extruder_map is not None:
+        raw["ams_extruder_map"] = ams_extruder_map
+    else:
+        raw["ams_extruder_map"] = {}
+    status.raw_data = raw
+    return status
+
+
+class TestAvailableFilaments:
+    """Tests for /api/v1/printers/available-filaments endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_tray_sub_brands(self, async_client: AsyncClient, printer_factory):
+        """Verify tray_sub_brands is included in the response."""
+        await printer_factory(name="Test Printer", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "000000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        },
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["tray_sub_brands"] == "PLA Basic"
+        assert data[0]["type"] == "PLA"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dedup_distinguishes_subtypes(self, async_client: AsyncClient, printer_factory):
+        """PLA Basic Black and PLA Matte Black should be separate entries."""
+        await printer_factory(name="Printer 1", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "000000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        },
+                        {
+                            "id": 1,
+                            "tray_type": "PLA",
+                            "tray_color": "000000FF",
+                            "tray_info_idx": "GFL05",
+                            "tray_sub_brands": "PLA Matte",
+                        },
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        # Same type + color but different tray_sub_brands → 2 entries
+        assert len(data) == 2
+        sub_brands = {d["tray_sub_brands"] for d in data}
+        assert sub_brands == {"PLA Basic", "PLA Matte"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dedup_same_subtype_same_color(self, async_client: AsyncClient, printer_factory):
+        """Same subtype + same color across two printers should be deduped to one entry."""
+        await printer_factory(name="Printer 1", model="X1C")
+        await printer_factory(name="Printer 2", model="X1C")
+
+        status1 = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "FF0000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        }
+                    ],
+                },
+            ]
+        )
+        status2 = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "FF0000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        }
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.side_effect = [status1, status2]
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_sub_brands_handled(self, async_client: AsyncClient, printer_factory):
+        """Filaments with empty/missing tray_sub_brands should still be returned."""
+        await printer_factory(name="Test Printer", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"},
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["tray_sub_brands"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_spool_includes_sub_brands(self, async_client: AsyncClient, printer_factory):
+        """External spools (vt_tray) should also include tray_sub_brands."""
+        await printer_factory(name="Test Printer", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[],
+            vt_tray=[
+                {
+                    "id": 254,
+                    "tray_type": "PETG",
+                    "tray_color": "00FF00FF",
+                    "tray_info_idx": "GFG00",
+                    "tray_sub_brands": "PETG HF",
+                },
+            ],
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["tray_sub_brands"] == "PETG HF"
+        assert data[0]["type"] == "PETG"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_printers_returns_empty(self, async_client: AsyncClient):
+        """Verify empty list when no printers match the model."""
+        response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        assert response.json() == []

+ 60 - 13
frontend/src/__tests__/components/FilamentOverride.test.tsx

@@ -18,9 +18,9 @@ const defaultFilamentReqs: FilamentReqsData = {
 };
 };
 
 
 const defaultAvailable = [
 const defaultAvailable = [
-  { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: null },
-  { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: null },
-  { type: 'PETG', color: '#0000FF', tray_info_idx: 'GFG00', extruder_id: null },
+  { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+  { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
+  { type: 'PETG', color: '#0000FF', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG Basic', extruder_id: null },
 ];
 ];
 
 
 const mockOnChange = vi.fn();
 const mockOnChange = vi.fn();
@@ -133,9 +133,9 @@ describe('FilamentOverride', () => {
 
 
     it('shows all same-type options regardless of color', () => {
     it('shows all same-type options regardless of color', () => {
       const threeColorAvailable = [
       const threeColorAvailable = [
-        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: null },
-        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: null },
-        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', extruder_id: null },
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
+        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Basic', extruder_id: null },
       ];
       ];
 
 
       render(
       render(
@@ -155,6 +155,53 @@ describe('FilamentOverride', () => {
     });
     });
   });
   });
 
 
+  describe('subtype display', () => {
+    it('shows tray_sub_brands in dropdown options when available', () => {
+      const subtypeAvailable = [
+        { type: 'PLA', color: '#000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic', extruder_id: null },
+        { type: 'PLA', color: '#000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte', extruder_id: null },
+      ];
+
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={subtypeAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = Array.from(select.querySelectorAll('option'));
+      const optionTexts = options.map((o) => o.textContent);
+
+      // Should show "PLA Basic" and "PLA Matte", not just "PLA"
+      expect(optionTexts.some((t) => t?.includes('PLA Basic'))).toBe(true);
+      expect(optionTexts.some((t) => t?.includes('PLA Matte'))).toBe(true);
+    });
+
+    it('falls back to type when tray_sub_brands is empty', () => {
+      const noSubtypeAvailable = [
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: '', extruder_id: null },
+      ];
+
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={noSubtypeAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = Array.from(select.querySelectorAll('option'));
+      // Non-default option should show "PLA" as the type fallback
+      const nonDefaultOptions = options.filter((o) => o.getAttribute('value') !== '');
+      expect(nonDefaultOptions[0].textContent).toContain('PLA');
+    });
+  });
+
   describe('nozzle filtering', () => {
   describe('nozzle filtering', () => {
     it('filters by extruder_id when nozzle_id is set', () => {
     it('filters by extruder_id when nozzle_id is set', () => {
       const nozzleReqs: FilamentReqsData = {
       const nozzleReqs: FilamentReqsData = {
@@ -164,8 +211,8 @@ describe('FilamentOverride', () => {
       };
       };
 
 
       const dualExtruderAvailable = [
       const dualExtruderAvailable = [
-        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: 0 },
-        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: 1 },
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: 1 },
       ];
       ];
 
 
       render(
       render(
@@ -196,8 +243,8 @@ describe('FilamentOverride', () => {
       };
       };
 
 
       const mixedExtruderAvailable = [
       const mixedExtruderAvailable = [
-        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: 0 },
-        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: 1 },
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: 1 },
       ];
       ];
 
 
       render(
       render(
@@ -224,9 +271,9 @@ describe('FilamentOverride', () => {
       };
       };
 
 
       const mixedAvailable = [
       const mixedAvailable = [
-        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: 0 },
-        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: null },
-        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', extruder_id: 1 },
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
+        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Basic', extruder_id: 1 },
       ];
       ];
 
 
       render(
       render(

+ 43 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -86,6 +86,49 @@ describe('buildLoadedFilaments', () => {
     expect(result[0].trayInfoIdx).toBe('');
     expect(result[0].trayInfoIdx).toBe('');
   });
   });
 
 
+  it('includes tray_sub_brands from AMS trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].traySubBrands).toBe('PLA Basic');
+    expect(result[1].traySubBrands).toBe('PLA Matte');
+  });
+
+  it('handles missing tray_sub_brands', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].traySubBrands).toBe('');
+  });
+
+  it('includes tray_sub_brands from external spool', () => {
+    const status = createPrinterStatus(
+      [],
+      [{ tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG HF' }]
+    );
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].traySubBrands).toBe('PETG HF');
+  });
+
   it('extracts external spool with tray_info_idx', () => {
   it('extracts external spool with tray_info_idx', () => {
     const status = createPrinterStatus(
     const status = createPrinterStatus(
       [],
       [],

+ 1 - 1
frontend/src/api/client.ts

@@ -2328,7 +2328,7 @@ export const api = {
   getAvailableFilaments: (model: string, location?: string) => {
   getAvailableFilaments: (model: string, location?: string) => {
     const params = new URLSearchParams({ model });
     const params = new URLSearchParams({ model });
     if (location) params.set('location', location);
     if (location) params.set('location', location);
-    return request<Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);
+    return request<Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);
   },
   },
   getPrinterStatus: (id: number) =>
   getPrinterStatus: (id: number) =>
     request<PrinterStatus>(`/printers/${id}/status`),
     request<PrinterStatus>(`/printers/${id}/status`),

+ 1 - 1
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -223,7 +223,7 @@ export function FilamentMapping({
                       : '';
                       : '';
                     return (
                     return (
                       <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                       <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                        {f.label}: {f.type} ({f.colorName}){remainingLabel}
+                        {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}
                       </option>
                       </option>
                     );
                     );
                 })}
                 })}

+ 4 - 4
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -6,7 +6,7 @@ import type { FilamentReqsData } from './types';
 
 
 interface FilamentOverrideProps {
 interface FilamentOverrideProps {
   filamentReqs: FilamentReqsData | undefined;
   filamentReqs: FilamentReqsData | undefined;
-  availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>;
+  availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>;
   overrides: Record<number, { type: string; color: string }>;
   overrides: Record<number, { type: string; color: string }>;
   onChange: (overrides: Record<number, { type: string; color: string }>) => void;
   onChange: (overrides: Record<number, { type: string; color: string }>) => void;
 }
 }
@@ -26,7 +26,7 @@ export function FilamentOverride({
 
 
   // Index available filaments by type (uppercased) for per-slot filtering
   // Index available filaments by type (uppercased) for per-slot filtering
   const filamentsByType = useMemo(() => {
   const filamentsByType = useMemo(() => {
-    const map: Record<string, Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>> = {};
+    const map: Record<string, Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>> = {};
     for (const f of availableFilaments) {
     for (const f of availableFilaments) {
       const key = f.type.toUpperCase();
       const key = f.type.toUpperCase();
       if (!map[key]) map[key] = [];
       if (!map[key]) map[key] = [];
@@ -104,11 +104,11 @@ export function FilamentOverride({
                 </option>
                 </option>
                 {compatible.map((f, idx) => (
                 {compatible.map((f, idx) => (
                   <option
                   <option
-                    key={`${f.type}-${f.color}-${idx}`}
+                    key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
                     value={`${f.type}|${f.color}`}
                     value={`${f.type}|${f.color}`}
                     className="bg-bambu-dark text-white"
                     className="bg-bambu-dark text-white"
                   >
                   >
-                    {f.type} ({getColorName(f.color)})
+                    {f.tray_sub_brands || f.type} ({getColorName(f.color)})
                   </option>
                   </option>
                 ))}
                 ))}
               </select>
               </select>

+ 1 - 1
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -171,7 +171,7 @@ function InlineMappingEditor({
             {filterFilamentsByNozzle(printerResult.loadedFilaments, req.nozzle_id)
             {filterFilamentsByNozzle(printerResult.loadedFilaments, req.nozzle_id)
               .map((f) => (
               .map((f) => (
               <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
               <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                {f.label}: {f.type} ({f.colorName})
+                {f.label}: {f.traySubBrands || f.type} ({f.colorName})
               </option>
               </option>
             ))}
             ))}
           </select>
           </select>

+ 4 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -35,6 +35,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           trayInfoIdx: tray.tray_info_idx || '',
           trayInfoIdx: tray.tray_info_idx || '',
+          traySubBrands: tray.tray_sub_brands || '',
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
         });
         });
       }
       }
@@ -58,6 +59,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         globalTrayId: trayId,
         globalTrayId: trayId,
         trayInfoIdx: extTray.tray_info_idx || '',
         trayInfoIdx: extTray.tray_info_idx || '',
+        traySubBrands: extTray.tray_sub_brands || '',
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
       });
       });
     }
     }
@@ -206,6 +208,8 @@ export interface LoadedFilament {
   globalTrayId: number;
   globalTrayId: number;
   /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
   /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
   trayInfoIdx?: string;
   trayInfoIdx?: string;
+  /** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
+  traySubBrands?: string;
   /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
   /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
   extruderId?: number;
   extruderId?: number;
 }
 }

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


+ 1 - 1
static/index.html

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

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