Started to implement full printer control

This commit is contained in:
Martin Ziegler
2025-12-01 16:25:54 +01:00
parent 16bb3d488a
commit 37caef239f
24 changed files with 3719 additions and 423 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, Gamepad2, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -16,6 +16,7 @@ interface NavItem {
export const defaultNavItems: NavItem[] = [
{ id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
{ id: 'control', to: '/control', icon: Gamepad2, labelKey: 'nav.control' },
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
{ id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
@@ -0,0 +1,223 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Package, Loader2, ArrowDown, ArrowUp } from 'lucide-react';
interface AMSPanelProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
interface AMSTray {
id: number;
color: string;
type: string;
remain: number;
active: boolean;
}
interface AMSUnit {
id: number;
trays: AMSTray[];
}
// Parse AMS data from raw_data in status
function parseAMSData(status: PrinterStatus | null | undefined): AMSUnit[] {
// AMS data comes from raw MQTT data
// This is a simplified parser - actual data structure may vary
const rawData = (status as { raw_data?: { ams?: unknown[] } })?.raw_data;
if (!rawData?.ams) return [];
try {
return (rawData.ams as Array<{
id?: number;
tray?: Array<{
id?: number;
tray_color?: string;
tray_type?: string;
remain?: number;
}>;
}>).map((unit) => ({
id: unit.id ?? 0,
trays: (unit.tray ?? []).map((tray) => ({
id: tray.id ?? 0,
color: tray.tray_color ?? 'FFFFFF',
type: tray.tray_type ?? 'Unknown',
remain: tray.remain ?? 0,
active: false, // Would need additional MQTT data to determine
})),
}));
} catch {
return [];
}
}
function hexToRgb(hex: string): string {
// Handle RRGGBBAA format
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
return `rgb(${r}, ${g}, ${b})`;
}
export function AMSPanel({ printerId, status }: AMSPanelProps) {
const isConnected = status?.connected ?? false;
const isPrinting = status?.state === 'RUNNING';
const amsUnits = parseAMSData(status);
const [selectedTray, setSelectedTray] = useState<number | null>(null);
const loadMutation = useMutation({
mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId),
});
const unloadMutation = useMutation({
mutationFn: () => api.amsUnloadFilament(printerId),
});
const handleLoad = () => {
if (selectedTray !== null) {
loadMutation.mutate(selectedTray);
}
};
const handleUnload = () => {
unloadMutation.mutate();
};
const isLoading = loadMutation.isPending || unloadMutation.isPending;
if (amsUnits.length === 0) {
return (
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Package className="w-4 h-4 text-bambu-gray" />
<h3 className="text-sm font-medium">AMS</h3>
</div>
<p className="text-sm text-bambu-gray text-center py-4">
No AMS detected or using external spool
</p>
</div>
);
}
return (
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-bambu-gray" />
<h3 className="text-sm font-medium">AMS</h3>
</div>
{isLoading && <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />}
</div>
{/* AMS Units */}
{amsUnits.map((unit) => (
<div key={unit.id} className="mb-4">
{amsUnits.length > 1 && (
<div className="text-xs text-bambu-gray mb-2">AMS {unit.id + 1}</div>
)}
<div className="grid grid-cols-4 gap-2">
{unit.trays.map((tray) => {
const globalTrayId = unit.id * 4 + tray.id;
const isSelected = selectedTray === globalTrayId;
const isEmpty = tray.type === 'NONE' || !tray.type;
return (
<button
key={tray.id}
onClick={() => !isEmpty && setSelectedTray(isSelected ? null : globalTrayId)}
disabled={isEmpty || isPrinting}
className={`relative p-2 rounded-lg transition-all ${
isSelected
? 'ring-2 ring-bambu-green bg-bambu-dark'
: 'bg-bambu-dark hover:bg-bambu-dark-tertiary'
} ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
>
{/* Color Indicator */}
<div
className="w-8 h-8 mx-auto rounded-full mb-1 border-2 border-bambu-dark-tertiary"
style={{
backgroundColor: isEmpty ? '#333' : hexToRgb(tray.color),
}}
/>
{/* Tray Number */}
<div className="text-xs text-center text-bambu-gray">
{tray.id + 1}
</div>
{/* Type */}
<div className="text-xs text-center truncate" title={tray.type}>
{isEmpty ? '--' : tray.type}
</div>
{/* Remaining */}
{!isEmpty && (
<div className="mt-1">
<div className="h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-bambu-green"
style={{ width: `${Math.min(100, tray.remain)}%` }}
/>
</div>
<div className="text-[10px] text-center text-bambu-gray mt-0.5">
{tray.remain}%
</div>
</div>
)}
{/* Active Indicator */}
{tray.active && (
<div className="absolute top-1 right-1 w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
)}
</button>
);
})}
</div>
</div>
))}
{/* Load/Unload Controls */}
<div className="flex gap-2 mt-4">
<button
onClick={handleLoad}
disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowDown className="w-4 h-4" />
<span className="text-sm">Load</span>
</button>
<button
onClick={handleUnload}
disabled={!isConnected || isPrinting || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowUp className="w-4 h-4" />
<span className="text-sm">Unload</span>
</button>
</div>
{selectedTray !== null && (
<p className="mt-2 text-xs text-bambu-gray text-center">
Selected: Slot {(selectedTray % 4) + 1}
{amsUnits.length > 1 && ` (AMS ${Math.floor(selectedTray / 4) + 1})`}
</p>
)}
{isPrinting && (
<p className="mt-2 text-xs text-yellow-500 text-center">
Filament change disabled during print
</p>
)}
{(loadMutation.error || unloadMutation.error) && (
<p className="mt-2 text-sm text-red-400">
{(loadMutation.error || unloadMutation.error)?.message}
</p>
)}
</div>
);
}
@@ -0,0 +1,138 @@
import { useState, useRef } from 'react';
import { api } from '../../api/client';
import { Camera, CameraOff, Maximize2, RefreshCw, Loader2 } from 'lucide-react';
interface CameraFeedProps {
printerId: number;
isConnected: boolean;
}
export function CameraFeed({ printerId, isConnected }: CameraFeedProps) {
const [streamEnabled, setStreamEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
const streamUrl = api.getCameraStreamUrl(printerId, 10);
const handleToggleStream = () => {
if (streamEnabled) {
setStreamEnabled(false);
setError(null);
} else {
setIsLoading(true);
setError(null);
setStreamEnabled(true);
}
};
const handleImageLoad = () => {
setIsLoading(false);
setError(null);
};
const handleImageError = () => {
setIsLoading(false);
setError('Failed to load camera stream');
};
const handleFullscreen = () => {
if (imgRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
imgRef.current.requestFullscreen();
}
}
};
const handleRefresh = () => {
setStreamEnabled(false);
setTimeout(() => {
setIsLoading(true);
setStreamEnabled(true);
}, 100);
};
return (
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Camera className="w-4 h-4 text-bambu-gray" />
<span className="text-sm font-medium">Camera</span>
</div>
<div className="flex items-center gap-2">
{streamEnabled && (
<>
<button
onClick={handleRefresh}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title="Refresh stream"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={handleFullscreen}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-4 h-4" />
</button>
</>
)}
<button
onClick={handleToggleStream}
disabled={!isConnected}
className={`px-3 py-1 rounded text-sm transition-colors ${
streamEnabled
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{streamEnabled ? 'Stop' : 'Start'}
</button>
</div>
</div>
<div className="relative aspect-video bg-bambu-dark">
{!streamEnabled ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-bambu-gray">
<CameraOff className="w-12 h-12 mb-2" />
<span className="text-sm">
{isConnected ? 'Click Start to view camera' : 'Printer not connected'}
</span>
</div>
) : (
<>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
)}
{error ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400">
<CameraOff className="w-12 h-12 mb-2" />
<span className="text-sm">{error}</span>
<button
onClick={handleRefresh}
className="mt-2 text-xs text-bambu-green hover:underline"
>
Retry
</button>
</div>
) : (
<img
ref={imgRef}
src={streamUrl}
alt="Camera stream"
className="w-full h-full object-contain"
onLoad={handleImageLoad}
onError={handleImageError}
/>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Fan, Loader2 } from 'lucide-react';
interface FanControlsProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
interface FanSliderProps {
label: string;
value: number;
onChange: (value: number) => void;
disabled: boolean;
isLoading: boolean;
}
function FanSlider({ label, value, onChange, disabled, isLoading }: FanSliderProps) {
const [localValue, setLocalValue] = useState(value);
const [isDragging, setIsDragging] = useState(false);
const displayValue = isDragging ? localValue : value;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseInt(e.target.value, 10);
setLocalValue(newValue);
};
const handleMouseUp = () => {
setIsDragging(false);
if (localValue !== value) {
onChange(localValue);
}
};
return (
<div className="p-3 rounded bg-bambu-dark">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">{label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-white">{displayValue}%</span>
{isLoading && <Loader2 className="w-3 h-3 animate-spin" />}
</div>
</div>
<input
type="range"
min="0"
max="100"
step="5"
value={displayValue}
onChange={handleChange}
onMouseDown={() => setIsDragging(true)}
onMouseUp={handleMouseUp}
onTouchStart={() => setIsDragging(true)}
onTouchEnd={handleMouseUp}
disabled={disabled}
className="w-full h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-bambu-green [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
/>
<div className="flex justify-between text-xs text-bambu-gray mt-1">
<span>Off</span>
<span>Max</span>
</div>
</div>
);
}
export function FanControls({ printerId, status }: FanControlsProps) {
const isConnected = status?.connected ?? false;
// Note: Bambu printers don't report fan speeds via MQTT
// So we track locally and can't show actual current values
const [fanValues, setFanValues] = useState({
part: 100,
aux: 0,
chamber: 0,
});
const partFanMutation = useMutation({
mutationFn: (speed: number) => api.setPartFan(printerId, speed),
onSuccess: (_, speed) => setFanValues((prev) => ({ ...prev, part: speed })),
});
const auxFanMutation = useMutation({
mutationFn: (speed: number) => api.setAuxFan(printerId, speed),
onSuccess: (_, speed) => setFanValues((prev) => ({ ...prev, aux: speed })),
});
const chamberFanMutation = useMutation({
mutationFn: (speed: number) => api.setChamberFan(printerId, speed),
onSuccess: (_, speed) => setFanValues((prev) => ({ ...prev, chamber: speed })),
});
return (
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Fan className="w-4 h-4 text-bambu-gray" />
<h3 className="text-sm font-medium">Fans</h3>
</div>
<div className="space-y-3">
<FanSlider
label="Part Cooling"
value={fanValues.part}
onChange={(v) => partFanMutation.mutate(v)}
disabled={!isConnected}
isLoading={partFanMutation.isPending}
/>
<FanSlider
label="Auxiliary"
value={fanValues.aux}
onChange={(v) => auxFanMutation.mutate(v)}
disabled={!isConnected}
isLoading={auxFanMutation.isPending}
/>
<FanSlider
label="Chamber"
value={fanValues.chamber}
onChange={(v) => chamberFanMutation.mutate(v)}
disabled={!isConnected}
isLoading={chamberFanMutation.isPending}
/>
</div>
{(partFanMutation.error || auxFanMutation.error || chamberFanMutation.error) && (
<p className="mt-2 text-sm text-red-400">
{(partFanMutation.error || auxFanMutation.error || chamberFanMutation.error)?.message}
</p>
)}
</div>
);
}
@@ -0,0 +1,68 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Lightbulb, LightbulbOff, Loader2 } from 'lucide-react';
interface LightToggleProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
export function LightToggle({ printerId, status }: LightToggleProps) {
const isConnected = status?.connected ?? false;
// Note: Bambu printers don't report light state via standard MQTT
// Track locally
const [isOn, setIsOn] = useState(true);
const lightMutation = useMutation({
mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
onSuccess: (_, on) => setIsOn(on),
});
const handleToggle = () => {
lightMutation.mutate(!isOn);
};
return (
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isOn ? (
<Lightbulb className="w-4 h-4 text-yellow-400" />
) : (
<LightbulbOff className="w-4 h-4 text-bambu-gray" />
)}
<h3 className="text-sm font-medium">Chamber Light</h3>
</div>
<button
onClick={handleToggle}
disabled={!isConnected || lightMutation.isPending}
className={`relative w-12 h-6 rounded-full transition-colors ${
isOn ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{lightMutation.isPending ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
) : (
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
isOn ? 'translate-x-7' : 'translate-x-1'
}`}
/>
)}
</button>
</div>
{lightMutation.error && (
<p className="mt-2 text-sm text-red-400">
{lightMutation.error.message}
</p>
)}
</div>
);
}
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, isConfirmationRequired } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Home, Move, Loader2, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Power } from 'lucide-react';
import { ConfirmModal } from '../ConfirmModal';
interface MovementControlsProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
export function MovementControls({ printerId, status }: MovementControlsProps) {
const isConnected = status?.connected ?? false;
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
const [confirmModal, setConfirmModal] = useState<{
action: string;
token: string;
warning: string;
onConfirm: () => void;
} | null>(null);
const [moveDistance, setMoveDistance] = useState(10);
const homeMutation = useMutation({
mutationFn: ({ axes, token }: { axes: string; token?: string }) =>
api.homeAxes(printerId, axes, token),
onSuccess: (result) => {
if (isConfirmationRequired(result)) {
setConfirmModal({
action: 'home',
token: result.token,
warning: result.warning,
onConfirm: () => homeMutation.mutate({ axes: 'XYZ', token: result.token }),
});
}
},
});
const moveMutation = useMutation({
mutationFn: ({ axis, distance, token }: { axis: string; distance: number; token?: string }) =>
api.moveAxis(printerId, axis, distance, 3000, token),
onSuccess: (result, variables) => {
if (isConfirmationRequired(result)) {
setConfirmModal({
action: 'move',
token: result.token,
warning: result.warning,
onConfirm: () =>
moveMutation.mutate({
axis: variables.axis,
distance: variables.distance,
token: result.token,
}),
});
}
},
});
const disableMotorsMutation = useMutation({
mutationFn: (token?: string) => api.disableMotors(printerId, token),
onSuccess: (result) => {
if (isConfirmationRequired(result)) {
setConfirmModal({
action: 'disable',
token: result.token,
warning: result.warning,
onConfirm: () => disableMotorsMutation.mutate(result.token),
});
}
},
});
const handleHome = () => {
homeMutation.mutate({ axes: 'XYZ' });
};
const handleMove = (axis: string, distance: number) => {
moveMutation.mutate({ axis, distance });
};
const handleDisableMotors = () => {
disableMotorsMutation.mutate(undefined);
};
const handleConfirm = () => {
if (confirmModal) {
confirmModal.onConfirm();
setConfirmModal(null);
}
};
const isLoading =
homeMutation.isPending || moveMutation.isPending || disableMotorsMutation.isPending;
return (
<>
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Move className="w-4 h-4 text-bambu-gray" />
<h3 className="text-sm font-medium">Movement</h3>
</div>
{isLoading && <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />}
</div>
{isPrinting && (
<div className="mb-4 p-2 rounded bg-yellow-500/20 text-yellow-500 text-xs text-center">
Movement is restricted during printing
</div>
)}
{/* Distance Selector */}
<div className="flex items-center justify-center gap-2 mb-4">
<span className="text-xs text-bambu-gray">Distance:</span>
{[1, 10, 50].map((d) => (
<button
key={d}
onClick={() => setMoveDistance(d)}
className={`px-3 py-1 rounded text-xs transition-colors ${
moveDistance === d
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark hover:text-white'
}`}
>
{d}mm
</button>
))}
</div>
{/* Movement Grid */}
<div className="grid grid-cols-5 gap-2 mb-4">
{/* Row 1: Y+ */}
<div />
<div />
<button
onClick={() => handleMove('Y', moveDistance)}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title={`Y+${moveDistance}`}
>
<ChevronUp className="w-5 h-5 mx-auto" />
</button>
<div />
<button
onClick={() => handleMove('Z', moveDistance)}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title={`Z+${moveDistance}`}
>
<ChevronUp className="w-5 h-5 mx-auto text-blue-400" />
</button>
{/* Row 2: X-, Home, X+ */}
<div />
<button
onClick={() => handleMove('X', -moveDistance)}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title={`X-${moveDistance}`}
>
<ChevronLeft className="w-5 h-5 mx-auto" />
</button>
<button
onClick={handleHome}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 disabled:opacity-50 disabled:cursor-not-allowed"
title="Home all axes"
>
<Home className="w-5 h-5 mx-auto" />
</button>
<button
onClick={() => handleMove('X', moveDistance)}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title={`X+${moveDistance}`}
>
<ChevronRight className="w-5 h-5 mx-auto" />
</button>
<div className="text-center text-xs text-bambu-gray self-center">Z</div>
{/* Row 3: Y- */}
<div />
<div />
<button
onClick={() => handleMove('Y', -moveDistance)}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title={`Y-${moveDistance}`}
>
<ChevronDown className="w-5 h-5 mx-auto" />
</button>
<div />
<button
onClick={() => handleMove('Z', -moveDistance)}
disabled={!isConnected || isLoading}
className="p-3 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title={`Z-${moveDistance}`}
>
<ChevronDown className="w-5 h-5 mx-auto text-blue-400" />
</button>
</div>
{/* Disable Motors */}
<button
onClick={handleDisableMotors}
disabled={!isConnected || isLoading}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Power className="w-4 h-4" />
<span className="text-sm">Disable Motors</span>
</button>
{(homeMutation.error || moveMutation.error || disableMotorsMutation.error) && (
<p className="mt-2 text-sm text-red-400">
{(homeMutation.error || moveMutation.error || disableMotorsMutation.error)?.message}
</p>
)}
</div>
{/* Confirmation Modal */}
{confirmModal && (
<ConfirmModal
title="Confirm Action"
message={confirmModal.warning}
confirmText="Continue"
variant="warning"
onConfirm={handleConfirm}
onCancel={() => setConfirmModal(null)}
/>
)}
</>
);
}
@@ -0,0 +1,137 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api, isConfirmationRequired } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Pause, Play, Square, Loader2 } from 'lucide-react';
import { ConfirmModal } from '../ConfirmModal';
interface PrintControlsProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
export function PrintControls({ printerId, status }: PrintControlsProps) {
const queryClient = useQueryClient();
const [confirmModal, setConfirmModal] = useState<{
action: string;
token: string;
warning: string;
} | null>(null);
const isConnected = status?.connected ?? false;
const isPrinting = status?.state === 'RUNNING';
const isPaused = status?.state === 'PAUSE';
const pauseMutation = useMutation({
mutationFn: () => api.pausePrint(printerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
},
});
const resumeMutation = useMutation({
mutationFn: () => api.resumePrint(printerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
},
});
const stopMutation = useMutation({
mutationFn: (token?: string) => api.stopPrint(printerId, token),
onSuccess: (result) => {
if (isConfirmationRequired(result)) {
setConfirmModal({
action: 'stop',
token: result.token,
warning: result.warning,
});
} else {
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
}
},
});
const handleStop = () => {
stopMutation.mutate(undefined);
};
const handleConfirmStop = () => {
if (confirmModal) {
stopMutation.mutate(confirmModal.token);
setConfirmModal(null);
}
};
const isLoading = pauseMutation.isPending || resumeMutation.isPending || stopMutation.isPending;
return (
<>
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<h3 className="text-sm font-medium text-bambu-gray mb-3">Print Controls</h3>
<div className="flex gap-2">
{/* Pause Button */}
<button
onClick={() => pauseMutation.mutate()}
disabled={!isConnected || !isPrinting || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-yellow-500/20 text-yellow-500 hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{pauseMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Pause className="w-5 h-5" />
)}
<span className="font-medium">Pause</span>
</button>
{/* Resume Button */}
<button
onClick={() => resumeMutation.mutate()}
disabled={!isConnected || !isPaused || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{resumeMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Play className="w-5 h-5" />
)}
<span className="font-medium">Resume</span>
</button>
{/* Stop Button */}
<button
onClick={handleStop}
disabled={!isConnected || (!isPrinting && !isPaused) || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-red-500/20 text-red-500 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{stopMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Square className="w-5 h-5" />
)}
<span className="font-medium">Stop</span>
</button>
</div>
{/* Error Message */}
{(pauseMutation.error || resumeMutation.error || stopMutation.error) && (
<p className="mt-2 text-sm text-red-400">
{(pauseMutation.error || resumeMutation.error || stopMutation.error)?.message}
</p>
)}
</div>
{/* Confirmation Modal */}
{confirmModal && (
<ConfirmModal
title="Confirm Stop"
message={confirmModal.warning}
confirmText="Stop Print"
variant="danger"
onConfirm={handleConfirmStop}
onCancel={() => setConfirmModal(null)}
/>
)}
</>
);
}
@@ -0,0 +1,90 @@
import type { PrinterStatus } from '../../api/client';
import { Clock, Layers, FileText } from 'lucide-react';
interface PrintStatusProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
function formatTime(seconds: number | null | undefined): string {
if (!seconds) return '--:--';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
}
export function PrintStatus({ status }: PrintStatusProps) {
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
const progress = status?.progress ?? 0;
return (
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-bambu-gray">Print Status</h3>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
status?.state === 'RUNNING'
? 'bg-bambu-green/20 text-bambu-green'
: status?.state === 'PAUSE'
? 'bg-yellow-500/20 text-yellow-500'
: status?.state === 'FINISH'
? 'bg-blue-500/20 text-blue-500'
: status?.state === 'FAILED'
? 'bg-red-500/20 text-red-500'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}
>
{status?.state || 'IDLE'}
</span>
</div>
{isPrinting && status?.subtask_name && (
<div className="flex items-center gap-2 mb-3 text-sm">
<FileText className="w-4 h-4 text-bambu-gray" />
<span className="truncate" title={status.subtask_name}>
{status.subtask_name}
</span>
</div>
)}
{/* Progress Bar */}
<div className="mb-3">
<div className="flex justify-between text-xs text-bambu-gray mb-1">
<span>Progress</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
status?.state === 'PAUSE' ? 'bg-yellow-500' : 'bg-bambu-green'
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-gray" />
<div>
<div className="text-xs text-bambu-gray">Remaining</div>
<div className="font-medium">{formatTime(status?.remaining_time)}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-bambu-gray" />
<div>
<div className="text-xs text-bambu-gray">Layer</div>
<div className="font-medium">
{status?.layer_num ?? 0} / {status?.total_layers ?? 0}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { useMutation } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Gauge, Loader2 } from 'lucide-react';
interface SpeedControlProps {
printerId: number;
status: PrinterStatus | null | undefined;
}
const SPEED_MODES = [
{ mode: 1, name: 'Silent', icon: '🔇' },
{ mode: 2, name: 'Standard', icon: '⚡' },
{ mode: 3, name: 'Sport', icon: '🚀' },
{ mode: 4, name: 'Ludicrous', icon: '💨' },
];
export function SpeedControl({ printerId, status }: SpeedControlProps) {
const isConnected = status?.connected ?? false;
const isPrinting = status?.state === 'RUNNING';
// Note: Bambu printers don't report current speed mode via MQTT
// So we can't show which mode is currently active
const speedMutation = useMutation({
mutationFn: (mode: number) => api.setPrintSpeed(printerId, mode),
});
return (
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Gauge className="w-4 h-4 text-bambu-gray" />
<h3 className="text-sm font-medium">Print Speed</h3>
{speedMutation.isPending && (
<Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
)}
</div>
<div className="grid grid-cols-4 gap-2">
{SPEED_MODES.map(({ mode, name, icon }) => (
<button
key={mode}
onClick={() => speedMutation.mutate(mode)}
disabled={!isConnected || !isPrinting || speedMutation.isPending}
className={`flex flex-col items-center gap-1 px-3 py-3 rounded-lg transition-colors ${
mode === 4
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-bambu-dark hover:bg-bambu-dark-tertiary'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={name}
>
<span className="text-lg">{icon}</span>
<span className="text-xs">{name}</span>
</button>
))}
</div>
{!isPrinting && (
<p className="mt-3 text-xs text-bambu-gray text-center">
Speed control only available during print
</p>
)}
{speedMutation.error && (
<p className="mt-2 text-sm text-red-400">
{speedMutation.error.message}
</p>
)}
</div>
);
}
@@ -0,0 +1,246 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, isConfirmationRequired } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { Thermometer, Loader2, Plus, Minus } from 'lucide-react';
import { ConfirmModal } from '../ConfirmModal';
// Extended temperature interface for dual nozzle support
interface Temperatures {
bed?: number;
bed_target?: number;
nozzle?: number;
nozzle_target?: number;
nozzle_2?: number;
nozzle_2_target?: number;
chamber?: number;
}
interface TemperaturePanelProps {
printerId: number;
status: PrinterStatus | null | undefined;
nozzleCount: number;
}
interface TempPreset {
name: string;
bed: number;
nozzle: number;
}
const PRESETS: TempPreset[] = [
{ name: 'Off', bed: 0, nozzle: 0 },
{ name: 'PLA', bed: 55, nozzle: 220 },
{ name: 'PETG', bed: 70, nozzle: 250 },
{ name: 'ABS', bed: 90, nozzle: 260 },
{ name: 'TPU', bed: 50, nozzle: 230 },
];
export function TemperaturePanel({ printerId, status, nozzleCount }: TemperaturePanelProps) {
const [confirmModal, setConfirmModal] = useState<{
type: 'bed' | 'nozzle';
target: number;
nozzle?: number;
token: string;
warning: string;
} | null>(null);
const isConnected = status?.connected ?? false;
const temps = (status?.temperatures ?? {}) as Temperatures;
const bedMutation = useMutation({
mutationFn: ({ target, token }: { target: number; token?: string }) =>
api.setBedTemperature(printerId, target, token),
onSuccess: (result, variables) => {
if (isConfirmationRequired(result)) {
setConfirmModal({
type: 'bed',
target: variables.target,
token: result.token,
warning: result.warning,
});
}
},
});
const nozzleMutation = useMutation({
mutationFn: ({ target, nozzle, token }: { target: number; nozzle: number; token?: string }) =>
api.setNozzleTemperature(printerId, target, nozzle, token),
onSuccess: (result, variables) => {
if (isConfirmationRequired(result)) {
setConfirmModal({
type: 'nozzle',
target: variables.target,
nozzle: variables.nozzle,
token: result.token,
warning: result.warning,
});
}
},
});
const handleConfirm = () => {
if (confirmModal) {
if (confirmModal.type === 'bed') {
bedMutation.mutate({ target: confirmModal.target, token: confirmModal.token });
} else {
nozzleMutation.mutate({
target: confirmModal.target,
nozzle: confirmModal.nozzle ?? 0,
token: confirmModal.token,
});
}
setConfirmModal(null);
}
};
const handlePreset = (preset: TempPreset) => {
bedMutation.mutate({ target: preset.bed });
nozzleMutation.mutate({ target: preset.nozzle, nozzle: 0 });
if (nozzleCount > 1) {
nozzleMutation.mutate({ target: preset.nozzle, nozzle: 1 });
}
};
const adjustTemp = (type: 'bed' | 'nozzle', delta: number, nozzle = 0) => {
const currentTarget =
type === 'bed'
? (temps.bed_target ?? 0)
: nozzle === 0
? (temps.nozzle_target ?? 0)
: (temps.nozzle_2_target ?? 0);
const newTarget = Math.max(0, Math.min(type === 'bed' ? 120 : 300, currentTarget + delta));
if (type === 'bed') {
bedMutation.mutate({ target: newTarget });
} else {
nozzleMutation.mutate({ target: newTarget, nozzle });
}
};
return (
<>
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Thermometer className="w-4 h-4 text-bambu-gray" />
<h3 className="text-sm font-medium">Temperatures</h3>
</div>
{/* Presets */}
<div className="flex gap-2 mb-4">
{PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => handlePreset(preset)}
disabled={!isConnected}
className="flex-1 px-2 py-1.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{preset.name}
</button>
))}
</div>
{/* Bed Temperature */}
<div className="mb-4 p-3 rounded bg-bambu-dark">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">Bed</span>
<span className="text-sm font-mono">
<span className="text-orange-400">{Math.round(temps.bed ?? 0)}°C</span>
<span className="text-bambu-gray mx-1">/</span>
<span className="text-white">{Math.round(temps.bed_target ?? 0)}°C</span>
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => adjustTemp('bed', -5)}
disabled={!isConnected || bedMutation.isPending}
className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
>
<Minus className="w-4 h-4" />
</button>
<div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${Math.min(100, ((temps.bed ?? 0) / 120) * 100)}%` }}
/>
</div>
<button
onClick={() => adjustTemp('bed', 5)}
disabled={!isConnected || bedMutation.isPending}
className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
</button>
{bedMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
</div>
</div>
{/* Nozzle Temperature(s) */}
{[0, ...(nozzleCount > 1 ? [1] : [])].map((nozzle) => {
const current = nozzle === 0 ? temps.nozzle : temps.nozzle_2;
const target = nozzle === 0 ? temps.nozzle_target : temps.nozzle_2_target;
return (
<div key={nozzle} className="mb-4 p-3 rounded bg-bambu-dark">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">
Nozzle {nozzleCount > 1 ? nozzle + 1 : ''}
</span>
<span className="text-sm font-mono">
<span className="text-red-400">{Math.round(current ?? 0)}°C</span>
<span className="text-bambu-gray mx-1">/</span>
<span className="text-white">{Math.round(target ?? 0)}°C</span>
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => adjustTemp('nozzle', -5, nozzle)}
disabled={!isConnected || nozzleMutation.isPending}
className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
>
<Minus className="w-4 h-4" />
</button>
<div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${Math.min(100, ((current ?? 0) / 300) * 100)}%` }}
/>
</div>
<button
onClick={() => adjustTemp('nozzle', 5, nozzle)}
disabled={!isConnected || nozzleMutation.isPending}
className="p-2 rounded bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
</button>
{nozzleMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
</div>
</div>
);
})}
{/* Chamber Temperature (read-only) */}
{temps.chamber !== undefined && (
<div className="p-3 rounded bg-bambu-dark">
<div className="flex items-center justify-between">
<span className="text-sm text-bambu-gray">Chamber</span>
<span className="text-sm font-mono text-blue-400">{Math.round(temps.chamber)}°C</span>
</div>
</div>
)}
</div>
{/* Confirmation Modal */}
{confirmModal && (
<ConfirmModal
title="Confirm Temperature"
message={confirmModal.warning}
confirmText="Set Temperature"
variant="warning"
onConfirm={handleConfirm}
onCancel={() => setConfirmModal(null)}
/>
)}
</>
);
}