瀏覽代碼

Fix SpoolBuddy AMS page fill levels, ext-R active bug, and layout

  - Show inventory-based fill levels for non-BL spools in AMS slots by
    fetching spool assignments and computing fill from weight_used/label_weight,
    falling back to AMS remain when no assignment exists
  - Fix ext-R slot falsely highlighted as active when idle: tray_now=255
    (no tray loaded) matched ext-R's id=255 without a guard
  - Improve single-slot card layout: use responsive grid aligned with AMS
    card columns, fix vertical stretching with few AMS units
  - Remove redundant L/R nozzle badges from ext slots (label already shows
    Ext-L/Ext-R)
  - Add 16 tests for ext active state logic and fill override fallback
maziggy 2 月之前
父節點
當前提交
8b36a129b7

+ 3 - 0
CHANGELOG.md

@@ -17,6 +17,8 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Fixed
 - **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
+- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.
+- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.
 - **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
 - **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.
@@ -36,6 +38,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
 
 ### Improved
+- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
 - **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
 - **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
 - **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.

+ 114 - 0
frontend/src/__tests__/pages/SpoolBuddyAmsPageLogic.test.ts

@@ -0,0 +1,114 @@
+/**
+ * Tests for SpoolBuddy AMS page logic:
+ * - External slot active state (tray_now=255 bug fix)
+ * - Fill level override fallback chain (inventory → AMS remain)
+ *
+ * These mirror inline logic from SpoolBuddyAmsPage.tsx, extracted for testability.
+ */
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Mirrors the ext slot isExtActive calculation from SpoolBuddyAmsPage.tsx.
+ * tray_now=255 means "no tray loaded" (idle) — should never mark any slot active.
+ */
+function computeExtActive(
+  trayNow: number,
+  isDualNozzle: boolean,
+  extTrayId: number,
+  activeExtruder: number | undefined,
+): boolean {
+  return trayNow === 255 ? false
+    : isDualNozzle && trayNow === 254
+      ? (extTrayId === 254 && activeExtruder === 1) ||
+        (extTrayId === 255 && activeExtruder === 0)
+      : trayNow === extTrayId;
+}
+
+/**
+ * Mirrors the effective fill fallback from SpoolBuddyAmsPage.tsx and AmsUnitCard.tsx.
+ * Priority: inventory fill override → AMS remain (if >= 0)
+ */
+function computeEffectiveFill(
+  fillOverride: number | null,
+  amsRemain: number | null | undefined,
+): number | null {
+  const amsFill = amsRemain != null && amsRemain >= 0 ? amsRemain : null;
+  return fillOverride ?? amsFill;
+}
+
+describe('ext slot active state', () => {
+  describe('tray_now=255 (idle) — no slot should be active', () => {
+    it('single-nozzle: ext (id=254) not active when tray_now=255', () => {
+      expect(computeExtActive(255, false, 254, undefined)).toBe(false);
+    });
+
+    it('dual-nozzle: ext-L (id=254) not active when tray_now=255', () => {
+      expect(computeExtActive(255, true, 254, 1)).toBe(false);
+    });
+
+    it('dual-nozzle: ext-R (id=255) not active when tray_now=255', () => {
+      // This was the bug: trayNow(255) === extTrayId(255) without the guard
+      expect(computeExtActive(255, true, 255, 0)).toBe(false);
+    });
+  });
+
+  describe('tray_now=254 on dual-nozzle — uses active_extruder', () => {
+    it('ext-L active when active_extruder=1 (left)', () => {
+      expect(computeExtActive(254, true, 254, 1)).toBe(true);
+    });
+
+    it('ext-R active when active_extruder=0 (right)', () => {
+      expect(computeExtActive(254, true, 255, 0)).toBe(true);
+    });
+
+    it('ext-L not active when active_extruder=0 (right)', () => {
+      expect(computeExtActive(254, true, 254, 0)).toBe(false);
+    });
+
+    it('ext-R not active when active_extruder=1 (left)', () => {
+      expect(computeExtActive(254, true, 255, 1)).toBe(false);
+    });
+  });
+
+  describe('tray_now=254 on single-nozzle — direct ID match', () => {
+    it('ext (id=254) active when tray_now=254', () => {
+      expect(computeExtActive(254, false, 254, undefined)).toBe(true);
+    });
+  });
+
+  describe('AMS tray active — ext slots not active', () => {
+    it('ext not active when AMS slot is active (tray_now=5)', () => {
+      expect(computeExtActive(5, false, 254, undefined)).toBe(false);
+    });
+  });
+});
+
+describe('fill level override fallback', () => {
+  it('uses inventory fill when available, ignoring AMS remain', () => {
+    expect(computeEffectiveFill(75, 50)).toBe(75);
+  });
+
+  it('falls back to AMS remain when no inventory fill', () => {
+    expect(computeEffectiveFill(null, 50)).toBe(50);
+  });
+
+  it('returns null when neither source available', () => {
+    expect(computeEffectiveFill(null, null)).toBeNull();
+  });
+
+  it('returns null when AMS remain is -1 (unknown) and no inventory fill', () => {
+    expect(computeEffectiveFill(null, -1)).toBeNull();
+  });
+
+  it('uses inventory fill even when AMS remain is -1', () => {
+    expect(computeEffectiveFill(80, -1)).toBe(80);
+  });
+
+  it('uses AMS remain of 0 (empty) as valid fill', () => {
+    expect(computeEffectiveFill(null, 0)).toBe(0);
+  });
+
+  it('uses inventory fill of 0 over AMS remain', () => {
+    expect(computeEffectiveFill(0, 50)).toBe(0);
+  });
+});

+ 10 - 5
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -139,12 +139,15 @@ interface SpoolSlotProps {
   tray: AMSTray;
   slotIndex: number;
   isActive: boolean;
+  fillOverride?: number | null;
   onClick?: () => void;
 }
 
-function SpoolSlot({ tray, slotIndex, isActive, onClick }: SpoolSlotProps) {
+function SpoolSlot({ tray, slotIndex, isActive, fillOverride, onClick }: SpoolSlotProps) {
   const isEmpty = isTrayEmpty(tray);
   const color = trayColorToCSS(tray.tray_color);
+  const amsFill = tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 ? tray.remain : null;
+  const effectiveFill = fillOverride ?? amsFill;
 
   return (
     <div
@@ -177,13 +180,13 @@ function SpoolSlot({ tray, slotIndex, isActive, onClick }: SpoolSlotProps) {
       </span>
 
       {/* Fill level bar */}
-      {!isEmpty && tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 && (
+      {!isEmpty && effectiveFill !== null && effectiveFill >= 0 && (
         <div className="w-full h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mt-1">
           <div
             className="h-full rounded-full transition-all"
             style={{
-              width: `${tray.remain}%`,
-              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
+              width: `${effectiveFill}%`,
+              backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
             }}
           />
         </div>
@@ -209,9 +212,10 @@ interface AmsUnitCardProps {
   isDualNozzle?: boolean;
   nozzleSide?: 'L' | 'R' | null;
   thresholds?: AmsThresholds;
+  fillOverrides?: Record<string, number>;
 }
 
-export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds }: AmsUnitCardProps) {
+export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides }: AmsUnitCardProps) {
   const trays = unit.tray || [];
   const isHt = unit.is_ams_ht;
   const slotCount = isHt ? 1 : 4;
@@ -268,6 +272,7 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
               tray={tray}
               slotIndex={i}
               isActive={activeSlot === i}
+              fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}
               onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
             />
           );

+ 47 - 14
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -70,6 +70,28 @@ export function SpoolBuddyAmsPage() {
     staleTime: 5 * 60 * 1000,
   });
 
+  const { data: assignments } = useQuery({
+    queryKey: ['spool-assignments', selectedPrinterId],
+    queryFn: () => api.getAssignments(selectedPrinterId!),
+    enabled: selectedPrinterId !== null,
+    staleTime: 30 * 1000,
+  });
+
+  // Build fill-level override map from inventory assignments
+  // Key: "amsId-trayId", Value: fill percentage (0-100)
+  const fillOverrides = useMemo(() => {
+    const map: Record<string, number> = {};
+    if (!assignments) return map;
+    for (const a of assignments) {
+      const sp = a.spool;
+      if (sp && sp.label_weight > 0 && sp.weight_used != null) {
+        const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+        map[`${a.ams_id}-${a.tray_id}`] = fill;
+      }
+    }
+    return map;
+  }, [assignments]);
+
   const isConnected = status?.connected ?? false;
   const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
   const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
@@ -194,6 +216,7 @@ export function SpoolBuddyAmsPage() {
     const items: {
       key: string; label: string; tray: AMSTray; isEmpty: boolean; isActive: boolean;
       temp?: number | null; humidity?: number | null; nozzleSide?: 'L' | 'R' | null;
+      effectiveFill: number | null;
       onClick: () => void;
     }[] = [];
 
@@ -203,6 +226,8 @@ export function SpoolBuddyAmsPage() {
         tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
         cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
       };
+      const invFill = fillOverrides[`${unit.id}-0`] ?? null;
+      const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
       items.push({
         key: `ht-${unit.id}`,
         label: getAmsName(unit.id),
@@ -212,16 +237,22 @@ export function SpoolBuddyAmsPage() {
         temp: unit.temp,
         humidity: unit.humidity,
         nozzleSide: getNozzleSide(unit.id),
+        effectiveFill: invFill ?? amsFill,
         onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),
       });
     }
 
     for (const extTray of vtTrays) {
       const extTrayId = extTray.id ?? 254;
-      const isExtActive = isDualNozzle && trayNow === 254
-        ? (extTrayId === 254 && status?.active_extruder === 1) ||
-          (extTrayId === 255 && status?.active_extruder === 0)
-        : trayNow === extTrayId;
+      // tray_now=255 means "no tray loaded" (idle) — never active
+      const isExtActive = trayNow === 255 ? false
+        : isDualNozzle && trayNow === 254
+          ? (extTrayId === 254 && status?.active_extruder === 1) ||
+            (extTrayId === 255 && status?.active_extruder === 0)
+          : trayNow === extTrayId;
+      const extSlotTrayId = extTrayId - 254;
+      const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
+      const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
       items.push({
         key: `ext-${extTrayId}`,
         label: isDualNozzle
@@ -230,13 +261,14 @@ export function SpoolBuddyAmsPage() {
         tray: extTray,
         isEmpty: isTrayEmpty(extTray),
         isActive: isExtActive,
-        nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
+        nozzleSide: null,
+        effectiveFill: extInvFill ?? extAmsFill,
         onClick: () => handleExtSlotClick(extTray),
       });
     }
 
     return items;
-  }, [htAms, vtTrays, isDualNozzle, trayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick]);
+  }, [htAms, vtTrays, isDualNozzle, trayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides]);
 
   return (
     <div className="h-full flex flex-col p-4">
@@ -265,7 +297,7 @@ export function SpoolBuddyAmsPage() {
         ) : (
           <div className="flex flex-col gap-3 h-full">
             {/* Regular AMS cards — 4-slot, 2-col grid */}
-            <div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
               {regularAms.map((unit) => (
                 <AmsUnitCard
                   key={unit.id}
@@ -275,19 +307,20 @@ export function SpoolBuddyAmsPage() {
                   isDualNozzle={isDualNozzle}
                   nozzleSide={getNozzleSide(unit.id)}
                   thresholds={amsThresholds}
+                  fillOverrides={fillOverrides}
                 />
               ))}
             </div>
 
-            {/* Third row: single-slot cards (AMS-HT + External) */}
+            {/* Third row: single-slot cards (AMS-HT + External) — half-width to align with AMS cards */}
             {singleSlots.length > 0 && (
-              <div className="flex gap-2 flex-shrink-0">
-                {singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, onClick }) => {
+              <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
+                {singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, effectiveFill, onClick }) => {
                   const color = trayColorToCSS(tray.tray_color);
                   return (
                     <div
                       key={key}
-                      className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}
+                      className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-3 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}
                       onClick={onClick}
                     >
                       {/* Spool */}
@@ -338,13 +371,13 @@ export function SpoolBuddyAmsPage() {
                         )}
                       </div>
                       {/* Fill bar */}
-                      {!isEmpty && tray.remain != null && tray.remain >= 0 && (
+                      {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
                         <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0 flex flex-col-reverse">
                           <div
                             className="w-full rounded-full"
                             style={{
-                              height: `${tray.remain}%`,
-                              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
+                              height: `${effectiveFill}%`,
+                              backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
                             }}
                           />
                         </div>

文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-BSznhDCP.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-CWKU4EHC.js"></script>
+    <script type="module" crossorigin src="/assets/index-BSznhDCP.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BZSO31OK.css">
   </head>
   <body>

部分文件因文件數量過多而無法顯示