mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Added power switch and automation controls to printer card
This commit is contained in:
@@ -62,6 +62,18 @@ async def create_smart_plug(
|
||||
return plug
|
||||
|
||||
|
||||
@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
|
||||
async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the smart plug assigned to a printer."""
|
||||
result = await db.execute(
|
||||
select(SmartPlug).where(SmartPlug.printer_id == printer_id)
|
||||
)
|
||||
plug = result.scalar_one_or_none()
|
||||
if not plug:
|
||||
return None
|
||||
return plug
|
||||
|
||||
|
||||
@router.get("/{plug_id}", response_model=SmartPlugResponse)
|
||||
async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific smart plug."""
|
||||
|
||||
@@ -519,6 +519,7 @@ export const api = {
|
||||
// Smart Plugs
|
||||
getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
|
||||
getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
|
||||
getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
|
||||
createSmartPlug: (data: SmartPlugCreate) =>
|
||||
request<SmartPlug>('/smart-plugs/', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -15,6 +15,8 @@ interface SmartPlugCardProps {
|
||||
export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
|
||||
const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Fetch current status
|
||||
@@ -108,7 +110,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
size="sm"
|
||||
variant={isOn ? 'primary' : 'secondary'}
|
||||
disabled={!isReachable || isPending}
|
||||
onClick={() => controlMutation.mutate('on')}
|
||||
onClick={() => setShowPowerOnConfirm(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
|
||||
@@ -118,7 +120,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
size="sm"
|
||||
variant={!isOn ? 'primary' : 'secondary'}
|
||||
disabled={!isReachable || isPending}
|
||||
onClick={() => controlMutation.mutate('off')}
|
||||
onClick={() => setShowPowerOffConfirm(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
|
||||
@@ -291,6 +293,36 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Power On Confirmation */}
|
||||
{showPowerOnConfirm && (
|
||||
<ConfirmModal
|
||||
title="Turn On Smart Plug"
|
||||
message={`Are you sure you want to turn on "${plug.name}"?`}
|
||||
confirmText="Turn On"
|
||||
variant="default"
|
||||
onConfirm={() => {
|
||||
controlMutation.mutate('on');
|
||||
setShowPowerOnConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowPowerOnConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Power Off Confirmation */}
|
||||
{showPowerOffConfirm && (
|
||||
<ConfirmModal
|
||||
title="Turn Off Smart Plug"
|
||||
message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
|
||||
confirmText="Turn Off"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
controlMutation.mutate('off');
|
||||
setShowPowerOffConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowPowerOffConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
Terminal,
|
||||
Power,
|
||||
PowerOff,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { Printer, PrinterCreate } from '../api/client';
|
||||
@@ -83,6 +86,8 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [showMQTTDebug, setShowMQTTDebug] = useState(false);
|
||||
const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
|
||||
const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', printer.id],
|
||||
@@ -90,6 +95,20 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
|
||||
});
|
||||
|
||||
// Fetch smart plug for this printer
|
||||
const { data: smartPlug } = useQuery({
|
||||
queryKey: ['smartPlugByPrinter', printer.id],
|
||||
queryFn: () => api.getSmartPlugByPrinter(printer.id),
|
||||
});
|
||||
|
||||
// Fetch smart plug status if plug exists
|
||||
const { data: plugStatus } = useQuery({
|
||||
queryKey: ['smartPlugStatus', smartPlug?.id],
|
||||
queryFn: () => smartPlug ? api.getSmartPlugStatus(smartPlug.id) : null,
|
||||
enabled: !!smartPlug,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Determine if this card should be hidden
|
||||
const shouldHide = hideIfDisconnected && status && !status.connected;
|
||||
|
||||
@@ -107,6 +126,23 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
},
|
||||
});
|
||||
|
||||
// Smart plug control mutations
|
||||
const powerControlMutation = useMutation({
|
||||
mutationFn: (action: 'on' | 'off') =>
|
||||
smartPlug ? api.controlSmartPlug(smartPlug.id, action) : Promise.reject('No plug'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['smartPlugStatus', smartPlug?.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAutoOffMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] });
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldHide) {
|
||||
return null;
|
||||
}
|
||||
@@ -229,44 +265,63 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Print */}
|
||||
{status.current_print && status.state === 'RUNNING' && (
|
||||
<div className="mb-4 p-3 bg-bambu-dark rounded-lg">
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<CoverImage url={status.cover_url} printName={status.subtask_name || status.current_print || undefined} />
|
||||
{/* Print Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-bambu-gray mb-1">Printing</p>
|
||||
<p className="text-white text-sm mb-2 truncate">
|
||||
{status.subtask_name || status.current_print}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
|
||||
<div
|
||||
className="bg-bambu-green h-2 rounded-full transition-all"
|
||||
style={{ width: `${status.progress || 0}%` }}
|
||||
/>
|
||||
{/* Current Print or Idle Placeholder */}
|
||||
<div className="mb-4 p-3 bg-bambu-dark rounded-lg">
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<CoverImage
|
||||
url={status.state === 'RUNNING' ? status.cover_url : undefined}
|
||||
printName={status.state === 'RUNNING' ? (status.subtask_name || status.current_print || undefined) : undefined}
|
||||
/>
|
||||
{/* Print Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{status.current_print && status.state === 'RUNNING' ? (
|
||||
<>
|
||||
<p className="text-sm text-bambu-gray mb-1">Printing</p>
|
||||
<p className="text-white text-sm mb-2 truncate">
|
||||
{status.subtask_name || status.current_print}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
|
||||
<div
|
||||
className="bg-bambu-green h-2 rounded-full transition-all"
|
||||
style={{ width: `${status.progress || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white">{Math.round(status.progress || 0)}%</span>
|
||||
</div>
|
||||
<span className="text-white">{Math.round(status.progress || 0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
|
||||
{status.remaining_time != null && status.remaining_time > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(status.remaining_time * 60)}
|
||||
</span>
|
||||
)}
|
||||
{status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
|
||||
<span>
|
||||
Layer {status.layer_num}/{status.total_layers}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
|
||||
{status.remaining_time != null && status.remaining_time > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(status.remaining_time * 60)}
|
||||
</span>
|
||||
)}
|
||||
{status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
|
||||
<span>
|
||||
Layer {status.layer_num}/{status.total_layers}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-bambu-gray mb-1">Status</p>
|
||||
<p className="text-white text-sm mb-2 capitalize">
|
||||
{status.state?.toLowerCase() || 'Idle'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
|
||||
<div className="bg-bambu-dark-tertiary h-2 rounded-full" />
|
||||
</div>
|
||||
<span className="text-bambu-gray">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-2">Ready to print</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Temperatures */}
|
||||
{status.temperatures && (
|
||||
@@ -299,6 +354,82 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Smart Plug Controls */}
|
||||
{smartPlug && (
|
||||
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Plug name and status */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Zap className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
<span className="text-sm text-white truncate">{smartPlug.name}</span>
|
||||
{plugStatus && (
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${
|
||||
plugStatus.state === 'ON'
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: plugStatus.state === 'OFF'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-bambu-gray/20 text-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{plugStatus.state || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Power buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowPowerOnConfirm(true)}
|
||||
disabled={powerControlMutation.isPending || plugStatus?.state === 'ON'}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
|
||||
plugStatus?.state === 'ON'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-3 h-3" />
|
||||
On
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPowerOffConfirm(true)}
|
||||
disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF'}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
|
||||
plugStatus?.state === 'OFF'
|
||||
? 'bg-red-500/30 text-red-400'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<PowerOff className="w-3 h-3" />
|
||||
Off
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-off toggle */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-bambu-gray hidden sm:inline">Auto-off</span>
|
||||
<button
|
||||
onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
|
||||
disabled={toggleAutoOffMutation.isPending}
|
||||
title="Auto power-off after print"
|
||||
className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
|
||||
smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
smartPlug.auto_off ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection Info & Actions */}
|
||||
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
|
||||
<div className="text-xs text-bambu-gray">
|
||||
@@ -334,6 +465,40 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
onClose={() => setShowMQTTDebug(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Power On Confirmation */}
|
||||
{showPowerOnConfirm && smartPlug && (
|
||||
<ConfirmModal
|
||||
title="Power On Printer"
|
||||
message={`Are you sure you want to turn ON the power for "${printer.name}"?`}
|
||||
confirmText="Power On"
|
||||
variant="default"
|
||||
onConfirm={() => {
|
||||
powerControlMutation.mutate('on');
|
||||
setShowPowerOnConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowPowerOnConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Power Off Confirmation */}
|
||||
{showPowerOffConfirm && smartPlug && (
|
||||
<ConfirmModal
|
||||
title="Power Off Printer"
|
||||
message={
|
||||
status?.state === 'RUNNING'
|
||||
? `WARNING: "${printer.name}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.`
|
||||
: `Are you sure you want to turn OFF the power for "${printer.name}"?`
|
||||
}
|
||||
confirmText="Power Off"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
powerControlMutation.mutate('off');
|
||||
setShowPowerOffConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowPowerOffConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-in5STeRb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-q7064R9p.css">
|
||||
<script type="module" crossorigin src="/assets/index-CzQ_ZlhY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-LyIdyh0d.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user