diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 3c80e67ad..ca740b902 100644 --- a/backend/app/api/routes/printers.py +++ b/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, diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 31d028284..ffcd81887 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2ac675b2d..70e5526ae 100644 --- a/frontend/src/api/client.ts +++ b/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', diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 56ce7f081..77be03efe 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 58decc431..de2ff58b1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index acbd9450b..78fff668e 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index a2db29c58..569ce0123 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 036b81b4c..630fa8d2d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: '設定の更新に失敗しました', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index ea47ca5e2..7358e1457 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index d9b2a9c35..927dcf7d6 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '更新设置失败', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index a9782fb64..e2560034e 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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({ + + {/* External Filament Buttons - below controls, left-aligned */} +
+ + + +
); })()}