Browse Source

Started wiring up AMS module

Martin Ziegler 5 months ago
parent
commit
b8d32c04a7

BIN
frontend/public/icons/extruder-change-filament.png


+ 72 - 2
frontend/src/api/client.ts

@@ -46,8 +46,15 @@ export interface AMSTray {
   id: number;
   tray_color: string | null;
   tray_type: string | null;
+  tray_sub_brands: string | null;  // Full name like "PLA Basic", "PETG HF"
+  tray_id_name: string | null;  // Bambu filament ID like "A00-Y2" (can decode to color)
+  tray_info_idx: string | null;  // Filament preset ID like "GFA00" - maps to cloud setting_id
   remain: number;
   k: number | null;  // Pressure advance value
+  tag_uid: string | null;  // RFID tag UID (any tag)
+  tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
+  nozzle_temp_min: number | null;  // Min nozzle temperature
+  nozzle_temp_max: number | null;  // Max nozzle temperature
 }
 
 export interface AMSUnit {
@@ -124,6 +131,15 @@ export interface PrinterStatus {
   chamber_light: boolean;
   // Active extruder for dual nozzle (0=right, 1=left)
   active_extruder: number;
+  // AMS mapping - which AMS is connected to which nozzle
+  // Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS
+  ams_mapping: number[];
+  // Per-AMS extruder mapping - extracted from each AMS unit's info field
+  // Format: {ams_id: extruder_id} where extruder 0=right, 1=left
+  // Note: JSON keys are always strings
+  ams_extruder_map: Record<string, number>;
+  // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
+  tray_now: number;
 }
 
 export interface PrinterCreate {
@@ -421,6 +437,34 @@ export interface KProfilesResponse {
   nozzle_diameter: string;
 }
 
+// Slot Preset Mapping
+export interface SlotPresetMapping {
+  ams_id: number;
+  tray_id: number;
+  preset_id: string;
+  preset_name: string;
+}
+
+// Filament types
+export interface Filament {
+  id: number;
+  name: string;
+  type: string;  // PLA, PETG, ABS, etc.
+  brand: string | null;
+  color: string | null;
+  color_hex: string | null;
+  cost_per_kg: number;
+  spool_weight_g: number;
+  currency: string;
+  density: number | null;
+  print_temp_min: number | null;
+  print_temp_max: number | null;
+  bed_temp_min: number | null;
+  bed_temp_max: number | null;
+  created_at: string;
+  updated_at: string;
+}
+
 // Notification Provider types
 export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
 
@@ -1036,6 +1080,25 @@ export const api = {
       body: JSON.stringify(profile),
     }),
 
+  // Slot Preset Mappings
+  getSlotPresets: (printerId: number) =>
+    request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
+  getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
+    request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
+  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) =>
+    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}`, {
+      method: 'PUT',
+    }),
+  deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
+    request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
+      method: 'DELETE',
+    }),
+
+  // Filaments
+  listFilaments: () => request<Filament[]>('/filaments/'),
+  getFilament: (id: number) => request<Filament>(`/filaments/${id}`),
+  getFilamentsByType: (type: string) => request<Filament[]>(`/filaments/by-type/${type}`),
+
   // Notification Providers
   getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
   getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),
@@ -1204,10 +1267,10 @@ export const api = {
     }),
   enableMotors: (printerId: number) =>
     request<ControlResponse>(`/printers/${printerId}/control/motors/enable`, { method: 'POST' }),
-  amsLoadFilament: (printerId: number, trayId: number) =>
+  amsLoadFilament: (printerId: number, trayId: number, extruderId?: number) =>
     request<ControlResponse>(`/printers/${printerId}/control/ams/load`, {
       method: 'POST',
-      body: JSON.stringify({ tray_id: trayId }),
+      body: JSON.stringify({ tray_id: trayId, extruder_id: extruderId }),
     }),
   amsUnloadFilament: (printerId: number) =>
     request<ControlResponse>(`/printers/${printerId}/control/ams/unload`, { method: 'POST' }),
@@ -1239,6 +1302,13 @@ export const api = {
       method: 'POST',
     }),
 
+  // Refresh a specific AMS tray (trigger RFID re-read)
+  refreshAmsTray: (printerId: number, amsId: number, trayId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/ams/refresh-tray`, {
+      method: 'POST',
+      body: JSON.stringify({ ams_id: amsId, tray_id: trayId }),
+    }),
+
   // Print Options (AI Detection)
   setPrintOption: (
     printerId: number,

+ 100 - 0
frontend/src/components/control/AMSHumidityModal.tsx

@@ -0,0 +1,100 @@
+import { useEffect } from 'react';
+import { X } from 'lucide-react';
+
+interface AMSHumidityModalProps {
+  humidity: number;
+  temperature: number;
+  dryingStatus: 'idle' | 'drying';
+  remainingTime?: number; // in minutes
+  onClose: () => void;
+}
+
+// Single humidity icon that fills based on level (larger version for modal)
+function HumidityIconLarge({ humidity }: { humidity: number }) {
+  const getIconSrc = (): string => {
+    if (humidity < 25) return '/icons/humidity-empty.svg';
+    if (humidity < 40) return '/icons/humidity-half.svg';
+    return '/icons/humidity-full.svg';
+  };
+
+  return (
+    <img
+      src={getIconSrc()}
+      alt=""
+      className="w-16 h-24"
+    />
+  );
+}
+
+export function AMSHumidityModal({
+  humidity,
+  temperature,
+  dryingStatus,
+  remainingTime,
+  onClose,
+}: AMSHumidityModalProps) {
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const formatRemainingTime = () => {
+    if (dryingStatus === 'idle') return 'Idle';
+    if (!remainingTime) return 'Drying...';
+    const hours = Math.floor(remainingTime / 60);
+    const mins = remainingTime % 60;
+    if (hours > 0) {
+      return `${hours}h ${mins}m`;
+    }
+    return `${mins}m`;
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div className="bg-white dark:bg-bambu-dark-secondary rounded-lg shadow-xl max-w-md w-full">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-white">Current AMS humidity</h2>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-gray-100 dark:hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+          >
+            <X className="w-5 h-5 text-gray-500 dark:text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-6">
+          {/* Large humidity icon */}
+          <div className="flex flex-col items-center mb-6">
+            <HumidityIconLarge humidity={humidity} />
+            <div className="flex items-center gap-2 mt-2 text-gray-600 dark:text-bambu-gray">
+              <span className="text-yellow-500">❊</span>
+              <span>{dryingStatus === 'idle' ? 'Idle' : 'Drying'}</span>
+            </div>
+          </div>
+
+          {/* Stats row */}
+          <div className="grid grid-cols-3 gap-4 text-center">
+            <div>
+              <div className="text-sm text-gray-500 dark:text-bambu-gray mb-1">Humidity</div>
+              <div className="text-lg font-medium text-gray-900 dark:text-white">{humidity}%</div>
+            </div>
+            <div>
+              <div className="text-sm text-gray-500 dark:text-bambu-gray mb-1">Temperature</div>
+              <div className="text-lg font-medium text-gray-900 dark:text-white">{temperature} °C</div>
+            </div>
+            <div>
+              <div className="text-sm text-gray-500 dark:text-bambu-gray mb-1">Remaining Time</div>
+              <div className="text-lg font-medium text-gray-900 dark:text-white">{formatRemainingTime()}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 560 - 0
frontend/src/components/control/AMSMaterialsModal.tsx

@@ -0,0 +1,560 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, ExternalLink } from 'lucide-react';
+import { api } from '../../api/client';
+import type { AMSTray, KProfile } from '../../api/client';
+
+interface AMSMaterialsModalProps {
+  tray: AMSTray;
+  amsId: number;  // AMS unit ID (0, 1, 2, 3)
+  slotLabel: string;
+  printerId: number;
+  printerModel: string;  // e.g., "H2D", "X1C", "P1S"
+  nozzleDiameter?: string;
+  onClose: () => void;
+  onConfirm?: (data: MaterialSettings) => void;
+}
+
+// Extract base filament name (without printer/nozzle suffix)
+// e.g., "# Bambu PLA Basic @BBL H2D" -> "# Bambu PLA Basic"
+// e.g., "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" -> "Devil Design PLA Basic"
+function getBaseFilamentName(name: string): string {
+  const atIndex = name.indexOf(' @');
+  return atIndex > 0 ? name.substring(0, atIndex) : name;
+}
+
+// Get normalized name for deduplication (without # prefix and @ suffix)
+// e.g., "# Bambu PLA Basic @BBL H2D" -> "Bambu PLA Basic"
+// e.g., "Bambu PLA Basic @BBL X1C" -> "Bambu PLA Basic"
+function getNormalizedName(name: string): string {
+  let normalized = name;
+  if (normalized.startsWith('# ')) {
+    normalized = normalized.substring(2);
+  }
+  const atIndex = normalized.indexOf(' @');
+  return atIndex > 0 ? normalized.substring(0, atIndex) : normalized;
+}
+
+// Get clean name for K-profile matching (without # prefix and @ suffix)
+function getCleanFilamentName(name: string): string {
+  let cleanName = name;
+  if (cleanName.startsWith('# ')) {
+    cleanName = cleanName.substring(2);
+  }
+  const atIndex = cleanName.indexOf(' @');
+  return atIndex > 0 ? cleanName.substring(0, atIndex) : cleanName;
+}
+
+export interface MaterialSettings {
+  filamentType: string;
+  color: string;
+  nozzleTempMax: number;
+  nozzleTempMin: number;
+  kProfile: string | null;
+  kValue: number;
+}
+
+function hexToRgb(hex: string | null): string {
+  if (!hex) return 'rgb(128, 128, 128)';
+  const cleanHex = hex.replace('#', '').substring(0, 6);
+  const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
+  const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
+  const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
+  return `rgb(${r}, ${g}, ${b})`;
+}
+
+function trayColorToHex(trayColor: string | null): string {
+  if (!trayColor) return '#808080';
+  // Tray color comes as RRGGBBAA, we need #RRGGBB
+  const cleanHex = trayColor.replace('#', '').substring(0, 6);
+  return `#${cleanHex}`;
+}
+
+// Check if tray has valid Bambu Lab UUID (32-char hex)
+function isBambuLabSpool(trayUuid: string | null): boolean {
+  if (!trayUuid) return false;
+  const uuid = trayUuid.trim();
+  if (uuid.length !== 32) return false;
+  if (uuid === '00000000000000000000000000000000') return false;
+  return /^[0-9a-fA-F]{32}$/.test(uuid);
+}
+
+// Bambu Lab color codes from tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
+const BAMBU_COLOR_CODES: Record<string, string> = {
+  'Y2': 'Sunflower Yellow', 'Y0': 'Yellow', 'Y1': 'Lemon Yellow',
+  'K0': 'Black', 'W0': 'White', 'W1': 'Ivory White',
+  'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Magenta',
+  'B0': 'Blue', 'B1': 'Navy Blue', 'B2': 'Sky Blue', 'B3': 'Cyan',
+  'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Jade Green',
+  'O0': 'Orange', 'O1': 'Mandarin Orange',
+  'P0': 'Purple', 'P1': 'Pink', 'P2': 'Sakura Pink',
+  'N0': 'Gray', 'N1': 'Silver Gray', 'N2': 'Charcoal',
+  'D0': 'Brown', 'D1': 'Chocolate',
+  'T0': 'Titan Gray', 'T1': 'Jade White',
+};
+
+function getColorNameFromTrayId(trayIdName: string | null): string | null {
+  if (!trayIdName) return null;
+  // tray_id_name format: "A00-Y2" or "G02-K0" - color code is after the dash
+  const parts = trayIdName.split('-');
+  if (parts.length < 2) return null;
+  const colorCode = parts[1];
+  return BAMBU_COLOR_CODES[colorCode] || null;
+}
+
+// Find best matching K-profile for a filament
+// Profiles have names like "HF Bambu PLA Basic Sunflower Yellow", "High Flow_Bambu PLA Basic"
+// tray_sub_brands is like "PLA Basic", "PETG HF"
+// tray_type is like "PLA", "PETG"
+// colorName is like "Sunflower Yellow"
+function findBestKProfile(
+  profiles: KProfile[],
+  traySubBrands: string | null,
+  trayType: string | null,
+  colorName: string | null
+): KProfile | null {
+  if (!profiles.length) return null;
+
+  const subBrands = traySubBrands?.toLowerCase() || '';
+  const type = trayType?.toUpperCase() || '';
+  const color = colorName?.toLowerCase() || '';
+
+  // Priority 1: Match tray_sub_brands AND color (e.g., "PLA Basic" + "Sunflower Yellow")
+  if (subBrands && color) {
+    const exactColorMatch = profiles.find(p => {
+      const name = p.name.toLowerCase();
+      return name.includes(subBrands) && name.includes(color);
+    });
+    if (exactColorMatch) return exactColorMatch;
+  }
+
+  // Priority 2: Match tray_sub_brands without color (e.g., "PLA Basic" in "High Flow_Bambu PLA Basic")
+  if (subBrands) {
+    const subBrandsMatch = profiles.find(p =>
+      p.name.toLowerCase().includes(subBrands)
+    );
+    if (subBrandsMatch) return subBrandsMatch;
+  }
+
+  // Priority 3: Match filament type in profile name (e.g., "PLA" in "High Flow_Bambu PLA Basic")
+  if (type) {
+    const typeMatches = profiles.filter(p =>
+      p.name.toUpperCase().includes(type)
+    );
+
+    if (typeMatches.length > 0) {
+      // Prefer "Basic" profiles for generic type matching
+      const basicMatch = typeMatches.find(p =>
+        p.name.toLowerCase().includes('basic')
+      );
+      if (basicMatch) return basicMatch;
+
+      return typeMatches[0];
+    }
+  }
+
+  // Priority 4: Default profile
+  const defaultProfile = profiles.find(p =>
+    p.name.toLowerCase() === 'default' || p.slot_id === 0
+  );
+  return defaultProfile || null;
+}
+
+export function AMSMaterialsModal({
+  tray,
+  amsId,
+  slotLabel,
+  printerId,
+  printerModel,
+  nozzleDiameter = '0.4',
+  onClose,
+  onConfirm,
+}: AMSMaterialsModalProps) {
+  const queryClient = useQueryClient();
+
+  // Determine slot type
+  const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE';
+  const isBambuSpool = isBambuLabSpool(tray.tray_uuid);
+  const isEditable = isEmpty || !isBambuSpool;
+
+  // Fetch K-profiles
+  const { data: kProfilesData } = useQuery({
+    queryKey: ['kprofiles', printerId, nozzleDiameter],
+    queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
+  });
+
+  // Fetch cloud filament presets
+  const { data: cloudSettings } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+  });
+
+  // Fetch saved slot preset mapping
+  const { data: savedPreset } = useQuery({
+    queryKey: ['slotPreset', printerId, amsId, tray.id],
+    queryFn: () => api.getSlotPreset(printerId, amsId, tray.id),
+    enabled: isEditable,  // Only fetch for editable (non-Bambu) slots
+  });
+
+  // Mutation for saving preset mapping
+  const savePresetMutation = useMutation({
+    mutationFn: ({ presetId, presetName }: { presetId: string; presetName: string }) =>
+      api.saveSlotPreset(printerId, amsId, tray.id, presetId, presetName),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slotPreset', printerId, amsId, tray.id] });
+    },
+  });
+
+  // Get filament presets from cloud settings:
+  // 1. Show ALL filament presets (like Bambu Studio does in cloud profiles)
+  // 2. Prioritize current printer model, then deduplicate by base name
+  // 3. Sort alphabetically
+  const filamentPresets = (() => {
+    const allPresets = cloudSettings?.filament || [];
+
+    // Sort presets: current printer model first, then others
+    const modelPattern = new RegExp(`@.*\\b${printerModel}\\b`, 'i');
+    const sortedByModel = [...allPresets].sort((a, b) => {
+      const aMatches = modelPattern.test(a.name);
+      const bMatches = modelPattern.test(b.name);
+      if (aMatches && !bMatches) return -1;
+      if (!aMatches && bMatches) return 1;
+      return 0;
+    });
+
+    // Remove duplicates - keep first occurrence of each normalized name
+    // (presets for current printer model will be kept due to sorting above)
+    // Uses normalized name (without # prefix) so "# Bambu ABS" and "Bambu ABS" are treated as same
+    const seenNormalizedNames = new Set<string>();
+    const unique = sortedByModel.filter(p => {
+      const normalizedName = getNormalizedName(p.name);
+      if (seenNormalizedNames.has(normalizedName)) return false;
+      seenNormalizedNames.add(normalizedName);
+      return true;
+    });
+
+    // Sort by base name alphabetically
+    const sorted = unique.sort((a, b) =>
+      getBaseFilamentName(a.name).localeCompare(getBaseFilamentName(b.name))
+    );
+
+    return sorted;
+  })();
+
+  // State
+  const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
+  const [selectedPresetName, setSelectedPresetName] = useState<string | null>(null);
+  const [selectedFilamentType, setSelectedFilamentType] = useState(tray.tray_type || '');
+  const [selectedColor, setSelectedColor] = useState(trayColorToHex(tray.tray_color));
+  const [nozzleTempMax, setNozzleTempMax] = useState(tray.nozzle_temp_max || 220);
+  const [nozzleTempMin, setNozzleTempMin] = useState(tray.nozzle_temp_min || 190);
+  const [selectedKProfile, setSelectedKProfile] = useState<string>('');
+  const [kValue, setKValue] = useState(tray.k || 0);
+
+  // Load saved preset when data is available
+  useEffect(() => {
+    if (savedPreset && !selectedPresetId) {
+      setSelectedPresetId(savedPreset.preset_id);
+      setSelectedPresetName(savedPreset.preset_name);
+    }
+  }, [savedPreset, selectedPresetId]);
+
+  // Handle filament preset selection from dropdown
+  const handlePresetSelect = (settingId: string) => {
+    if (!settingId) {
+      setSelectedPresetId(null);
+      setSelectedPresetName(null);
+      return;
+    }
+    setSelectedPresetId(settingId);
+    const preset = filamentPresets.find(f => f.setting_id === settingId);
+    if (preset) {
+      setSelectedPresetName(preset.name);
+      // Extract filament type from preset name (e.g., "Devil Design PLA Basic @BBL X1C" -> look for PLA, PETG, etc.)
+      const typeMatch = preset.name.match(/\b(PLA|PETG|ABS|TPU|ASA|PA|PC|PVA|HIPS|PLA-S|PETG-CF|PA-CF|PET-CF)\b/i);
+      if (typeMatch) {
+        setSelectedFilamentType(typeMatch[1].toUpperCase());
+      }
+    }
+  };
+
+  // Find best K-profile when data loads or filament type changes
+  const profiles = kProfilesData?.profiles || [];
+
+  // Get color name from Bambu tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
+  const bambuColorName = getColorNameFromTrayId(tray.tray_id_name);
+
+  useEffect(() => {
+    // 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
+      ? tray.tray_sub_brands
+      : (selectedPresetName ? getCleanFilamentName(selectedPresetName) : null);
+    const type = isBambuSpool ? tray.tray_type : (selectedFilamentType || tray.tray_type);
+    const colorName = isBambuSpool ? bambuColorName : null;
+
+    const bestProfile = findBestKProfile(profiles, subBrands, type, colorName);
+
+    if (bestProfile) {
+      setSelectedKProfile(bestProfile.name);
+      setKValue(parseFloat(bestProfile.k_value) || 0);
+    } else {
+      setSelectedKProfile('Default');
+      setKValue(0);
+    }
+  }, [profiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, isBambuSpool, bambuColorName]);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const handleKProfileChange = (profileName: string) => {
+    setSelectedKProfile(profileName);
+    const profile = profiles.find(p => p.name === profileName);
+    if (profile) {
+      setKValue(parseFloat(profile.k_value) || 0);
+    }
+  };
+
+  const handleConfirm = () => {
+    // Save preset mapping for non-Bambu slots
+    if (isEditable && selectedPresetId && selectedPresetName) {
+      savePresetMutation.mutate({
+        presetId: selectedPresetId,
+        presetName: selectedPresetName,
+      });
+    }
+
+    onConfirm?.({
+      filamentType: selectedFilamentType,
+      color: selectedColor,
+      nozzleTempMax,
+      nozzleTempMin,
+      kProfile: selectedKProfile,
+      kValue,
+    });
+    onClose();
+  };
+
+  const handleReset = () => {
+    setSelectedFilamentType(tray.tray_type || '');
+    setSelectedColor(trayColorToHex(tray.tray_color));
+    setNozzleTempMax(tray.nozzle_temp_max || 220);
+    setNozzleTempMin(tray.nozzle_temp_min || 190);
+    setKValue(tray.k || 0);
+  };
+
+  const filamentDisplayName = isBambuSpool
+    ? (tray.tray_sub_brands || tray.tray_type || 'Unknown')
+    : (selectedFilamentType || 'Select filament');
+
+  const serialNumber = isBambuSpool && tray.tray_uuid
+    ? tray.tray_uuid
+    : 'N/A';
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div className="bg-white dark:bg-bambu-dark-secondary rounded-lg shadow-xl max-w-md w-full">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
+            AMS Materials Setting - {slotLabel}
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-gray-100 dark:hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+          >
+            <X className="w-5 h-5 text-gray-500 dark:text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-6 space-y-4">
+          {/* Status indicator */}
+          {isBambuSpool && (
+            <div className="flex items-center gap-2 text-xs text-bambu-green bg-bambu-green/10 px-3 py-1.5 rounded-md">
+              <span className="w-2 h-2 bg-bambu-green rounded-full"></span>
+              Bambu Lab RFID detected - fields auto-filled
+            </div>
+          )}
+          {!isBambuSpool && !isEmpty && (
+            <div className="flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/20 px-3 py-1.5 rounded-md">
+              <span className="w-2 h-2 bg-yellow-500 rounded-full"></span>
+              Non-Bambu Lab filament - please verify settings
+            </div>
+          )}
+          {isEmpty && (
+            <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-bambu-gray bg-gray-100 dark:bg-bambu-dark px-3 py-1.5 rounded-md">
+              <span className="w-2 h-2 bg-gray-400 rounded-full"></span>
+              Empty slot - configure filament settings
+            </div>
+          )}
+
+          {/* Filament */}
+          <div className="flex items-center gap-4">
+            <label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Filament</label>
+            {isEditable ? (
+              <select
+                value={selectedPresetId || ''}
+                onChange={(e) => handlePresetSelect(e.target.value)}
+                className="flex-1 min-w-0 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white text-sm truncate"
+              >
+                <option value="">Select filament...</option>
+                {filamentPresets.length > 0 ? (
+                  filamentPresets.map(preset => (
+                    <option key={preset.setting_id} value={preset.setting_id}>
+                      {getBaseFilamentName(preset.name)}
+                    </option>
+                  ))
+                ) : (
+                  <option value="" disabled>No filament presets for {printerModel}</option>
+                )}
+              </select>
+            ) : (
+              <div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md text-gray-900 dark:text-white">
+                {filamentDisplayName}
+              </div>
+            )}
+          </div>
+
+          {/* Color */}
+          <div className="flex items-center gap-4">
+            <label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Color</label>
+            <div className="flex items-center gap-2 flex-1">
+              {isEditable ? (
+                <>
+                  <input
+                    type="color"
+                    value={selectedColor}
+                    onChange={(e) => setSelectedColor(e.target.value)}
+                    className="w-10 h-10 rounded cursor-pointer border-2 border-gray-300 dark:border-bambu-dark-tertiary"
+                  />
+                  <span className="text-gray-900 dark:text-white text-sm">{selectedColor.toUpperCase()}</span>
+                </>
+              ) : (
+                <>
+                  <div
+                    className="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-bambu-dark-tertiary flex-shrink-0"
+                    style={{ backgroundColor: hexToRgb(tray.tray_color) }}
+                  />
+                  <span className="text-gray-900 dark:text-white">
+                    {bambuColorName || trayColorToHex(tray.tray_color).toUpperCase()}
+                  </span>
+                </>
+              )}
+            </div>
+          </div>
+
+          {/* Nozzle Temperature */}
+          <div className="flex items-center gap-4">
+            <label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Nozzle Temp</label>
+            <div className="flex items-center gap-2 flex-1">
+              <div>
+                <div className="text-xs text-gray-500 dark:text-bambu-gray mb-1">min</div>
+                <div className="flex items-center gap-1">
+                  <input
+                    type="number"
+                    value={nozzleTempMin}
+                    onChange={(e) => setNozzleTempMin(Number(e.target.value))}
+                    disabled={!isEditable}
+                    className="w-20 px-2 py-1 bg-gray-100 dark:bg-bambu-dark rounded border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white disabled:opacity-60"
+                  />
+                  <span className="text-gray-600 dark:text-bambu-gray">°C</span>
+                </div>
+              </div>
+              <div>
+                <div className="text-xs text-gray-500 dark:text-bambu-gray mb-1">max</div>
+                <div className="flex items-center gap-1">
+                  <input
+                    type="number"
+                    value={nozzleTempMax}
+                    onChange={(e) => setNozzleTempMax(Number(e.target.value))}
+                    disabled={!isEditable}
+                    className="w-20 px-2 py-1 bg-gray-100 dark:bg-bambu-dark rounded border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white disabled:opacity-60"
+                  />
+                  <span className="text-gray-600 dark:text-bambu-gray">°C</span>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          {/* SN */}
+          <div className="flex items-center gap-4">
+            <label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">SN</label>
+            <div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md text-gray-900 dark:text-white text-xs font-mono truncate">
+              {serialNumber}
+            </div>
+          </div>
+
+          {/* Flow Dynamics Calibration */}
+          <div className="pt-4 border-t border-gray-200 dark:border-bambu-dark-tertiary">
+            <h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
+              Flow Dynamics Calibration{' '}
+              <a
+                href="https://wiki.bambulab.com/en/software/bambu-studio/calibration_pa"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="text-bambu-green hover:underline inline-flex items-center gap-1"
+              >
+                Wiki
+                <ExternalLink className="w-3 h-3" />
+              </a>
+            </h3>
+
+            {/* K Profile */}
+            <div className="flex items-center gap-4 mb-3">
+              <label className="w-28 text-sm text-gray-600 dark:text-bambu-gray flex-shrink-0">K Profile</label>
+              <select
+                value={selectedKProfile}
+                onChange={(e) => handleKProfileChange(e.target.value)}
+                className="flex-1 min-w-0 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white text-sm truncate"
+              >
+                <option value="Default">Default</option>
+                {profiles.map(profile => (
+                  <option key={profile.slot_id} value={profile.name}>
+                    {profile.name} (K={parseFloat(profile.k_value).toFixed(3)})
+                  </option>
+                ))}
+              </select>
+            </div>
+
+            {/* Factor K */}
+            <div className="flex items-center gap-4">
+              <label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Factor K</label>
+              <div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md text-gray-900 dark:text-white">
+                {kValue.toFixed(3)}
+              </div>
+            </div>
+          </div>
+        </div>
+
+        {/* Footer */}
+        <div className="flex items-center justify-center gap-3 p-4 border-t border-gray-200 dark:border-bambu-dark-tertiary">
+          <button
+            onClick={handleConfirm}
+            className="px-6 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green-dark transition-colors"
+          >
+            Confirm
+          </button>
+          <button
+            onClick={handleReset}
+            className="px-6 py-2 bg-gray-200 dark:bg-bambu-dark text-gray-700 dark:text-bambu-gray rounded-md hover:bg-gray-300 dark:hover:bg-bambu-dark-tertiary transition-colors"
+          >
+            Reset
+          </button>
+          <button
+            onClick={onClose}
+            className="px-6 py-2 bg-gray-200 dark:bg-bambu-dark text-gray-700 dark:text-bambu-gray rounded-md hover:bg-gray-300 dark:hover:bg-bambu-dark-tertiary transition-colors"
+          >
+            Close
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 745 - 100
frontend/src/components/control/AMSSectionDual.tsx

@@ -1,11 +1,20 @@
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { useMutation } from '@tanstack/react-query';
 import { api } from '../../api/client';
-import type { PrinterStatus, AMSUnit } from '../../api/client';
-import { Loader2 } from 'lucide-react';
+import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client';
+import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react';
+import { AMSHumidityModal } from './AMSHumidityModal';
+import { AMSMaterialsModal } from './AMSMaterialsModal';
+
+// Filament change stages from MQTT stg_cur
+const STAGE_HEATING_NOZZLE = 7;
+const STAGE_FILAMENT_UNLOADING = 22;
+const STAGE_FILAMENT_LOADING = 24;
+const STAGE_CHANGING_FILAMENT = 4;
 
 interface AMSSectionDualProps {
   printerId: number;
+  printerModel: string;
   status: PrinterStatus | null | undefined;
   nozzleCount: number;
 }
@@ -49,6 +58,144 @@ function HumidityIcon({ humidity }: { humidity: number }) {
   );
 }
 
+// Filament change progress card - appears during load/unload operations
+interface FilamentChangeCardProps {
+  isLoading: boolean;  // true = loading, false = unloading
+  currentStage: number;
+  onRetry?: () => void;
+}
+
+interface StepInfo {
+  label: string;
+  status: 'completed' | 'in_progress' | 'pending';
+  stepNumber: number;
+}
+
+function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) {
+  const [isCollapsed, setIsCollapsed] = useState(false);
+
+  // Determine step status based on current stage
+  // When stage is -1 (initial/waiting), show first step as in_progress
+  const getLoadingSteps = (): StepInfo[] => {
+    // Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete)
+    let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
+    let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
+    let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending';
+
+    if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
+      // Initial state or heating - step 1 is active
+      step1Status = 'in_progress';
+    } else if (currentStage === STAGE_FILAMENT_LOADING) {
+      // Loading filament - step 1 done, step 2 active
+      step1Status = 'completed';
+      step2Status = 'in_progress';
+    }
+
+    return [
+      { label: 'Heat the nozzle', stepNumber: 1, status: step1Status },
+      { label: 'Push new filament into extruder', stepNumber: 2, status: step2Status },
+      { label: 'Purge old filament', stepNumber: 3, status: step3Status },
+    ];
+  };
+
+  const getUnloadingSteps = (): StepInfo[] => {
+    let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
+    let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
+
+    if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
+      // Initial state or heating - step 1 is active
+      step1Status = 'in_progress';
+    } else if (currentStage === STAGE_FILAMENT_UNLOADING) {
+      // Unloading filament - step 1 done, step 2 active
+      step1Status = 'completed';
+      step2Status = 'in_progress';
+    }
+
+    return [
+      { label: 'Heat the nozzle', stepNumber: 1, status: step1Status },
+      { label: 'Retract filament from extruder', stepNumber: 2, status: step2Status },
+    ];
+  };
+
+  const steps = isLoading ? getLoadingSteps() : getUnloadingSteps();
+  const title = isLoading ? 'Loading' : 'Unloading';
+  const headerText = isLoading ? 'Filament loading...' : 'Filament unloading...';
+
+  return (
+    <div className="mt-3 border-l-4 border-bambu-green bg-white dark:bg-bambu-dark-secondary rounded-r-lg overflow-hidden">
+      {/* Collapsible header */}
+      <button
+        onClick={() => setIsCollapsed(!isCollapsed)}
+        className="w-full flex items-center justify-between px-4 py-2 text-bambu-green hover:bg-gray-50 dark:hover:bg-bambu-dark-tertiary transition-colors"
+      >
+        <span className="text-sm font-medium">{headerText}</span>
+        {isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
+      </button>
+
+      {/* Content */}
+      {!isCollapsed && (
+        <div className="px-4 pb-4">
+          <div className="flex gap-6">
+            {/* Steps list */}
+            <div className="flex-1">
+              <h3 className="text-bambu-green font-semibold mb-3">{title}</h3>
+              <div className="space-y-2">
+                {steps.map((step) => (
+                  <div key={step.stepNumber} className="flex items-center gap-2">
+                    {/* Step indicator */}
+                    {step.status === 'completed' ? (
+                      <div className="w-5 h-5 rounded-full bg-bambu-green flex items-center justify-center flex-shrink-0">
+                        <svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
+                        </svg>
+                      </div>
+                    ) : step.status === 'in_progress' ? (
+                      <div className="w-5 h-5 rounded-full bg-bambu-green flex items-center justify-center flex-shrink-0">
+                        <span className="text-white text-xs font-bold">{step.stepNumber}</span>
+                      </div>
+                    ) : (
+                      <div className="w-5 h-5 rounded-full border-2 border-gray-400 dark:border-gray-500 flex items-center justify-center flex-shrink-0">
+                        <span className="text-gray-400 dark:text-gray-500 text-xs font-medium">{step.stepNumber}</span>
+                      </div>
+                    )}
+                    {/* Step label */}
+                    <span className={`text-sm ${
+                      step.status === 'in_progress' ? 'text-gray-900 dark:text-white font-semibold' :
+                      step.status === 'completed' ? 'text-gray-500 dark:text-gray-400' : 'text-gray-400 dark:text-gray-500'
+                    }`}>
+                      {step.label}
+                    </span>
+                  </div>
+                ))}
+              </div>
+            </div>
+
+            {/* Extruder image */}
+            <div className="flex-shrink-0">
+              <img
+                src="/icons/extruder-change-filament.png"
+                alt="Extruder"
+                className="w-[150px] h-auto"
+              />
+            </div>
+          </div>
+
+          {/* Retry button */}
+          {onRetry && (
+            <button
+              onClick={onRetry}
+              className="mt-3 px-4 py-1.5 border border-bambu-gray rounded-full text-sm text-bambu-gray hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-1.5"
+            >
+              <RotateCw className="w-3.5 h-3.5" />
+              Retry
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
 interface AMSPanelContentProps {
   units: AMSUnit[];
   side: 'left' | 'right';
@@ -57,9 +204,35 @@ interface AMSPanelContentProps {
   onSelectAms: (index: number) => void;
   selectedTray: number | null;
   onSelectTray: (trayId: number | null) => void;
+  onHumidityClick: (humidity: number, temp: number) => void;
+  onSlotRefresh: (amsId: number, slotId: number) => void;
+  onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void;
 }
 
 // Panel content - NO wiring, just slots and info
+// Get slot label based on AMS unit ID and tray index
+// Regular AMS (ID 0-3): A1, A2, A3, A4 / B1, B2, B3, B4 / etc.
+// AMS-HT (ID >= 128): HT-A, HT-B (for first HT unit), HT2-A, HT2-B (for second), etc.
+function getSlotLabel(amsId: number, trayIndex: number): string {
+  if (amsId >= 128) {
+    // AMS-HT unit - uses HT-A, HT-B naming
+    const htUnitNumber = amsId - 128; // 0 for first HT, 1 for second, etc.
+    const slotLetter = String.fromCharCode(65 + trayIndex); // A, B
+    if (htUnitNumber === 0) {
+      return `HT-${slotLetter}`;
+    }
+    return `HT${htUnitNumber + 1}-${slotLetter}`;
+  }
+  // Regular AMS - uses A1, B2, etc. naming
+  const prefix = String.fromCharCode(65 + amsId); // 65 is ASCII for 'A'
+  return `${prefix}${trayIndex + 1}`;
+}
+
+// Check if AMS unit is an AMS-HT (ID >= 128)
+function isAmsHT(amsId: number): boolean {
+  return amsId >= 128;
+}
+
 function AMSPanelContent({
   units,
   side,
@@ -68,22 +241,25 @@ function AMSPanelContent({
   onSelectAms,
   selectedTray,
   onSelectTray,
+  onHumidityClick,
+  onSlotRefresh,
+  onEyeClick,
 }: AMSPanelContentProps) {
   const selectedUnit = units[selectedAmsIndex];
-  const slotPrefix = side === 'left' ? 'A' : 'B';
+  const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false;
 
   return (
     <div className="flex-1 min-w-0">
       {/* AMS Tab Selectors */}
-      <div className="flex gap-1.5 mb-2.5 p-1.5 bg-white dark:bg-bambu-dark rounded-lg">
+      <div className="flex gap-1.5 mb-2.5 p-1.5 bg-gray-300 dark:bg-bambu-dark rounded-lg">
         {units.map((unit, index) => (
           <button
             key={unit.id}
             onClick={() => onSelectAms(index)}
             className={`flex items-center p-1.5 rounded border-2 transition-colors ${
               selectedAmsIndex === index
-                ? 'border-bambu-green bg-bambu-dark-secondary'
-                : 'bg-bambu-dark border-transparent hover:border-bambu-gray'
+                ? 'border-bambu-green bg-white dark:bg-bambu-dark-tertiary'
+                : 'bg-gray-200 dark:bg-bambu-dark-secondary border-transparent hover:border-bambu-gray'
             }`}
           >
             <div className="flex gap-0.5">
@@ -103,9 +279,12 @@ function AMSPanelContent({
 
       {/* AMS Content */}
       {selectedUnit && (
-        <div className="bg-white dark:bg-bambu-dark-secondary rounded-[10px] p-2.5">
-          {/* AMS Header - Humidity & Temp - Centered */}
-          <div className="flex items-center justify-center gap-4 text-xs text-bambu-gray mb-2.5">
+        <div className="bg-gray-100 dark:bg-bambu-dark-secondary rounded-[10px] p-2.5">
+          {/* AMS Header - Humidity & Temp - Centered - Clickable */}
+          <button
+            onClick={() => onHumidityClick(selectedUnit.humidity ?? 0, selectedUnit.temp ?? 0)}
+            className="flex items-center justify-center gap-4 text-xs text-bambu-gray mb-2.5 w-full py-1 hover:bg-gray-50 dark:hover:bg-bambu-dark-tertiary rounded-md transition-colors cursor-pointer"
+          >
             {selectedUnit.humidity !== null && (
               <span className="flex items-center gap-1.5">
                 <HumidityIcon humidity={selectedUnit.humidity} />
@@ -118,46 +297,79 @@ function AMSPanelContent({
                 {selectedUnit.temp}°C
               </span>
             )}
-          </div>
+          </button>
 
           {/* Slot Labels */}
-          <div className="flex justify-center gap-2 mb-1.5">
-            {selectedUnit.tray.map((tray, index) => (
-              <div
-                key={tray.id}
-                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"
-              >
-                {slotPrefix}{index + 1}
-                <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
-              </div>
-            ))}
+          <div className={`flex gap-2 mb-1.5 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
+            {selectedUnit.tray.map((tray, index) => {
+              const slotLabel = getSlotLabel(selectedUnit.id, index);
+              return (
+                <button
+                  key={tray.id}
+                  onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
+                  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 hover:bg-bambu-dark-tertiary transition-colors"
+                >
+                  {slotLabel}
+                  <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
+                </button>
+              );
+            })}
           </div>
 
           {/* AMS Slots - NO wiring here */}
-          <div className="flex justify-center gap-2">
-            {selectedUnit.tray.map((tray) => {
+          <div className={`flex gap-2 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
+            {selectedUnit.tray.map((tray, index) => {
               const globalTrayId = selectedUnit.id * 4 + tray.id;
               const isSelected = selectedTray === globalTrayId;
               const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE';
               const isLight = isLightColor(tray.tray_color);
+              const slotLabel = getSlotLabel(selectedUnit.id, index);
 
               return (
-                <button
+                <div
                   key={tray.id}
-                  onClick={() => !isEmpty && onSelectTray(isSelected ? null : globalTrayId)}
-                  disabled={isEmpty || isPrinting}
-                  className={`w-14 h-[80px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark ${
+                  onClick={() => {
+                    console.log(`[AMSSectionDual] Slot clicked: AMS ${selectedUnit.id}, tray ${tray.id}, globalTrayId: ${globalTrayId}, isEmpty: ${isEmpty}, isPrinting: ${isPrinting}, isSelected: ${isSelected}`);
+                    if (!isEmpty && !isPrinting) {
+                      onSelectTray(isSelected ? null : globalTrayId);
+                    }
+                  }}
+                  className={`w-14 h-[80px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark relative ${
                     isSelected
-                      ? 'border-[#d4a84b]'
+                      ? 'border-bambu-green'
                       : 'border-bambu-dark-tertiary hover:border-bambu-gray'
-                  } ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
+                  } ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`}
                 >
-                  <div
-                    className="w-full h-full flex flex-col items-center justify-end pb-[5px]"
-                    style={{
-                      backgroundColor: isEmpty ? undefined : hexToRgb(tray.tray_color),
-                    }}
-                  >
+                  {/* Fill level indicator - only for Bambu filaments with valid remain data */}
+                  {!isEmpty && tray.tray_uuid && tray.remain >= 0 && (
+                    <div
+                      className="absolute bottom-0 left-0 right-0 transition-all"
+                      style={{
+                        height: `${Math.min(100, Math.max(0, tray.remain))}%`,
+                        backgroundColor: hexToRgb(tray.tray_color),
+                      }}
+                    />
+                  )}
+                  {/* Full color background for non-Bambu filaments or no remain data */}
+                  {!isEmpty && (!tray.tray_uuid || tray.remain < 0) && (
+                    <div
+                      className="absolute inset-0"
+                      style={{
+                        backgroundColor: hexToRgb(tray.tray_color),
+                      }}
+                    />
+                  )}
+                  {/* Striped pattern for empty slots */}
+                  {isEmpty && (
+                    <div
+                      className="absolute inset-0"
+                      style={{
+                        background: 'repeating-linear-gradient(45deg, #3a3a3a, #3a3a3a 4px, #4a4a4a 4px, #4a4a4a 8px)',
+                      }}
+                    />
+                  )}
+                  {/* Content overlay */}
+                  <div className="relative w-full h-full flex flex-col items-center justify-end pb-[5px]">
                     <span
                       className={`text-[11px] font-semibold mb-1 ${
                         isLight ? 'text-gray-800' : 'text-white'
@@ -166,15 +378,23 @@ function AMSPanelContent({
                       {isEmpty ? '--' : tray.tray_type}
                     </span>
                     {!isEmpty && (
-                      <img
-                        src="/icons/eye.svg"
-                        alt=""
-                        className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
-                        style={{ opacity: 0.8 }}
-                      />
+                      <button
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          onEyeClick(tray, slotLabel, selectedUnit.id);
+                        }}
+                        className={`w-4 h-4 flex items-center justify-center rounded hover:bg-black/20 transition-colors`}
+                      >
+                        <img
+                          src="/icons/eye.svg"
+                          alt="Settings"
+                          className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
+                          style={{ opacity: 0.8 }}
+                        />
+                      </button>
                     )}
                   </div>
-                </button>
+                </div>
               );
             })}
           </div>
@@ -194,117 +414,487 @@ function AMSPanelContent({
 // Unified wiring layer - draws ALL wiring in one place
 interface WiringLayerProps {
   isDualNozzle: boolean;
+  leftSlotCount: number;  // Number of slots on left panel (4 for regular AMS, 1-2 for AMS-HT)
+  rightSlotCount: number; // Number of slots on right panel
+  leftIsHT: boolean;      // Is left panel an AMS-HT
+  rightIsHT: boolean;     // Is right panel an AMS-HT
+  leftActiveSlot?: number | null;   // Currently active slot index on left panel (0-3)
+  rightActiveSlot?: number | null;  // Currently active slot index on right panel (0-3)
+  leftFilamentColor?: string | null;  // Filament color for left active path
+  rightFilamentColor?: string | null; // Filament color for right active path
 }
 
-function WiringLayer({ isDualNozzle }: WiringLayerProps) {
+function WiringLayer({
+  isDualNozzle,
+  leftSlotCount,
+  rightSlotCount,
+  leftIsHT,
+  rightIsHT,
+  leftActiveSlot,
+  rightActiveSlot,
+  leftFilamentColor,
+  rightFilamentColor,
+}: WiringLayerProps) {
   if (!isDualNozzle) return null;
 
   // All measurements relative to this container
   // Container spans full width between panels
-  // Left panel wiring: slots → hub → down → right → down to extruder
-  // Right panel wiring: slots → hub → down → left → down to extruder
+  // Regular AMS: slots → hub → down → toward center → down to extruder
+  // AMS-HT: single slot on left → direct line down to extruder
+
+  // Regular AMS: Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel
+  // Left panel center ~150, slots start at 150 - 124 = 26
+  // Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246
+
+  // AMS-HT: Left aligned with pl-2 (8px), slot starts at 8px + 28px = 36px center
+  // For 2 slots: 36, 100 (36 + 64)
+
+  // Right panel calculations for regular AMS:
+  // Right panel center ~450, slots start at 450 - 124 = 326
+  // Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546
+
+  // Right panel AMS-HT: Left aligned, starts at ~308 (300 panel offset + 8px padding)
+  // Slot center: 308 + 28 = 336
+
+  // Determine colors for wiring paths
+  const defaultColor = '#909090';
+  const leftActiveColor = leftFilamentColor ? hexToRgb(leftFilamentColor) : null;
+  const rightActiveColor = rightFilamentColor ? hexToRgb(rightFilamentColor) : null;
+
+  // Slot X positions for regular AMS (4 slots)
+  const leftSlotX = [54, 118, 182, 246];
+  // Right slot positions
+  const rightSlotX = [354, 418, 482, 546];
 
   return (
-    <div className="relative w-full" style={{ height: '120px' }}>
-      {/* SVG for all wiring - single coordinate system */}
-      {/* Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel */}
-      {/* Left panel center ~150, slots start at 150 - 124 = 26 */}
-      {/* Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246 */}
-      {/* Right panel center ~450, slots start at 450 - 124 = 326 */}
-      {/* Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546 */}
+    <div className="relative w-full pointer-events-none" style={{ height: '120px' }}>
       <svg
         className="absolute inset-0 w-full h-full"
         viewBox="0 0 600 120"
         preserveAspectRatio="xMidYMid meet"
       >
         {/* Left panel wiring */}
-        {/* Vertical lines from 4 slots */}
-        <line x1="54" y1="0" x2="54" y2="14" stroke="#909090" strokeWidth="2" />
-        <line x1="118" y1="0" x2="118" y2="14" stroke="#909090" strokeWidth="2" />
-        <line x1="182" y1="0" x2="182" y2="14" stroke="#909090" strokeWidth="2" />
-        <line x1="246" y1="0" x2="246" y2="14" stroke="#909090" strokeWidth="2" />
+        {leftIsHT ? (
+          <>
+            {/* AMS-HT: Simple direct line from slot to extruder */}
+            {/* Slot vertical lines - highlight active slot */}
+            <line x1="36" y1="0" x2="36" y2="36" stroke={leftActiveSlot === 0 && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === 0 && leftActiveColor ? 3 : 2} />
+            {leftSlotCount > 1 && (
+              <line x1="100" y1="0" x2="100" y2="36" stroke={leftActiveSlot === 1 && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === 1 && leftActiveColor ? 3 : 2} />
+            )}
+            {leftSlotCount > 1 && (
+              <line x1="36" y1="36" x2="100" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
+            )}
+            {/* Path to extruder - always colored if filament loaded */}
+            <line x1={leftSlotCount > 1 ? "68" : "36"} y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
+            <line x1="288" y1="36" x2="288" y2="85" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
+          </>
+        ) : (
+          <>
+            {/* Regular AMS: 4 slots with hub */}
+            {/* Vertical lines from 4 slots - highlight active slot */}
+            {leftSlotX.map((x, i) => (
+              <line key={`left-slot-${i}`} x1={x} y1="0" x2={x} y2="14" stroke={leftActiveSlot === i && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === i && leftActiveColor ? 3 : 2} />
+            ))}
 
-        {/* Horizontal bar connecting left slots */}
-        <line x1="54" y1="14" x2="246" y2="14" stroke="#909090" strokeWidth="2" />
+            {/* Horizontal bar connecting left slots - highlight from active slot to hub */}
+            {leftActiveSlot !== null && leftActiveSlot !== undefined && leftActiveColor ? (
+              <>
+                {/* Background bar */}
+                <line x1="54" y1="14" x2="246" y2="14" stroke={defaultColor} strokeWidth="2" />
+                {/* Highlight segment from active slot to hub (center at 150) */}
+                <line
+                  x1={Math.min(leftSlotX[leftActiveSlot], 150)}
+                  y1="14"
+                  x2={Math.max(leftSlotX[leftActiveSlot], 150)}
+                  y2="14"
+                  stroke={leftActiveColor}
+                  strokeWidth="3"
+                />
+              </>
+            ) : (
+              <line x1="54" y1="14" x2="246" y2="14" stroke={defaultColor} strokeWidth="2" />
+            )}
 
-        {/* Left hub */}
-        <rect x="136" y="8" width="28" height="14" rx="2" fill="#c0c0c0" stroke="#909090" strokeWidth="1" />
+            {/* Left hub */}
+            <rect x="136" y="8" width="28" height="14" rx="2" fill={leftActiveColor ?? '#c0c0c0'} stroke={leftActiveColor ?? defaultColor} strokeWidth="1" />
 
-        {/* Vertical from left hub down */}
-        <line x1="150" y1="22" x2="150" y2="36" stroke="#909090" strokeWidth="2" />
+            {/* Vertical from left hub down */}
+            <line x1="150" y1="22" x2="150" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
 
-        {/* Horizontal from left hub toward center */}
-        <line x1="150" y1="36" x2="288" y2="36" stroke="#909090" strokeWidth="2" />
+            {/* Horizontal from left hub toward center */}
+            <line x1="150" y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
 
-        {/* Vertical down to left extruder inlet */}
-        <line x1="288" y1="36" x2="288" y2="85" stroke="#909090" strokeWidth="2" />
+            {/* Vertical down to left extruder inlet */}
+            <line x1="288" y1="36" x2="288" y2="85" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
+          </>
+        )}
 
         {/* Right panel wiring */}
-        {/* Vertical lines from 4 slots */}
-        <line x1="354" y1="0" x2="354" y2="14" stroke="#909090" strokeWidth="2" />
-        <line x1="418" y1="0" x2="418" y2="14" stroke="#909090" strokeWidth="2" />
-        <line x1="482" y1="0" x2="482" y2="14" stroke="#909090" strokeWidth="2" />
-        <line x1="546" y1="0" x2="546" y2="14" stroke="#909090" strokeWidth="2" />
+        {rightIsHT ? (
+          <>
+            {/* AMS-HT: Simple direct line from slot to extruder */}
+            <line x1="336" y1="0" x2="336" y2="36" stroke={rightActiveSlot === 0 && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === 0 && rightActiveColor ? 3 : 2} />
+            {rightSlotCount > 1 && (
+              <line x1="400" y1="0" x2="400" y2="36" stroke={rightActiveSlot === 1 && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === 1 && rightActiveColor ? 3 : 2} />
+            )}
+            {rightSlotCount > 1 && (
+              <line x1="336" y1="36" x2="400" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
+            )}
+            <line x1="312" y1="36" x2={rightSlotCount > 1 ? "368" : "336"} y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
+            <line x1="312" y1="36" x2="312" y2="85" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
+          </>
+        ) : (
+          <>
+            {/* Regular AMS: 4 slots with hub */}
+            {/* Vertical lines from 4 slots - highlight active slot */}
+            {rightSlotX.map((x, i) => (
+              <line key={`right-slot-${i}`} x1={x} y1="0" x2={x} y2="14" stroke={rightActiveSlot === i && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === i && rightActiveColor ? 3 : 2} />
+            ))}
 
-        {/* Horizontal bar connecting right slots */}
-        <line x1="354" y1="14" x2="546" y2="14" stroke="#909090" strokeWidth="2" />
+            {/* Horizontal bar connecting right slots - highlight from active slot to hub */}
+            {rightActiveSlot !== null && rightActiveSlot !== undefined && rightActiveColor ? (
+              <>
+                {/* Background bar */}
+                <line x1="354" y1="14" x2="546" y2="14" stroke={defaultColor} strokeWidth="2" />
+                {/* Highlight segment from active slot to hub (center at 450) */}
+                <line
+                  x1={Math.min(rightSlotX[rightActiveSlot], 450)}
+                  y1="14"
+                  x2={Math.max(rightSlotX[rightActiveSlot], 450)}
+                  y2="14"
+                  stroke={rightActiveColor}
+                  strokeWidth="3"
+                />
+              </>
+            ) : (
+              <line x1="354" y1="14" x2="546" y2="14" stroke={defaultColor} strokeWidth="2" />
+            )}
 
-        {/* Right hub */}
-        <rect x="436" y="8" width="28" height="14" rx="2" fill="#c0c0c0" stroke="#909090" strokeWidth="1" />
+            {/* Right hub */}
+            <rect x="436" y="8" width="28" height="14" rx="2" fill={rightActiveColor ?? '#c0c0c0'} stroke={rightActiveColor ?? defaultColor} strokeWidth="1" />
 
-        {/* Vertical from right hub down */}
-        <line x1="450" y1="22" x2="450" y2="36" stroke="#909090" strokeWidth="2" />
+            {/* Vertical from right hub down */}
+            <line x1="450" y1="22" x2="450" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
 
-        {/* Horizontal from right hub toward center */}
-        <line x1="312" y1="36" x2="450" y2="36" stroke="#909090" strokeWidth="2" />
+            {/* Horizontal from right hub toward center */}
+            <line x1="312" y1="36" x2="450" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
 
-        {/* Vertical down to right extruder inlet */}
-        <line x1="312" y1="36" x2="312" y2="85" stroke="#909090" strokeWidth="2" />
+            {/* Vertical down to right extruder inlet */}
+            <line x1="312" y1="36" x2="312" y2="85" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
+          </>
+        )}
       </svg>
 
-      {/* Extruder image - positioned at bottom center */}
-      <img
-        src="/icons/extruder-left-right.png"
-        alt="Extruder"
-        className="absolute left-1/2 -translate-x-1/2 bottom-0 h-[50px]"
-      />
+      {/* Extruder image container - positioned at bottom center */}
+      {/* Image is 56x71 pixels, scaled to h=50px = width ~39px */}
+      {/* Scale factor: 50/71 = 0.704 */}
+      {/* Green circles in original image: left center ~(15.2,34.2), right center ~(41.0,33.9) */}
+      {/* Scaled positions: left x≈10.7, right x≈28.9, y≈24 from top */}
+      <div className="absolute left-1/2 -translate-x-1/2 bottom-0 h-[50px] w-[39px]">
+        <img
+          src="/icons/extruder-left-right.png"
+          alt="Extruder"
+          className="h-full w-full"
+        />
+        {/* Extruder inlet indicator circles - overlay on extruder image */}
+        {/* Left inlet (extruder 1) - left side of extruder */}
+        <div
+          className="absolute w-[8px] h-[8px] rounded-full"
+          style={{
+            left: '7px',
+            top: '20px',
+            backgroundColor: leftActiveColor ?? 'transparent',
+          }}
+        />
+        {/* Right inlet (extruder 0) - right side of extruder */}
+        <div
+          className="absolute w-[8px] h-[8px] rounded-full"
+          style={{
+            left: '25px',
+            top: '20px',
+            backgroundColor: rightActiveColor ?? 'transparent',
+          }}
+        />
+      </div>
     </div>
   );
 }
 
-export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDualProps) {
+export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) {
   const isConnected = status?.connected ?? false;
   const isPrinting = status?.state === 'RUNNING';
   const isDualNozzle = nozzleCount > 1;
   const amsUnits: AMSUnit[] = status?.ams ?? [];
+  // Per-AMS extruder map: {ams_id: extruder_id} where extruder 0=right, 1=left
+  // This is extracted from each AMS unit's info field bit 8 in the backend
+  // Note: JSON keys are always strings, so we use Record<string, number>
+  const amsExtruderMap: Record<string, number> = status?.ams_extruder_map ?? {};
+
+  // Distribute AMS units based on ams_extruder_map
+  // Each AMS unit's info field tells us which extruder it's connected to:
+  // extruder 0 = right nozzle, extruder 1 = left nozzle
+  const leftUnits = (() => {
+    if (!isDualNozzle) return amsUnits;
+    if (Object.keys(amsExtruderMap).length > 0) {
+      // Filter AMS units assigned to extruder 1 (left nozzle)
+      // JSON keys are strings, so convert unit.id to string
+      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
+    }
+    // Fallback: odd indices go to left (extruder 1)
+    return amsUnits.filter((_, i) => i % 2 === 1);
+  })();
 
-  const leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits;
-  const rightUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 1) : [];
+  const rightUnits = (() => {
+    if (!isDualNozzle) return [];
+    if (Object.keys(amsExtruderMap).length > 0) {
+      // Filter AMS units assigned to extruder 0 (right nozzle)
+      // JSON keys are strings, so convert unit.id to string
+      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
+    }
+    // Fallback: even indices go to right (extruder 0)
+    return amsUnits.filter((_, i) => i % 2 === 0);
+  })();
 
   const [leftAmsIndex, setLeftAmsIndex] = useState(0);
   const [rightAmsIndex, setRightAmsIndex] = useState(0);
   const [selectedTray, setSelectedTray] = useState<number | null>(null);
+  // Track if load has been triggered (to disable Load button until unload or slot change)
+  const [loadTriggered, setLoadTriggered] = useState(false);
+
+  // Modal states
+  const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
+  const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null);
+
+  // Track user-initiated filament change operations (for showing progress card immediately)
+  const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null);
+  // Track the previous stage for detecting when operation completes
+  const prevStageRef = useRef<number>(-1);
+
+  // Track if we've done initial sync from tray_now
+  const initialSyncDone = useRef(false);
+
+  // Sync selectedTray and loadTriggered from status.tray_now on initial load
+  // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool
+  useEffect(() => {
+    if (initialSyncDone.current) return;
+
+    const trayNow = status?.tray_now;
+    if (trayNow !== undefined && trayNow !== null) {
+      initialSyncDone.current = true;
+      if (trayNow !== 255 && trayNow !== 254) {
+        // Valid AMS tray is loaded - select it and set loadTriggered
+        console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`);
+        setSelectedTray(trayNow);
+        setLoadTriggered(true);
+      } else {
+        // No filament loaded or external spool
+        console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`);
+      }
+    }
+  }, [status?.tray_now]);
 
   const loadMutation = useMutation({
-    mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId),
+    mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
+      api.amsLoadFilament(printerId, trayId, extruderId),
+    onSuccess: (data, { trayId, extruderId }) => {
+      console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data);
+      // Disable Load button after successful load
+      setLoadTriggered(true);
+    },
+    onError: (error, { trayId, extruderId }) => {
+      console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error);
+    },
   });
 
   const unloadMutation = useMutation({
     mutationFn: () => api.amsUnloadFilament(printerId),
+    onSuccess: (data) => {
+      console.log(`[AMSSectionDual] Unload filament success:`, data);
+      // Re-enable Load button after unload
+      setLoadTriggered(false);
+    },
+    onError: (error) => {
+      console.error(`[AMSSectionDual] Unload filament error:`, error);
+    },
   });
 
+  // Handle tray selection - also re-enables Load button when changing slot
+  const handleTraySelect = (trayId: number | null) => {
+    if (trayId !== selectedTray) {
+      // Slot changed - re-enable Load button
+      setLoadTriggered(false);
+    }
+    setSelectedTray(trayId);
+  };
+
+  // Helper to get extruder ID for a given tray
+  const getExtruderIdForTray = (trayId: number): number | undefined => {
+    // For dual-nozzle printers, calculate which AMS unit the tray belongs to
+    // and look up which extruder it's connected to
+    if (!isDualNozzle) return undefined;
+
+    // Find which AMS unit contains this tray
+    // Global tray ID format: amsId * 4 + slotIndex (for regular AMS)
+    // For AMS-HT (id >= 128): amsId * 4 + slotIndex (but only 2 slots)
+    for (const unit of amsUnits) {
+      const slotsInUnit = unit.id >= 128 ? 2 : 4; // AMS-HT has 2 slots
+      const baseSlotId = unit.id * 4;
+      if (trayId >= baseSlotId && trayId < baseSlotId + slotsInUnit) {
+        // Found the AMS unit - look up its extruder
+        const extruderId = amsExtruderMap[String(unit.id)];
+        console.log(`[AMSSectionDual] Tray ${trayId} belongs to AMS ${unit.id}, extruder: ${extruderId}`);
+        return extruderId;
+      }
+    }
+    return undefined;
+  };
+
   const handleLoad = () => {
+    console.log(`[AMSSectionDual] handleLoad called, selectedTray: ${selectedTray}`);
     if (selectedTray !== null) {
-      loadMutation.mutate(selectedTray);
+      const extruderId = getExtruderIdForTray(selectedTray);
+      console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`);
+      // Show filament change card immediately
+      setUserFilamentChange({ isLoading: true });
+      loadMutation.mutate({ trayId: selectedTray, extruderId });
     }
   };
 
   const handleUnload = () => {
+    // Show filament change card immediately
+    setUserFilamentChange({ isLoading: false });
     unloadMutation.mutate();
   };
 
   const isLoading = loadMutation.isPending || unloadMutation.isPending;
 
+  // Handlers for modals and actions
+  const handleHumidityClick = (humidity: number, temp: number) => {
+    setHumidityModal({ humidity, temp });
+  };
+
+  const refreshMutation = useMutation({
+    mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
+      api.refreshAmsTray(printerId, amsId, trayId),
+    onSuccess: (data, variables) => {
+      console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data);
+    },
+    onError: (error, variables) => {
+      console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error);
+    },
+  });
+
+  const handleSlotRefresh = (amsId: number, slotId: number) => {
+    // Trigger RFID re-read for the specific tray
+    console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`);
+    refreshMutation.mutate({ amsId, trayId: slotId });
+  };
+
+  const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => {
+    setMaterialsModal({ tray, slotLabel, amsId });
+  };
+
+  // Determine if we're in a filament change stage (from MQTT)
+  const currentStage = status?.stg_cur ?? -1;
+  const isMqttFilamentChangeActive = [
+    STAGE_HEATING_NOZZLE,
+    STAGE_FILAMENT_UNLOADING,
+    STAGE_FILAMENT_LOADING,
+    STAGE_CHANGING_FILAMENT,
+  ].includes(currentStage);
+
+  // Auto-close card when operation completes
+  // Track when we transition from an active filament change stage back to -1
+  useEffect(() => {
+    const wasInFilamentChange = [
+      STAGE_HEATING_NOZZLE,
+      STAGE_FILAMENT_UNLOADING,
+      STAGE_FILAMENT_LOADING,
+      STAGE_CHANGING_FILAMENT,
+    ].includes(prevStageRef.current);
+
+    if (isMqttFilamentChangeActive) {
+      // MQTT is now reporting a stage, clear user-triggered state
+      // Card will continue showing because isMqttFilamentChangeActive is true
+      setUserFilamentChange(null);
+    } else if (wasInFilamentChange && currentStage === -1) {
+      // Transition from active stage to idle - operation completed
+      // Close the card by clearing user state
+      setUserFilamentChange(null);
+    }
+
+    // Update previous stage for next comparison
+    prevStageRef.current = currentStage;
+  }, [isMqttFilamentChangeActive, currentStage]);
+
+  // Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload
+  const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null;
+
+  // Determine if loading or unloading for the card display
+  const isFilamentLoading = userFilamentChange !== null
+    ? userFilamentChange.isLoading
+    : (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE);
+
+  // Get the loaded tray info for wire coloring
+  // Wire coloring should show the path from the currently loaded filament to the extruder
+  // But ONLY if the currently displayed AMS panel is the one with the loaded filament
+  const trayNow = status?.tray_now ?? 255;
+  const getLoadedTrayInfo = (): {
+    leftActiveSlot: number | null;
+    rightActiveSlot: number | null;
+    leftFilamentColor: string | null;
+    rightFilamentColor: string | null;
+  } => {
+    // tray_now: 255 = no filament, 254 = external spool, 0-253 = valid tray ID
+    if (trayNow === 255 || trayNow === 254) {
+      return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null };
+    }
+
+    // Find which AMS and slot contains the loaded tray
+    for (const unit of amsUnits) {
+      const slotsInUnit = unit.id >= 128 ? 2 : 4;
+      const baseSlotId = unit.id * 4;
+      if (trayNow >= baseSlotId && trayNow < baseSlotId + slotsInUnit) {
+        const slotIndex = trayNow - baseSlotId;
+        const tray = unit.tray[slotIndex];
+        const color = tray?.tray_color ?? null;
+
+        // Determine if this AMS is on left or right side
+        const extruderId = amsExtruderMap[String(unit.id)];
+
+        // Check if this AMS unit is the one currently displayed in the panel
+        const currentLeftUnit = leftUnits[leftAmsIndex];
+        const currentRightUnit = rightUnits[rightAmsIndex];
+
+        if (extruderId === 1) {
+          // Left side (extruder 1)
+          // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
+          const isDisplayed = currentLeftUnit?.id === unit.id;
+          return {
+            leftActiveSlot: isDisplayed ? slotIndex : null,
+            rightActiveSlot: null,
+            leftFilamentColor: isDisplayed ? color : null,  // Hide color if different AMS is selected
+            rightFilamentColor: null
+          };
+        } else {
+          // Right side (extruder 0)
+          const isDisplayed = currentRightUnit?.id === unit.id;
+          return {
+            leftActiveSlot: null,
+            rightActiveSlot: isDisplayed ? slotIndex : null,
+            leftFilamentColor: null,
+            rightFilamentColor: isDisplayed ? color : null  // Hide color if different AMS is selected
+          };
+        }
+      }
+    }
+
+    return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null };
+  };
+
+  const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo();
+
   return (
     <div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
       {/* Dual Panel Layout - just the panels, no wiring */}
@@ -316,7 +906,10 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
           selectedAmsIndex={leftAmsIndex}
           onSelectAms={setLeftAmsIndex}
           selectedTray={selectedTray}
-          onSelectTray={setSelectedTray}
+          onSelectTray={handleTraySelect}
+          onHumidityClick={handleHumidityClick}
+          onSlotRefresh={handleSlotRefresh}
+          onEyeClick={handleEyeClick}
         />
 
         {isDualNozzle && (
@@ -327,13 +920,26 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
             selectedAmsIndex={rightAmsIndex}
             onSelectAms={setRightAmsIndex}
             selectedTray={selectedTray}
-            onSelectTray={setSelectedTray}
+            onSelectTray={handleTraySelect}
+            onHumidityClick={handleHumidityClick}
+            onSlotRefresh={handleSlotRefresh}
+            onEyeClick={handleEyeClick}
           />
         )}
       </div>
 
       {/* Unified Wiring Layer - ALL wiring drawn here */}
-      <WiringLayer isDualNozzle={isDualNozzle} />
+      <WiringLayer
+        isDualNozzle={isDualNozzle}
+        leftSlotCount={leftUnits[leftAmsIndex]?.tray?.length ?? 4}
+        rightSlotCount={rightUnits[rightAmsIndex]?.tray?.length ?? 4}
+        leftIsHT={leftUnits[leftAmsIndex] ? isAmsHT(leftUnits[leftAmsIndex].id) : false}
+        rightIsHT={rightUnits[rightAmsIndex] ? isAmsHT(rightUnits[rightAmsIndex].id) : false}
+        leftActiveSlot={leftActiveSlot}
+        rightActiveSlot={rightActiveSlot}
+        leftFilamentColor={leftFilamentColor}
+        rightFilamentColor={rightFilamentColor}
+      />
 
       {/* Action Buttons Row - aligned with extruder */}
       <div className="flex items-start -mt-[50px]">
@@ -351,8 +957,12 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
         <div className="flex items-center gap-2">
           <button
             onClick={handleUnload}
-            disabled={!isConnected || isPrinting || isLoading}
-            className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
+            disabled={!isConnected || isPrinting || isLoading || !loadTriggered}
+            className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
+              !isConnected || isPrinting || isLoading || !loadTriggered
+                ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
+                : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
+            }`}
           >
             {unloadMutation.isPending ? (
               <Loader2 className="w-4 h-4 animate-spin" />
@@ -362,8 +972,12 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
           </button>
           <button
             onClick={handleLoad}
-            disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
-            className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
+            disabled={!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered}
+            className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
+              !isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered
+                ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
+                : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
+            }`}
           >
             {loadMutation.isPending ? (
               <Loader2 className="w-4 h-4 animate-spin" />
@@ -380,6 +994,37 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
           {(loadMutation.error || unloadMutation.error)?.message}
         </p>
       )}
+
+      {/* Filament Change Progress Card - appears during load/unload operations */}
+      {showFilamentChangeCard && (
+        <FilamentChangeCard
+          isLoading={isFilamentLoading}
+          currentStage={currentStage}
+        />
+      )}
+
+      {/* Humidity Modal */}
+      {humidityModal && (
+        <AMSHumidityModal
+          humidity={humidityModal.humidity}
+          temperature={humidityModal.temp}
+          dryingStatus="idle"
+          onClose={() => setHumidityModal(null)}
+        />
+      )}
+
+      {/* Materials Settings Modal */}
+      {materialsModal && (
+        <AMSMaterialsModal
+          tray={materialsModal.tray}
+          amsId={materialsModal.amsId}
+          slotLabel={materialsModal.slotLabel}
+          printerId={printerId}
+          printerModel={printerModel}
+          nozzleDiameter={status?.nozzles?.[0]?.nozzle_diameter || '0.4'}
+          onClose={() => setMaterialsModal(null)}
+        />
+      )}
     </div>
   );
 }

+ 1 - 0
frontend/src/pages/ControlPage.tsx

@@ -255,6 +255,7 @@ export function ControlPage() {
               {/* AMS Section */}
               <AMSSectionDual
                 printerId={selectedPrinter.id}
+                printerModel={selectedPrinter.model || 'X1C'}
                 status={selectedStatus}
                 nozzleCount={selectedPrinter.nozzle_count}
               />