mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
feat(printers): airduct mode + status badges + force refresh on printer card
Surface four Home Assistant-style controls on the Printers page card:
- SD Card badge in the top status row (green / red, icon-only).
- Enclosure Door badge in the top status row (green / yellow, icon-only).
Detection per printer family — X1/X1C/X1E read home_flag bit 23, all
others read top-level `stat` (hex string) bit 23 — so X1 firmware that
does not flip stat bit 23 stops false-triggering "open". WebSocket
status-change dedup key now includes door_open so toggling the door
alone publishes a push, no 30s REST-poll wait.
- Airduct Mode badge beside the speed control (cooling / heating)
for P2S/H2D/H2C/H2S; one-click dropdown calls the existing
set_airduct MQTT command via a new POST /printers/{id}/airduct-mode
route.
- Force Refresh entry in the kebab menu — calls the existing
/printers/{id}/refresh-status endpoint to request a pushall snapshot
without forcing a reconnect.
Tests: door-open parsing (X1 home_flag, non-X1 stat, ignore mismatched
source, invalid hex) and airduct route (validation, not-connected,
success, failure).
This commit is contained in:
@@ -206,3 +206,38 @@ describe('FormData requests include auth header', () => {
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Printer control endpoints', () => {
|
||||
it('refreshPrinterStatus POSTs to /printers/:id/refresh-status', async () => {
|
||||
let calledUrl: string | null = null;
|
||||
let calledMethod: string | null = null;
|
||||
server.use(
|
||||
http.post('/api/v1/printers/:id/refresh-status', ({ request, params }) => {
|
||||
calledUrl = `/printers/${params.id}/refresh-status`;
|
||||
calledMethod = request.method;
|
||||
return HttpResponse.json({ status: 'ok' });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await api.refreshPrinterStatus(7);
|
||||
expect(calledMethod).toBe('POST');
|
||||
expect(calledUrl).toBe('/printers/7/refresh-status');
|
||||
expect(result).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('setAirductMode passes mode in query string', async () => {
|
||||
let capturedUrl = '';
|
||||
server.use(
|
||||
http.post('/api/v1/printers/:id/airduct-mode', ({ request }) => {
|
||||
capturedUrl = request.url;
|
||||
return HttpResponse.json({ success: true, message: 'ok' });
|
||||
}),
|
||||
);
|
||||
|
||||
await api.setAirductMode(3, 'cooling');
|
||||
expect(capturedUrl).toContain('mode=cooling');
|
||||
|
||||
await api.setAirductMode(3, 'heating');
|
||||
expect(capturedUrl).toContain('mode=heating');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,7 @@ export interface PrinterStatus {
|
||||
ipcam: boolean; // Live view enabled
|
||||
wifi_signal: number | null; // WiFi signal strength in dBm
|
||||
wired_network: boolean; // Ethernet connection detected
|
||||
door_open: boolean; // Enclosure door open (X1/P1S/P2S/H2*)
|
||||
nozzles: NozzleInfo[]; // Nozzle hardware info (index 0=left/primary, 1=right)
|
||||
nozzle_rack: NozzleRackSlot[]; // H2C 6-nozzle tool-changer rack
|
||||
print_options: PrintOptions | null; // AI detection and print options
|
||||
@@ -2643,6 +2644,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
|
||||
request<{ success: boolean; message: string }>(`/printers/${printerId}/airduct-mode?mode=${mode}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Chamber Light Control
|
||||
setChamberLight: (printerId: number, on: boolean) =>
|
||||
request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
|
||||
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
camera: 'Kamera',
|
||||
skipObject: 'Objekt überspringen',
|
||||
reconnect: 'Neu verbinden',
|
||||
forceRefresh: 'Aktualisierung erzwingen',
|
||||
forceRefreshSuccess: 'Aktualisierung angefordert',
|
||||
mqttDebug: 'MQTT-Debug',
|
||||
printerInformation: 'Druckerinformationen',
|
||||
copyToClipboard: 'Kopieren',
|
||||
@@ -497,6 +499,16 @@ export default {
|
||||
sport: 'Sport (124%)',
|
||||
ludicrous: 'Ludicrous (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: 'Luftkanal-Modus',
|
||||
cooling: 'Kühlen',
|
||||
heating: 'Heizen',
|
||||
},
|
||||
noSdCard: 'Keine SD',
|
||||
door: {
|
||||
open: 'Offen',
|
||||
closed: 'Zu',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: 'Bauteilkühlung',
|
||||
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
camera: 'Camera',
|
||||
skipObject: 'Skip Object',
|
||||
reconnect: 'Reconnect',
|
||||
forceRefresh: 'Force Refresh',
|
||||
forceRefreshSuccess: 'Refresh requested',
|
||||
mqttDebug: 'MQTT Debug',
|
||||
printerInformation: 'Printer Information',
|
||||
copyToClipboard: 'Copy',
|
||||
@@ -497,6 +499,16 @@ export default {
|
||||
sport: 'Sport (124%)',
|
||||
ludicrous: 'Ludicrous (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: 'Airduct Mode',
|
||||
cooling: 'Cooling',
|
||||
heating: 'Heating',
|
||||
},
|
||||
noSdCard: 'No SD',
|
||||
door: {
|
||||
open: 'Open',
|
||||
closed: 'Closed',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: 'Part Cooling Fan',
|
||||
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
camera: 'Caméra',
|
||||
skipObject: 'Sauter l\'objet',
|
||||
reconnect: 'Reconnecter',
|
||||
forceRefresh: 'Forcer l\'actualisation',
|
||||
forceRefreshSuccess: 'Actualisation demandée',
|
||||
mqttDebug: 'Débogage MQTT',
|
||||
printerInformation: 'Informations imprimante',
|
||||
copyToClipboard: 'Copier',
|
||||
@@ -497,6 +499,16 @@ export default {
|
||||
sport: 'Sport (124%)',
|
||||
ludicrous: 'Ludicrous (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: 'Mode conduit d\'air',
|
||||
cooling: 'Refroidissement',
|
||||
heating: 'Chauffage',
|
||||
},
|
||||
noSdCard: 'Pas de SD',
|
||||
door: {
|
||||
open: 'Ouverte',
|
||||
closed: 'Fermée',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: 'Ventilateur pièce',
|
||||
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
camera: 'Camera',
|
||||
skipObject: 'Salta Oggetto',
|
||||
reconnect: 'Riconnetti',
|
||||
forceRefresh: 'Forza aggiornamento',
|
||||
forceRefreshSuccess: 'Aggiornamento richiesto',
|
||||
mqttDebug: 'Debug MQTT',
|
||||
printerInformation: 'Informazioni stampante',
|
||||
copyToClipboard: 'Copia',
|
||||
@@ -497,6 +499,16 @@ export default {
|
||||
sport: 'Sport (124%)',
|
||||
ludicrous: 'Ludicrous (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: 'Modalità condotto d\'aria',
|
||||
cooling: 'Raffreddamento',
|
||||
heating: 'Riscaldamento',
|
||||
},
|
||||
noSdCard: 'Nessuna SD',
|
||||
door: {
|
||||
open: 'Aperta',
|
||||
closed: 'Chiusa',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: 'Ventola raffreddamento parte',
|
||||
|
||||
@@ -223,6 +223,8 @@ export default {
|
||||
camera: 'カメラ',
|
||||
skipObject: 'オブジェクトスキップ',
|
||||
reconnect: '再接続',
|
||||
forceRefresh: '強制更新',
|
||||
forceRefreshSuccess: '更新をリクエストしました',
|
||||
mqttDebug: 'MQTTデバッグ',
|
||||
printerInformation: 'プリンター情報',
|
||||
copyToClipboard: 'コピー',
|
||||
@@ -496,6 +498,16 @@ export default {
|
||||
sport: 'スポーツ (124%)',
|
||||
ludicrous: 'ルディクラス (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: 'エアダクトモード',
|
||||
cooling: '冷却',
|
||||
heating: '加熱',
|
||||
},
|
||||
noSdCard: 'SDなし',
|
||||
door: {
|
||||
open: '開',
|
||||
closed: '閉',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: 'パーツ冷却ファン',
|
||||
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
camera: 'âmera',
|
||||
skipObject: 'Ignorar objeto',
|
||||
reconnect: 'Reconectar',
|
||||
forceRefresh: 'Forçar atualização',
|
||||
forceRefreshSuccess: 'Atualização solicitada',
|
||||
mqttDebug: 'Depuração MQTT',
|
||||
printerInformation: 'Informações da impressora',
|
||||
copyToClipboard: 'Copiar',
|
||||
@@ -497,6 +499,16 @@ export default {
|
||||
sport: 'Sport (124%)',
|
||||
ludicrous: 'Ludicrous (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: 'Modo do duto de ar',
|
||||
cooling: 'Resfriamento',
|
||||
heating: 'Aquecimento',
|
||||
},
|
||||
noSdCard: 'Sem SD',
|
||||
door: {
|
||||
open: 'Aberta',
|
||||
closed: 'Fechada',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: 'Ventilador de resfriamento da peça',
|
||||
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
camera: '摄像头',
|
||||
skipObject: '跳过对象',
|
||||
reconnect: '重新连接',
|
||||
forceRefresh: '强制刷新',
|
||||
forceRefreshSuccess: '已请求刷新',
|
||||
mqttDebug: 'MQTT 调试',
|
||||
printerInformation: '打印机信息',
|
||||
copyToClipboard: '复制',
|
||||
@@ -497,6 +499,16 @@ export default {
|
||||
sport: '运动 (124%)',
|
||||
ludicrous: '疯狂 (166%)',
|
||||
},
|
||||
airduct: {
|
||||
title: '风道模式',
|
||||
cooling: '制冷',
|
||||
heating: '加热',
|
||||
},
|
||||
noSdCard: '无SD',
|
||||
door: {
|
||||
open: '开',
|
||||
closed: '关',
|
||||
},
|
||||
// Fans
|
||||
fans: {
|
||||
partCooling: '零件冷却风扇',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Box,
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
@@ -47,7 +48,10 @@ import {
|
||||
Info,
|
||||
Cable,
|
||||
Flame,
|
||||
Snowflake,
|
||||
Gauge,
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -1326,6 +1330,7 @@ function PrinterCard({
|
||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||
const [showPauseConfirm, setShowPauseConfirm] = useState(false);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
|
||||
const [showAirductMenu, setShowAirductMenu] = useState<number | null>(null);
|
||||
const [showResumeConfirm, setShowResumeConfirm] = useState(false);
|
||||
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
|
||||
const [showUploadForPrint, setShowUploadForPrint] = useState(false);
|
||||
@@ -1625,6 +1630,15 @@ function PrinterCard({
|
||||
},
|
||||
});
|
||||
|
||||
const forceRefreshMutation = useMutation({
|
||||
mutationFn: () => api.refreshPrinterStatus(printer.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
|
||||
showToast(t('printers.forceRefreshSuccess'), 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
|
||||
});
|
||||
|
||||
const unlinkSpoolMutation = useMutation({
|
||||
mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
|
||||
onSuccess: (result) => {
|
||||
@@ -1759,6 +1773,25 @@ function PrinterCard({
|
||||
},
|
||||
});
|
||||
|
||||
const airductMutation = useMutation({
|
||||
mutationFn: (mode: 'cooling' | 'heating') => api.setAirductMode(printer.id, mode),
|
||||
onMutate: async (mode) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
|
||||
const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);
|
||||
queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({
|
||||
...old,
|
||||
airduct_mode: mode === 'cooling' ? 0 : 1,
|
||||
}));
|
||||
return { previousStatus };
|
||||
},
|
||||
onError: (error: Error, _, context) => {
|
||||
if (context?.previousStatus) {
|
||||
queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);
|
||||
}
|
||||
showToast(error.message || t('printers.toast.failedToSendCommand'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Plate detection setting mutation
|
||||
const plateDetectionMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),
|
||||
@@ -2260,6 +2293,17 @@ function PrinterCard({
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{t('printers.reconnect')}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2 disabled:opacity-50"
|
||||
disabled={forceRefreshMutation.isPending}
|
||||
onClick={() => {
|
||||
forceRefreshMutation.mutate();
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<RotateCw className={`w-4 h-4 ${forceRefreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{t('printers.forceRefresh')}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
|
||||
onClick={() => {
|
||||
@@ -2416,6 +2460,34 @@ function PrinterCard({
|
||||
{status.firmware_version}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* SD Card Badge */}
|
||||
{status && (
|
||||
<span
|
||||
className={`flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
status.sdcard
|
||||
? 'bg-status-ok/20 text-status-ok'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
title={`${t('printers.sdCard')}: ${status.sdcard ? t('printers.inserted') : t('printers.notInserted')}`}
|
||||
>
|
||||
<HardDrive className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Enclosure Door Badge (X1/P1S/P2S/H2*) */}
|
||||
{status && ['X1C', 'X1', 'X1E', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (
|
||||
<span
|
||||
className={`flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
status.door_open
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-status-ok/20 text-status-ok'
|
||||
}`}
|
||||
title={status.door_open ? t('printers.door.open') : t('printers.door.closed')}
|
||||
>
|
||||
{status.door_open ? <DoorOpen className="w-3 h-3" /> : <DoorClosed className="w-3 h-3" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2772,6 +2844,56 @@ function PrinterCard({
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-bambu-gray/30" />
|
||||
|
||||
{/* Airduct Mode (P2S / H2*) */}
|
||||
{(['P2S', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
|
||||
const isHeating = status.airduct_mode === 1;
|
||||
const Icon = isHeating ? Flame : Snowflake;
|
||||
const color = isHeating ? 'text-orange-400' : 'text-sky-400';
|
||||
const bg = isHeating ? 'bg-orange-500/10 hover:bg-orange-500/20' : 'bg-sky-500/10 hover:bg-sky-500/20';
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAirductMenu(showAirductMenu === printer.id ? null : printer.id)}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${bg} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
title={t('printers.airduct.title')}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
||||
<span className={`text-[10px] ${color}`}>
|
||||
{isHeating ? t('printers.airduct.heating') : t('printers.airduct.cooling')}
|
||||
</span>
|
||||
</button>
|
||||
{showAirductMenu === printer.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAirductMenu(null)} />
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]">
|
||||
{([
|
||||
{ mode: 'cooling', label: t('printers.airduct.cooling'), modeId: 0 },
|
||||
{ mode: 'heating', label: t('printers.airduct.heating'), modeId: 1 },
|
||||
] as const).map(({ mode, label, modeId }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
airductMutation.mutate(mode);
|
||||
setShowAirductMenu(null);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${
|
||||
status.airduct_mode === modeId
|
||||
? 'text-bambu-green bg-bambu-green/10'
|
||||
: 'text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{mode === 'heating' ? <Flame className="w-3 h-3" /> : <Snowflake className="w-3 h-3" />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Print Speed */}
|
||||
{(() => {
|
||||
const speedLabels: Record<number, string> = { 1: '50%', 2: '100%', 3: '124%', 4: '166%' };
|
||||
|
||||
Reference in New Issue
Block a user