Browse Source

Add SpoolBuddy quick menu with power control and system commands (#893)

  Swipe down from the top of the SpoolBuddy display to open a quick-access
  menu for toggling printer smart plugs and managing the device (restart
  daemon, restart browser, reboot, shutdown). All destructive actions
  require confirmation.

  Backend: new POST /spoolbuddy/devices/{id}/system/command endpoint
  queuing reboot/shutdown/restart_daemon/restart_browser commands.
  Daemon: handles commands via subprocess (sudo reboot, systemctl restart).
  Frontend: SpoolBuddyQuickMenu component, swipe-down gesture detection,
  i18n keys for all 7 locales.
maziggy 1 month ago
parent
commit
b76d6210cf

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Auto-Print G-code Injection** ([#422](https://github.com/maziggy/bambuddy/issues/422)) — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable "Inject G-code" to have the scheduler inject the configured snippets into the 3MF before uploading to the printer. The original file is never modified — injection creates a temporary copy for upload only.
 - **External Folder Subfolder Preservation** ([#890](https://github.com/maziggy/bambuddy/issues/890)) — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root. Subdirectories are created as child LibraryFolders with correct parent/child hierarchy, and files are assigned to their matching subfolder. Hidden directories are skipped when "Show hidden files" is disabled. Subfolders that are deleted from disk are automatically cleaned up on the next scan. Created subfolders inherit the parent's read-only and show-hidden settings.
 - **LDAP Authentication** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — Users can now authenticate against an LDAP/Active Directory server. Configure the LDAP server URL, bind DN, search base, and user filter in Settings > Authentication > LDAP. Supports StartTLS, LDAPS (SSL), and plaintext connections. LDAP groups can be mapped to BamBuddy groups (Administrators, Operators, Viewers) for automatic role assignment. Auto-provisioning creates BamBuddy accounts on first LDAP login when enabled. Local admin accounts remain as fallback when the LDAP server is unreachable. Password management features (change password, forgot password, admin reset) are automatically disabled for LDAP users.
+- **SpoolBuddy Quick Menu** ([#893](https://github.com/maziggy/bambuddy/issues/893)) — Swipe down from the top of the SpoolBuddy display to open a quick-access control panel. Toggle printer power via smart plugs directly from the display, and manage the SpoolBuddy system with restart daemon, restart browser, reboot, and shutdown controls. All destructive actions require confirmation. The menu shows real-time smart plug state (ON/OFF) for each printer that has a linked power plug.
 
 ### Improved
 - **Database Engine Info on System Page** — The System Information page now shows the active database engine (SQLite or PostgreSQL) and its version in the Database section, making it easy to verify which backend is in use.

+ 33 - 0
backend/app/api/routes/spoolbuddy.py

@@ -28,6 +28,7 @@ from backend.app.schemas.spoolbuddy import (
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
     SetTareRequest,
+    SystemCommandRequest,
     SystemCommandResultRequest,
     SystemConfigRequest,
     TagRemovedRequest,
@@ -669,6 +670,38 @@ async def queue_system_config_update(
     return {"status": "queued", "message": "System config update queued"}
 
 
+VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browser"}
+
+
+@router.post("/devices/{device_id}/system/command")
+async def queue_system_command(
+    device_id: str,
+    req: SystemCommandRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device."""
+    if req.command not in VALID_SYSTEM_COMMANDS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Invalid command. Must be one of: {', '.join(sorted(VALID_SYSTEM_COMMANDS))}",
+        )
+
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if not _is_online(device):
+        raise HTTPException(status_code=409, detail="Device is offline")
+
+    device.pending_command = req.command
+    await db.commit()
+
+    logger.info("System command queued for device %s: %s", device_id, req.command)
+    return {"status": "queued", "command": req.command}
+
+
 @router.post("/devices/{device_id}/system/command-result")
 async def system_command_result(
     device_id: str,

+ 4 - 0
backend/app/schemas/spoolbuddy.py

@@ -152,6 +152,10 @@ class SystemConfigRequest(BaseModel):
     api_key: str | None = Field(default=None, max_length=255)
 
 
+class SystemCommandRequest(BaseModel):
+    command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser")
+
+
 class SystemCommandResultRequest(BaseModel):
     command: str
     success: bool

+ 127 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -1037,3 +1037,130 @@ class TestUpdateEndpoints:
         assert msg["type"] == "spoolbuddy_update"
         assert msg["device_id"] == "sb-upd-ws"
         assert msg["update_status"] == "pending"
+
+
+# ============================================================================
+# System command endpoints
+# ============================================================================
+
+
+class TestSystemCommandEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_reboot(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-reboot")
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-reboot/system/command",
+            json={"command": "reboot"},
+        )
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["status"] == "queued"
+        assert data["command"] == "reboot"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-shutdown")
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-shutdown/system/command",
+            json={"command": "shutdown"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["command"] == "shutdown"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-rd")
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-rd/system/command",
+            json={"command": "restart_daemon"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["command"] == "restart_daemon"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-rb")
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-rb/system/command",
+            json={"command": "restart_browser"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["command"] == "restart_browser"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-invalid")
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-invalid/system/command",
+            json={"command": "format_disk"},
+        )
+        assert resp.status_code == 400
+        assert "Invalid command" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_command_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/devices/ghost/system/command",
+            json={"command": "reboot"},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-offline-cmd",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-offline-cmd/system/command",
+            json={"command": "reboot"},
+        )
+        assert resp.status_code == 409
+        assert "offline" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):
+        device = await device_factory(device_id="sb-pending")
+
+        await async_client.post(
+            f"{API}/devices/sb-pending/system/command",
+            json={"command": "restart_daemon"},
+        )
+
+        await db_session.refresh(device)
+        assert device.pending_command == "restart_daemon"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):
+        """System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them."""
+        await device_factory(device_id="sb-hb-clear")
+
+        # Queue a command
+        await async_client.post(
+            f"{API}/devices/sb-hb-clear/system/command",
+            json={"command": "restart_browser"},
+        )
+
+        # Heartbeat should return the command and clear it
+        resp = await async_client.post(
+            f"{API}/devices/sb-hb-clear/heartbeat",
+            json={"nfc_ok": True, "scale_ok": True, "uptime_s": 100},
+        )
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["pending_command"] == "restart_browser"

+ 228 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyQuickMenu.test.tsx

@@ -0,0 +1,228 @@
+/**
+ * Tests for SpoolBuddyQuickMenu component:
+ * - Renders nothing when closed
+ * - Shows printer power section with smart plugs
+ * - Shows system control buttons
+ * - Confirmation dialogs for destructive actions
+ * - Handles system commands
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../../utils';
+import { SpoolBuddyQuickMenu } from '../../../components/spoolbuddy/SpoolBuddyQuickMenu';
+import { api, spoolbuddyApi } from '../../../api/client';
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getSmartPlugs: vi.fn().mockResolvedValue([]),
+    getSmartPlugStatus: vi.fn().mockResolvedValue({ state: 'OFF', reachable: true, device_name: null, energy: null }),
+    controlSmartPlug: vi.fn().mockResolvedValue({ success: true, action: 'toggle' }),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    systemCommand: vi.fn().mockResolvedValue({ status: 'queued', command: 'reboot' }),
+  },
+}));
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  deviceId: 'sb-0001',
+  deviceOnline: true,
+};
+
+const mockPrinter = {
+  id: 1,
+  name: 'Test P1S',
+  model: 'P1S',
+  serial: 'SERIAL001',
+  ip_address: '10.0.0.1',
+};
+
+const mockSmartPlug = {
+  id: 10,
+  name: 'P1S Plug',
+  plug_type: 'tasmota' as const,
+  ip_address: '10.0.0.100',
+  printer_id: 1,
+  enabled: true,
+  ha_entity_id: null,
+  ha_power_entity: null,
+  ha_energy_today_entity: null,
+  ha_energy_total_entity: null,
+  mqtt_topic: null,
+  mqtt_multiplier: 1,
+  mqtt_power_topic: null,
+  mqtt_power_multiplier: 1,
+  mqtt_energy_topic: null,
+  mqtt_energy_multiplier: 1,
+  mqtt_state_topic: null,
+  rest_on_url: null,
+  rest_off_url: null,
+  rest_status_url: null,
+  rest_status_path: null,
+  rest_on_value: null,
+  rest_off_value: null,
+  rest_method: null,
+  rest_power_url: null,
+  rest_power_path: null,
+  rest_power_multiplier: 1,
+  rest_energy_url: null,
+  rest_energy_path: null,
+  rest_energy_multiplier: 1,
+  auto_on: false,
+  auto_off: false,
+  auto_off_persistent: false,
+  off_delay_mode: 'time' as const,
+  off_delay_minutes: 5,
+  off_temp_threshold: 50,
+  username: null,
+  password: null,
+  power_alert_enabled: false,
+  power_alert_threshold: 0,
+  power_alert_duration: 0,
+  schedule_enabled: false,
+  schedule_on_time: null,
+  schedule_off_time: null,
+  last_state: null,
+  last_checked: null,
+  auto_off_executed: false,
+  auto_off_pending: false,
+};
+
+describe('SpoolBuddyQuickMenu', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.getSmartPlugs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+  });
+
+  it('renders nothing when closed', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('System')).not.toBeInTheDocument();
+  });
+
+  it('shows system control buttons when open', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+    expect(screen.getByText('Restart Daemon')).toBeInTheDocument();
+    expect(screen.getByText('Restart Browser')).toBeInTheDocument();
+    expect(screen.getByText('Reboot')).toBeInTheDocument();
+    expect(screen.getByText('Shutdown')).toBeInTheDocument();
+  });
+
+  it('shows system section header', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+    expect(screen.getByText('System')).toBeInTheDocument();
+  });
+
+  it('shows swipe hint', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+    expect(screen.getByText('Swipe down to close')).toBeInTheDocument();
+  });
+
+  it('shows printer power section when printers have smart plugs', async () => {
+    (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValue([mockPrinter]);
+    (api.getSmartPlugs as ReturnType<typeof vi.fn>).mockResolvedValue([mockSmartPlug]);
+
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Printer Power')).toBeInTheDocument();
+    });
+
+    await waitFor(() => {
+      expect(screen.getByText('Test P1S')).toBeInTheDocument();
+    });
+  });
+
+  it('does not show printer power section when no smart plugs', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+    expect(screen.queryByText('Printer Power')).not.toBeInTheDocument();
+  });
+
+  it('shows confirmation dialog for reboot', async () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    fireEvent.click(screen.getByText('Reboot'));
+
+    await waitFor(() => {
+      expect(screen.getByText('Are you sure you want to reboot the SpoolBuddy?')).toBeInTheDocument();
+    });
+  });
+
+  it('shows confirmation dialog for shutdown with warning', async () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    fireEvent.click(screen.getByText('Shutdown'));
+
+    await waitFor(() => {
+      expect(screen.getByText(/physical access/)).toBeInTheDocument();
+    });
+  });
+
+  it('shows confirmation dialog for restart daemon', async () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    fireEvent.click(screen.getByText('Restart Daemon'));
+
+    await waitFor(() => {
+      expect(screen.getByText(/NFC and scale will be temporarily unavailable/)).toBeInTheDocument();
+    });
+  });
+
+  it('shows confirmation dialog for restart browser', async () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    fireEvent.click(screen.getByText('Restart Browser'));
+
+    await waitFor(() => {
+      expect(screen.getByText(/display will briefly go blank/)).toBeInTheDocument();
+    });
+  });
+
+  it('cancels confirmation dialog', async () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    fireEvent.click(screen.getByText('Reboot'));
+    await waitFor(() => {
+      expect(screen.getByText('Are you sure you want to reboot the SpoolBuddy?')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByText('Cancel'));
+    await waitFor(() => {
+      expect(screen.queryByText('Are you sure you want to reboot the SpoolBuddy?')).not.toBeInTheDocument();
+    });
+  });
+
+  it('sends system command on confirm', async () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} />);
+
+    fireEvent.click(screen.getByText('Reboot'));
+    await waitFor(() => {
+      expect(screen.getByText('Are you sure you want to reboot the SpoolBuddy?')).toBeInTheDocument();
+    });
+
+    // Click the Confirm button (not the title)
+    fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
+    await waitFor(() => {
+      expect(spoolbuddyApi.systemCommand).toHaveBeenCalledWith('sb-0001', 'reboot');
+    });
+  });
+
+  it('disables system buttons when device offline', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} deviceOnline={false} />);
+
+    const rebootBtn = screen.getByText('Reboot').closest('button');
+    expect(rebootBtn).toBeDisabled();
+  });
+
+  it('disables system buttons when no device ID', () => {
+    render(<SpoolBuddyQuickMenu {...defaultProps} deviceId={null} />);
+
+    const shutdownBtn = screen.getByText('Shutdown').closest('button');
+    expect(shutdownBtn).toBeDisabled();
+  });
+});

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

@@ -5267,6 +5267,12 @@ export const spoolbuddyApi = {
       body: '{}',
     }),
 
+  systemCommand: (deviceId: string, command: 'reboot' | 'shutdown' | 'restart_daemon' | 'restart_browser') =>
+    request<{ status: string; command: string }>(`/spoolbuddy/devices/${deviceId}/system/command`, {
+      method: 'POST',
+      body: JSON.stringify({ command }),
+    }),
+
   queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
     request<{ status: string; diagnostic: string; message: string }>(
       `/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,

+ 26 - 1
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
+import { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
@@ -159,11 +160,24 @@ export function SpoolBuddyLayout() {
     swipeLockedRef.current = false;
   }, []);
   const handleTouchEnd = useCallback((e: React.TouchEvent) => {
-    if (!touchStartRef.current || onlinePrinters.length < 2) return;
+    if (!touchStartRef.current) return;
     const dx = e.changedTouches[0].clientX - touchStartRef.current.x;
     const dy = e.changedTouches[0].clientY - touchStartRef.current.y;
+    const startY = touchStartRef.current.y;
     touchStartRef.current = null;
     swipeLockedRef.current = false;
+
+    // Vertical swipe: open/close quick menu
+    if (Math.abs(dy) >= SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
+      if (dy > 0 && startY < 80) {
+        // Swipe down from top area → open quick menu
+        setQuickMenuOpen(true);
+      }
+      return;
+    }
+
+    // Horizontal swipe: cycle printers
+    if (onlinePrinters.length < 2) return;
     if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
     const currentIdx = onlinePrinters.findIndex((p: Printer) => p.id === selectedPrinterId);
     const nextIdx = dx < 0
@@ -191,6 +205,9 @@ export function SpoolBuddyLayout() {
   // Track virtual keyboard visibility to hide bottom bars
   const [keyboardVisible, setKeyboardVisible] = useState(false);
 
+  // Quick menu (swipe down to open)
+  const [quickMenuOpen, setQuickMenuOpen] = useState(false);
+
   // CSS brightness filter (software dimming)
   const brightnessStyle = displayBrightness < 100
     ? { filter: `brightness(${displayBrightness / 100})` } as const
@@ -225,6 +242,14 @@ export function SpoolBuddyLayout() {
         <VirtualKeyboard onVisibilityChange={setKeyboardVisible} />
       </div>
 
+      {/* Quick menu (swipe down from top) */}
+      <SpoolBuddyQuickMenu
+        isOpen={quickMenuOpen}
+        onClose={() => setQuickMenuOpen(false)}
+        deviceId={device?.device_id ?? null}
+        deviceOnline={effectiveDeviceOnline}
+      />
+
       {/* Screen blank overlay — touch to wake */}
       {blanked && (
         <div

+ 316 - 0
frontend/src/components/spoolbuddy/SpoolBuddyQuickMenu.tsx

@@ -0,0 +1,316 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Power, PowerOff, RotateCw, Monitor, ChevronDown, Loader2 } from 'lucide-react';
+import { api, spoolbuddyApi, type Printer, type SmartPlug, type SmartPlugStatus } from '../../api/client';
+
+interface SpoolBuddyQuickMenuProps {
+  isOpen: boolean;
+  onClose: () => void;
+  deviceId: string | null;
+  deviceOnline: boolean;
+}
+
+type SystemCommand = 'reboot' | 'shutdown' | 'restart_daemon' | 'restart_browser';
+
+interface PlugState {
+  plug: SmartPlug;
+  printer: Printer;
+  status: SmartPlugStatus | null;
+  loading: boolean;
+}
+
+export function SpoolBuddyQuickMenu({ isOpen, onClose, deviceId, deviceOnline }: SpoolBuddyQuickMenuProps) {
+  const { t } = useTranslation();
+  const [confirmAction, setConfirmAction] = useState<SystemCommand | null>(null);
+  const [commandBusy, setCommandBusy] = useState(false);
+  const [plugStates, setPlugStates] = useState<Map<number, { loading: boolean; state: string | null }>>(new Map());
+
+  // Fetch printers and smart plugs
+  const { data: printers = [] } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+    enabled: isOpen,
+  });
+
+  const { data: smartPlugs = [] } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: () => api.getSmartPlugs(),
+    enabled: isOpen,
+  });
+
+  // Build printer-plug pairs (only main power plugs linked to printers)
+  const printerPlugs: PlugState[] = printers
+    .map((printer) => {
+      const plug = smartPlugs.find(
+        (p) => p.printer_id === printer.id && p.plug_type !== 'mqtt' && p.enabled
+      );
+      if (!plug) return null;
+      const state = plugStates.get(plug.id);
+      return {
+        plug,
+        printer,
+        status: state ? { state: state.state, reachable: true, device_name: null, energy: null } : null,
+        loading: state?.loading ?? false,
+      };
+    })
+    .filter(Boolean) as PlugState[];
+
+  // Fetch plug statuses when menu opens
+  useEffect(() => {
+    if (!isOpen || smartPlugs.length === 0) return;
+
+    const linkedPlugs = smartPlugs.filter(
+      (p) => p.printer_id !== null && p.plug_type !== 'mqtt' && p.enabled
+    );
+
+    linkedPlugs.forEach(async (plug) => {
+      try {
+        const status = await api.getSmartPlugStatus(plug.id);
+        setPlugStates((prev) => {
+          const next = new Map(prev);
+          next.set(plug.id, { loading: false, state: status.state });
+          return next;
+        });
+      } catch {
+        setPlugStates((prev) => {
+          const next = new Map(prev);
+          next.set(plug.id, { loading: false, state: null });
+          return next;
+        });
+      }
+    });
+  }, [isOpen, smartPlugs]);
+
+  // Clear state when menu closes
+  useEffect(() => {
+    if (!isOpen) {
+      setConfirmAction(null);
+      setCommandBusy(false);
+    }
+  }, [isOpen]);
+
+  const handleTogglePlug = useCallback(async (plug: SmartPlug) => {
+    setPlugStates((prev) => {
+      const next = new Map(prev);
+      const current = next.get(plug.id);
+      next.set(plug.id, { loading: true, state: current?.state ?? null });
+      return next;
+    });
+
+    try {
+      await api.controlSmartPlug(plug.id, 'toggle');
+      const status = await api.getSmartPlugStatus(plug.id);
+      setPlugStates((prev) => {
+        const next = new Map(prev);
+        next.set(plug.id, { loading: false, state: status.state });
+        return next;
+      });
+    } catch {
+      setPlugStates((prev) => {
+        const next = new Map(prev);
+        const current = next.get(plug.id);
+        next.set(plug.id, { loading: false, state: current?.state ?? null });
+        return next;
+      });
+    }
+  }, []);
+
+  const handleSystemCommand = useCallback(async (command: SystemCommand) => {
+    if (!deviceId) return;
+    setCommandBusy(true);
+    try {
+      await spoolbuddyApi.systemCommand(deviceId, command);
+      // Close menu after successful command
+      setTimeout(() => onClose(), 500);
+    } catch {
+      setCommandBusy(false);
+    }
+  }, [deviceId, onClose]);
+
+  const executeConfirmed = useCallback(() => {
+    if (confirmAction) {
+      handleSystemCommand(confirmAction);
+      setConfirmAction(null);
+    }
+  }, [confirmAction, handleSystemCommand]);
+
+  if (!isOpen) return null;
+
+  const isPlugOn = (state: string | null) => state === 'ON' || state === 'on';
+
+  return (
+    <>
+      {/* Backdrop */}
+      <div className="fixed inset-0 z-40 bg-black/50" onPointerDown={onClose} />
+
+      {/* Slide-down panel */}
+      <div className="fixed top-0 left-0 right-0 z-50 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-b-2xl shadow-2xl animate-slide-down">
+        {/* Handle bar */}
+        <div className="flex justify-center pt-2 pb-1">
+          <div className="w-10 h-1 rounded-full bg-zinc-600" />
+        </div>
+
+        <div className="px-4 pb-4 max-h-[80vh] overflow-y-auto">
+          {/* Printer Power Section */}
+          {printerPlugs.length > 0 && (
+            <div className="mb-4">
+              <h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2">
+                {t('spoolbuddy.quickMenu.printerPower', 'Printer Power')}
+              </h3>
+              <div className="space-y-2">
+                {printerPlugs.map(({ plug, printer, loading }) => {
+                  const state = plugStates.get(plug.id);
+                  const on = isPlugOn(state?.state ?? null);
+                  return (
+                    <button
+                      key={plug.id}
+                      onClick={() => handleTogglePlug(plug)}
+                      disabled={loading}
+                      className="w-full flex items-center gap-3 p-3 rounded-xl bg-zinc-800/60 hover:bg-zinc-700/60 transition-colors min-h-[48px]"
+                    >
+                      <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
+                        on ? 'bg-green-500/20 text-green-400' : 'bg-zinc-700 text-zinc-500'
+                      }`}>
+                        {loading ? (
+                          <Loader2 className="w-4 h-4 animate-spin" />
+                        ) : on ? (
+                          <Power className="w-4 h-4" />
+                        ) : (
+                          <PowerOff className="w-4 h-4" />
+                        )}
+                      </div>
+                      <div className="flex-1 text-left">
+                        <div className="text-sm font-medium text-zinc-200">{printer.name}</div>
+                        <div className="text-xs text-zinc-500">{plug.name}</div>
+                      </div>
+                      <div className={`text-xs font-medium px-2 py-0.5 rounded-full ${
+                        on ? 'bg-green-500/20 text-green-400' : 'bg-zinc-700 text-zinc-500'
+                      }`}>
+                        {state?.state ?? '—'}
+                      </div>
+                    </button>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+
+          {/* System Controls Section */}
+          <div>
+            <h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2">
+              {t('spoolbuddy.quickMenu.systemControls', 'System')}
+            </h3>
+            <div className="grid grid-cols-2 gap-2">
+              <SystemButton
+                icon={<RotateCw className="w-4 h-4" />}
+                label={t('spoolbuddy.quickMenu.restartDaemon', 'Restart Daemon')}
+                onClick={() => setConfirmAction('restart_daemon')}
+                disabled={!deviceId || !deviceOnline || commandBusy}
+              />
+              <SystemButton
+                icon={<Monitor className="w-4 h-4" />}
+                label={t('spoolbuddy.quickMenu.restartBrowser', 'Restart Browser')}
+                onClick={() => setConfirmAction('restart_browser')}
+                disabled={!deviceId || !deviceOnline || commandBusy}
+              />
+              <SystemButton
+                icon={<RotateCw className="w-4 h-4" />}
+                label={t('spoolbuddy.quickMenu.reboot', 'Reboot')}
+                onClick={() => setConfirmAction('reboot')}
+                disabled={!deviceId || !deviceOnline || commandBusy}
+                variant="warning"
+              />
+              <SystemButton
+                icon={<PowerOff className="w-4 h-4" />}
+                label={t('spoolbuddy.quickMenu.shutdown', 'Shutdown')}
+                onClick={() => setConfirmAction('shutdown')}
+                disabled={!deviceId || !deviceOnline || commandBusy}
+                variant="danger"
+              />
+            </div>
+          </div>
+
+          {/* Swipe hint */}
+          <div className="flex justify-center mt-3">
+            <div className="flex items-center gap-1 text-xs text-zinc-600">
+              <ChevronDown className="w-3 h-3" />
+              <span>{t('spoolbuddy.quickMenu.swipeToClose', 'Swipe down to close')}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Confirmation Dialog */}
+      {confirmAction && (
+        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60">
+          <div className="bg-zinc-800 rounded-2xl p-5 mx-4 max-w-sm w-full border border-zinc-700">
+            <h3 className="text-lg font-semibold text-zinc-100 mb-2">
+              {t('spoolbuddy.quickMenu.confirmTitle', 'Confirm')}
+            </h3>
+            <p className="text-sm text-zinc-400 mb-5">
+              {confirmAction === 'shutdown'
+                ? t('spoolbuddy.quickMenu.confirmShutdown', 'Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.')
+                : confirmAction === 'reboot'
+                  ? t('spoolbuddy.quickMenu.confirmReboot', 'Are you sure you want to reboot the SpoolBuddy?')
+                  : confirmAction === 'restart_daemon'
+                    ? t('spoolbuddy.quickMenu.confirmRestartDaemon', 'Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.')
+                    : t('spoolbuddy.quickMenu.confirmRestartBrowser', 'Restart the kiosk browser? The display will briefly go blank.')}
+            </p>
+            <div className="flex gap-3">
+              <button
+                onClick={() => setConfirmAction(null)}
+                className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+              >
+                {t('common.cancel', 'Cancel')}
+              </button>
+              <button
+                onClick={executeConfirmed}
+                disabled={commandBusy}
+                className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white transition-colors min-h-[44px] ${
+                  confirmAction === 'shutdown' ? 'bg-red-600 hover:bg-red-700' :
+                  confirmAction === 'reboot' ? 'bg-amber-600 hover:bg-amber-700' :
+                  'bg-blue-600 hover:bg-blue-700'
+                } disabled:opacity-50`}
+              >
+                {commandBusy ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> :
+                  t('spoolbuddy.quickMenu.confirm', 'Confirm')}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </>
+  );
+}
+
+function SystemButton({
+  icon,
+  label,
+  onClick,
+  disabled,
+  variant = 'default',
+}: {
+  icon: React.ReactNode;
+  label: string;
+  onClick: () => void;
+  disabled: boolean;
+  variant?: 'default' | 'warning' | 'danger';
+}) {
+  const variantClasses = {
+    default: 'bg-zinc-800/60 hover:bg-zinc-700/60 text-zinc-300',
+    warning: 'bg-amber-900/30 hover:bg-amber-900/50 text-amber-400',
+    danger: 'bg-red-900/30 hover:bg-red-900/50 text-red-400',
+  };
+
+  return (
+    <button
+      onClick={onClick}
+      disabled={disabled}
+      className={`flex items-center gap-2.5 p-3 rounded-xl transition-colors min-h-[48px] disabled:opacity-40 ${variantClasses[variant]}`}
+    >
+      {icon}
+      <span className="text-sm font-medium">{label}</span>
+    </button>
+  );
+}

+ 15 - 0
frontend/src/i18n/locales/de.ts

@@ -4733,6 +4733,21 @@ export default {
       spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',
       createFailed: 'Spule konnte nicht erstellt werden',
     },
+    quickMenu: {
+      printerPower: 'Drucker-Strom',
+      systemControls: 'System',
+      restartDaemon: 'Daemon neustarten',
+      restartBrowser: 'Browser neustarten',
+      reboot: 'Neustart',
+      shutdown: 'Herunterfahren',
+      swipeToClose: 'Nach unten wischen zum Schließen',
+      confirmTitle: 'Bestätigen',
+      confirmShutdown: 'Möchten Sie das SpoolBuddy wirklich herunterfahren? Sie benötigen physischen Zugang, um es wieder einzuschalten.',
+      confirmReboot: 'Möchten Sie das SpoolBuddy wirklich neu starten?',
+      confirmRestartDaemon: 'SpoolBuddy-Daemon neustarten? NFC und Waage sind vorübergehend nicht verfügbar.',
+      confirmRestartBrowser: 'Kiosk-Browser neustarten? Das Display wird kurz schwarz.',
+      confirm: 'Bestätigen',
+    },
   },
 
   bugReport: {

+ 15 - 0
frontend/src/i18n/locales/en.ts

@@ -4740,6 +4740,21 @@ export default {
       spoolCreated: 'Spool created! Ready to write.',
       createFailed: 'Failed to create spool',
     },
+    quickMenu: {
+      printerPower: 'Printer Power',
+      systemControls: 'System',
+      restartDaemon: 'Restart Daemon',
+      restartBrowser: 'Restart Browser',
+      reboot: 'Reboot',
+      shutdown: 'Shutdown',
+      swipeToClose: 'Swipe down to close',
+      confirmTitle: 'Confirm',
+      confirmShutdown: 'Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.',
+      confirmReboot: 'Are you sure you want to reboot the SpoolBuddy?',
+      confirmRestartDaemon: 'Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.',
+      confirmRestartBrowser: 'Restart the kiosk browser? The display will briefly go blank.',
+      confirm: 'Confirm',
+    },
   },
 
   bugReport: {

+ 15 - 0
frontend/src/i18n/locales/fr.ts

@@ -4712,6 +4712,21 @@ export default {
       spoolCreated: 'Bobine créée ! Prêt à écrire.',
       createFailed: 'Impossible de créer la bobine',
     },
+    quickMenu: {
+      printerPower: 'Alimentation imprimante',
+      systemControls: 'Système',
+      restartDaemon: 'Redémarrer le daemon',
+      restartBrowser: 'Redémarrer le navigateur',
+      reboot: 'Redémarrer',
+      shutdown: 'Éteindre',
+      swipeToClose: 'Glisser vers le bas pour fermer',
+      confirmTitle: 'Confirmer',
+      confirmShutdown: 'Êtes-vous sûr de vouloir éteindre le SpoolBuddy ? Vous aurez besoin d\'un accès physique pour le rallumer.',
+      confirmReboot: 'Êtes-vous sûr de vouloir redémarrer le SpoolBuddy ?',
+      confirmRestartDaemon: 'Redémarrer le daemon SpoolBuddy ? Le NFC et la balance seront temporairement indisponibles.',
+      confirmRestartBrowser: 'Redémarrer le navigateur kiosque ? L\'écran sera brièvement noir.',
+      confirm: 'Confirmer',
+    },
   },
 
   bugReport: {

+ 15 - 0
frontend/src/i18n/locales/it.ts

@@ -4711,6 +4711,21 @@ export default {
       spoolCreated: 'Bobina creata! Pronto per la scrittura.',
       createFailed: 'Impossibile creare la bobina',
     },
+    quickMenu: {
+      printerPower: 'Alimentazione stampante',
+      systemControls: 'Sistema',
+      restartDaemon: 'Riavvia daemon',
+      restartBrowser: 'Riavvia browser',
+      reboot: 'Riavvia',
+      shutdown: 'Spegni',
+      swipeToClose: 'Scorri verso il basso per chiudere',
+      confirmTitle: 'Conferma',
+      confirmShutdown: 'Sei sicuro di voler spegnere lo SpoolBuddy? Avrai bisogno di accesso fisico per riaccenderlo.',
+      confirmReboot: 'Sei sicuro di voler riavviare lo SpoolBuddy?',
+      confirmRestartDaemon: 'Riavviare il daemon SpoolBuddy? NFC e bilancia saranno temporaneamente non disponibili.',
+      confirmRestartBrowser: 'Riavviare il browser kiosk? Lo schermo diventerà brevemente nero.',
+      confirm: 'Conferma',
+    },
   },
 
   bugReport: {

+ 15 - 0
frontend/src/i18n/locales/ja.ts

@@ -4724,6 +4724,21 @@ export default {
       spoolCreated: 'スプール作成完了!書込み準備ができました。',
       createFailed: 'スプールの作成に失敗しました',
     },
+    quickMenu: {
+      printerPower: 'プリンター電源',
+      systemControls: 'システム',
+      restartDaemon: 'デーモン再起動',
+      restartBrowser: 'ブラウザ再起動',
+      reboot: '再起動',
+      shutdown: 'シャットダウン',
+      swipeToClose: '下にスワイプして閉じる',
+      confirmTitle: '確認',
+      confirmShutdown: 'SpoolBuddyをシャットダウンしますか?再起動するには物理的なアクセスが必要です。',
+      confirmReboot: 'SpoolBuddyを再起動しますか?',
+      confirmRestartDaemon: 'SpoolBuddyデーモンを再起動しますか?NFCとスケールが一時的に使用できなくなります。',
+      confirmRestartBrowser: 'キオスクブラウザを再起動しますか?画面が一時的に暗くなります。',
+      confirm: '確認',
+    },
   },
 
   bugReport: {

+ 15 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4711,6 +4711,21 @@ export default {
       spoolCreated: 'Bobina criada! Pronto para gravar.',
       createFailed: 'Falha ao criar bobina',
     },
+    quickMenu: {
+      printerPower: 'Energia da impressora',
+      systemControls: 'Sistema',
+      restartDaemon: 'Reiniciar daemon',
+      restartBrowser: 'Reiniciar navegador',
+      reboot: 'Reiniciar',
+      shutdown: 'Desligar',
+      swipeToClose: 'Deslize para baixo para fechar',
+      confirmTitle: 'Confirmar',
+      confirmShutdown: 'Tem certeza de que deseja desligar o SpoolBuddy? Você precisará de acesso físico para ligá-lo novamente.',
+      confirmReboot: 'Tem certeza de que deseja reiniciar o SpoolBuddy?',
+      confirmRestartDaemon: 'Reiniciar o daemon do SpoolBuddy? NFC e balança ficarão temporariamente indisponíveis.',
+      confirmRestartBrowser: 'Reiniciar o navegador kiosk? A tela ficará brevemente preta.',
+      confirm: 'Confirmar',
+    },
   },
 
   bugReport: {

+ 15 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4710,6 +4710,21 @@ export default {
       spoolCreated: '耗材已创建!准备写入。',
       createFailed: '创建耗材失败',
     },
+    quickMenu: {
+      printerPower: '打印机电源',
+      systemControls: '系统',
+      restartDaemon: '重启守护进程',
+      restartBrowser: '重启浏览器',
+      reboot: '重启',
+      shutdown: '关机',
+      swipeToClose: '向下滑动关闭',
+      confirmTitle: '确认',
+      confirmShutdown: '确定要关闭SpoolBuddy吗?您需要物理访问才能重新开启。',
+      confirmReboot: '确定要重启SpoolBuddy吗?',
+      confirmRestartDaemon: '重启SpoolBuddy守护进程?NFC和秤将暂时不可用。',
+      confirmRestartBrowser: '重启kiosk浏览器?屏幕将短暂变黑。',
+      confirm: '确认',
+    },
   },
 
   bugReport: {

+ 14 - 0
frontend/src/index.css

@@ -423,6 +423,20 @@ body {
   }
 }
 
+/* SpoolBuddy quick menu slide-down */
+@keyframes slide-down {
+  from {
+    transform: translateY(-100%);
+  }
+  to {
+    transform: translateY(0);
+  }
+}
+
+.animate-slide-down {
+  animation: slide-down 0.25s ease-out;
+}
+
 /* Card shadows - uses theme-specific shadow */
 .card-shadow {
   box-shadow: var(--card-shadow);

+ 19 - 0
spoolbuddy/daemon/main.py

@@ -343,6 +343,25 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
                         "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
                     }
                     logger.info("Write tag command received for spool %d", write_payload["spool_id"])
+            elif cmd in ("reboot", "shutdown", "restart_daemon", "restart_browser"):
+                logger.info("System command received: %s", cmd)
+                try:
+                    await api.system_command_result(config.device_id, cmd, True, f"Executing {cmd}")
+                except Exception:
+                    pass  # Best effort — we're about to restart/shutdown anyway
+                if cmd == "reboot":
+                    await asyncio.to_thread(subprocess.run, ["sudo", "reboot"], check=False)
+                elif cmd == "shutdown":
+                    await asyncio.to_thread(subprocess.run, ["sudo", "shutdown", "-h", "now"], check=False)
+                elif cmd == "restart_daemon":
+                    await asyncio.to_thread(
+                        subprocess.run, ["sudo", "systemctl", "restart", "spoolbuddy.service"], check=False
+                    )
+                elif cmd == "restart_browser":
+                    await asyncio.to_thread(
+                        subprocess.run, ["sudo", "systemctl", "restart", "getty@tty1.service"], check=False
+                    )
+                continue
 
             tare = result.get("tare_offset", config.tare_offset)
             cal = result.get("calibration_factor", config.calibration_factor)

+ 3 - 1
spoolbuddy/install/install.sh

@@ -518,9 +518,11 @@ create_spoolbuddy_user() {
 spoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart spoolbuddy.service
 spoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart getty@tty1.service
 spoolbuddy ALL=(root) NOPASSWD: /usr/bin/find /home -maxdepth 5 *
+spoolbuddy ALL=(root) NOPASSWD: /sbin/reboot
+spoolbuddy ALL=(root) NOPASSWD: /sbin/shutdown -h now
 SUDOERS
     chmod 440 /etc/sudoers.d/spoolbuddy
-    success "Sudoers entries created for service and kiosk restart"
+    success "Sudoers entries created for service, kiosk restart, reboot and shutdown"
 }
 
 download_spoolbuddy() {

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C9HinTzu.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C2yp8RGP.css">
+    <script type="module" crossorigin src="/assets/index-DPDJoHI3.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-nqJ5Rq6m.css">
   </head>
   <body>
     <div id="root"></div>

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