Parcourir la source

Misc Spoolbuddy frontend fixes

maziggy il y a 2 mois
Parent
commit
d6e883652d

+ 9 - 4
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -1,4 +1,5 @@
 import type { AMSUnit, AMSTray } from '../../api/client';
+import { getFillBarColor } from '../../utils/amsHelpers';
 
 function trayColorToCSS(color: string | null): string {
   if (!color) return '#808080';
@@ -140,16 +141,18 @@ interface SpoolSlotProps {
   slotIndex: number;
   isActive: boolean;
   fillOverride?: number | null;
+  spoolmanFill?: number | null;
   onClick?: () => void;
 }
 
-function SpoolSlot({ tray, slotIndex, isActive, fillOverride, onClick }: SpoolSlotProps) {
+function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onClick }: SpoolSlotProps) {
   const isEmpty = isTrayEmpty(tray);
   const color = trayColorToCSS(tray.tray_color);
   const amsFill = tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 ? tray.remain : null;
   // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
   const resolvedOverride = (fillOverride === 0 && amsFill !== null && amsFill > 0) ? null : fillOverride;
-  const effectiveFill = resolvedOverride ?? amsFill;
+  // Fill level fallback chain: Spoolman → Inventory → AMS remain
+  const effectiveFill = spoolmanFill ?? resolvedOverride ?? amsFill;
 
   return (
     <div
@@ -188,7 +191,7 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, onClick }: SpoolSl
             className="h-full rounded-full transition-all"
             style={{
               width: `${effectiveFill}%`,
-              backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
+              backgroundColor: getFillBarColor(effectiveFill),
             }}
           />
         </div>
@@ -215,9 +218,10 @@ interface AmsUnitCardProps {
   nozzleSide?: 'L' | 'R' | null;
   thresholds?: AmsThresholds;
   fillOverrides?: Record<string, number>;
+  spoolmanFillOverrides?: Record<string, number>;
 }
 
-export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides }: AmsUnitCardProps) {
+export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides, spoolmanFillOverrides }: AmsUnitCardProps) {
   const trays = unit.tray || [];
   const isHt = unit.is_ams_ht;
   const slotCount = isHt ? 1 : 4;
@@ -275,6 +279,7 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
               slotIndex={i}
               isActive={activeSlot === i}
               fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}
+              spoolmanFill={spoolmanFillOverrides?.[`${unit.id}-${i}`] ?? null}
               onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
             />
           );

+ 55 - 6
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -4,6 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
 import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
+import type { AmsThresholds } from './AmsUnitCard';
+import { getFillBarColor } from '../../utils/amsHelpers';
 
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -62,6 +64,41 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
     enabled: isOpen && printerId !== null,
   });
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const { data: assignments } = useQuery({
+    queryKey: ['spool-assignments', printerId],
+    queryFn: () => api.getAssignments(printerId!),
+    enabled: isOpen && printerId !== null,
+    staleTime: 30 * 1000,
+  });
+
+  // Build fill-level override map from inventory assignments
+  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]);
+
+  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;
+
   const isConnected = status?.connected ?? false;
   const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
   const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
@@ -130,6 +167,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
     const items: {
       key: string; label: string; amsId: number; trayId: number;
       tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
+      effectiveFill: number | null;
     }[] = [];
 
     for (const unit of htAms) {
@@ -138,28 +176,37 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
         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,
       };
+      const invFill = fillOverrides[`${unit.id}-0`] ?? null;
+      const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
+      const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       items.push({
         key: `ht-${unit.id}`, label: getAmsName(unit.id),
         amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
         nozzleSide: getNozzleSide(unit.id),
+        effectiveFill: resolvedInvFill ?? amsFill,
       });
     }
 
     for (const extTray of vtTrays) {
       const extTrayId = extTray.id ?? 254;
+      const extSlotTrayId = extTrayId - 254;
+      const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
+      const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
+      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'),
-        amsId: 255, trayId: extTrayId - 254, tray: extTray,
+        amsId: 255, trayId: extSlotTrayId, tray: extTray,
         isEmpty: isTrayEmpty(extTray),
         nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
+        effectiveFill: extResolvedInvFill ?? extAmsFill,
       });
     }
 
     return items;
-  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide]);
+  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);
 
   if (!isOpen) return null;
 
@@ -236,6 +283,8 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
                     onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
                     isDualNozzle={isDualNozzle}
                     nozzleSide={getNozzleSide(unit.id)}
+                    thresholds={amsThresholds}
+                    fillOverrides={fillOverrides}
                   />
                 ))}
               </div>
@@ -244,7 +293,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
             {/* Single-slot items (HT + External) */}
             {singleSlots.length > 0 && (
               <div className="flex gap-2 shrink-0">
-                {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide }) => {
+                {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
                   const color = trayColorToCSS(tray.tray_color);
                   return (
                     <div
@@ -278,13 +327,13 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
                           {isEmpty ? 'Empty' : tray.tray_type || '?'}
                         </div>
                       </div>
-                      {!isEmpty && tray.remain != null && tray.remain >= 0 && (
+                      {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
                         <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden 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',
+                              height: `${effectiveFill}%`,
+                              backgroundColor: getFillBarColor(effectiveFill),
                             }}
                           />
                         </div>

+ 1 - 38
frontend/src/pages/PrintersPage.tsx

@@ -72,7 +72,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
-import { getGlobalTrayId } from '../utils/amsHelpers';
+import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers';
 import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
@@ -999,23 +999,6 @@ function getAmsLabel(amsId: number | string, trayCount: number): string {
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
 }
 
-// Get fill bar color based on spool fill level
-function getFillBarColor(fillLevel: number): string {
-  if (fillLevel > 50) return '#00ae42'; // Green - good
-  if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
-  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
-  ));
-}
 
 /**
  * Check if a tray contains a Bambu Lab spool (RFID-tagged).
@@ -1042,26 +1025,6 @@ function isBambuLabSpool(tray: {
   return false;
 }
 
-function toFixedHex(value: number, width: number): string {
-  const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
-  return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
-}
-
-// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
-function hashSerialToHex32(serial: string): string {
-  const input = (serial || '').trim().toUpperCase();
-  let hash = 0x811c9dc5;
-  for (let i = 0; i < input.length; i++) {
-    hash ^= input.charCodeAt(i);
-    hash = Math.imul(hash, 0x01000193);
-  }
-  return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
-}
-
-function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
-  // 16-char stable hex tag for slots without RFID identifiers
-  return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
-}
 
 function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
   const { t } = useTranslation();

+ 89 - 18
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -6,6 +6,7 @@ import { Layers } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray } from '../../api/client';
+import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../../utils/amsHelpers';
 import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
 import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
 import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
@@ -70,6 +71,23 @@ export function SpoolBuddyAmsPage() {
     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;
+
   const { data: assignments } = useQuery({
     queryKey: ['spool-assignments', selectedPrinterId],
     queryFn: () => api.getAssignments(selectedPrinterId!),
@@ -92,11 +110,58 @@ export function SpoolBuddyAmsPage() {
     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 => {
+    if (!linkedSpools || !printerSerial) return null;
+    const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
+    const linkedSpool = tag ? linkedSpools[tag] : undefined;
+    return getSpoolmanFillLevel(linkedSpool);
+  }, [linkedSpools, printerSerial]);
+
   const isConnected = status?.connected ?? false;
-  const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
+
+  // Cache AMS data to prevent it disappearing on idle/offline printers
+  const cachedAmsData = useRef<PrinterStatus['ams']>([]);
+  useEffect(() => {
+    if (status?.ams && status.ams.length > 0) {
+      cachedAmsData.current = status.ams;
+    }
+  }, [status?.ams]);
+  const amsUnits = useMemo(() => {
+    const live = status?.ams;
+    return (live && live.length > 0) ? live : (cachedAmsData.current ?? []);
+  }, [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;
+
+  // 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]);
 
@@ -141,17 +206,17 @@ export function SpoolBuddyAmsPage() {
   } | null>(null);
 
   const getActiveSlotForAms = useCallback((amsId: number): number | null => {
-    if (trayNow === 255 || trayNow === 254) return null;
+    if (effectiveTrayNow === undefined) return null;
     if (amsId <= 3) {
-      const activeAmsId = Math.floor(trayNow / 4);
-      if (activeAmsId === amsId) return trayNow % 4;
+      const activeAmsId = Math.floor(effectiveTrayNow / 4);
+      if (activeAmsId === amsId) return effectiveTrayNow % 4;
     }
     if (amsId >= 128 && amsId <= 135) {
-      const htIndex = amsId - 128;
-      if (trayNow === 16 + htIndex) return 0;
+      // AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...)
+      if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0;
     }
     return null;
-  }, [trayNow]);
+  }, [effectiveTrayNow]);
 
   const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
     const globalTrayId = amsId >= 128 ? (amsId - 128) * 4 + trayId + 64 : amsId * 4 + trayId;
@@ -226,6 +291,8 @@ export function SpoolBuddyAmsPage() {
         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)
@@ -239,20 +306,23 @@ export function SpoolBuddyAmsPage() {
         temp: unit.temp,
         humidity: unit.humidity,
         nozzleSide: getNozzleSide(unit.id),
-        effectiveFill: resolvedInvFill ?? amsFill,
+        effectiveFill: spoolmanFill ?? resolvedInvFill ?? amsFill,
         onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),
       });
     }
 
     for (const extTray of vtTrays) {
       const extTrayId = extTray.id ?? 254;
-      // tray_now=255 means "no tray loaded" (idle) — never active
-      const isExtActive = trayNow === 255 ? false
-        : isDualNozzle && trayNow === 254
-          ? (extTrayId === 254 && status?.active_extruder === 1) ||
-            (extTrayId === 255 && status?.active_extruder === 0)
-          : trayNow === extTrayId;
+      // 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)
@@ -266,13 +336,13 @@ export function SpoolBuddyAmsPage() {
         isEmpty: isTrayEmpty(extTray),
         isActive: isExtActive,
         nozzleSide: null,
-        effectiveFill: extResolvedInvFill ?? extAmsFill,
+        effectiveFill: extSpoolmanFill ?? extResolvedInvFill ?? extAmsFill,
         onClick: () => handleExtSlotClick(extTray),
       });
     }
 
     return items;
-  }, [htAms, vtTrays, isDualNozzle, trayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides]);
+  }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]);
 
   return (
     <div className="h-full flex flex-col p-4">
@@ -312,6 +382,7 @@ export function SpoolBuddyAmsPage() {
                   nozzleSide={getNozzleSide(unit.id)}
                   thresholds={amsThresholds}
                   fillOverrides={fillOverrides}
+                  spoolmanFillOverrides={spoolmanFillOverrides}
                 />
               ))}
             </div>
@@ -381,7 +452,7 @@ export function SpoolBuddyAmsPage() {
                             className="w-full rounded-full"
                             style={{
                               height: `${effectiveFill}%`,
-                              backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
+                              backgroundColor: getFillBarColor(effectiveFill),
                             }}
                           />
                         </div>

+ 48 - 0
frontend/src/utils/amsHelpers.ts

@@ -129,6 +129,54 @@ export function getGlobalTrayId(
   return amsId * 4 + trayId;
 }
 
+/**
+ * Get fill bar color based on spool fill level.
+ * Matches PrintersPage thresholds and Bambu Lab brand green.
+ */
+export function getFillBarColor(fillLevel: number): string {
+  if (fillLevel > 50) return '#00ae42'; // Green - good
+  if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
+  return '#ef4444'; // Red - critical (< 15%)
+}
+
+/**
+ * Calculate fill level from Spoolman weight data.
+ * Used as the first source in the Spoolman → Inventory → AMS fill chain.
+ */
+export function getSpoolmanFillLevel(
+  linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | 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 toFixedHex(value: number, width: number): string {
+  const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
+  return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
+}
+
+// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
+function hashSerialToHex32(serial: string): string {
+  const input = (serial || '').trim().toUpperCase();
+  let hash = 0x811c9dc5;
+  for (let i = 0; i < input.length; i++) {
+    hash ^= input.charCodeAt(i);
+    hash = Math.imul(hash, 0x01000193);
+  }
+  return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
+}
+
+/**
+ * Generate a stable fallback spool tag for slots without RFID identifiers.
+ * Returns a 16-char hex string derived from the printer serial + slot position.
+ */
+export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
+  return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
+}
+
 /**
  * Get minimum datetime for scheduling (now + 1 minute).
  * Returns ISO string format for datetime-local input.

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CJ-drcFM.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-D5Vkuxg1.js


+ 2 - 2
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff