mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '設定の更新に失敗しました',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '更新设置失败',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user