mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Started to implement full printer control
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user