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.
This commit is contained in:
NNeerr00
2026-04-05 00:04:26 +02:00
committed by Oemer
parent f20beae6cc
commit e3cde14a0b
11 changed files with 242 additions and 1 deletions
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
})()}