| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931 |
- import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
- import { useOutletContext } from 'react-router-dom';
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import { useTranslation } from 'react-i18next';
- import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
- import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
- import { api } from '../../api/client';
- import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
- import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool } from '../../utils/amsHelpers';
- import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
- import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
- import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
- import { AssignSpoolModal } from '../../components/AssignSpoolModal';
- import { LinkSpoolModal } from '../../components/LinkSpoolModal';
- import { useToast } from '../../contexts/ToastContext';
- function getAmsName(amsId: number): string {
- if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
- if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;
- 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', 'O1C2': 'H2C', 'O1S': 'H2S',
- 'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E',
- 'N6': 'X2D',
- 'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',
- 'N2S': 'A1', 'N1': 'A1 Mini',
- 'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'X2D': 'X2D', '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 { showToast } = useToast();
- const { data: status } = useQuery<PrinterStatus>({
- queryKey: ['printerStatus', selectedPrinterId],
- queryFn: () => api.getPrinterStatus(selectedPrinterId!),
- enabled: selectedPrinterId !== null,
- 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,
- });
- // Fetch Spoolman status to enable fill-level chain
- const { data: spoolmanStatus } = useQuery({
- queryKey: ['spoolman-status'],
- queryFn: api.getSpoolmanStatus,
- staleTime: 60 * 1000,
- });
- const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;
- // Fetch linked spools map (tag -> spool info) for Spoolman fill levels
- const { data: linkedSpoolsData } = useQuery({
- queryKey: ['linked-spools'],
- queryFn: api.getLinkedSpools,
- enabled: !!spoolmanEnabled,
- staleTime: 30 * 1000,
- });
- const linkedSpools = linkedSpoolsData?.linked;
- // Fetch all Spoolman slot assignments + spool metadata so the fill-bar
- // and the Link/Unlink button reflect slot-assigned-only Spoolman spools
- // (spools assigned to a slot via AssignToAmsModal but not tag-linked).
- // Without these, AmsUnitCard shows an empty fill bar and the Link button
- // stays active even though the slot is occupied.
- const { data: spoolmanSlotAssignmentsAll = [] } = useQuery({
- queryKey: ['spoolman-slot-assignments-all'],
- queryFn: () => api.getSpoolmanSlotAssignments(),
- enabled: !!spoolmanEnabled,
- staleTime: 30 * 1000,
- });
- const { data: spoolmanInventorySpoolsCache = [] } = useQuery({
- queryKey: ['spoolman-inventory-spools'],
- queryFn: () => api.getSpoolmanInventorySpools(false),
- enabled: !!spoolmanEnabled,
- staleTime: 30 * 1000,
- });
- const { data: assignments } = useQuery({
- queryKey: ['spool-assignments', selectedPrinterId],
- queryFn: () => api.getAssignments(selectedPrinterId!),
- enabled: selectedPrinterId !== null,
- staleTime: 30 * 1000,
- });
- // Build fill-level override map from inventory assignments
- // Key: "amsId-trayId", Value: fill percentage (0-100)
- const fillOverrides = useMemo(() => {
- const map: Record<string, number> = {};
- if (!assignments) return map;
- for (const a of assignments) {
- const sp = a.spool;
- if (sp && sp.label_weight > 0 && sp.weight_used != null) {
- const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
- map[`${a.ams_id}-${a.tray_id}`] = fill;
- }
- }
- return map;
- }, [assignments]);
- // Look up Spoolman fill level for a given tray
- const printerSerial = printer?.serial_number ?? '';
- const getSpoolmanFillForSlot = useCallback((amsId: number, trayId: number, tray: AMSTray | null): number | null => {
- // Stage 1: slot-assigned Spoolman spool. The user's explicit, recent
- // action — must outrank the tag-link to avoid #1457, where a non-RFID
- // slot's deterministic fallback tag stayed bound to the previous spool
- // in Spoolman's extra.tag and the fill bar reported the old (stale)
- // spool's remaining weight instead of the freshly assigned one.
- if (selectedPrinterId !== null && spoolmanSlotAssignmentsAll.length && spoolmanInventorySpoolsCache.length) {
- const slotAssign = spoolmanSlotAssignmentsAll.find(a =>
- a.printer_id === selectedPrinterId &&
- a.ams_id === amsId &&
- a.tray_id === trayId,
- );
- if (slotAssign) {
- const spool = spoolmanInventorySpoolsCache.find(s => s.id === slotAssign.spoolman_spool_id);
- if (spool && (spool.label_weight ?? 0) > 0) {
- return Math.round(Math.max(0, spool.label_weight - spool.weight_used) / spool.label_weight * 100);
- }
- }
- }
- // Stage 2: tag-linked spool (linkedSpools map keyed by tag/UUID).
- if (linkedSpools && printerSerial) {
- const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
- const linkedSpool = tag ? linkedSpools[tag] : undefined;
- const tagFill = getSpoolmanFillLevel(linkedSpool);
- if (tagFill !== null) return tagFill;
- }
- return null;
- }, [linkedSpools, printerSerial, selectedPrinterId, spoolmanSlotAssignmentsAll, spoolmanInventorySpoolsCache]);
- const isConnected = status?.connected ?? false;
- // Cache AMS data per printer to prevent it disappearing on idle/offline printers
- const cachedAmsData = useRef<Record<number, PrinterStatus['ams']>>({});
- useEffect(() => {
- if (selectedPrinterId && status?.ams && status.ams.length > 0) {
- cachedAmsData.current[selectedPrinterId] = status.ams;
- }
- }, [status?.ams, selectedPrinterId]);
- const amsUnits = useMemo(() => {
- const live = status?.ams;
- if (live && live.length > 0) return live;
- return (selectedPrinterId ? cachedAmsData.current[selectedPrinterId] : null) ?? [];
- }, [status?.ams, selectedPrinterId]);
- const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
- const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
- // Build Spoolman fill-level override map for regular AMS cards
- const spoolmanFillOverrides = useMemo(() => {
- const map: Record<string, number> = {};
- if (!linkedSpools || !printerSerial) return map;
- for (const unit of regularAms) {
- for (let i = 0; i < (unit.tray?.length ?? 0); i++) {
- const tray = unit.tray![i];
- const fill = getSpoolmanFillForSlot(unit.id, i, isTrayEmpty(tray) ? null : tray);
- if (fill !== null) map[`${unit.id}-${i}`] = fill;
- }
- }
- return map;
- }, [linkedSpools, printerSerial, regularAms, getSpoolmanFillForSlot]);
- // Cache tray_now to prevent flickering when undefined values come in
- // Valid tray IDs: 0-253 for AMS, 254 for external spool
- // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
- const cachedTrayNow = useRef<number | undefined>(undefined);
- const currentTrayNow = status?.tray_now;
- if (currentTrayNow !== undefined && currentTrayNow !== 255) {
- cachedTrayNow.current = currentTrayNow;
- } else if (currentTrayNow === 255) {
- cachedTrayNow.current = undefined;
- }
- const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)
- ? currentTrayNow
- : cachedTrayNow.current;
- 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 = useCallback((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';
- }, [isDualNozzle, amsExtruderMap]);
- 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);
- // Slot action picker: shown before opening configure or assign modal
- const [slotActionPicker, setSlotActionPicker] = useState<{
- amsId: number;
- trayId: number;
- trayCount: number;
- tray: AMSTray | null;
- trayType?: string;
- trayColor?: string;
- traySubBrands?: string;
- trayInfoIdx?: string;
- extruderId?: number;
- caliIdx?: number | null;
- savedPresetId?: string;
- location: string;
- } | null>(null);
- // Assign spool modal state (inventory)
- const [assignSpoolModal, setAssignSpoolModal] = useState<{
- printerId: number;
- amsId: number;
- trayId: number;
- trayInfo: { type: string; material?: string; profile?: string; color: string; location: string };
- } | null>(null);
- // Link spool modal state (Spoolman)
- const [linkSpoolModal, setLinkSpoolModal] = useState<{
- tagUid: string;
- trayUuid: string;
- printerId: number;
- amsId: number;
- trayId: number;
- } | null>(null);
- const getAssignment = useCallback((amsId: number, trayId: number): SpoolAssignment | undefined => {
- return assignments?.find(a => a.ams_id === Number(amsId) && a.tray_id === Number(trayId));
- }, [assignments]);
- const getLinkedSpool = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
- if (!linkedSpools || !printerSerial) return undefined;
- const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
- return tag ? linkedSpools[tag] : undefined;
- }, [linkedSpools, printerSerial]);
- const unassignMutation = useMutation({
- mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
- api.unassignSpool(printerId, amsId, trayId),
- onSuccess: () => {
- // Two cache-key shapes coexist for spool assignments: this page and a
- // few SpoolBuddy components key by printerId, while AssignSpoolModal
- // (and most of Bambuddy) keys without it. Both must be invalidated
- // here, otherwise after a SpoolBuddy unassign the modal opens with a
- // stale assignments list, sees the just-freed spool as still taken,
- // filters it out, and shows "no spools available" — even though it's
- // sitting in inventory ready to re-assign (#1133 follow-up).
- queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
- queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
- showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
- setSlotActionPicker(null);
- },
- });
- const unlinkSpoolMutation = useMutation({
- mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
- onSuccess: (result) => {
- showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');
- queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
- queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
- // Backend (spoolman.py:986) also deletes the SpoolmanSlotAssignment row,
- // so invalidate every cache that depends on slot assignments. Without
- // these PrintersPage / InventoryPage / SpoolBuddyDashboard keep showing
- // the spool as still assigned for ~30s until refetchInterval kicks in.
- queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
- queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
- queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
- setSlotActionPicker(null);
- },
- onError: (error: Error) => {
- showToast(error.message || t('spoolman.unlinkFailed'), 'error');
- },
- });
- // Unassign a Spoolman spool from an AMS slot (slot-only assignment, no tag link).
- // Distinct from unlinkSpoolMutation, which also clears the tag binding via
- // /spoolman/spools/<id>/unlink. This one only deletes the SpoolmanSlotAssignment
- // row so the spool remains tag-linked (if it was) but is no longer bound to a slot.
- const unassignSpoolmanSlotMutation = useMutation({
- mutationFn: (spoolmanSpoolId: number) => api.unassignSpoolmanSlot(spoolmanSpoolId),
- onSuccess: () => {
- showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
- // Invalidate every query that filters or counts spools by slot-assignment
- // state, otherwise re-opening LinkSpoolModal right after an unassign
- // shows a stale list that still treats the freed spool as taken.
- // unlinkSpoolMutation does the same set — keep them aligned so both
- // unbind paths refresh the same caches.
- queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
- queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
- queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
- queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
- queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
- setSlotActionPicker(null);
- },
- onError: (error: Error) => {
- showToast(error.message || t('inventory.assignFailed', 'Failed to unassign spool'), 'error');
- },
- });
- const getActiveSlotForAms = useCallback((amsId: number): number | null => {
- if (effectiveTrayNow === undefined) return null;
- if (amsId <= 3) {
- const activeAmsId = Math.floor(effectiveTrayNow / 4);
- if (activeAmsId === amsId) return effectiveTrayNow % 4;
- }
- if (amsId >= 128 && amsId <= 135) {
- // AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...)
- if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0;
- }
- return null;
- }, [effectiveTrayNow]);
- const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
- const globalTrayId = getGlobalTrayId(amsId, trayId, false);
- const slotPreset = slotPresets?.[globalTrayId];
- const mappedExtruderId = amsExtruderMap[String(amsId)];
- const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
- const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
- const slotData = {
- amsId,
- trayId,
- trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
- tray,
- 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,
- location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
- };
- setSlotActionPicker(slotData);
- }, [slotPresets, amsExtruderMap, isDualNozzle]);
- const handleExtSlotClick = useCallback((extTray: AMSTray) => {
- const extTrayId = extTray.id ?? 254;
- const slotTrayId = extTrayId - 254;
- const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
- const slotData = {
- amsId: 255,
- trayId: slotTrayId,
- trayCount: 1,
- tray: isTrayEmpty(extTray) ? null : extTray,
- 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,
- location: isDualNozzle
- ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
- : 'External',
- };
- setSlotActionPicker(slotData);
- }, [slotPresets, isDualNozzle]);
- const openConfigureFromPicker = useCallback(() => {
- if (!slotActionPicker) return;
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { tray, location, ...configData } = slotActionPicker;
- setSlotActionPicker(null);
- setConfigureSlotModal(configData);
- }, [slotActionPicker]);
- const openAssignFromPicker = useCallback(() => {
- if (!slotActionPicker || !selectedPrinterId) return;
- const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;
- setSlotActionPicker(null);
- setAssignSpoolModal({
- printerId: selectedPrinterId,
- amsId,
- trayId,
- trayInfo: {
- type: trayType || '',
- material: trayType,
- color: trayColor?.slice(0, 6) || '',
- location,
- },
- });
- }, [slotActionPicker, selectedPrinterId]);
- const openLinkFromPicker = useCallback(() => {
- if (!slotActionPicker || !selectedPrinterId) return;
- const { amsId, trayId, tray } = slotActionPicker;
- const linkTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase() || '';
- setSlotActionPicker(null);
- setLinkSpoolModal({
- tagUid: tray?.tag_uid || linkTag,
- trayUuid: tray?.tray_uuid || '',
- printerId: selectedPrinterId,
- amsId,
- trayId,
- });
- }, [slotActionPicker, selectedPrinterId, printerSerial]);
- const handleUnassignFromPicker = useCallback(() => {
- if (!slotActionPicker || !selectedPrinterId) return;
- const { amsId, trayId } = slotActionPicker;
- unassignMutation.mutate({ printerId: selectedPrinterId, amsId, trayId });
- }, [slotActionPicker, selectedPrinterId, unassignMutation]);
- // Set alert for low filament in status bar
- useEffect(() => {
- if (!isConnected && selectedPrinterId) {
- setAlert({ type: 'warning', message: t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected') });
- return;
- }
- for (const unit of amsUnits) {
- const trays = unit.tray || [];
- for (let i = 0; i < trays.length; i++) {
- const tray = trays[i];
- if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {
- const isExternal = unit.id === 254 || unit.id === 255;
- const isHt = !isExternal && unit.id >= 128;
- const slot = formatSlotLabel(unit.id, i, isHt, isExternal);
- setAlert({
- type: 'warning',
- message: `Low Filament: ${tray.tray_type} (${slot}) - ${tray.remain}% remaining`,
- });
- return;
- }
- }
- }
- 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;
- effectiveFill: number | 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,
- };
- // Fill level fallback chain: Spoolman → Inventory → AMS remain
- const spoolmanFill = getSpoolmanFillForSlot(unit.id, 0, isTrayEmpty(tray) ? null : tray);
- const invFill = fillOverrides[`${unit.id}-0`] ?? null;
- const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
- // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
- const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
- 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),
- effectiveFill: spoolmanFill ?? resolvedInvFill ?? amsFill,
- onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),
- });
- }
- for (const extTray of vtTrays) {
- const extTrayId = extTray.id ?? 254;
- // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool"
- // generically — use active_extruder to determine L vs R:
- // extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255)
- const isExtActive = isDualNozzle && effectiveTrayNow === 254
- ? (extTrayId === 254 && status?.active_extruder === 1) ||
- (extTrayId === 255 && status?.active_extruder === 0)
- : effectiveTrayNow === extTrayId;
- const extSlotTrayId = extTrayId - 254;
- // Fill level fallback chain: Spoolman → Inventory → AMS remain
- const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);
- const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
- const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
- // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
- const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
- 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: null,
- effectiveFill: extSpoolmanFill ?? extResolvedInvFill ?? extAmsFill,
- onClick: () => handleExtSlotClick(extTray),
- });
- }
- return items;
- }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]);
- return (
- <div className="h-full flex flex-col p-4">
- <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">
- <p className="text-lg mb-2">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
- <p className="text-sm">{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}</p>
- </div>
- </div>
- ) : !isConnected ? (
- <div className="flex-1 flex items-center justify-center h-full">
- <div className="text-center text-white/50">
- <p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
- </div>
- </div>
- ) : 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" />
- <p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
- <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
- </div>
- </div>
- ) : (
- <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">
- {regularAms.map((unit) => (
- <AmsUnitCard
- key={unit.id}
- unit={unit}
- activeSlot={getActiveSlotForAms(unit.id)}
- onConfigureSlot={handleAmsSlotClick}
- isDualNozzle={isDualNozzle}
- nozzleSide={getNozzleSide(unit.id)}
- thresholds={amsThresholds}
- fillOverrides={fillOverrides}
- spoolmanFillOverrides={spoolmanFillOverrides}
- />
- ))}
- </div>
- {/* Third row: single-slot cards (AMS-HT + External) — half-width to align with AMS cards */}
- {singleSlots.length > 0 && (
- <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
- {singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, effectiveFill, onClick }) => {
- const color = trayColorToCSS(tray.tray_color);
- return (
- <div
- key={key}
- className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-3 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}
- onClick={onClick}
- >
- {/* Spool */}
- <div className="relative w-10 h-10 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-xs text-white/50 font-medium truncate">{label}</span>
- {nozzleSide && <NozzleBadge side={nozzleSide} />}
- </div>
- <div className="text-sm 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 && effectiveFill != null && effectiveFill >= 0 && (
- <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0 flex flex-col-reverse">
- <div
- className="w-full rounded-full"
- style={{
- height: `${effectiveFill}%`,
- backgroundColor: getFillBarColor(effectiveFill),
- }}
- />
- </div>
- )}
- </div>
- );
- })}
- </div>
- )}
- </div>
- )}
- </div>
- {configureSlotModal && selectedPrinterId && (
- <ConfigureAmsSlotModal
- isOpen={!!configureSlotModal}
- onClose={() => setConfigureSlotModal(null)}
- printerId={selectedPrinterId}
- slotInfo={configureSlotModal}
- printerModel={mapModelCode(printer?.model ?? null) || undefined}
- fullScreen
- onSuccess={() => {
- queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });
- queryClient.invalidateQueries({ queryKey: ['printerStatus', selectedPrinterId] });
- }}
- />
- )}
- {/* Slot action picker */}
- {slotActionPicker && selectedPrinterId && (() => {
- const assignment = getAssignment(slotActionPicker.amsId, slotActionPicker.trayId);
- const linked = getLinkedSpool(slotActionPicker.amsId, slotActionPicker.trayId, slotActionPicker.tray);
- // Slot-only Spoolman assignment (no tag link). Resolves the spool details
- // from spoolmanInventorySpoolsCache so we can show "Assigned spool: …"
- // and an Unassign button — without this branch, picking a slot that was
- // assigned via the dashboard's Assign-to-AMS flow showed only "Configure"
- // with no info about which spool was bound.
- const spoolmanAssign = spoolmanEnabled
- ? spoolmanSlotAssignmentsAll.find(a =>
- a.printer_id === selectedPrinterId &&
- a.ams_id === slotActionPicker.amsId &&
- a.tray_id === slotActionPicker.trayId,
- )
- : undefined;
- const spoolmanAssignedSpool = spoolmanAssign
- ? spoolmanInventorySpoolsCache.find(s => s.id === spoolmanAssign.spoolman_spool_id) ?? null
- : null;
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center">
- <div
- className="absolute inset-0 bg-black/60 backdrop-blur-sm"
- onClick={() => setSlotActionPicker(null)}
- />
- <div className="relative w-full max-w-sm mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div className="flex items-center gap-2">
- {slotActionPicker.trayColor && (
- <span
- className="w-4 h-4 rounded-full border border-black/20"
- style={{ backgroundColor: `#${slotActionPicker.trayColor.slice(0, 6)}` }}
- />
- )}
- <h2 className="text-lg font-semibold text-white">{slotActionPicker.location}</h2>
- {slotActionPicker.traySubBrands && (
- <span className="text-sm text-bambu-gray">({slotActionPicker.traySubBrands})</span>
- )}
- </div>
- <button
- onClick={() => setSlotActionPicker(null)}
- className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
- >
- <X className="w-5 h-5" />
- </button>
- </div>
- <div className="p-4 space-y-2">
- {/* Currently assigned/linked spool info */}
- {!spoolmanEnabled && assignment?.spool && (
- <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
- <p className="text-xs text-bambu-gray mb-1">{t('inventory.assignedSpool', 'Assigned spool')}</p>
- <div className="flex items-center gap-2">
- {assignment.spool.rgba && (
- <span
- className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
- style={{ backgroundColor: `#${assignment.spool.rgba.substring(0, 6)}` }}
- />
- )}
- <span className="text-sm text-white">
- {assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material}
- {assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''}
- </span>
- <span className="text-[10px] font-mono text-zinc-500 shrink-0 ml-auto">#{assignment.spool.id}</span>
- </div>
- </div>
- )}
- {/* #1457: Assigned-spool block is rendered FIRST when a slot
- assignment exists, regardless of whether a (possibly stale)
- tag-link also exists. The tag-link block is the fallback
- for slots that have only a tag-link. */}
- {spoolmanEnabled && linked && !spoolmanAssignedSpool && (
- <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
- <p className="text-xs text-bambu-gray mb-1">{t('spoolman.linkedSpool', 'Linked spool')}</p>
- <div className="flex items-center gap-2">
- <span className="text-sm text-white">
- Spoolman #{linked.id}
- {linked.remaining_weight != null ? ` (${Math.round(linked.remaining_weight)}g)` : ''}
- </span>
- </div>
- </div>
- )}
- {spoolmanEnabled && spoolmanAssignedSpool && (
- <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
- <p className="text-xs text-bambu-gray mb-1">{t('inventory.assignedSpool', 'Assigned spool')}</p>
- <div className="flex items-center gap-2">
- {spoolmanAssignedSpool.rgba && (
- <span
- className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
- style={{ backgroundColor: `#${spoolmanAssignedSpool.rgba.substring(0, 6)}` }}
- />
- )}
- <span className="text-sm text-white">
- {spoolmanAssignedSpool.brand ? `${spoolmanAssignedSpool.brand} ` : ''}{spoolmanAssignedSpool.material}
- {spoolmanAssignedSpool.color_name ? ` - ${spoolmanAssignedSpool.color_name}` : ''}
- </span>
- </div>
- </div>
- )}
- <button
- onClick={openConfigureFromPicker}
- className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-blue transition-colors text-left"
- >
- <Settings2 className="w-5 h-5 text-bambu-blue flex-shrink-0" />
- <div>
- <p className="text-white font-medium">{t('configureAmsSlot.title')}</p>
- <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.configureDesc', 'Set filament preset, K-profile, and color')}</p>
- </div>
- </button>
- {/* Inventory: Assign or Unassign in local mode.
- BL-RFID-detected slots are owned by the printer firmware —
- suppress assign/unassign there to keep parity with the
- Spoolman branch (Phase 14 A3). Manual changes would be
- overwritten on the next RFID re-read. */}
- {!spoolmanEnabled && (() => {
- if (isBambuLabSpool(slotActionPicker?.tray)) return null;
- if (assignment) {
- return (
- <button
- onClick={handleUnassignFromPicker}
- disabled={unassignMutation.isPending}
- className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left"
- >
- <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
- <div>
- <p className="text-amber-400 font-medium">{t('inventory.unassignSpool', 'Unassign')}</p>
- <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>
- </div>
- </button>
- );
- }
- return (
- <button
- onClick={openAssignFromPicker}
- className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
- >
- <Package className="w-5 h-5 text-bambu-green flex-shrink-0" />
- <div>
- <p className="text-white font-medium">{t('inventory.assignSpool')}</p>
- <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.assignDesc', 'Track a spool from your inventory')}</p>
- </div>
- </button>
- );
- })()}
- {/* Spoolman: Link / Unlink (tag-linked) or Unassign (slot-only) */}
- {spoolmanEnabled && (() => {
- if (linked?.id) {
- return (
- <button
- onClick={() => unlinkSpoolMutation.mutate(linked.id)}
- disabled={unlinkSpoolMutation.isPending}
- className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left active:opacity-50"
- >
- <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
- <div>
- <p className="text-amber-400 font-medium">{t('inventory.unassignSpool')}</p>
- <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unlinkDesc', 'Remove Spoolman link from this slot')}</p>
- </div>
- </button>
- );
- }
- // Slot-only assignment (no tag link): show Unassign so the
- // user can clear it. Previously this branch returned null
- // and only the Configure button remained, hiding the fact
- // that a spool was bound to the slot at all.
- if (spoolmanAssignedSpool) {
- return (
- <button
- onClick={() => unassignSpoolmanSlotMutation.mutate(spoolmanAssignedSpool.id)}
- disabled={unassignSpoolmanSlotMutation.isPending}
- className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left active:opacity-50"
- >
- <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
- <div>
- <p className="text-amber-400 font-medium">{t('inventory.unassignSpool')}</p>
- <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>
- </div>
- </button>
- );
- }
- return (
- <button
- onClick={openLinkFromPicker}
- className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
- >
- <Link2 className="w-5 h-5 text-bambu-green flex-shrink-0" />
- <div>
- <p className="text-white font-medium">{t('inventory.assignSpool')}</p>
- <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.linkDesc', 'Link a Spoolman spool to this slot')}</p>
- </div>
- </button>
- );
- })()}
- </div>
- </div>
- </div>
- );
- })()}
- {/* Assign spool modal (inventory) */}
- {assignSpoolModal && (
- <AssignSpoolModal
- isOpen={!!assignSpoolModal}
- onClose={() => {
- setAssignSpoolModal(null);
- // Same dual-key invalidation as the unassign path — the AMS
- // status panel reads the printerId-keyed query while the
- // shared AssignSpoolModal reads the unkeyed one.
- queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
- queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
- }}
- printerId={assignSpoolModal.printerId}
- amsId={assignSpoolModal.amsId}
- trayId={assignSpoolModal.trayId}
- trayInfo={assignSpoolModal.trayInfo}
- spoolmanEnabled={!!spoolmanEnabled}
- />
- )}
- {/* Link spool modal (Spoolman) */}
- {linkSpoolModal && (
- <LinkSpoolModal
- isOpen={!!linkSpoolModal}
- onClose={() => setLinkSpoolModal(null)}
- tagUid={linkSpoolModal.tagUid}
- trayUuid={linkSpoolModal.trayUuid}
- printerId={linkSpoolModal.printerId}
- amsId={linkSpoolModal.amsId}
- trayId={linkSpoolModal.trayId}
- />
- )}
- </div>
- );
- }
|