Browse Source

Add Load/Unload External filament buttons to printer controls

New dedicated MQTT methods, API endpoints, and UI buttons for
loading/unloading filament from the external spool holder without
requiring an AMS. Includes state guards to prevent load when filament
is already loaded and unload when nothing is loaded.
NNeerr00 1 tháng trước cách đây
mục cha
commit
e3cde14a0b

+ 46 - 0
backend/app/api/routes/printers.py

@@ -2303,6 +2303,52 @@ async def resume_print(
     return {"success": True, "message": "Print resume command sent"}
 
 
+@router.post("/{printer_id}/filament/load-external")
+async def load_external_filament(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Load filament from the external spool holder (no AMS required)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success, message = client.load_external_filament()
+    if not success:
+        raise HTTPException(400, message)
+
+    return {"success": True, "message": message}
+
+
+@router.post("/{printer_id}/filament/unload-external")
+async def unload_external_filament(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Unload filament from the external spool holder (no AMS required)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success, message = client.unload_external_filament()
+    if not success:
+        raise HTTPException(400, message)
+
+    return {"success": True, "message": message}
+
+
 @router.post("/{printer_id}/print-speed")
 async def set_print_speed(
     printer_id: int,

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

@@ -4117,6 +4117,94 @@ class BambuMQTTClient:
 
         return True
 
+    def load_external_filament(self) -> tuple[bool, str]:
+        """Load filament from the external spool holder.
+
+        Works on all printers regardless of AMS presence.
+        Refuses if filament is already loaded (tray_now != 255) to prevent
+        the firmware from auto-unloading first.
+
+        Returns:
+            (success, message) tuple
+        """
+        if not self._client or not self.state.connected:
+            logger.warning("[%s] Cannot load external filament: not connected", self.serial_number)
+            return False, "Printer not connected"
+
+        if self.state.tray_now != 255:
+            logger.info(
+                "[%s] Cannot load external filament: filament already loaded (tray_now=%s)",
+                self.serial_number, self.state.tray_now,
+            )
+            return False, "Filament is already loaded. Unload first before loading."
+
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "command": "ams_change_filament",
+                "sequence_id": str(self._sequence_id),
+                "ams_id": 255,
+                "slot_id": 254,
+                "target": 254,
+                "curr_temp": -1,
+                "tar_temp": -1,
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info("[%s] Publishing load external filament command: %s", self.serial_number, command_json)
+        self._client.publish(self.topic_publish, command_json, qos=1)
+
+        self._last_load_tray_id = 254
+        self.state.pending_tray_target = 254
+        logger.info("[%s] Set pending_tray_target=254 (external spool load)", self.serial_number)
+
+        return True, "Load external filament command sent"
+
+    def unload_external_filament(self) -> tuple[bool, str]:
+        """Unload filament from the external spool holder.
+
+        Works on all printers regardless of AMS presence.
+        Always uses ams_id=255 (external), never derives a source AMS unit.
+
+        Returns:
+            (success, message) tuple
+        """
+        if not self._client or not self.state.connected:
+            logger.warning("[%s] Cannot unload external filament: not connected", self.serial_number)
+            return False, "Printer not connected"
+
+        if self.state.tray_now == 255:
+            logger.info("[%s] Cannot unload external filament: no filament loaded", self.serial_number)
+            return False, "No filament is currently loaded."
+
+        nozzle_temp = int(self.state.temperatures.get("nozzle", 210))
+        if nozzle_temp < 180:
+            nozzle_temp = 210
+
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "command": "ams_change_filament",
+                "sequence_id": str(self._sequence_id),
+                "ams_id": 255,
+                "slot_id": 255,
+                "target": 255,
+                "curr_temp": nozzle_temp,
+                "tar_temp": nozzle_temp,
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info("[%s] Publishing unload external filament command: %s", self.serial_number, command_json)
+        self._client.publish(self.topic_publish, command_json, qos=1)
+
+        self._last_load_tray_id = None
+        self.state.pending_tray_target = None
+        logger.info("[%s] Cleared pending_tray_target (external spool unload)", self.serial_number)
+
+        return True, "Unload external filament command sent"
+
     def ams_control(self, action: str) -> bool:
         """Control AMS operations.
 

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

@@ -2520,6 +2520,14 @@ export const api = {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
       method: 'POST',
     }),
+  loadExternalFilament: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/filament/load-external`, {
+      method: 'POST',
+    }),
+  unloadExternalFilament: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/filament/unload-external`, {
+      method: 'POST',
+    }),
   clearPlate: (printerId: number) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
       method: 'POST',

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -215,6 +215,8 @@ export default {
     resume: 'Fortsetzen',
     pause: 'Pausieren',
     stop: 'Stoppen',
+    loadExternal: 'Laden',
+    unloadExternal: 'Entladen',
     camera: 'Kamera',
     skipObject: 'Objekt überspringen',
     reconnect: 'Neu verbinden',
@@ -285,6 +287,10 @@ export default {
       failedToStopPrint: 'Druck konnte nicht gestoppt werden',
       failedToPausePrint: 'Druck konnte nicht pausiert werden',
       failedToResumePrint: 'Druck konnte nicht fortgesetzt werden',
+      filamentLoadExternalSent: 'Externes Filament laden Befehl gesendet',
+      filamentUnloadExternalSent: 'Externes Filament entladen Befehl gesendet',
+      failedToLoadExternalFilament: 'Externes Filament konnte nicht geladen werden',
+      failedToUnloadExternalFilament: 'Externes Filament konnte nicht entladen werden',
       failedToControlChamberLight: 'Kammerbeleuchtung konnte nicht gesteuert werden',
       failedToSetSpeed: 'Druckgeschwindigkeit konnte nicht eingestellt werden',
       failedToUpdateSetting: 'Einstellung konnte nicht aktualisiert werden',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -215,6 +215,8 @@ export default {
     resume: 'Resume',
     pause: 'Pause',
     stop: 'Stop',
+    loadExternal: 'Load',
+    unloadExternal: 'Unload',
     camera: 'Camera',
     skipObject: 'Skip Object',
     reconnect: 'Reconnect',
@@ -285,6 +287,10 @@ export default {
       failedToStopPrint: 'Failed to stop print',
       failedToPausePrint: 'Failed to pause print',
       failedToResumePrint: 'Failed to resume print',
+      filamentLoadExternalSent: 'Load external filament command sent',
+      filamentUnloadExternalSent: 'Unload external filament command sent',
+      failedToLoadExternalFilament: 'Failed to load external filament',
+      failedToUnloadExternalFilament: 'Failed to unload external filament',
       failedToControlChamberLight: 'Failed to control chamber light',
       failedToSetSpeed: 'Failed to set print speed',
       failedToUpdateSetting: 'Failed to update setting',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -215,6 +215,8 @@ export default {
     resume: 'Reprendre',
     pause: 'Pause',
     stop: 'Arrêter',
+    loadExternal: 'Charger',
+    unloadExternal: 'Décharger',
     camera: 'Caméra',
     skipObject: 'Sauter l\'objet',
     reconnect: 'Reconnecter',
@@ -285,6 +287,10 @@ export default {
       failedToStopPrint: 'Échec de l\'arrêt',
       failedToPausePrint: 'Échec de la mise en pause',
       failedToResumePrint: 'Échec de la reprise',
+      filamentLoadExternalSent: 'Commande de chargement filament externe envoyée',
+      filamentUnloadExternalSent: 'Commande de déchargement filament externe envoyée',
+      failedToLoadExternalFilament: 'Échec du chargement du filament externe',
+      failedToUnloadExternalFilament: 'Échec du déchargement du filament externe',
       failedToControlChamberLight: 'Échec du contrôle de la lumière',
       failedToSetSpeed: 'Échec du réglage de la vitesse',
       failedToUpdateSetting: 'Échec de mise à jour du paramètre',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -215,6 +215,8 @@ export default {
     resume: 'Riprendi',
     pause: 'Pausa',
     stop: 'Ferma',
+    loadExternal: 'Carica',
+    unloadExternal: 'Scarica',
     camera: 'Camera',
     skipObject: 'Salta Oggetto',
     reconnect: 'Riconnetti',
@@ -285,6 +287,10 @@ export default {
       failedToStopPrint: 'Impossibile fermare stampa',
       failedToPausePrint: 'Impossibile mettere in pausa stampa',
       failedToResumePrint: 'Impossibile riprendere stampa',
+      filamentLoadExternalSent: 'Comando caricamento filamento esterno inviato',
+      filamentUnloadExternalSent: 'Comando scaricamento filamento esterno inviato',
+      failedToLoadExternalFilament: 'Impossibile caricare filamento esterno',
+      failedToUnloadExternalFilament: 'Impossibile scaricare filamento esterno',
       failedToControlChamberLight: 'Impossibile controllare luce camera',
       failedToSetSpeed: 'Impossibile impostare la velocità di stampa',
       failedToUpdateSetting: 'Impossibile aggiornare impostazione',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -214,6 +214,8 @@ export default {
     resume: '再開',
     pause: '一時停止',
     stop: '停止',
+    loadExternal: '装填',
+    unloadExternal: '排出',
     camera: 'カメラ',
     skipObject: 'オブジェクトスキップ',
     reconnect: '再接続',
@@ -284,6 +286,10 @@ export default {
       failedToStopPrint: '印刷の停止に失敗しました',
       failedToPausePrint: '印刷の一時停止に失敗しました',
       failedToResumePrint: '印刷の再開に失敗しました',
+      filamentLoadExternalSent: '外部フィラメント装填コマンドを送信しました',
+      filamentUnloadExternalSent: '外部フィラメント排出コマンドを送信しました',
+      failedToLoadExternalFilament: '外部フィラメントの装填に失敗しました',
+      failedToUnloadExternalFilament: '外部フィラメントの排出に失敗しました',
       failedToControlChamberLight: 'チャンバーライトの制御に失敗しました',
       failedToSetSpeed: '印刷速度の設定に失敗しました',
       failedToUpdateSetting: '設定の更新に失敗しました',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -215,6 +215,8 @@ export default {
     resume: 'Retomar',
     pause: 'Pausar',
     stop: 'Parar',
+    loadExternal: 'Carregar',
+    unloadExternal: 'Descarregar',
     camera: 'âmera',
     skipObject: 'Ignorar objeto',
     reconnect: 'Reconectar',
@@ -285,6 +287,10 @@ export default {
       failedToStopPrint: 'Falha ao parar impressão',
       failedToPausePrint: 'Falha ao pausar impressão',
       failedToResumePrint: 'Falha ao retomar impressão',
+      filamentLoadExternalSent: 'Comando de carregamento de filamento externo enviado',
+      filamentUnloadExternalSent: 'Comando de descarregamento de filamento externo enviado',
+      failedToLoadExternalFilament: 'Falha ao carregar filamento externo',
+      failedToUnloadExternalFilament: 'Falha ao descarregar filamento externo',
       failedToControlChamberLight: 'Falha ao controlar a luz da câmara',
       failedToSetSpeed: 'Falha ao definir a velocidade de impressão',
       failedToUpdateSetting: 'Falha ao atualizar configuração',

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -215,6 +215,8 @@ export default {
     resume: '继续',
     pause: '暂停',
     stop: '停止',
+    loadExternal: '装载',
+    unloadExternal: '卸载',
     camera: '摄像头',
     skipObject: '跳过对象',
     reconnect: '重新连接',
@@ -285,6 +287,10 @@ export default {
       failedToStopPrint: '停止打印失败',
       failedToPausePrint: '暂停打印失败',
       failedToResumePrint: '继续打印失败',
+      filamentLoadExternalSent: '加载外部耗材命令已发送',
+      filamentUnloadExternalSent: '卸载外部耗材命令已发送',
+      failedToLoadExternalFilament: '加载外部耗材失败',
+      failedToUnloadExternalFilament: '卸载外部耗材失败',
       failedToControlChamberLight: '控制腔室灯失败',
       failedToSetSpeed: '设置打印速度失败',
       failedToUpdateSetting: '更新设置失败',

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

@@ -48,6 +48,8 @@ import {
   Cable,
   Flame,
   Gauge,
+  ArrowDownToLine,
+  ArrowUpFromLine,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -1968,6 +1970,24 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'),
   });
 
+  const loadExternalMutation = useMutation({
+    mutationFn: () => api.loadExternalFilament(printer.id),
+    onSuccess: () => {
+      showToast(t('printers.toast.filamentLoadExternalSent'));
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToLoadExternalFilament'), 'error'),
+  });
+
+  const unloadExternalMutation = useMutation({
+    mutationFn: () => api.unloadExternalFilament(printer.id),
+    onSuccess: () => {
+      showToast(t('printers.toast.filamentUnloadExternalSent'));
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUnloadExternalFilament'), 'error'),
+  });
+
   // Chamber light mutation with optimistic update
   const chamberLightMutation = useMutation({
     mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
@@ -2972,7 +2992,7 @@ function PrinterCard({
               const isRunning = status.state === 'RUNNING';
               const isPaused = status.state === 'PAUSE';
               const isPrinting = isRunning || isPaused;
-              const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending;
+              const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending || loadExternalMutation.isPending || unloadExternalMutation.isPending;
 
               // Fan data
               const partFan = status.cooling_fan_speed;
@@ -3128,6 +3148,43 @@ function PrinterCard({
                       </button>
                     </div>
                   </div>
+
+                  {/* External Filament Buttons - below controls, left-aligned */}
+                  <div className="flex items-center gap-2 mt-2">
+                    <button
+                      onClick={() => { if (!loadExternalMutation.isPending) loadExternalMutation.mutate(); }}
+                      disabled={isPrinting || !status.connected || isControlBusy || !hasPermission('printers:control')}
+                      className={`
+                        flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
+                        transition-colors
+                        ${!isPrinting && status.connected && hasPermission('printers:control')
+                          ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
+                          : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                        }
+                      `}
+                      title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('printers.loadExternal')}
+                    >
+                      {loadExternalMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <ArrowDownToLine className="w-3 h-3" />}
+                      {t('printers.loadExternal')}
+                    </button>
+
+                    <button
+                      onClick={() => { if (!unloadExternalMutation.isPending) unloadExternalMutation.mutate(); }}
+                      disabled={isPrinting || !status.connected || isControlBusy || !hasPermission('printers:control')}
+                      className={`
+                        flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
+                        transition-colors
+                        ${!isPrinting && status.connected && hasPermission('printers:control')
+                          ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
+                          : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                        }
+                      `}
+                      title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('printers.unloadExternal')}
+                    >
+                      {unloadExternalMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <ArrowUpFromLine className="w-3 h-3" />}
+                      {t('printers.unloadExternal')}
+                    </button>
+                  </div>
                 </div>
               );
             })()}