Przeglądaj źródła

fix: resolve PFUS preset IDs causing slicer slot resets + fill level for new spools

BambuStudio actively resets AMS slots configured with unrecognized PFUS*
(user-local) preset IDs. Replace PFUS* with generic Bambu filament IDs
(e.g. GFL99 for PLA) in both the slot configure and inventory assignment
endpoints. When the slot already has a recognized cloud-synced preset for
the same material, reuse it to preserve K-profile calibration.

Also fix fill level bar not showing for brand new spools (weight_used=0)
by changing the condition from weight_used > 0 to weight_used != null.
maziggy 3 miesięcy temu
rodzic
commit
7b39026429

+ 3 - 0
CHANGELOG.md

@@ -21,6 +21,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.
 
 - **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
+- **AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots** — When assigning a spool with a user-local `PFUS*` preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replaces `PFUS*` IDs with generic Bambu filament IDs (e.g., `GFL99` for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g., `P4d64437`), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow.
+- **Fill Level Bar Missing for Brand New Spools** — Spools with `weight_used = 0` (brand new, never printed) showed no fill level bar on the printer card. The condition checked `weight_used > 0` instead of `weight_used != null`, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (`null`).
 - **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
 - **npm audit: fix minimatch ReDoS finding** — Added an npm override for `minimatch@^10.2.1` in `package.json` to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.
 - **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
@@ -38,6 +40,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### Improved
 - **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
 - **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
+- **Tray Info Idx Resolution Test Coverage** — Added 12 backend integration tests for PFUS→generic tray_info_idx resolution across both the slot configure and inventory assignment endpoints, plus 10 frontend unit tests for the fill level calculation logic.
 
 
 ## [0.2.0] - 2026-02-17

+ 46 - 1
backend/app/api/routes/inventory.py

@@ -640,9 +640,10 @@ async def assign_spool(
     if spool.archived_at:
         raise HTTPException(400, "Cannot assign an archived spool")
 
-    # 2. Get current AMS tray state for fingerprint
+    # 2. Get current AMS tray state for fingerprint + existing filament ID
     fingerprint_color = None
     fingerprint_type = None
+    current_tray_info_idx = ""
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
         if data.ams_id == 255:
@@ -653,6 +654,7 @@ async def assign_spool(
                 if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                     fingerprint_color = vt.get("tray_color", "")
                     fingerprint_type = vt.get("tray_type", "")
+                    current_tray_info_idx = vt.get("tray_info_idx", "")
                     break
         else:
             ams_data = state.raw_data.get("ams", {})
@@ -671,6 +673,7 @@ async def assign_spool(
             if tray:
                 fingerprint_color = tray.get("tray_color", "")
                 fingerprint_type = tray.get("tray_type", "")
+                current_tray_info_idx = tray.get("tray_info_idx", "")
 
     # 3. Upsert assignment (replace if same printer+ams+tray)
     existing = await db.execute(
@@ -709,6 +712,48 @@ async def assign_spool(
             tray_info_idx = spool.slicer_filament or ""
             setting_id = ""
 
+            # Resolve tray_info_idx for the MQTT command.
+            # Priority:
+            #   1. Reuse the slot's existing tray_info_idx if it's a recognised
+            #      preset (GF*/P*) for the same material — this preserves the
+            #      slicer's K-profile association.
+            #   2. Replace PFUS* (user-local IDs unknown to other slicers) and
+            #      empty IDs with a generic Bambu filament ID.
+            if (
+                current_tray_info_idx
+                and not current_tray_info_idx.startswith("PFUS")
+                and fingerprint_type
+                and fingerprint_type.upper() == tray_type.upper()
+            ):
+                logger.info(
+                    "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
+                    current_tray_info_idx,
+                    tray_type,
+                )
+                tray_info_idx = current_tray_info_idx
+            elif (not tray_info_idx or tray_info_idx.startswith("PFUS")) and tray_type:
+                _GENERIC_IDS = {
+                    "PLA": "GFL99",
+                    "PETG": "GFG99",
+                    "ABS": "GFB99",
+                    "ASA": "GFB98",
+                    "PC": "GFC99",
+                    "PA": "GFN99",
+                    "NYLON": "GFN99",
+                    "TPU": "GFU99",
+                    "PVA": "GFS99",
+                    "HIPS": "GFS98",
+                    "PLA-CF": "GFL98",
+                    "PETG-CF": "GFG98",
+                    "PA-CF": "GFN98",
+                    "PETG HF": "GFG96",
+                }
+                material = tray_type.upper().strip()
+                generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
+                if generic:
+                    logger.info("Spool assign: replacing %r with generic %r", tray_info_idx, generic)
+                    tray_info_idx = generic
+
             # Temperature: use spool overrides if set, else material defaults
             temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
             if spool.nozzle_temp_min is not None:

+ 99 - 42
backend/app/api/routes/printers.py

@@ -1642,53 +1642,110 @@ async def configure_ams_slot(
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
 
-    # Detect RFID spool before sending commands
-    is_rfid_spool = False
-    state = printer_manager.get_status(printer_id)
-    if state and state.raw_data:
-        from backend.app.api.routes.inventory import _find_tray_in_ams_data
-        from backend.app.services.spool_tag_matcher import is_valid_tag
+    # Resolve tray_info_idx for the MQTT command.
+    # PFUS* IDs are user-local preset IDs that only the originating slicer
+    # recognises.  When Bambuddy sends them, BambuStudio sees an unknown
+    # filament and actively resets the slot to empty.
+    # Priority:
+    #   1. If the slot already has a recognised preset (GF*/P*) for the same
+    #      material, reuse it — preserves slicer's K-profile association.
+    #   2. Replace PFUS* / empty with a generic Bambu filament ID.
+    _GENERIC_FILAMENT_IDS = {
+        "PLA": "GFL99",
+        "PETG": "GFG99",
+        "ABS": "GFB99",
+        "ASA": "GFB98",
+        "PC": "GFC99",
+        "PA": "GFN99",
+        "NYLON": "GFN99",
+        "TPU": "GFU99",
+        "PVA": "GFS99",
+        "HIPS": "GFS98",
+        "PLA-CF": "GFL98",
+        "PETG-CF": "GFG98",
+        "PA-CF": "GFN98",
+        "PETG HF": "GFG96",
+    }
+    effective_tray_info_idx = tray_info_idx
 
-        ams_data = state.raw_data.get("ams", {})
-        ams_list = (
-            ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
-        )
-        current_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
-        if current_tray:
-            is_rfid_spool = is_valid_tag(
-                current_tray.get("tag_uid", ""),
-                current_tray.get("tray_uuid", ""),
+    if not tray_info_idx or tray_info_idx.startswith("PFUS"):
+        # Check if the slot already has a recognised preset for same material
+        current_tray_info_idx = ""
+        current_tray_type = ""
+        state = printer_manager.get_status(printer_id)
+        if state and state.raw_data:
+            from backend.app.api.routes.inventory import _find_tray_in_ams_data
+
+            if ams_id == 255:
+                vt_tray = state.raw_data.get("vt_tray") or []
+                ext_id = tray_id + 254
+                for vt in vt_tray:
+                    if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
+                        current_tray_info_idx = vt.get("tray_info_idx", "")
+                        current_tray_type = vt.get("tray_type", "")
+                        break
+            else:
+                ams_data = state.raw_data.get("ams", {})
+                ams_list = (
+                    ams_data.get("ams", [])
+                    if isinstance(ams_data, dict)
+                    else ams_data
+                    if isinstance(ams_data, list)
+                    else []
+                )
+                cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
+                if cur_tray:
+                    current_tray_info_idx = cur_tray.get("tray_info_idx", "")
+                    current_tray_type = cur_tray.get("tray_type", "")
+
+        if (
+            current_tray_info_idx
+            and not current_tray_info_idx.startswith("PFUS")
+            and current_tray_type
+            and current_tray_type.upper() == tray_type.upper()
+        ):
+            logger.info(
+                "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
+                current_tray_info_idx,
+                tray_type,
             )
+            effective_tray_info_idx = current_tray_info_idx
+        elif tray_type:
+            material = tray_type.upper().strip()
+            generic = (
+                _GENERIC_FILAMENT_IDS.get(material)
+                or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or ""
+            )
+            if generic:
+                if tray_info_idx:
+                    logger.info("[configure_ams_slot] Replacing user-local %r with generic %r", tray_info_idx, generic)
+                else:
+                    logger.info("[configure_ams_slot] Deriving tray_info_idx=%r from tray_type=%r", generic, tray_type)
+                effective_tray_info_idx = generic
 
     # Send filament setting + K-profile commands
-    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
-
-    if is_rfid_spool:
-        # RFID spool: skip ams_set_filament_setting to preserve RFID state (eye icon).
-        # The firmware already has filament config from the RFID tag.
-        logger.info("[configure_ams_slot] RFID spool detected — skipping ams_set_filament_setting")
-    else:
-        # Non-RFID spool: send filament setting (type, color, temp)
-        # When a K-profile is selected, use the K-profile's filament_id as
-        # tray_info_idx so BambuStudio queries the right PA history table.
-        # But always use the PRESET's setting_id (not the K-profile's) —
-        # BambuStudio uses setting_id to identify the filament preset and
-        # overriding it with the K-profile's setting_id confuses the slicer.
-        effective_tray_info_idx = filament_id_for_kprofile if cali_idx >= 0 else tray_info_idx
-        success = client.ams_set_filament_setting(
-            ams_id=ams_id,
-            tray_id=tray_id,
-            tray_info_idx=effective_tray_info_idx,
-            tray_type=tray_type,
-            tray_sub_brands=tray_sub_brands,
-            tray_color=tray_color,
-            nozzle_temp_min=nozzle_temp_min,
-            nozzle_temp_max=nozzle_temp_max,
-            setting_id=setting_id,
-        )
+    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
+
+    # Always send ams_set_filament_setting — the user explicitly clicked
+    # "Configure Slot", so honor that.  Previous versions skipped this for
+    # RFID-tagged slots to preserve the slicer eye icon, but printers cache
+    # stale tag_uid/tray_uuid after a BL spool is removed, causing the check
+    # to false-positive on non-RFID slots and silently drop the command.
+    success = client.ams_set_filament_setting(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_type=tray_type,
+        tray_sub_brands=tray_sub_brands,
+        tray_color=tray_color,
+        nozzle_temp_min=nozzle_temp_min,
+        nozzle_temp_max=nozzle_temp_max,
+        setting_id=setting_id,
+    )
 
-        if not success:
-            raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
 
     # Method 1: Select existing calibration profile by cali_idx
     # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,

+ 225 - 0
backend/tests/integration/test_inventory_assign.py

@@ -0,0 +1,225 @@
+"""Integration tests for inventory spool assignment — tray_info_idx resolution.
+
+Tests that PFUS* user-local preset IDs are replaced with generic Bambu IDs,
+and that existing recognised presets on slots are reused when the material matches.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    """Factory to create test spools."""
+    _counter = [0]
+
+    async def _create_spool(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Devil Design",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+            "slicer_filament": "PFUS9ac902733670a9",
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create_spool
+
+
+def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
+    """Build a mock printer status with optional AMS/nozzle data."""
+    status = MagicMock()
+    raw = {}
+    if ams_data is not None:
+        raw["ams"] = {"ams": ams_data}
+    if vt_tray is not None:
+        raw["vt_tray"] = vt_tray
+    status.raw_data = raw
+    status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
+    status.ams_extruder_map = ams_extruder_map
+    return status
+
+
+class TestAssignSpoolTrayInfoIdx:
+    """Tests for tray_info_idx resolution during spool assignment."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfus_replaced_with_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """PFUS* user-local IDs are replaced with generic Bambu IDs."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": ""}]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_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": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reuses_existing_recognised_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """When slot already has a recognised preset for same material, reuse it."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot already configured by slicer with cloud-synced preset
+        status = _make_mock_status(
+            ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_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": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Should reuse the slicer's cloud-synced ID
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_different_material_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """When slot has a preset for a DIFFERENT material, use generic ID."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot currently has PLA but spool is PETG
+        status = _make_mock_status(
+            ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_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": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Standard GF* IDs from spool.slicer_filament are used directly."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_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
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Spool with no slicer_filament gets a generic ID from material type."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament=None, material="ABS")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_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
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_existing_pfus_on_slot_not_reused(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """A PFUS* ID already on the slot should NOT be reused (it's also user-local)."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot has a PFUS* ID from some previous config
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_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
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Should NOT reuse the PFUS on the slot — use generic instead
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"

+ 224 - 0
backend/tests/integration/test_printers_api.py

@@ -580,6 +580,230 @@ class TestAMSRefreshAPI:
             assert "unload" in response.json()["detail"].lower()
 
 
+class TestConfigureAMSSlotAPI:
+    """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/0/configure",
+                params={
+                    "tray_info_idx": "GFL99",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
+        """Standard Bambu GF* filament IDs are sent as-is."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = None  # No existing state
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_replaced_with_generic(self, async_client: AsyncClient, printer_factory):
+        """PFUS* user-local IDs are replaced with generic Bambu IDs."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}  # No existing tray data
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Devil Design PLA",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_reuses_existing_slot_id(self, async_client: AsyncClient, printer_factory):
+        """When slot already has a recognised preset for same material, reuse it."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        # Simulate slot already configured by slicer with cloud-synced preset
+        mock_status = MagicMock()
+        mock_status.raw_data = {
+            "ams": {
+                "ams": [
+                    {
+                        "id": 2,
+                        "tray": [
+                            {
+                                "id": 3,
+                                "tray_info_idx": "P4d64437",
+                                "tray_type": "PLA",
+                                "tray_color": "FF0000FF",
+                            }
+                        ],
+                    }
+                ]
+            }
+        }
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Devil Design PLA",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Should reuse the slicer's P4d64437, not replace with GFL99
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_different_material_uses_generic(self, async_client: AsyncClient, printer_factory):
+        """When slot has a recognised preset but for DIFFERENT material, use generic."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        # Slot currently has PETG but user is configuring PLA
+        mock_status = MagicMock()
+        mock_status.raw_data = {
+            "ams": {
+                "ams": [
+                    {
+                        "id": 2,
+                        "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
+                    }
+                ]
+            }
+        }
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Devil Design PLA",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Different material → should NOT reuse PETG ID, use generic PLA
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
+        """Empty tray_info_idx (local preset) is replaced with generic."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "",
+                    "tray_type": "PETG",
+                    "tray_sub_brands": "PETG Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 220,
+                    "nozzle_temp_max": 260,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
+
+
 class TestSkipObjectsAPI:
     """Integration tests for skip objects endpoints."""
 

+ 81 - 0
frontend/src/__tests__/pages/PrintersPageFillLevel.test.ts

@@ -0,0 +1,81 @@
+/**
+ * Tests for inventory fill level calculation logic.
+ *
+ * The fill level is calculated inline in PrintersPage.tsx as:
+ *   if (sp && sp.label_weight > 0 && sp.weight_used != null)
+ *     → Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100)
+ *   else → null
+ *
+ * These tests validate the calculation logic directly, particularly the
+ * fix for weight_used == null (brand new spools) and weight_used == 0.
+ */
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Mirrors the inline inventoryFill calculation from PrintersPage.tsx.
+ * Extracted here for testability.
+ */
+function computeInventoryFill(spool: { label_weight: number; weight_used: number | null } | null): number | null {
+  const sp = spool;
+  if (sp && sp.label_weight > 0 && sp.weight_used != null) {
+    return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+  }
+  return null;
+}
+
+describe('inventoryFill calculation', () => {
+  it('returns 100% for brand new spool with weight_used = 0', () => {
+    expect(computeInventoryFill({ label_weight: 1000, weight_used: 0 })).toBe(100);
+  });
+
+  it('returns null for brand new spool with weight_used = null', () => {
+    // weight_used null means "never tracked" — we can't compute fill
+    expect(computeInventoryFill({ label_weight: 1000, weight_used: null })).toBeNull();
+  });
+
+  it('returns correct percentage for partially used spool', () => {
+    expect(computeInventoryFill({ label_weight: 1000, weight_used: 250 })).toBe(75);
+  });
+
+  it('returns 0% for fully used spool', () => {
+    expect(computeInventoryFill({ label_weight: 1000, weight_used: 1000 })).toBe(0);
+  });
+
+  it('returns 0% when weight_used exceeds label_weight', () => {
+    // Math.max(0, ...) prevents negative fill
+    expect(computeInventoryFill({ label_weight: 1000, weight_used: 1200 })).toBe(0);
+  });
+
+  it('returns null when no spool data', () => {
+    expect(computeInventoryFill(null)).toBeNull();
+  });
+
+  it('returns null when label_weight is 0', () => {
+    expect(computeInventoryFill({ label_weight: 0, weight_used: 0 })).toBeNull();
+  });
+
+  it('rounds to nearest integer', () => {
+    // 1000 - 333 = 667, 667/1000 = 66.7 → 67
+    expect(computeInventoryFill({ label_weight: 1000, weight_used: 333 })).toBe(67);
+  });
+});
+
+describe('inventoryFill: old bug — weight_used > 0 vs weight_used != null', () => {
+  /**
+   * The old condition was: sp.weight_used > 0
+   * This caused brand-new spools (weight_used=0) to show no fill bar.
+   * The fix changed it to: sp.weight_used != null
+   */
+  it('weight_used = 0 now shows fill (was broken with > 0 check)', () => {
+    // Old: 0 > 0 = false → null (no fill bar)
+    // New: 0 != null = true → 100% fill
+    const result = computeInventoryFill({ label_weight: 1000, weight_used: 0 });
+    expect(result).toBe(100);
+    expect(result).not.toBeNull();
+  });
+
+  it('weight_used = 0.1 shows fill (small usage)', () => {
+    const result = computeInventoryFill({ label_weight: 1000, weight_used: 0.1 });
+    expect(result).toBe(100); // rounds to 100 since 0.1g from 1000g is negligible
+  });
+});

+ 20 - 2
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -322,8 +322,26 @@ export function ConfigureAmsSlotModal({
       let settingId: string;
 
       if (isLocal) {
-        // Local presets have no Bambu Cloud mapping
-        trayInfoIdx = '';
+        // Local presets have no Bambu Cloud setting_id, but need a valid
+        // tray_info_idx for the printer to recognize the filament type.
+        // Map the material type to the closest generic Bambu filament ID.
+        const material = (localPreset?.filament_type || parsed.material || '').toUpperCase();
+        const GENERIC_IDS: Record<string, string> = {
+          'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
+          'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
+          'ABS': 'GFB99', 'ASA': 'GFB98',
+          'PC': 'GFC99',
+          'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
+          'TPU': 'GFU99',
+          'PVA': 'GFS99', 'HIPS': 'GFS98',
+          'PE': 'GFP99', 'PP': 'GFP97',
+        };
+        // Try exact match first, then base material (strip suffixes like "-CF", "+", " HF")
+        trayInfoIdx = GENERIC_IDS[material]
+          || GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
+          || GENERIC_IDS[material.replace(/\+$/, '')]
+          || GENERIC_IDS[material.split(/[-\s]/)[0]]
+          || '';
         settingId = '';
       } else if (isBuiltin) {
         // Built-in presets use the filament_id directly as tray_info_idx

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

@@ -2770,7 +2770,7 @@ function PrinterCard({
                                 const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
                                 const inventoryFill = (() => {
                                   const sp = inventoryAssignment?.spool;
-                                  if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                                  if (sp && sp.label_weight > 0 && sp.weight_used != null) {
                                     return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
                                   }
                                   return null;
@@ -2995,7 +2995,7 @@ function PrinterCard({
                         const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId);
                         const htInventoryFill = (() => {
                           const sp = htInventoryAssignment?.spool;
-                          if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                          if (sp && sp.label_weight > 0 && sp.weight_used != null) {
                             return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
                           }
                           return null;
@@ -3253,7 +3253,7 @@ function PrinterCard({
                               const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
                               const extInventoryFill = (() => {
                                 const sp = extInventoryAssignment?.spool;
-                                if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                                if (sp && sp.label_weight > 0 && sp.weight_used != null) {
                                   return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
                                 }
                                 return null;

Plik diff jest za duży
+ 0 - 0
static/assets/index-jjif866q.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-ADBQB8en.js"></script>
+    <script type="module" crossorigin src="/assets/index-jjif866q.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DPf6CLKV.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików