Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo
This commit is contained in:
maziggy
2026-02-27 13:31:48 +01:00
parent f46c134253
commit 46f209e2ac
15 changed files with 10228 additions and 434 deletions
@@ -15,24 +15,142 @@ function getAmsName(id: number): string {
return `AMS ${id}`;
}
function formatHumidity(value: number | null): string {
if (value === null || value === undefined) return '-';
if (value > 5) return `${value}%`;
return `Level ${value}`;
// --- SVG Icons (matching PrintersPage Bambu Lab style) ---
function WaterDropEmpty({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#C3C2C1"/>
</svg>
);
}
function WaterDropHalf({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#C3C2C1"/>
<path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
</svg>
);
}
function WaterDropFull({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
<path d="M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#C3C2C1"/>
</svg>
);
}
function ThermometerEmpty({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
<circle cx="6" cy="15" r="2.5" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
</svg>
);
}
function ThermometerHalf({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4.5" y="8" width="3" height="4.5" fill="#d4a017" rx="0.5"/>
<circle cx="6" cy="15" r="2" fill="#d4a017"/>
<path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
</svg>
);
}
function ThermometerFull({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4.5" y="3" width="3" height="9.5" fill="#c62828" rx="0.5"/>
<circle cx="6" cy="15" r="2" fill="#c62828"/>
<path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
</svg>
);
}
// --- Threshold-colored indicators ---
function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: { humidity: number; goodThreshold?: number; fairThreshold?: number }) {
let textColor: string;
let DropComponent: React.FC<{ className?: string }>;
if (humidity <= goodThreshold) {
textColor = '#22a352';
DropComponent = WaterDropEmpty;
} else if (humidity <= fairThreshold) {
textColor = '#d4a017';
DropComponent = WaterDropHalf;
} else {
textColor = '#c62828';
DropComponent = WaterDropFull;
}
return (
<div className="flex items-center gap-0.5">
<DropComponent className="w-2.5 h-3" />
<span className="font-medium tabular-nums text-[10px]" style={{ color: textColor }}>{humidity}%</span>
</div>
);
}
function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: { temp: number; goodThreshold?: number; fairThreshold?: number }) {
let textColor: string;
let ThermoComponent: React.FC<{ className?: string }>;
if (temp <= goodThreshold) {
textColor = '#22a352';
ThermoComponent = ThermometerEmpty;
} else if (temp <= fairThreshold) {
textColor = '#d4a017';
ThermoComponent = ThermometerHalf;
} else {
textColor = '#c62828';
ThermoComponent = ThermometerFull;
}
return (
<div className="flex items-center gap-0.5">
<ThermoComponent className="w-2.5 h-3" />
<span className="font-medium tabular-nums text-[10px]" style={{ color: textColor }}>{temp}°C</span>
</div>
);
}
// --- Nozzle badge ---
function NozzleBadge({ side }: { side: 'L' | 'R' }) {
return (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 text-[8px] font-bold rounded"
style={{ backgroundColor: '#1a4d2e', color: '#00ae42' }}
>
{side}
</span>
);
}
// --- Components ---
interface SpoolSlotProps {
tray: AMSTray;
slotIndex: number;
isActive: boolean;
onClick?: () => void;
}
function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
function SpoolSlot({ tray, slotIndex, isActive, onClick }: SpoolSlotProps) {
const isEmpty = isTrayEmpty(tray);
const color = trayColorToCSS(tray.tray_color);
return (
<div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''}`}>
<div
className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}
onClick={onClick}
>
{/* Spool visualization */}
<div className="relative w-14 h-14 mb-1">
{isEmpty ? (
@@ -77,12 +195,23 @@ function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
);
}
export interface AmsThresholds {
humidityGood: number;
humidityFair: number;
tempGood: number;
tempFair: number;
}
interface AmsUnitCardProps {
unit: AMSUnit;
activeSlot: number | null;
onConfigureSlot?: (amsId: number, trayId: number, tray: AMSTray | null) => void;
isDualNozzle?: boolean;
nozzleSide?: 'L' | 'R' | null;
thresholds?: AmsThresholds;
}
export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds }: AmsUnitCardProps) {
const trays = unit.tray || [];
const isHt = unit.is_ams_ht;
const slotCount = isHt ? 1 : 4;
@@ -90,16 +219,29 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
return (
<div className="bg-bambu-dark-secondary rounded-lg p-3">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<span className="text-white font-medium">{getAmsName(unit.id)}</span>
{unit.humidity !== null && unit.humidity !== undefined && (
<div className="flex items-center gap-1 text-xs text-white/50">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
</svg>
<span>{formatHumidity(unit.humidity)}</span>
</div>
)}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-white font-medium text-sm">{getAmsName(unit.id)}</span>
{isDualNozzle && nozzleSide && (
<NozzleBadge side={nozzleSide} />
)}
</div>
<div className="flex items-center gap-2">
{unit.temp != null && (
<TemperatureIndicator
temp={unit.temp}
goodThreshold={thresholds?.tempGood}
fairThreshold={thresholds?.tempFair}
/>
)}
{unit.humidity != null && (
<HumidityIndicator
humidity={unit.humidity}
goodThreshold={thresholds?.humidityGood}
fairThreshold={thresholds?.humidityFair}
/>
)}
</div>
</div>
{/* Slots grid */}
@@ -126,6 +268,7 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
tray={tray}
slotIndex={i}
isActive={activeSlot === i}
onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
/>
);
})}
@@ -133,3 +276,6 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
</div>
);
}
// Exported for use in SpoolBuddyAmsPage compact cards
export { HumidityIndicator, TemperatureIndicator, NozzleBadge };
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useQueries } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { WifiOff } from 'lucide-react';
import { api, type Printer } from '../../api/client';
@@ -19,12 +19,26 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
queryFn: () => api.getPrinters(),
});
// Auto-select first printer
// Fetch status for each printer to determine which are online
const statusQueries = useQueries({
queries: printers.map((printer: Printer) => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
refetchInterval: 10000,
})),
});
const onlinePrinters = useMemo(() => {
return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);
}, [printers, statusQueries]);
// Auto-select first online printer
useEffect(() => {
if (!selectedPrinterId && printers.length > 0) {
onPrinterChange(printers[0].id);
const currentStillOnline = onlinePrinters.some((p: Printer) => p.id === selectedPrinterId);
if ((!selectedPrinterId || !currentStillOnline) && onlinePrinters.length > 0) {
onPrinterChange(onlinePrinters[0].id);
}
}, [printers, selectedPrinterId, onPrinterChange]);
}, [onlinePrinters, selectedPrinterId, onPrinterChange]);
// Clock - update every second for kiosk display
useEffect(() => {
@@ -38,13 +52,8 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
return (
<div className="h-11 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
{/* Logo */}
<div className="flex items-center gap-2 shrink-0">
<div className="w-6 h-6 rounded bg-bambu-green flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<span className="text-white font-semibold text-sm">SpoolBuddy</span>
<div className="flex items-center shrink-0">
<img src="/img/spoolbuddy_logo_dark.png" alt="SpoolBuddy" className="h-7" />
</div>
{/* Printer selector - centered */}
@@ -54,10 +63,10 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
onChange={(e) => onPrinterChange(Number(e.target.value))}
className="bg-bambu-dark text-white text-sm px-3 py-1.5 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[150px]"
>
{printers.length === 0 ? (
<option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
{onlinePrinters.length === 0 ? (
<option value="">{t('spoolbuddy.status.noPrinters', 'No printers online')}</option>
) : (
printers.map((printer: Printer) => (
onlinePrinters.map((printer: Printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
@@ -1,12 +1,14 @@
import { useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Layers } from 'lucide-react';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { AmsUnitCard } from '../../components/spoolbuddy/AmsUnitCard';
import type { PrinterStatus, AMSTray } from '../../api/client';
import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
function getAmsName(amsId: number): string {
if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
@@ -14,9 +16,32 @@ function getAmsName(amsId: number): string {
return `AMS ${amsId}`;
}
function mapModelCode(ssdpModel: string | null): string {
if (!ssdpModel) return '';
const modelMap: Record<string, string> = {
'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1S': 'H2S',
'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E',
'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',
'N2S': 'A1', 'N1': 'A1 Mini',
'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S',
'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S',
};
return modelMap[ssdpModel] || ssdpModel;
}
function isTrayEmpty(tray: AMSTray): boolean {
return !tray.tray_type || tray.tray_type === '';
}
function trayColorToCSS(color: string | null): string {
if (!color) return '#808080';
return `#${color.slice(0, 6)}`;
}
export function SpoolBuddyAmsPage() {
const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: status } = useQuery<PrinterStatus>({
queryKey: ['printerStatus', selectedPrinterId],
@@ -25,9 +50,73 @@ export function SpoolBuddyAmsPage() {
staleTime: 30 * 1000,
});
const { data: printer } = useQuery({
queryKey: ['printer', selectedPrinterId],
queryFn: () => api.getPrinter(selectedPrinterId!),
enabled: selectedPrinterId !== null,
staleTime: 60 * 1000,
});
const { data: slotPresets } = useQuery({
queryKey: ['slotPresets', selectedPrinterId],
queryFn: () => api.getSlotPresets(selectedPrinterId!),
enabled: selectedPrinterId !== null,
staleTime: 2 * 60 * 1000,
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: () => api.getSettings(),
staleTime: 5 * 60 * 1000,
});
const isConnected = status?.connected ?? false;
const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
const trayNow = status?.tray_now ?? 255;
const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
const amsThresholds: AmsThresholds | undefined = settings ? {
humidityGood: Number(settings.ams_humidity_good) || 40,
humidityFair: Number(settings.ams_humidity_fair) || 60,
tempGood: Number(settings.ams_temp_good) || 28,
tempFair: Number(settings.ams_temp_fair) || 35,
} : undefined;
// Cache ams_extruder_map to prevent L/R indicators bouncing on updates
const cachedAmsExtruderMap = useRef<Record<string, number>>({});
useEffect(() => {
if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
cachedAmsExtruderMap.current = status.ams_extruder_map;
}
}, [status?.ams_extruder_map]);
const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
? status.ams_extruder_map
: cachedAmsExtruderMap.current;
const getNozzleSide = (amsId: number): 'L' | 'R' | null => {
if (!isDualNozzle) return null;
const mappedExtruderId = amsExtruderMap[String(amsId)];
const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
// extruder 0 = right, 1 = left
return extruderId === 1 ? 'L' : 'R';
};
const [configureSlotModal, setConfigureSlotModal] = useState<{
amsId: number;
trayId: number;
trayCount: number;
trayType?: string;
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
extruderId?: number;
caliIdx?: number | null;
savedPresetId?: string;
} | null>(null);
const getActiveSlotForAms = (amsId: number): number | null => {
if (trayNow === 255 || trayNow === 254) return null;
@@ -35,7 +124,6 @@ export function SpoolBuddyAmsPage() {
const activeAmsId = Math.floor(trayNow / 4);
if (activeAmsId === amsId) return trayNow % 4;
}
// AMS-HT: tray_now 16-23 maps to AMS-HT 128-135
if (amsId >= 128 && amsId <= 135) {
const htIndex = amsId - 128;
if (trayNow === 16 + htIndex) return 0;
@@ -43,6 +131,44 @@ export function SpoolBuddyAmsPage() {
return null;
};
const handleAmsSlotClick = (amsId: number, trayId: number, tray: AMSTray | null) => {
const globalTrayId = amsId >= 128 ? (amsId - 128) * 4 + trayId + 64 : amsId * 4 + trayId;
const slotPreset = slotPresets?.[globalTrayId];
const mappedExtruderId = amsExtruderMap[String(amsId)];
const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
setConfigureSlotModal({
amsId,
trayId,
trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
trayType: tray?.tray_type || undefined,
trayColor: tray?.tray_color || undefined,
traySubBrands: tray?.tray_sub_brands || undefined,
trayInfoIdx: tray?.tray_info_idx || undefined,
extruderId: isDualNozzle ? extruderId : undefined,
caliIdx: tray?.cali_idx,
savedPresetId: slotPreset?.preset_id,
});
};
const handleExtSlotClick = (extTray: AMSTray) => {
const extTrayId = extTray.id ?? 254;
const slotTrayId = extTrayId - 254;
const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
setConfigureSlotModal({
amsId: 255,
trayId: slotTrayId,
trayCount: 1,
trayType: extTray.tray_type || undefined,
trayColor: extTray.tray_color || undefined,
traySubBrands: extTray.tray_sub_brands || undefined,
trayInfoIdx: extTray.tray_info_idx || undefined,
extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
caliIdx: extTray.cali_idx,
savedPresetId: extSlotPreset?.preset_id,
});
};
// Set alert for low filament in status bar
useEffect(() => {
if (!isConnected && selectedPrinterId) {
@@ -63,9 +189,58 @@ export function SpoolBuddyAmsPage() {
setAlert(null);
}, [amsUnits, isConnected, selectedPrinterId, setAlert, t]);
// Build list of single-slot items (AMS-HT + External) for compact rendering
const singleSlots = useMemo(() => {
const items: {
key: string; label: string; tray: AMSTray; isEmpty: boolean; isActive: boolean;
temp?: number | null; humidity?: number | null; nozzleSide?: 'L' | 'R' | null;
onClick: () => void;
}[] = [];
for (const unit of htAms) {
const tray = unit.tray?.[0] || {
id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
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,
};
items.push({
key: `ht-${unit.id}`,
label: getAmsName(unit.id),
tray,
isEmpty: isTrayEmpty(tray),
isActive: getActiveSlotForAms(unit.id) === 0,
temp: unit.temp,
humidity: unit.humidity,
nozzleSide: getNozzleSide(unit.id),
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;
items.push({
key: `ext-${extTrayId}`,
label: isDualNozzle
? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
: t('printers.ext', 'Ext'),
tray: extTray,
isEmpty: isTrayEmpty(extTray),
isActive: isExtActive,
nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
onClick: () => handleExtSlotClick(extTray),
});
}
return items;
}, [htAms, vtTrays, isDualNozzle, trayNow, status?.active_extruder, slotPresets, amsExtruderMap, t]);
return (
<div className="h-full flex flex-col p-4">
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="h-full flex flex-col p-3">
<div className="flex-1 min-h-0">
{!selectedPrinterId ? (
<div className="flex-1 flex items-center justify-center h-full">
<div className="text-center text-white/50">
@@ -79,7 +254,7 @@ export function SpoolBuddyAmsPage() {
<p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
</div>
</div>
) : amsUnits.length === 0 ? (
) : amsUnits.length === 0 && vtTrays.length === 0 ? (
<div className="flex-1 flex items-center justify-center h-full">
<div className="text-center text-white/50">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
@@ -88,17 +263,114 @@ export function SpoolBuddyAmsPage() {
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{amsUnits.map((unit) => (
<AmsUnitCard
key={unit.id}
unit={unit}
activeSlot={getActiveSlotForAms(unit.id)}
/>
))}
<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">
{regularAms.map((unit) => (
<AmsUnitCard
key={unit.id}
unit={unit}
activeSlot={getActiveSlotForAms(unit.id)}
onConfigureSlot={handleAmsSlotClick}
isDualNozzle={isDualNozzle}
nozzleSide={getNozzleSide(unit.id)}
thresholds={amsThresholds}
/>
))}
</div>
{/* Third row: single-slot cards (AMS-HT + External) */}
{singleSlots.length > 0 && (
<div className="flex gap-2 flex-shrink-0">
{singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, onClick }) => {
const color = trayColorToCSS(tray.tray_color);
return (
<div
key={key}
className={`bg-bambu-dark-secondary rounded-lg px-2 py-1.5 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}
onClick={onClick}
>
{/* Spool */}
<div className="relative w-8 h-8 flex-shrink-0">
{isEmpty ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
</div>
) : (
<svg viewBox="0 0 56 56" className="w-full h-full">
<circle cx="28" cy="28" r="26" fill={color} />
<circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
<ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
<circle cx="28" cy="28" r="8" fill="#2d2d2d" />
<circle cx="28" cy="28" r="5" fill="#1a1a1a" />
</svg>
)}
{isActive && (
<div className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-bambu-green rounded-full" />
)}
</div>
{/* Info */}
<div className="min-w-0">
<div className="flex items-center gap-1">
<span className="text-[10px] text-white/50 font-medium truncate">{label}</span>
{nozzleSide && <NozzleBadge side={nozzleSide} />}
</div>
<div className="text-xs text-white/80 truncate">
{isEmpty ? 'Empty' : tray.tray_type || '?'}
</div>
{(temp != null || humidity != null) && (
<div className="flex items-center gap-1.5">
{temp != null && (
<TemperatureIndicator
temp={temp}
goodThreshold={amsThresholds?.tempGood}
fairThreshold={amsThresholds?.tempFair}
/>
)}
{humidity != null && (
<HumidityIndicator
humidity={humidity}
goodThreshold={amsThresholds?.humidityGood}
fairThreshold={amsThresholds?.humidityFair}
/>
)}
</div>
)}
</div>
{/* Fill bar */}
{!isEmpty && tray.remain != null && tray.remain >= 0 && (
<div className="w-1 h-6 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',
}}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
{configureSlotModal && selectedPrinterId && (
<ConfigureAmsSlotModal
isOpen={!!configureSlotModal}
onClose={() => setConfigureSlotModal(null)}
printerId={selectedPrinterId}
slotInfo={configureSlotModal}
printerModel={mapModelCode(printer?.model ?? null) || undefined}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });
queryClient.invalidateQueries({ queryKey: ['printerStatus', selectedPrinterId] });
}}
/>
)}
</div>
);
}
@@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
import { api, spoolbuddyApi, type InventorySpool, type PrinterStatus } from '../../api/client';
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
@@ -90,7 +90,7 @@ function DeviceOfflineState() {
{t('spoolbuddy.status.deviceOffline', 'Device Offline')}
</p>
<p className="text-sm text-zinc-600">
{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}
{t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}
</p>
</div>
@@ -105,8 +105,29 @@ function DeviceOfflineState() {
}
// --- Main Dashboard ---
// Helper to get printer status label
function getPrinterStateLabel(state: string | null, connected: boolean): string {
if (!connected) return 'Offline';
if (!state || state === 'IDLE') return 'Idle';
if (state === 'RUNNING') return 'Printing';
if (state === 'PAUSE') return 'Paused';
if (state === 'FINISH') return 'Finished';
if (state === 'FAILED') return 'Failed';
return state;
}
function getPrinterStateColor(state: string | null, connected: boolean): string {
if (!connected) return 'bg-zinc-500';
if (!state || state === 'IDLE') return 'bg-bambu-green';
if (state === 'RUNNING') return 'bg-bambu-green animate-pulse';
if (state === 'PAUSE') return 'bg-amber-500';
if (state === 'FINISH') return 'bg-bambu-green';
if (state === 'FAILED') return 'bg-red-500';
return 'bg-zinc-500';
}
export function SpoolBuddyDashboard() {
const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
const { sbState, selectedPrinterId, setSelectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
const { t } = useTranslation();
// Fetch spools for stats, tag lookup, and untagged list
@@ -115,6 +136,31 @@ export function SpoolBuddyDashboard() {
queryFn: () => api.getSpools(false),
});
// Fetch printers list
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
staleTime: 30 * 1000,
});
// Fetch status for each printer
const printerStatuses = useQuery({
queryKey: ['printerStatuses', printers.map(p => p.id).join(',')],
queryFn: async () => {
const statuses: Record<number, PrinterStatus> = {};
await Promise.all(
printers.map(async (p) => {
try {
statuses[p.id] = await api.getPrinterStatus(p.id);
} catch { /* ignore */ }
})
);
return statuses;
},
enabled: printers.length > 0,
staleTime: 30 * 1000,
});
// Current Spool card state - persists until user closes or new tag detected
const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
@@ -126,6 +172,16 @@ export function SpoolBuddyDashboard() {
const currentWeight = sbState.weight;
const weightStable = sbState.weightStable;
// Stabilized scale display: only update when change exceeds threshold to prevent bouncing
const stableDisplayWeight = useRef<number | null>(null);
const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this
if (currentWeight === null) {
stableDisplayWeight.current = null;
} else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) {
stableDisplayWeight.current = currentWeight;
}
const scaleDisplayValue = stableDisplayWeight.current;
// Find spool by tag_id in the loaded spools list
const displayedSpool = useMemo(() => {
if (!displayedTagId) return null;
@@ -215,10 +271,12 @@ export function SpoolBuddyDashboard() {
const materials = new Set(spools.map((s) => s.material)).size;
const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
const statuses = printerStatuses.data ?? {};
return (
<div className="h-full flex flex-col p-4">
{/* Compact stats bar */}
<div className="flex items-center gap-6 px-4 py-2 bg-zinc-800 rounded-xl border border-zinc-700 mb-4 shrink-0">
<div className="flex items-center gap-6 px-4 py-2 bg-zinc-800/50 rounded-xl border border-zinc-700/50 mb-4 shrink-0">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-zinc-100">{totalSpools}</span>
<span className="text-sm text-zinc-500">{t('spoolbuddy.inventory.spools', 'Spools')}</span>
@@ -235,46 +293,42 @@ export function SpoolBuddyDashboard() {
</div>
</div>
{/* Main content: Device status (left) + Hero spool card (right) */}
{/* Main content: Device + Printers (left) + Current Spool (right) */}
<div className="flex-1 flex gap-4 min-h-0">
{/* Left column: Device Status */}
<div className="w-1/3 flex flex-col gap-3">
<div className="bg-zinc-800 rounded-lg p-4">
{/* Left column */}
<div className="w-5/12 flex flex-col gap-4 min-h-0">
{/* Device card */}
<div className="border border-dashed border-zinc-700/50 rounded-xl p-4 shrink-0">
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">
{t('spoolbuddy.dashboard.device', 'Device')}
</h2>
<div className="space-y-3">
<div className="space-y-2.5">
{/* Connection status */}
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
<span className="text-sm text-zinc-400">
{sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
{sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}
</span>
</div>
{/* Scale weight */}
<div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>
<span className="text-xs text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-mono font-semibold text-zinc-100">
{currentWeight !== null ? `${Math.abs(currentWeight) <= 20 ? 0 : Math.round(Math.max(0, currentWeight))}g` : '\u2014'}
</span>
{weightStable && currentWeight !== null && (
<span className="w-2 h-2 rounded-full bg-green-500" title={t('spoolbuddy.weight.stable', 'Stable')} />
)}
</div>
<span className="text-lg font-mono font-semibold text-zinc-100">
{scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\u2014'}
</span>
</div>
{/* NFC status */}
<div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
<span className="text-xs text-zinc-500">NFC</span>
@@ -286,63 +340,80 @@ export function SpoolBuddyDashboard() {
</div>
</div>
{/* Weight display */}
<div className="bg-zinc-800 rounded-lg p-4 flex flex-col items-center">
<span className="text-4xl font-light tabular-nums text-zinc-100">
{currentWeight !== null ? currentWeight.toFixed(1) : '--.-'}
</span>
<span className="text-lg text-zinc-500">g</span>
<div className="flex items-center gap-2 mt-2">
<div className={`w-2 h-2 rounded-full ${
!sbState.deviceOnline
? 'bg-zinc-600'
: weightStable
? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
: 'bg-amber-500 animate-pulse'
}`} />
<span className="text-xs text-zinc-400">
{!sbState.deviceOnline
? t('spoolbuddy.weight.noReading', 'No reading')
: weightStable
? t('spoolbuddy.weight.stable', 'Stable')
: t('spoolbuddy.weight.measuring', 'Measuring...')}
</span>
{/* Printers card */}
<div className="border border-dashed border-zinc-700/50 rounded-xl p-4 flex-1 min-h-0 flex flex-col">
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-2">
{t('spoolbuddy.dashboard.printers', 'Printers')}
</h2>
<div className="space-y-1.5 overflow-y-auto flex-1 min-h-0">
{printers.length === 0 ? (
<p className="text-sm text-zinc-600">{t('spoolbuddy.dashboard.noPrinters', 'No printers configured')}</p>
) : (
printers.map((p) => {
const st = statuses[p.id];
const stateLabel = getPrinterStateLabel(st?.state ?? null, st?.connected ?? false);
const stateColor = getPrinterStateColor(st?.state ?? null, st?.connected ?? false);
const isSelected = selectedPrinterId === p.id;
return (
<button
key={p.id}
onClick={() => setSelectedPrinterId(p.id)}
className={`w-full text-left py-1.5 px-3 bg-zinc-800/50 rounded-lg border-l-2 transition-colors hover:bg-zinc-800 ${
isSelected ? 'border-l-bambu-green' : 'border-l-bambu-green/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-200">{p.name}</span>
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${stateColor}`} />
<span className="text-xs text-zinc-500">{stateLabel}</span>
</div>
</div>
</button>
);
})
)}
</div>
</div>
</div>
{/* Right column: Hero Spool Card */}
<div className="w-2/3">
<div className="bg-zinc-800 rounded-lg p-6 h-full flex items-center justify-center">
{showCard && isMatchedSpool && displayedSpool ? (
<SpoolInfoCard
spool={{
id: displayedSpool.id,
tag_uid: displayedTagId!,
material: displayedSpool.material,
subtype: displayedSpool.subtype,
color_name: displayedSpool.color_name,
rgba: displayedSpool.rgba,
brand: displayedSpool.brand,
label_weight: displayedSpool.label_weight,
core_weight: displayedSpool.core_weight,
weight_used: displayedSpool.weight_used,
}}
scaleWeight={liveWeight}
weightStable={weightStable}
onClose={handleCloseSpoolCard}
onSyncWeight={() => refetchSpools()}
/>
) : showCard && isUnknownTag ? (
<UnknownTagCard
tagUid={displayedTagId!}
scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
onClose={handleCloseSpoolCard}
/>
) : (
sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
)}
{/* Right column: Current Spool */}
<div className="w-7/12 min-h-0">
<div className="border border-dashed border-zinc-700/50 rounded-xl p-6 h-full flex flex-col">
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-4 shrink-0">
{t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
</h2>
<div className="flex-1 flex items-center justify-center min-h-0">
{showCard && isMatchedSpool && displayedSpool ? (
<SpoolInfoCard
spool={{
id: displayedSpool.id,
tag_uid: displayedTagId!,
material: displayedSpool.material,
subtype: displayedSpool.subtype,
color_name: displayedSpool.color_name,
rgba: displayedSpool.rgba,
brand: displayedSpool.brand,
label_weight: displayedSpool.label_weight,
core_weight: displayedSpool.core_weight,
weight_used: displayedSpool.weight_used,
}}
scaleWeight={liveWeight}
weightStable={weightStable}
onClose={handleCloseSpoolCard}
onSyncWeight={() => refetchSpools()}
/>
) : showCard && isUnknownTag ? (
<UnknownTagCard
tagUid={displayedTagId!}
scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
onClose={handleCloseSpoolCard}
/>
) : (
sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
)}
</div>
</div>
</div>
</div>