Browse Source

Added power switch and automation controls to printer card

Martin Ziegler 6 months ago
parent
commit
a2b889485d

+ 12 - 0
backend/app/api/routes/smart_plugs.py

@@ -62,6 +62,18 @@ async def create_smart_plug(
     return 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)
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific smart plug."""
     """Get a specific smart plug."""

+ 1 - 0
frontend/src/api/client.ts

@@ -519,6 +519,7 @@ export const api = {
   // Smart Plugs
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
+  getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
   createSmartPlug: (data: SmartPlugCreate) =>
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
       method: 'POST',

+ 34 - 2
frontend/src/components/SmartPlugCard.tsx

@@ -15,6 +15,8 @@ interface SmartPlugCardProps {
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
+  const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
 
   // Fetch current status
   // Fetch current status
@@ -108,7 +110,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               size="sm"
               size="sm"
               variant={isOn ? 'primary' : 'secondary'}
               variant={isOn ? 'primary' : 'secondary'}
               disabled={!isReachable || isPending}
               disabled={!isReachable || isPending}
-              onClick={() => controlMutation.mutate('on')}
+              onClick={() => setShowPowerOnConfirm(true)}
               className="flex-1"
               className="flex-1"
             >
             >
               {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
               {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"
               size="sm"
               variant={!isOn ? 'primary' : 'secondary'}
               variant={!isOn ? 'primary' : 'secondary'}
               disabled={!isReachable || isPending}
               disabled={!isReachable || isPending}
-              onClick={() => controlMutation.mutate('off')}
+              onClick={() => setShowPowerOffConfirm(true)}
               className="flex-1"
               className="flex-1"
             >
             >
               {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
               {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)}
           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
frontend/src/pages/PrintersPage.tsx

@@ -13,6 +13,9 @@ import {
   HardDrive,
   HardDrive,
   AlertTriangle,
   AlertTriangle,
   Terminal,
   Terminal,
+  Power,
+  PowerOff,
+  Zap,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { Printer, PrinterCreate } 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
+  const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
+  const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printer.id],
     queryKey: ['printerStatus', printer.id],
@@ -90,6 +95,20 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
     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
   // Determine if this card should be hidden
   const shouldHide = hideIfDisconnected && status && !status.connected;
   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) {
   if (shouldHide) {
     return null;
     return null;
   }
   }
@@ -229,44 +265,63 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
               </p>
               </p>
             </div>
             </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>
                       </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>
               </div>
-            )}
+            </div>
 
 
             {/* Temperatures */}
             {/* Temperatures */}
             {status.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 */}
         {/* Connection Info & Actions */}
         <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
         <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
           <div className="text-xs text-bambu-gray">
           <div className="text-xs text-bambu-gray">
@@ -334,6 +465,40 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
           onClose={() => setShowMQTTDebug(false)}
           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>
     </Card>
   );
   );
 }
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CzQ_ZlhY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-LyIdyh0d.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-q7064R9p.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <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="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff