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.
This commit is contained in:
maziggy
2026-02-08 08:47:18 +01:00
parent e073e494c7
commit 9cbd66593e
16 changed files with 819 additions and 439 deletions
@@ -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();
});
});
});
});
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1123,6 +1123,7 @@ export default {
disableWeightSyncDesc: 'AMS推定値から残量を更新しません。AMSの割合ベースの推定よりもSpoolmanの使用量追跡を優先する場合に使用してください。新しいスプールは引き続きAMS推定値を初期重量として使用します。',
reportPartialUsage: '失敗した印刷の部分使用量を報告',
reportPartialUsageDesc: '印刷が失敗またはキャンセルされた場合、レイヤー進捗に基づいてその時点までの推定フィラメント使用量を報告します。',
fillSourceLabel: '(Spoolman)',
},
// Page
+63 -16
View File
@@ -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({