Browse Source

[Fix] Spool assignment UI shows wrong filament preset name (#681)

  After assigning a spool to an AMS slot, the Bambuddy UI could show the
  wrong filament preset (e.g. "Bambu PLA Matte" instead of "Bambu PLA
  Silk") even though the printer was configured correctly.

  Two bugs:
  1. AssignSpoolModal (PrintersPage hover card path) never saved the slot
     preset mapping to the DB, so the display fell back to the old/stale
     mapping from a previous manual configuration.
  2. AssignToAmsModal (SpoolBuddy path) constructed the preset name from
     spool.material + spool.subtype ("PLA Silk") instead of using the
     authoritative spool.slicer_filament_name ("Bambu PLA Silk").

  Fix: the backend now saves the slot preset mapping in assign_spool()
  after successful MQTT configuration, using slicer_filament_name as the
  display name. This covers both frontend paths and ensures the correct
  name is always stored.
maziggy 2 months ago
parent
commit
dcdebef9a8

+ 1 - 1
CHANGELOG.md

@@ -75,7 +75,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **HMS Notifications for Unknown/Phantom Error Codes** — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from "notify all, suppress specific codes" to "only notify for errors with known descriptions", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.
 - **Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Three bugs in the ethernet badge feature: (1) `home_flag` bit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded `-90 dBm` sentinel, while real WiFi signals vary. (2) The lazy import used `from app.utils.printer_models` which crashes with `ModuleNotFoundError` in paho-mqtt's background thread (correct path is `backend.app.utils.printer_models`). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox.
 - **Inventory Usage Tracker Missing External Spool Mapping** ([#677](https://github.com/maziggy/bambuddy/issues/677)) — When all higher-priority slot-to-tray mapping methods failed (MQTT mapping, print command mapping, queue mapping, color matching), the internal inventory usage tracker fell back to `slot_id - 1` which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr.
-- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for "Generic PLA Silk"). Reported by @peter-k-de.
+- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for "Generic PLA Silk"). Also fixed the UI showing a stale preset name (e.g. "Bambu PLA Matte" instead of "Bambu PLA Silk") after assignment — the slot preset mapping was only saved when assigning via SpoolBuddy, not via the PrintersPage hover card. The backend now saves the slot preset mapping using the spool's authoritative `slicer_filament_name` after every successful MQTT configuration, regardless of which UI path triggered the assignment. Reported by @peter-k-de, @RosdasHH.
 - **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
 - **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
 - **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.

+ 52 - 0
backend/app/api/routes/inventory.py

@@ -1032,6 +1032,58 @@ async def assign_spool(
                 spool.id,
                 data.printer_id,
             )
+
+            # Save slot preset mapping so the UI shows the correct preset name.
+            # Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.
+            try:
+                from backend.app.models.slot_preset import SlotPresetMapping
+
+                preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
+                preset_source = "cloud"
+                if sf:
+                    base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
+                    try:
+                        local_id = int(base_sf_mapping)
+                        preset_id_to_save = f"local_{local_id}"
+                        preset_source = "local"
+                    except (ValueError, TypeError):
+                        # Cloud or builtin preset — convert filament_id to setting_id
+                        preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
+                else:
+                    preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+                if preset_id_to_save:
+                    existing_mapping = await db.execute(
+                        select(SlotPresetMapping).where(
+                            SlotPresetMapping.printer_id == data.printer_id,
+                            SlotPresetMapping.ams_id == data.ams_id,
+                            SlotPresetMapping.tray_id == data.tray_id,
+                        )
+                    )
+                    mapping = existing_mapping.scalar_one_or_none()
+                    if mapping:
+                        mapping.preset_id = preset_id_to_save
+                        mapping.preset_name = preset_name
+                        mapping.preset_source = preset_source
+                    else:
+                        mapping = SlotPresetMapping(
+                            printer_id=data.printer_id,
+                            ams_id=data.ams_id,
+                            tray_id=data.tray_id,
+                            preset_id=preset_id_to_save,
+                            preset_name=preset_name,
+                            preset_source=preset_source,
+                        )
+                        db.add(mapping)
+                    await db.commit()
+                    logger.info(
+                        "Saved slot preset mapping: preset_id=%r, preset_name=%r",
+                        preset_id_to_save,
+                        preset_name,
+                    )
+            except Exception as e:
+                logger.warning("Failed to save slot preset mapping: %s", e)
+
     except Exception as e:
         logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
 

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

@@ -324,3 +324,147 @@ class TestAssignSpoolTrayInfoIdx:
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             # Slot's specific preset is reused when spool has no own preset
             assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
+
+
+class TestAssignSpoolPresetMapping:
+    """Tests that assign_spool saves the slot preset mapping for correct UI display."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preset_mapping_saved_with_slicer_filament_name(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(
+            slicer_filament="GFA05",
+            slicer_filament_name="Bambu PLA Silk",
+            material="PLA",
+            subtype="Silk",
+            brand="Bambu",
+        )
+
+        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": 1},
+            )
+
+        assert response.status_code == 200
+
+        # Verify via the slot presets API
+        presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
+        assert presets_resp.status_code == 200
+        presets = presets_resp.json()
+        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
+        assert "1" in presets
+        # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
+        assert presets["1"]["preset_name"] == "Bambu PLA Silk"
+        assert presets["1"]["preset_id"] == "GFSA05"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preset_mapping_overwrites_old_mapping(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """Assigning a new spool overwrites the old slot preset mapping."""
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        printer = await printer_factory(name="X1C")
+
+        # Pre-existing mapping (e.g. from previous manual configuration)
+        old_mapping = SlotPresetMapping(
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=2,
+            preset_id="GFSA01",
+            preset_name="Bambu PLA Matte",
+            preset_source="cloud",
+        )
+        db_session.add(old_mapping)
+        await db_session.commit()
+
+        # Assign a "Generic PLA Silk" spool to same slot
+        spool = await spool_factory(
+            slicer_filament="GFL96",
+            slicer_filament_name="Generic PLA Silk",
+            material="PLA",
+            subtype="Silk",
+        )
+
+        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": 2},
+            )
+
+        assert response.status_code == 200
+
+        # Verify via the slot presets API to avoid stale session cache
+        presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
+        assert presets_resp.status_code == 200
+        presets = presets_resp.json()
+        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
+        assert "2" in presets
+        # Old "Bambu PLA Matte" must be overwritten
+        assert presets["2"]["preset_name"] == "Generic PLA Silk"
+        assert presets["2"]["preset_id"] == "GFSL96"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preset_mapping_fallback_to_tray_sub_brands(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """When slicer_filament_name is null, falls back to tray_sub_brands."""
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        printer = await printer_factory(name="A1M")
+        spool = await spool_factory(
+            slicer_filament="GFL05",
+            slicer_filament_name=None,
+            material="PLA",
+            subtype="Matte",
+            brand="Overture",
+        )
+
+        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
+
+        # Verify via the slot presets API
+        presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
+        assert presets_resp.status_code == 200
+        presets = presets_resp.json()
+        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
+        assert "0" in presets
+        # Falls back to tray_sub_brands ("Overture PLA Matte")
+        assert presets["0"]["preset_name"] == "Overture PLA Matte"

+ 3 - 19
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -100,25 +100,9 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
         tray_id: trayId,
       });
 
-      // Save slot preset mapping so ConfigureAmsSlotModal can show the preset
-      // (same as ConfigureAmsSlotModal does after configuring a slot)
-      if (spool.slicer_filament) {
-        const base = spool.slicer_filament.includes('_')
-          ? spool.slicer_filament.split('_')[0]
-          : spool.slicer_filament;
-        // Convert filament_id (GFL05) → setting_id (GFSL05); user presets (P*) pass through
-        const presetId = base.startsWith('GF') && !base.startsWith('GFS')
-          ? 'GFS' + base.slice(2)
-          : base;
-        const presetName = spool.subtype
-          ? `${spool.material} ${spool.subtype}`
-          : spool.material;
-        try {
-          await api.saveSlotPreset(printerId, amsId, trayId, presetId, presetName, 'cloud');
-        } catch (e) {
-          console.warn('Failed to save slot preset mapping:', e);
-        }
-      }
+      // Slot preset mapping is now saved by the backend in assign_spool()
+      // after successful MQTT configuration, using the authoritative
+      // slicer_filament_name from the spool record.
     },
     onSuccess: () => {
       setStatusType('success');

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D1Pa7vjK.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-CQ7KOjvZ.js"></script>
+    <script type="module" crossorigin src="/assets/index-D1Pa7vjK.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-hKLgfdEQ.css">
   </head>
   <body>

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