Browse Source

Improvements AMS card

Martin Ziegler 5 months ago
parent
commit
8f3c36e920

+ 39 - 0
backend/app/api/routes/printer_control.py

@@ -141,6 +141,18 @@ class AMSRefreshTrayRequest(BaseModel):
     tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
 
 
+class AMSFilamentSettingRequest(BaseModel):
+    ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
+    tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
+    tray_info_idx: str = Field(..., description="Filament preset ID (e.g., 'GFA00')")
+    tray_type: str = Field(..., description="Filament type (e.g., 'PLA', 'PETG')")
+    tray_sub_brands: str = Field(default="", description="Sub-brand name (e.g., 'PLA Basic')")
+    tray_color: str = Field(..., description="Color in RRGGBBAA hex format")
+    nozzle_temp_min: int = Field(..., ge=150, le=350, description="Minimum nozzle temperature")
+    nozzle_temp_max: int = Field(..., ge=150, le=350, description="Maximum nozzle temperature")
+    k: float = Field(..., ge=0, le=1, description="Pressure advance (K) value")
+
+
 class GcodeRequest(ConfirmableRequest):
     command: str = Field(..., min_length=1, max_length=500, description="G-code command(s)")
 
@@ -635,6 +647,33 @@ async def ams_refresh_tray(
     return ControlResponse(success=success, message=message)
 
 
+@router.post("/{printer_id}/control/ams/filament-setting", response_model=ControlResponse)
+async def ams_set_filament_setting(
+    printer_id: int,
+    request: AMSFilamentSettingRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Set filament settings for an AMS tray including K (pressure advance) value."""
+    await get_printer_or_404(printer_id, db)
+    client = get_mqtt_client_or_503(printer_id)
+
+    success = client.ams_set_filament_setting(
+        ams_id=request.ams_id,
+        tray_id=request.tray_id,
+        tray_info_idx=request.tray_info_idx,
+        tray_type=request.tray_type,
+        tray_sub_brands=request.tray_sub_brands,
+        tray_color=request.tray_color,
+        nozzle_temp_min=request.nozzle_temp_min,
+        nozzle_temp_max=request.nozzle_temp_max,
+        k=request.k,
+    )
+    return ControlResponse(
+        success=success,
+        message=f"Updated AMS {request.ams_id} tray {request.tray_id} with K={request.k}" if success else "Failed to update filament setting"
+    )
+
+
 # =============================================================================
 # Advanced: G-code Command
 # =============================================================================

+ 54 - 0
backend/app/services/bambu_mqtt.py

@@ -2627,6 +2627,60 @@ class BambuMQTTClient:
         logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
         return True, f"Refreshing AMS {ams_id} tray {tray_id}"
 
+    def ams_set_filament_setting(
+        self,
+        ams_id: int,
+        tray_id: int,
+        tray_info_idx: str,
+        tray_type: str,
+        tray_sub_brands: str,
+        tray_color: str,
+        nozzle_temp_min: int,
+        nozzle_temp_max: int,
+        k: float,
+    ) -> bool:
+        """Set AMS tray filament settings including K (pressure advance) value.
+
+        Args:
+            ams_id: AMS unit ID (0-3)
+            tray_id: Tray ID within the AMS (0-3)
+            tray_info_idx: Filament preset ID (e.g., "GFA00")
+            tray_type: Filament type (e.g., "PLA", "PETG")
+            tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
+            tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
+            nozzle_temp_min: Minimum nozzle temperature
+            nozzle_temp_max: Maximum nozzle temperature
+            k: Pressure advance (K) value (e.g., 0.020)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "ams_filament_setting",
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+                "tray_info_idx": tray_info_idx,
+                "tray_type": tray_type,
+                "tray_sub_brands": tray_sub_brands,
+                "tray_color": tray_color,
+                "nozzle_temp_min": nozzle_temp_min,
+                "nozzle_temp_max": nozzle_temp_max,
+                "k": k,
+                "sequence_id": "0"
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
+        logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
+        self._client.publish(self.topic_publish, command_json)
+        return True
+
     def set_timelapse(self, enable: bool) -> bool:
         """Enable or disable timelapse recording.
 

+ 15 - 0
frontend/src/api/client.ts

@@ -1281,6 +1281,21 @@ export const api = {
     }),
   amsUnloadFilament: (printerId: number) =>
     request<ControlResponse>(`/printers/${printerId}/control/ams/unload`, { method: 'POST' }),
+  amsSetFilamentSetting: (printerId: number, data: {
+    ams_id: number;
+    tray_id: number;
+    tray_info_idx: string;
+    tray_type: string;
+    tray_sub_brands: string;
+    tray_color: string;
+    nozzle_temp_min: number;
+    nozzle_temp_max: number;
+    k: number;
+  }) =>
+    request<ControlResponse>(`/printers/${printerId}/control/ams/filament-setting`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
   sendGcode: (printerId: number, command: string, confirmToken?: string) =>
     request<ControlResult>(`/printers/${printerId}/control/gcode`, {
       method: 'POST',

+ 79 - 3
frontend/src/components/control/AMSMaterialsModal.tsx

@@ -74,6 +74,12 @@ function trayColorToHex(trayColor: string | null): string {
   return `#${cleanHex}`;
 }
 
+function hexToTrayColor(hex: string): string {
+  // Convert #RRGGBB to RRGGBBFF (with full opacity)
+  const cleanHex = hex.replace('#', '').toUpperCase();
+  return cleanHex.length === 6 ? `${cleanHex}FF` : `${cleanHex.substring(0, 6)}FF`;
+}
+
 // Check if tray has valid Bambu Lab UUID (32-char hex)
 function isBambuLabSpool(trayUuid: string | null): boolean {
   if (!trayUuid) return false;
@@ -210,16 +216,40 @@ export function AMSMaterialsModal({
     },
   });
 
+  // Mutation for saving filament settings to printer (including K value)
+  const saveFilamentSettingMutation = useMutation({
+    mutationFn: (data: {
+      ams_id: number;
+      tray_id: number;
+      tray_info_idx: string;
+      tray_type: string;
+      tray_sub_brands: string;
+      tray_color: string;
+      nozzle_temp_min: number;
+      nozzle_temp_max: number;
+      k: number;
+    }) => api.amsSetFilamentSetting(printerId, data),
+    onSuccess: (result) => {
+      console.log('[AMSMaterialsModal] saveFilamentSettingMutation SUCCESS:', result);
+    },
+    onError: (error) => {
+      console.error('[AMSMaterialsModal] saveFilamentSettingMutation ERROR:', error);
+    },
+  });
+
   // Get filament presets from cloud settings:
-  // 1. Show ALL filament presets (like Bambu Studio does in cloud profiles)
+  // 1. Filter out presets starting with "#" (experimental/special presets)
   // 2. Prioritize current printer model, then deduplicate by base name
   // 3. Sort alphabetically
   const filamentPresets = (() => {
     const allPresets = cloudSettings?.filament || [];
 
+    // Filter out presets starting with "#" (experimental/special presets)
+    const filteredPresets = allPresets.filter(p => !p.name.startsWith('# '));
+
     // Sort presets: current printer model first, then others
     const modelPattern = new RegExp(`@.*\\b${printerModel}\\b`, 'i');
-    const sortedByModel = [...allPresets].sort((a, b) => {
+    const sortedByModel = [...filteredPresets].sort((a, b) => {
       const aMatches = modelPattern.test(a.name);
       const bMatches = modelPattern.test(b.name);
       if (aMatches && !bMatches) return -1;
@@ -323,6 +353,22 @@ export function AMSMaterialsModal({
   const bambuColorName = getColorNameFromTrayId(tray.tray_id_name);
 
   useEffect(() => {
+    // First, try to find a profile matching the tray's current K value (from printer)
+    // This preserves the user's selection when reopening the modal
+    const currentK = tray.k;
+    if (currentK && currentK > 0) {
+      const matchingKProfile = matchingProfiles.find(p => {
+        const profileK = parseFloat(p.k_value);
+        return Math.abs(profileK - currentK) < 0.0001; // Allow small floating point tolerance
+      });
+      if (matchingKProfile) {
+        setSelectedKProfile(matchingKProfile.name);
+        setKValue(currentK);
+        return;
+      }
+    }
+
+    // Fall back to name-based matching if no K-value match found
     // For Bambu spools, use tray_sub_brands (e.g., "PLA Basic") and color name
     // For non-Bambu, use clean name from preset (e.g., "Bambu PLA Basic" without #) for K-profile matching
     const subBrands = isBambuSpool
@@ -340,7 +386,7 @@ export function AMSMaterialsModal({
       setSelectedKProfile('Default');
       setKValue(0);
     }
-  }, [profiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, isBambuSpool, bambuColorName]);
+  }, [profiles, matchingProfiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, tray.k, isBambuSpool, bambuColorName]);
 
   // Close on Escape key
   useEffect(() => {
@@ -360,14 +406,44 @@ export function AMSMaterialsModal({
   };
 
   const handleConfirm = () => {
+    console.log('[AMSMaterialsModal] handleConfirm called');
+    console.log('[AMSMaterialsModal] amsId:', amsId, 'tray.id:', tray.id, 'kValue:', kValue);
+
     // Save preset mapping for non-Bambu slots
     if (isEditable && selectedPresetId && selectedPresetName) {
+      console.log('[AMSMaterialsModal] Saving preset mapping');
       savePresetMutation.mutate({
         presetId: selectedPresetId,
         presetName: selectedPresetName,
       });
     }
 
+    // Send filament settings to printer (including K value)
+    // Use current tray data for Bambu spools, or edited values for non-Bambu
+    const trayColor = isBambuSpool
+      ? (tray.tray_color || '808080FF')
+      : hexToTrayColor(selectedColor);
+    const trayType = isBambuSpool
+      ? (tray.tray_type || 'PLA')
+      : (selectedFilamentType || 'PLA');
+    const traySubBrands = isBambuSpool
+      ? (tray.tray_sub_brands || '')
+      : (selectedPresetName ? getCleanFilamentName(selectedPresetName) : '');
+
+    const payload = {
+      ams_id: amsId,
+      tray_id: tray.id,
+      tray_info_idx: tray.tray_info_idx || '',
+      tray_type: trayType,
+      tray_sub_brands: traySubBrands,
+      tray_color: trayColor,
+      nozzle_temp_min: nozzleTempMin,
+      nozzle_temp_max: nozzleTempMax,
+      k: kValue,
+    };
+    console.log('[AMSMaterialsModal] Calling saveFilamentSettingMutation with payload:', payload);
+    saveFilamentSettingMutation.mutate(payload);
+
     onConfirm?.({
       filamentType: selectedFilamentType,
       color: selectedColor,

+ 17 - 11
frontend/src/components/control/AMSSectionDual.tsx

@@ -1,11 +1,12 @@
 import { useState, useEffect, useRef } from 'react';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useQuery } from '@tanstack/react-query';
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSUnit, AMSTray, KProfile } from '../../api/client';
 import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react';
 import { AMSHumidityModal } from './AMSHumidityModal';
 import { AMSMaterialsModal } from './AMSMaterialsModal';
 import { useToast } from '../../contexts/ToastContext';
+import { useAmsOperations } from './useAmsOperations';
 
 
 interface AMSSectionDualProps {
@@ -519,7 +520,10 @@ function AMSPanelContent({
               return (
                 <button
                   key={tray.id}
-                  onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    onSlotRefresh(selectedUnit.id, tray.id);
+                  }}
                   disabled={isRefreshing}
                   className={`w-14 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary transition-colors ${
                     isRefreshing ? 'opacity-70 cursor-wait' : 'hover:bg-bambu-dark-tertiary'
@@ -927,20 +931,22 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
   const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
   const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null);
 
-  // Track refreshing slot - cleared when tray data updates from MQTT
-  const [refreshingSlotState, setRefreshingSlotState] = useState<{ amsId: number; trayId: number; startTime: number } | null>(null);
+  // Get ams_status values from printer status
+  const amsStatusMain = status?.ams_status_main ?? 0;
+  const trayNow = status?.tray_now ?? 255;
 
-  // Track user-initiated filament change operations (for showing progress card immediately)
-  // Store both the operation type (load/unload) and the target tray ID for load operations
-  const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean; targetTrayId: number | null } | null>(null);
+  // AMS Operations hook - manages state machine for refresh/load/unload
+  const amsOps = useAmsOperations({
+    printerId,
+    amsUnits,
+    amsStatusMain,
+    trayNow,
+    onToast: showToast,
+  });
 
   // Track if we've done initial sync from tray_now
   const initialSyncDone = useRef(false);
 
-  // Track intended operation type synchronously (refs update immediately, unlike state)
-  // This prevents race conditions where MQTT updates arrive before React state updates
-  const intendedOperationRef = useRef<'load' | 'unload' | null>(null);
-
   // Sync selectedTray from status.tray_now on initial load
   // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool
   useEffect(() => {

+ 348 - 0
frontend/src/components/control/useAmsOperations.ts

@@ -0,0 +1,348 @@
+import { useState, useRef, useCallback, useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { AMSUnit } from '../../api/client';
+
+/**
+ * AMS Operation State Machine
+ *
+ * States:
+ * - IDLE: No operation in progress, all buttons enabled
+ * - REFRESHING: RFID refresh in progress for a specific slot
+ * - LOADING: Filament load in progress
+ * - UNLOADING: Filament unload in progress
+ *
+ * Completion detection:
+ * - REFRESH: AMS tray data changes (tag_uid, tray_uuid, etc.) OR timeout (15s)
+ * - LOAD/UNLOAD: ams_status_main transitions from 1 (filament_change) to 0 (idle) OR timeout (60s)
+ *
+ * Rules:
+ * - Only one operation at a time
+ * - All operations have timeout fallback
+ * - Operation can be cancelled/reset manually
+ */
+
+export type OperationState = 'IDLE' | 'REFRESHING' | 'LOADING' | 'UNLOADING';
+
+export interface RefreshTarget {
+  amsId: number;
+  trayId: number;
+}
+
+export interface OperationContext {
+  // For REFRESHING: which slot is being refreshed
+  refreshTarget?: RefreshTarget;
+  // For LOADING: target tray ID we're loading
+  loadTargetTrayId?: number;
+  // Timestamp when operation started
+  startTime: number;
+}
+
+interface UseAmsOperationsProps {
+  printerId: number;
+  amsUnits: AMSUnit[];
+  amsStatusMain: number;
+  trayNow: number;
+  onToast: (message: string, type: 'success' | 'error') => void;
+}
+
+interface UseAmsOperationsReturn {
+  // Current state
+  state: OperationState;
+  context: OperationContext | null;
+
+  // Operation triggers
+  startRefresh: (amsId: number, trayId: number) => void;
+  startLoad: (trayId: number, extruderId?: number) => void;
+  startUnload: () => void;
+
+  // Manual reset (e.g., for retry)
+  reset: () => void;
+
+  // Derived state helpers
+  isOperationInProgress: boolean;
+  isRefreshingSlot: (amsId: number, trayId: number) => boolean;
+
+  // For FilamentChangeCard - which type of operation
+  isLoadOperation: boolean;
+  loadTargetTrayId: number | null;
+
+  // Mutation error states (for UI feedback)
+  loadError: Error | null;
+  unloadError: Error | null;
+  refreshError: Error | null;
+}
+
+// Timeouts for different operations
+const REFRESH_TIMEOUT_MS = 15000; // 15 seconds for RFID refresh
+const FILAMENT_CHANGE_TIMEOUT_MS = 120000; // 2 minutes for load/unload (these can take a while with heating)
+
+export function useAmsOperations({
+  printerId,
+  amsUnits,
+  amsStatusMain,
+  trayNow,
+  onToast,
+}: UseAmsOperationsProps): UseAmsOperationsReturn {
+  const [state, setState] = useState<OperationState>('IDLE');
+  const [context, setContext] = useState<OperationContext | null>(null);
+
+  // Track previous values for transition detection
+  const prevAmsStatusMainRef = useRef(amsStatusMain);
+  const prevTrayDataRef = useRef<string>('');
+
+  // Timeout ref for cleanup
+  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+  // Clear any pending timeout
+  const clearOperationTimeout = useCallback(() => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+      timeoutRef.current = null;
+    }
+  }, []);
+
+  // Reset to IDLE state
+  const reset = useCallback(() => {
+    clearOperationTimeout();
+    setState('IDLE');
+    setContext(null);
+    prevTrayDataRef.current = '';
+  }, [clearOperationTimeout]);
+
+  // Set up timeout for current operation
+  const startOperationTimeout = useCallback((timeoutMs: number) => {
+    clearOperationTimeout();
+    const startTime = context?.startTime ?? Date.now();
+    timeoutRef.current = setTimeout(() => {
+      console.log(`[useAmsOperations] Operation timed out after ${timeoutMs}ms`);
+      reset();
+    }, timeoutMs);
+  }, [clearOperationTimeout, reset, context?.startTime]);
+
+  // === Mutations ===
+
+  const refreshMutation = useMutation({
+    mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
+      api.refreshAmsTray(printerId, amsId, trayId),
+    onSuccess: (data) => {
+      if (data.success) {
+        onToast(data.message || 'RFID refresh started', 'success');
+      } else {
+        onToast(data.message || 'Failed to refresh tray', 'error');
+        reset();
+      }
+    },
+    onError: (error) => {
+      console.error('[useAmsOperations] Refresh error:', error);
+      onToast('Failed to refresh tray', 'error');
+      reset();
+    },
+  });
+
+  const loadMutation = useMutation({
+    mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
+      api.amsLoadFilament(printerId, trayId, extruderId),
+    onSuccess: (data) => {
+      console.log('[useAmsOperations] Load request sent:', data);
+      // Don't reset here - wait for ams_status_main transition
+    },
+    onError: (error) => {
+      console.error('[useAmsOperations] Load error:', error);
+      reset();
+    },
+  });
+
+  const unloadMutation = useMutation({
+    mutationFn: () => api.amsUnloadFilament(printerId),
+    onSuccess: (data) => {
+      console.log('[useAmsOperations] Unload request sent:', data);
+      // Don't reset here - wait for ams_status_main transition
+    },
+    onError: (error) => {
+      console.error('[useAmsOperations] Unload error:', error);
+      reset();
+    },
+  });
+
+  // === Operation Triggers ===
+
+  const startRefresh = useCallback((amsId: number, trayId: number) => {
+    if (state !== 'IDLE') {
+      console.log('[useAmsOperations] Cannot start refresh - operation in progress:', state);
+      return;
+    }
+
+    console.log(`[useAmsOperations] Starting refresh: AMS ${amsId}, Tray ${trayId}`);
+
+    // Capture current tray data signature for change detection
+    const unit = amsUnits.find(u => u.id === amsId);
+    const tray = unit?.tray?.find(t => t.id === trayId);
+    if (tray) {
+      prevTrayDataRef.current = JSON.stringify({
+        tag_uid: tray.tag_uid,
+        tray_uuid: tray.tray_uuid,
+        tray_id_name: tray.tray_id_name,
+        tray_type: tray.tray_type,
+        tray_color: tray.tray_color,
+      });
+    }
+
+    const startTime = Date.now();
+    setState('REFRESHING');
+    setContext({ refreshTarget: { amsId, trayId }, startTime });
+
+    // Set timeout
+    timeoutRef.current = setTimeout(() => {
+      console.log(`[useAmsOperations] Refresh timeout for AMS ${amsId} tray ${trayId}`);
+      reset();
+    }, REFRESH_TIMEOUT_MS);
+
+    refreshMutation.mutate({ amsId, trayId });
+  }, [state, amsUnits, reset, refreshMutation]);
+
+  const startLoad = useCallback((trayId: number, extruderId?: number) => {
+    if (state !== 'IDLE') {
+      console.log('[useAmsOperations] Cannot start load - operation in progress:', state);
+      return;
+    }
+
+    console.log(`[useAmsOperations] Starting load: tray ${trayId}, extruder ${extruderId}`);
+
+    const startTime = Date.now();
+    setState('LOADING');
+    setContext({ loadTargetTrayId: trayId, startTime });
+
+    // Set timeout
+    timeoutRef.current = setTimeout(() => {
+      console.log(`[useAmsOperations] Load timeout for tray ${trayId}`);
+      reset();
+    }, FILAMENT_CHANGE_TIMEOUT_MS);
+
+    loadMutation.mutate({ trayId, extruderId });
+  }, [state, reset, loadMutation]);
+
+  const startUnload = useCallback(() => {
+    if (state !== 'IDLE') {
+      console.log('[useAmsOperations] Cannot start unload - operation in progress:', state);
+      return;
+    }
+
+    console.log('[useAmsOperations] Starting unload');
+
+    const startTime = Date.now();
+    setState('UNLOADING');
+    setContext({ startTime });
+
+    // Set timeout
+    timeoutRef.current = setTimeout(() => {
+      console.log('[useAmsOperations] Unload timeout');
+      reset();
+    }, FILAMENT_CHANGE_TIMEOUT_MS);
+
+    unloadMutation.mutate();
+  }, [state, reset, unloadMutation]);
+
+  // === Completion Detection ===
+
+  // Detect REFRESH completion via tray data change
+  useEffect(() => {
+    if (state !== 'REFRESHING' || !context?.refreshTarget) return;
+
+    const { amsId, trayId } = context.refreshTarget;
+    const unit = amsUnits.find(u => u.id === amsId);
+    const tray = unit?.tray?.find(t => t.id === trayId);
+
+    if (!tray) return;
+
+    const currentSignature = JSON.stringify({
+      tag_uid: tray.tag_uid,
+      tray_uuid: tray.tray_uuid,
+      tray_id_name: tray.tray_id_name,
+      tray_type: tray.tray_type,
+      tray_color: tray.tray_color,
+    });
+
+    // Require minimum 500ms to avoid false positives from initial render
+    const elapsed = Date.now() - context.startTime;
+    if (prevTrayDataRef.current && prevTrayDataRef.current !== currentSignature && elapsed > 500) {
+      console.log(`[useAmsOperations] Refresh complete: data changed for AMS ${amsId} tray ${trayId} (took ${elapsed}ms)`);
+      reset();
+    }
+  }, [state, context, amsUnits, reset]);
+
+  // Detect LOAD/UNLOAD completion via ams_status_main transition 1 → 0
+  useEffect(() => {
+    if (state !== 'LOADING' && state !== 'UNLOADING') {
+      prevAmsStatusMainRef.current = amsStatusMain;
+      return;
+    }
+
+    const wasActive = prevAmsStatusMainRef.current === 1;
+    const isNowIdle = amsStatusMain === 0;
+
+    if (wasActive && isNowIdle) {
+      console.log(`[useAmsOperations] ${state} complete: ams_status_main transitioned 1→0`);
+      reset();
+    }
+
+    prevAmsStatusMainRef.current = amsStatusMain;
+  }, [state, amsStatusMain, reset]);
+
+  // Secondary completion detection for LOAD: tray_now matches target
+  useEffect(() => {
+    if (state !== 'LOADING' || !context?.loadTargetTrayId) return;
+
+    if (trayNow === context.loadTargetTrayId) {
+      console.log(`[useAmsOperations] Load complete: tray_now=${trayNow} matches target`);
+      reset();
+    }
+  }, [state, context, trayNow, reset]);
+
+  // Secondary completion detection for UNLOAD: tray_now becomes 255
+  useEffect(() => {
+    if (state !== 'UNLOADING') return;
+
+    // Only trigger if we're past the initial phase (give it 1s to start)
+    const elapsed = context?.startTime ? Date.now() - context.startTime : 0;
+    if (trayNow === 255 && elapsed > 1000) {
+      console.log('[useAmsOperations] Unload complete: tray_now=255');
+      reset();
+    }
+  }, [state, context, trayNow, reset]);
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      clearOperationTimeout();
+    };
+  }, [clearOperationTimeout]);
+
+  // === Derived State ===
+
+  const isOperationInProgress = state !== 'IDLE';
+
+  const isRefreshingSlot = useCallback((amsId: number, trayId: number) => {
+    if (state !== 'REFRESHING' || !context?.refreshTarget) return false;
+    return context.refreshTarget.amsId === amsId && context.refreshTarget.trayId === trayId;
+  }, [state, context]);
+
+  const isLoadOperation = state === 'LOADING';
+  const loadTargetTrayId = context?.loadTargetTrayId ?? null;
+
+  return {
+    state,
+    context,
+    startRefresh,
+    startLoad,
+    startUnload,
+    reset,
+    isOperationInProgress,
+    isRefreshingSlot,
+    isLoadOperation,
+    loadTargetTrayId,
+    loadError: loadMutation.error,
+    unloadError: unloadMutation.error,
+    refreshError: refreshMutation.error,
+  };
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CLNJVLog.js


+ 1 - 1
static/index.html

@@ -7,7 +7,7 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-CXerUj0x.js"></script>
+    <script type="module" crossorigin src="/assets/index-CLNJVLog.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BpSfhfce.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff