Browse Source

Show Spoolman fill level for AMS Lite and external spools (#293)

AMS Lite units (A1 series) have no weight sensor and always report 0%
fill level. When a spool is linked to Spoolman with weight data, use
Spoolman's remaining weight as a fallback. External spools also show
fill level from Spoolman data instead of always showing unknown.

Backend: Enrich GET /spoolman/spools/linked response with
remaining_weight and filament_weight alongside spool ID.

Frontend: Add getSpoolmanFillLevel() helper. Update regular AMS, HT
AMS, and external spool fill computations to use Spoolman fallback
when AMS reports 0%. Show "(Spoolman)" indicator in hover card when
fill data comes from Spoolman.
maziggy 3 months ago
parent
commit
9cbd66593e

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Per-Filament Spoolman Usage Tracking** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers `tray_uuid` over `tag_uid` for spool identification.
 - **Disable AMS Weight Sync Setting** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — New toggle to prevent AMS percentage-based weight estimates from overwriting Spoolman's granular usage-based calculations. Includes conditional "Report Partial Usage for Failed Prints" toggle.
 - **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.
+- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
 
 ### Fixed
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.

+ 1 - 1
README.md

@@ -148,7 +148,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Queue events (waiting, skipped, failed)
 
 ### 🔧 Integrations
-- [Spoolman](https://github.com/Donkie/Spoolman) filament sync
+- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management

+ 7 - 2
backend/app/api/routes/spoolman.py

@@ -598,7 +598,7 @@ async def get_linked_spools(
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
     spools = await client.get_spools()
-    linked: dict[str, int] = {}
+    linked: dict[str, dict] = {}
 
     for spool in spools:
         # Check if spool has a tag in extra field
@@ -608,7 +608,12 @@ async def get_linked_spools(
             # Remove quotes if present (JSON encoded string)
             clean_tag = tag.strip('"').upper()
             if clean_tag:
-                linked[clean_tag] = spool["id"]
+                filament = spool.get("filament") or {}
+                linked[clean_tag] = {
+                    "id": spool["id"],
+                    "remaining_weight": spool.get("remaining_weight"),
+                    "filament_weight": filament.get("weight"),
+                }
 
     return {"linked": linked}
 

+ 70 - 2
backend/tests/integration/test_spoolman_api.py

@@ -280,7 +280,7 @@ class TestSpoolmanAPI:
             "id": 42,
             "remaining_weight": 800,
             "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
-            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "weight": 1000},
         }
         mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
 
@@ -291,7 +291,10 @@ class TestSpoolmanAPI:
         assert isinstance(data["linked"], dict)
         # Tag should be uppercase and stripped of quotes
         assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"]
-        assert data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"] == 42
+        linked_info = data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"]
+        assert linked_info["id"] == 42
+        assert linked_info["remaining_weight"] == 800
+        assert linked_info["filament_weight"] == 1000
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -337,6 +340,71 @@ class TestSpoolmanAPI:
         data = response.json()
         assert len(data["linked"]) == 0  # Empty tag should be excluded
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_includes_weight_data(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools response includes remaining_weight and filament_weight."""
+        mock_spool = {
+            "id": 10,
+            "remaining_weight": 500.5,
+            "extra": {"tag": '"AABB11223344556677889900AABBCCDD"'},
+            "filament": {"id": 1, "name": "PETG Blue", "material": "PETG", "weight": 750},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        info = data["linked"]["AABB11223344556677889900AABBCCDD"]
+        assert info["id"] == 10
+        assert info["remaining_weight"] == 500.5
+        assert info["filament_weight"] == 750
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_missing_weight_fields(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools handles missing weight data gracefully."""
+        mock_spool = {
+            "id": 5,
+            "extra": {"tag": '"CCDD11223344556677889900AABBCCDD"'},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        info = data["linked"]["CCDD11223344556677889900AABBCCDD"]
+        assert info["id"] == 5
+        assert info["remaining_weight"] is None
+        assert info["filament_weight"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_null_filament(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools handles null filament object."""
+        mock_spool = {
+            "id": 7,
+            "remaining_weight": 300,
+            "extra": {"tag": '"EEFF11223344556677889900AABBCCDD"'},
+            "filament": None,
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        info = data["linked"]["EEFF11223344556677889900AABBCCDD"]
+        assert info["id"] == 7
+        assert info["remaining_weight"] == 300
+        assert info["filament_weight"] is None
+
     # =========================================================================
     # Link Spool Tests
     # =========================================================================

+ 168 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -0,0 +1,168 @@
+/**
+ * Tests for the FilamentHoverCard component.
+ * Focuses on fill level display and Spoolman source indicator.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '../utils';
+import { FilamentHoverCard } from '../../components/FilamentHoverCard';
+
+const baseFilamentData = {
+  vendor: 'Bambu Lab' as const,
+  profile: 'PLA Basic',
+  colorName: 'Red',
+  colorHex: 'FF0000',
+  kFactor: '0.030',
+  fillLevel: 75,
+  trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+};
+
+function renderWithHover(ui: React.ReactElement) {
+  const result = render(ui);
+  // Trigger hover to show the card
+  const trigger = result.container.firstElementChild as HTMLElement;
+  fireEvent.mouseEnter(trigger);
+  return result;
+}
+
+describe('FilamentHoverCard', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+
+  describe('fill level display', () => {
+    it('shows fill percentage when fillLevel is set', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('75%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows dash when fillLevel is null', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('—')).toBeInTheDocument();
+      });
+    });
+
+    it('shows 0% when fillLevel is zero', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('0%')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('Spoolman source indicator', () => {
+    it('shows Spoolman label when fillSource is spoolman', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('(Spoolman)')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillSource is ams', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'ams' }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('80%')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillLevel is null', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('—')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillSource is undefined', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 50 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('50%')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('hover behavior', () => {
+    it('does not show card when disabled', () => {
+      renderWithHover(
+        <FilamentHoverCard data={baseFilamentData} disabled>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      // Card should not be visible
+      expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
+    });
+
+    it('shows filament details on hover', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={baseFilamentData}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('Red')).toBeInTheDocument();
+        expect(screen.getByText('PLA Basic')).toBeInTheDocument();
+        expect(screen.getByText('0.030')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 78 - 0
frontend/src/__tests__/utils/getSpoolmanFillLevel.test.ts

@@ -0,0 +1,78 @@
+/**
+ * Tests for getSpoolmanFillLevel helper function.
+ * This function is defined in PrintersPage.tsx but tested here for isolation.
+ * We replicate the logic to test it independently.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+// Replicate the function from PrintersPage.tsx for testing
+interface LinkedSpoolInfo {
+  id: number;
+  remaining_weight: number | null;
+  filament_weight: number | null;
+}
+
+function getSpoolmanFillLevel(
+  linkedSpool: LinkedSpoolInfo | undefined
+): number | null {
+  if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
+      || linkedSpool.filament_weight <= 0) return null;
+  return Math.min(100, Math.round(
+    (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
+  ));
+}
+
+describe('getSpoolmanFillLevel', () => {
+  it('returns null for undefined spool', () => {
+    expect(getSpoolmanFillLevel(undefined)).toBeNull();
+  });
+
+  it('returns null when remaining_weight is null', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: null, filament_weight: 1000 })).toBeNull();
+  });
+
+  it('returns null when filament_weight is null', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: null })).toBeNull();
+  });
+
+  it('returns null when remaining_weight is 0', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 0, filament_weight: 1000 })).toBeNull();
+  });
+
+  it('returns null when filament_weight is 0', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 0 })).toBeNull();
+  });
+
+  it('returns null when filament_weight is negative', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: -100 })).toBeNull();
+  });
+
+  it('calculates correct percentage for half-full spool', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 1000 })).toBe(50);
+  });
+
+  it('calculates correct percentage for full spool', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1000, filament_weight: 1000 })).toBe(100);
+  });
+
+  it('calculates correct percentage for nearly empty spool', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 50, filament_weight: 1000 })).toBe(5);
+  });
+
+  it('caps at 100% when remaining exceeds filament weight', () => {
+    // This can happen if user manually sets remaining_weight higher
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1200, filament_weight: 1000 })).toBe(100);
+  });
+
+  it('rounds to nearest integer', () => {
+    // 333/1000 = 33.3% -> 33%
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 333, filament_weight: 1000 })).toBe(33);
+    // 666/1000 = 66.6% -> 67%
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 666, filament_weight: 1000 })).toBe(67);
+  });
+
+  it('handles small weights correctly', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1, filament_weight: 100 })).toBe(1);
+  });
+});

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

@@ -1622,8 +1622,14 @@ export interface UnlinkedSpool {
   location: string | null;
 }
 
+export interface LinkedSpoolInfo {
+  id: number;
+  remaining_weight: number | null;
+  filament_weight: number | null;
+}
+
 export interface LinkedSpoolsMap {
-  linked: Record<string, number>; // tag (uppercase) -> spool_id
+  linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
 }
 
 // Update types

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

@@ -10,6 +10,7 @@ interface FilamentData {
   kFactor: string;
   fillLevel: number | null; // null = unknown
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
+  fillSource?: 'ams' | 'spoolman'; // Source of fill level data
 }
 
 interface SpoolmanConfig {
@@ -229,8 +230,11 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     <Droplets className="w-3 h-3" />
                     {t('ams.fill')}
                   </span>
-                  <span className="text-xs text-white font-semibold">
+                  <span className="text-xs text-white font-semibold flex items-center gap-1">
                     {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
+                    {data.fillSource === 'spoolman' && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
+                    )}
                   </span>
                 </div>
                 {/* Fill bar */}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -2184,6 +2184,7 @@ export default {
     linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft',
     linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen',
     spoolId: 'Spulen-ID',
+    fillSourceLabel: '(Spoolman)',
     weight: 'Gewicht',
     remaining: 'Verbleibend',
     disableWeightSync: 'AMS-Gewichtsschätzung deaktivieren',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -2184,6 +2184,7 @@ export default {
     linkSuccess: 'Spool linked to Spoolman successfully',
     linkFailed: 'Failed to link spool',
     spoolId: 'Spool ID',
+    fillSourceLabel: '(Spoolman)',
     weight: 'Weight',
     remaining: 'Remaining',
     disableWeightSync: 'Disable AMS Estimated Weight Sync',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -1123,6 +1123,7 @@ export default {
       disableWeightSyncDesc: 'AMS推定値から残量を更新しません。AMSの割合ベースの推定よりもSpoolmanの使用量追跡を優先する場合に使用してください。新しいスプールは引き続きAMS推定値を初期重量として使用します。',
       reportPartialUsage: '失敗した印刷の部分使用量を報告',
       reportPartialUsageDesc: '印刷が失敗またはキャンセルされた場合、レイヤー進捗に基づいてその時点までの推定フィラメント使用量を報告します。',
+      fillSourceLabel: '(Spoolman)',
     },
 
     // Page

+ 63 - 16
frontend/src/pages/PrintersPage.tsx

@@ -47,7 +47,7 @@ import {
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { formatDateOnly } from '../utils/date';
-import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
+import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -637,6 +637,17 @@ function getFillBarColor(fillLevel: number): string {
   return '#ef4444'; // Red - critical (< 15%)
 }
 
+// Calculate fill level from Spoolman weight data (used as fallback when AMS reports 0%)
+function getSpoolmanFillLevel(
+  linkedSpool: LinkedSpoolInfo | undefined
+): number | null {
+  if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
+      || linkedSpool.filament_weight <= 0) return null;
+  return Math.min(100, Math.round(
+    (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
+  ));
+}
+
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
@@ -923,7 +934,7 @@ function PrinterCard({
   };
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
-  linkedSpools?: Record<string, number>;
+  linkedSpools?: Record<string, LinkedSpoolInfo>;
   spoolmanUrl?: string | null;
   timeFormat?: 'system' | '12h' | '24h';
   cameraViewMode?: 'window' | 'embedded';
@@ -2196,6 +2207,15 @@ function PrinterCard({
                                 // Get saved slot preset mapping (for user-configured slots)
                                 const slotPreset = slotPresets?.[globalTrayId];
 
+                                // Spoolman fill level fallback (when AMS reports 0%)
+                                const trayTag = tray?.tray_uuid?.toUpperCase();
+                                const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
+                                const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
+                                const effectiveFill = hasFillLevel && tray.remain > 0
+                                  ? tray.remain
+                                  : (spoolmanFill ?? (hasFillLevel ? tray.remain : null));
+                                const fillSource = (hasFillLevel && tray.remain === 0 && spoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
+
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2203,8 +2223,9 @@ function PrinterCard({
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
-                                  fillLevel: hasFillLevel ? tray.remain : null,
+                                  fillLevel: effectiveFill,
                                   trayUuid: tray.tray_uuid || null,
+                                  fillSource,
                                 } : null;
 
                                 // Check if this specific slot is being refreshed
@@ -2229,12 +2250,12 @@ function PrinterCard({
                                     </div>
                                     {/* Fill bar */}
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                                      {hasFillLevel && tray ? (
+                                      {effectiveFill !== null && effectiveFill >= 0 && tray ? (
                                         <div
                                           className="h-full rounded-full transition-all"
                                           style={{
-                                            width: `${tray.remain}%`,
-                                            backgroundColor: getFillBarColor(tray.remain),
+                                            width: `${effectiveFill}%`,
+                                            backgroundColor: getFillBarColor(effectiveFill),
                                           }}
                                         />
                                       ) : tray?.tray_type ? (
@@ -2300,7 +2321,7 @@ function PrinterCard({
                                         spoolman={{
                                           enabled: spoolmanEnabled,
                                           hasUnlinkedSpools,
-                                          linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
+                                          linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
                                           spoolmanUrl,
                                           onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                             setLinkSpoolModal({
@@ -2374,6 +2395,15 @@ function PrinterCard({
                         // Get saved slot preset mapping (for user-configured slots)
                         const slotPreset = slotPresets?.[globalTrayId];
 
+                        // Spoolman fill level fallback (when AMS reports 0%)
+                        const htTrayTag = tray?.tray_uuid?.toUpperCase();
+                        const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
+                        const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
+                        const htEffectiveFill = hasFillLevel && tray.remain > 0
+                          ? tray.remain
+                          : (htSpoolmanFill ?? (hasFillLevel ? tray.remain : null));
+                        const htFillSource = (hasFillLevel && tray.remain === 0 && htSpoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
+
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2381,8 +2411,9 @@ function PrinterCard({
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
-                          fillLevel: hasFillLevel ? tray.remain : null,
+                          fillLevel: htEffectiveFill,
                           trayUuid: tray.tray_uuid || null,
+                          fillSource: htFillSource,
                         } : null;
 
                         const htSlotId = tray?.id ?? 0;
@@ -2408,12 +2439,12 @@ function PrinterCard({
                             </div>
                             {/* Fill bar */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              {hasFillLevel ? (
+                              {htEffectiveFill !== null && htEffectiveFill >= 0 ? (
                                 <div
                                   className="h-full rounded-full transition-all"
                                   style={{
-                                    width: `${tray.remain}%`,
-                                    backgroundColor: getFillBarColor(tray.remain),
+                                    width: `${htEffectiveFill}%`,
+                                    backgroundColor: getFillBarColor(htEffectiveFill),
                                   }}
                                 />
                               ) : tray?.tray_type ? (
@@ -2491,7 +2522,7 @@ function PrinterCard({
                                     spoolman={{
                                       enabled: spoolmanEnabled,
                                       hasUnlinkedSpools,
-                                      linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
+                                      linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
                                       spoolmanUrl,
                                       onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                         setLinkSpoolModal({
@@ -2579,6 +2610,11 @@ function PrinterCard({
                         // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
                         const extSlotPreset = slotPresets?.[255 * 4 + 0];
 
+                        // Spoolman fill level for external spool
+                        const extTrayTag = extTray.tray_uuid?.toUpperCase();
+                        const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
+                        const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
+
                         // Build filament data for hover card
                         const extFilamentData = {
                           vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2586,8 +2622,9 @@ function PrinterCard({
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
-                          fillLevel: null, // External spool has unknown fill level
+                          fillLevel: extSpoolmanFill, // Use Spoolman data if available
                           trayUuid: extTray.tray_uuid || null,
+                          fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
                         };
 
                         const extSlotContent = (
@@ -2602,9 +2639,19 @@ function PrinterCard({
                             <div className="text-[9px] text-white font-bold truncate">
                               {extTray.tray_type || 'Spool'}
                             </div>
-                            {/* Unknown fill level - subtle bar */}
+                            {/* Fill bar - use Spoolman data if available */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                              {extSpoolmanFill !== null ? (
+                                <div
+                                  className="h-full rounded-full transition-all"
+                                  style={{
+                                    width: `${extSpoolmanFill}%`,
+                                    backgroundColor: getFillBarColor(extSpoolmanFill),
+                                  }}
+                                />
+                              ) : (
+                                <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                              )}
                             </div>
                           </div>
                         );
@@ -2621,7 +2668,7 @@ function PrinterCard({
                               spoolman={{
                                 enabled: spoolmanEnabled,
                                 hasUnlinkedSpools,
-                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()] : undefined,
+                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
                                 spoolmanUrl,
                                 onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
                                   setLinkSpoolModal({

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ueO9Fu53.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
+    <script type="module" crossorigin src="/assets/index-8oHBFhYF.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DCdsD82T.css">
   </head>
   <body>
     <div id="root"></div>

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