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:
maziggy
2026-04-13 12:50:17 +02:00
parent eec7793955
commit 8af0966e68
23 changed files with 862 additions and 446 deletions
+35
View File
@@ -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');
});
});
+6
View File
@@ -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}`, {
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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: 'パーツ冷却ファン',
+12
View File
@@ -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',
+12
View File
@@ -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: '零件冷却风扇',
+122
View File
@@ -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%' };