Added power switch and automation controls to printer card

This commit is contained in:
Martin Ziegler
2025-11-29 11:01:29 +01:00
parent 526de63acd
commit a2b889485d
8 changed files with 657 additions and 447 deletions
+12
View File
@@ -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."""
+1
View File
@@ -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',
+34 -2
View File
@@ -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)}
/>
)}
</>
);
}
+200 -35
View File
@@ -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
View File
@@ -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>