Quellcode durchsuchen

Fix SpoolBuddy "Assign to AMS" slot showing empty fields in slicer

  The assign_spool endpoint sent wrong MQTT field values for user presets,
  causing the slicer's AMS slot detail card to show all fields empty.

  Two bugs: (1) cloud API was called with the raw slicer_filament value
  including its version suffix (e.g. PFUS9ac902733670a9_07), returning 404;
  the silent fallback sent setting_id as tray_info_idx instead of the real
  filament_id; (2) no SlotPresetMapping was saved after assignment.

  Now strips version suffixes before cloud lookup, resolves the real
  filament_id via cloud API (with local preset and generic fallbacks),
  includes brand in tray_sub_brands, and saves slot preset mapping.
maziggy vor 2 Monaten
Ursprung
Commit
42eb19afce

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 
 ### Fixed
+- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
 - **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
 - **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
 - **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.

+ 110 - 30
backend/app/api/routes/inventory.py

@@ -732,17 +732,15 @@ async def assign_spool(
         if client:
             # Build filament setting from spool data
             tray_type = spool.material
-            tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
+            tray_sub_brands = (
+                f"{spool.brand} {spool.material} {spool.subtype}".strip()
+                if spool.brand
+                else f"{spool.material} {spool.subtype}"
+                if spool.subtype
+                else spool.material
+            )
             tray_color = spool.rgba or "FFFFFFFF"
-            tray_info_idx, setting_id = normalize_slicer_filament(spool.slicer_filament)
-
-            # Resolve tray_info_idx for the MQTT command.
-            # Priority:
-            #   1. Use the spool's own slicer_filament if set (including
-            #      cloud-synced custom presets like PFUS* / P*).
-            #   2. Reuse the slot's existing tray_info_idx if it's a specific
-            #      (non-generic) preset for the same material.
-            #   3. Fall back to a generic Bambu filament ID.
+
             _GENERIC_IDS = {
                 "PLA": "GFL99",
                 "PETG": "GFG99",
@@ -761,26 +759,108 @@ async def assign_spool(
             }
             _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
 
-            if tray_info_idx:
-                logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
-            elif (
-                current_tray_info_idx
-                and current_tray_info_idx not in _GENERIC_ID_VALUES
-                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 tray_type:
-                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: falling back to generic %r for material %r", generic, tray_type)
-                    tray_info_idx = generic
+            # Resolve tray_info_idx + setting_id for the MQTT command.
+            # Three sources in priority order:
+            #   1. Cloud profile (if cloud connected) — resolve filament_id
+            #      from setting_id via cloud API
+            #   2. Local profile — use generic filament ID for material
+            #   3. Hard-coded fallback — generic Bambu filament IDs
+            tray_info_idx = ""
+            setting_id = ""
+            sf = spool.slicer_filament or ""
+
+            if sf:
+                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
+                base_sf = sf.split("_")[0] if "_" in sf else sf
+                if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+                    # Cloud setting_id — need to resolve real filament_id
+                    # Use base_sf (version suffix stripped) for cloud API + MQTT
+                    setting_id = base_sf
+                    try:
+                        from backend.app.services.bambu_cloud import get_cloud_service
+
+                        cloud = get_cloud_service()
+                        if cloud.is_authenticated:
+                            detail = await cloud.get_setting_detail(base_sf)
+                            if detail.get("filament_id"):
+                                tray_info_idx = detail["filament_id"]
+                                logger.info(
+                                    "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
+                                    tray_info_idx,
+                                    sf,
+                                )
+                                # Use cloud preset name for tray_sub_brands if available
+                                cloud_name = detail.get("name", "")
+                                if cloud_name:
+                                    tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                            elif detail.get("base_id"):
+                                # Derive from base_id (e.g. "GFSL05" → "GFL05")
+                                bid = detail["base_id"].split("_")[0]
+                                if bid.startswith("GFS") and len(bid) >= 5:
+                                    tray_info_idx = f"GF{bid[3:]}"
+                                else:
+                                    tray_info_idx = bid
+                                logger.info(
+                                    "Spool assign: derived filament_id=%r from base_id=%r",
+                                    tray_info_idx,
+                                    detail["base_id"],
+                                )
+                    except Exception as e:
+                        logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
+
+                    if not tray_info_idx:
+                        # Cloud lookup failed — use normalize as fallback
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                elif base_sf.startswith("GF"):
+                    # Official Bambu filament_id (e.g. "GFL05")
+                    tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                    logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
+                else:
+                    # Could be a local preset ID or material type — try local DB
+                    try:
+                        local_id = int(sf)
+                        from backend.app.models.local_preset import LocalPreset as LP
+
+                        lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+                        lp = lp_result.scalar_one_or_none()
+                        if lp:
+                            mat = (lp.filament_type or spool.material or "").upper().strip()
+                            tray_info_idx = (
+                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
+                            )
+                            # Use local preset name for tray_sub_brands
+                            if lp.name:
+                                tray_sub_brands = lp.name.split("@")[0].strip()
+                            logger.info(
+                                "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
+                                local_id,
+                                mat,
+                                tray_info_idx,
+                            )
+                    except (ValueError, TypeError):
+                        # Not a numeric ID — treat as material type string
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+            if not tray_info_idx:
+                # Fallback: reuse slot's existing tray_info_idx or generic ID
+                if (
+                    current_tray_info_idx
+                    and current_tray_info_idx not in _GENERIC_ID_VALUES
+                    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 tray_type:
+                    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: falling back to generic %r for material %r", generic, tray_type)
+                        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))

+ 5 - 1
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -565,8 +565,12 @@ export function ConfigureAmsSlotModal({
     } else if (cloudSettings?.filament) {
       const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
       presetName = cp?.name || null;
+    } else {
+      // No cloud settings available
+    }
+    if (!presetName) {
+      return null;
     }
-    if (!presetName) return null;
 
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
     let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();

+ 23 - 1
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
 import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
@@ -29,6 +29,7 @@ interface AssignToAmsModalProps {
 
 export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
   const [statusMessage, setStatusMessage] = useState<string | null>(null);
   const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
 
@@ -98,10 +99,31 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
         ams_id: amsId,
         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);
+        }
+      }
     },
     onSuccess: () => {
       setStatusType('success');
       setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
+      queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
       setTimeout(() => onClose(), 1500);
     },
     onError: (err) => {

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-BZWZUfkf.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-C5QNmsOn.js"></script>
+    <script type="module" crossorigin src="/assets/index-BZWZUfkf.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Dgxdt1Fd.css">
   </head>
   <body>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.