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>