Jelajahi Sumber

[Feature] Add quick print speed control to printer card (#256)

  Add a speed control badge to the printer monitoring card controls row
  that lets users switch between Silent (50%), Standard (100%), Sport
  (124%), and Ludicrous (166%) presets during active prints. The badge
  displays a gauge icon with the current speed percentage, always visible
  but disabled when idle. Includes backend endpoint, optimistic UI
  updates, i18n for all 7 locales, and full test coverage.
maziggy 2 bulan lalu
induk
melakukan
0e712c72a1

+ 1 - 0
CHANGELOG.md

@@ -20,6 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.
 
 ### Added
+- **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.
 - **Spool Name Column & Filter in Filament Inventory** ([#740](https://github.com/maziggy/bambuddy/issues/740)) — Added a "Spool" column to the filament inventory table that displays the spool catalog entry name (e.g. "Bambu Lab AMS Tray", "Sunlu 1kg"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.
 
 ### Changed

+ 1 - 1
README.md

@@ -85,7 +85,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume, chamber light)
+- Printer control (stop, pause, resume, chamber light, print speed)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read

+ 25 - 0
backend/app/api/routes/printers.py

@@ -2299,6 +2299,31 @@ async def resume_print(
     return {"success": True, "message": "Print resume command sent"}
 
 
+@router.post("/{printer_id}/print-speed")
+async def set_print_speed(
+    printer_id: int,
+    mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the print speed mode."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_print_speed(mode)
+    if not success:
+        raise HTTPException(500, "Failed to set print speed")
+
+    speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
+    return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
+
+
 @router.post("/{printer_id}/chamber-light")
 async def set_chamber_light(
     printer_id: int,

+ 77 - 0
backend/tests/unit/test_print_speed.py

@@ -0,0 +1,77 @@
+"""Unit tests for the print speed control endpoint.
+
+Tests POST /api/v1/printers/{printer_id}/print-speed?mode=N
+where mode is 1=silent, 2=standard, 3=sport, 4=ludicrous.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestPrintSpeedAPI:
+    """Tests for the print speed control endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_print_speed_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print-speed?mode=2")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_print_speed_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed?mode=2")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_print_speed_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify 500 when client fails to set speed."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_print_speed.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed?mode=2")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "mode, expected_name",
+        [
+            (1, "Silent"),
+            (2, "Standard"),
+            (3, "Sport"),
+            (4, "Ludicrous"),
+        ],
+    )
+    async def test_print_speed_success(self, async_client: AsyncClient, printer_factory, mode, expected_name):
+        """Verify successful speed change for each mode (1-4)."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_print_speed.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed?mode={mode}")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert expected_name in result["message"]
+            mock_client.set_print_speed.assert_called_once_with(mode)

+ 321 - 0
frontend/src/__tests__/pages/PrintersPageSpeed.test.tsx

@@ -0,0 +1,321 @@
+/**
+ * Tests for the print speed control feature on the PrintersPage.
+ *
+ * Verifies that the speed badge renders, the dropdown menu opens on click,
+ * speed options are displayed, and selecting an option calls the API.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockPrintingStatus = {
+  connected: true,
+  state: 'RUNNING',
+  progress: 42,
+  layer_num: 10,
+  total_layers: 100,
+  temperatures: {
+    nozzle: 220,
+    bed: 60,
+    chamber: 35,
+  },
+  remaining_time: 3600,
+  filename: 'test_print.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+};
+
+const mockIdleStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: {
+    nozzle: 25,
+    bed: 25,
+    chamber: 25,
+  },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+};
+
+describe('PrintersPage - Print Speed Control', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/queue/', () => {
+        return HttpResponse.json([]);
+      })
+    );
+  });
+
+  describe('speed badge rendering', () => {
+    it('shows speed badge with current speed percentage when printing', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        // speed_level 2 = Standard = 100%
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows speed badge with 50% for silent mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 1 });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('50%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows speed badge with 124% for sport mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 3 });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('124%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows speed badge with 166% for ludicrous mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 4 });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('166%')).toBeInTheDocument();
+      });
+    });
+
+    it('disables speed badge button when printer is idle', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockIdleStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      // The button containing the speed percentage should be disabled
+      const speedBadge = screen.getByText('100%').closest('button');
+      expect(speedBadge).toBeDisabled();
+    });
+  });
+
+  describe('speed dropdown menu', () => {
+    it('opens speed menu on click when printing', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Silent (50%)')).toBeInTheDocument();
+        expect(screen.getByText('Standard (100%)')).toBeInTheDocument();
+        expect(screen.getByText('Sport (124%)')).toBeInTheDocument();
+        expect(screen.getByText('Ludicrous (166%)')).toBeInTheDocument();
+      });
+    });
+
+    it('displays all four speed options in the dropdown', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        const options = [
+          screen.getByText('Silent (50%)'),
+          screen.getByText('Standard (100%)'),
+          screen.getByText('Sport (124%)'),
+          screen.getByText('Ludicrous (166%)'),
+        ];
+        expect(options).toHaveLength(4);
+        options.forEach((opt) => expect(opt).toBeInTheDocument());
+      });
+    });
+
+    it('calls the API when a speed option is selected', async () => {
+      const user = userEvent.setup();
+      let capturedMode: number | null = null;
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        }),
+        http.post('/api/v1/printers/:id/print-speed', async ({ request }) => {
+          const url = new URL(request.url);
+          capturedMode = Number(url.searchParams.get('mode'));
+          return HttpResponse.json({ success: true, message: 'Speed set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      // Open the speed menu
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sport (124%)')).toBeInTheDocument();
+      });
+
+      // Select "Sport" speed
+      await user.click(screen.getByText('Sport (124%)'));
+
+      await waitFor(() => {
+        expect(capturedMode).toBe(3);
+      });
+    });
+
+    it('closes the dropdown after selecting a speed option', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        }),
+        http.post('/api/v1/printers/:id/print-speed', () => {
+          return HttpResponse.json({ success: true, message: 'Speed set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Silent (50%)')).toBeInTheDocument();
+      });
+
+      // Select an option
+      await user.click(screen.getByText('Silent (50%)'));
+
+      // Menu should close - speed labels should no longer be visible
+      await waitFor(() => {
+        expect(screen.queryByText('Silent (50%)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('optimistically updates the speed display when selecting a new speed', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus); // speed_level: 2 (100%)
+        }),
+        http.post('/api/v1/printers/:id/print-speed', () => {
+          return HttpResponse.json({ success: true, message: 'Speed set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      // Open the speed menu and select Ludicrous
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Ludicrous (166%)')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Ludicrous (166%)'));
+
+      // The badge should optimistically update to show 166%
+      await waitFor(() => {
+        expect(screen.getByText('166%')).toBeInTheDocument();
+      });
+    });
+  });
+});

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

@@ -2411,6 +2411,12 @@ export const api = {
   getCurrentPrintUser: (printerId: number) =>
     request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`),
 
+  // Print Speed Control
+  setPrintSpeed: (printerId: number, mode: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/print-speed?mode=${mode}`, {
+      method: 'POST',
+    }),
+
   // Chamber Light Control
   setChamberLight: (printerId: number, on: boolean) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {

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

@@ -284,6 +284,7 @@ export default {
       failedToPausePrint: 'Druck konnte nicht pausiert werden',
       failedToResumePrint: 'Druck konnte nicht fortgesetzt werden',
       failedToControlChamberLight: 'Kammerbeleuchtung konnte nicht gesteuert werden',
+      failedToSetSpeed: 'Druckgeschwindigkeit konnte nicht eingestellt werden',
       failedToUpdateSetting: 'Einstellung konnte nicht aktualisiert werden',
       failedToSkipObjects: 'Objekte konnten nicht übersprungen werden',
       failedToRereadRfid: 'RFID konnte nicht erneut gelesen werden',
@@ -450,6 +451,14 @@ export default {
       clickToEdit: '{{label}} - Zum Bearbeiten klicken',
       clickToAddLabel: 'Zum Hinzufügen einer Bezeichnung klicken',
     },
+    // Speed
+    speed: {
+      title: 'Druckgeschwindigkeit',
+      silent: 'Leise (50%)',
+      standard: 'Standard (100%)',
+      sport: 'Sport (124%)',
+      ludicrous: 'Ludicrous (166%)',
+    },
     // Fans
     fans: {
       partCooling: 'Bauteilkühlung',

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

@@ -284,6 +284,7 @@ export default {
       failedToPausePrint: 'Failed to pause print',
       failedToResumePrint: 'Failed to resume print',
       failedToControlChamberLight: 'Failed to control chamber light',
+      failedToSetSpeed: 'Failed to set print speed',
       failedToUpdateSetting: 'Failed to update setting',
       failedToSkipObjects: 'Failed to skip objects',
       failedToRereadRfid: 'Failed to re-read RFID',
@@ -450,6 +451,14 @@ export default {
       clickToEdit: '{{label}} - Click to edit',
       clickToAddLabel: 'Click to add label',
     },
+    // Speed
+    speed: {
+      title: 'Print Speed',
+      silent: 'Silent (50%)',
+      standard: 'Standard (100%)',
+      sport: 'Sport (124%)',
+      ludicrous: 'Ludicrous (166%)',
+    },
     // Fans
     fans: {
       partCooling: 'Part Cooling Fan',

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

@@ -284,6 +284,7 @@ export default {
       failedToPausePrint: 'Échec de la mise en pause',
       failedToResumePrint: 'Échec de la reprise',
       failedToControlChamberLight: 'Échec du contrôle de la lumière',
+      failedToSetSpeed: 'Échec du réglage de la vitesse',
       failedToUpdateSetting: 'Échec de mise à jour du paramètre',
       failedToSkipObjects: 'Échec du saut d\'objets',
       failedToRereadRfid: 'Échec lecture RFID',
@@ -450,6 +451,14 @@ export default {
       clickToEdit: '{{label}} - Modifier',
       clickToAddLabel: 'Ajouter une étiquette',
     },
+    // Speed
+    speed: {
+      title: 'Vitesse d\'impression',
+      silent: 'Silencieux (50%)',
+      standard: 'Standard (100%)',
+      sport: 'Sport (124%)',
+      ludicrous: 'Ludicrous (166%)',
+    },
     // Fans
     fans: {
       partCooling: 'Ventilateur pièce',

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

@@ -284,6 +284,7 @@ export default {
       failedToPausePrint: 'Impossibile mettere in pausa stampa',
       failedToResumePrint: 'Impossibile riprendere stampa',
       failedToControlChamberLight: 'Impossibile controllare luce camera',
+      failedToSetSpeed: 'Impossibile impostare la velocità di stampa',
       failedToUpdateSetting: 'Impossibile aggiornare impostazione',
       failedToSkipObjects: 'Impossibile saltare oggetti',
       failedToRereadRfid: 'Impossibile rileggere RFID',
@@ -450,6 +451,14 @@ export default {
       clickToEdit: '{{label}} - Clicca per modificare',
       clickToAddLabel: 'Clicca per aggiungere etichetta',
     },
+    // Speed
+    speed: {
+      title: 'Velocità di stampa',
+      silent: 'Silenzioso (50%)',
+      standard: 'Standard (100%)',
+      sport: 'Sport (124%)',
+      ludicrous: 'Ludicrous (166%)',
+    },
     // Fans
     fans: {
       partCooling: 'Ventola raffreddamento parte',

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

@@ -283,6 +283,7 @@ export default {
       failedToPausePrint: '印刷の一時停止に失敗しました',
       failedToResumePrint: '印刷の再開に失敗しました',
       failedToControlChamberLight: 'チャンバーライトの制御に失敗しました',
+      failedToSetSpeed: '印刷速度の設定に失敗しました',
       failedToUpdateSetting: '設定の更新に失敗しました',
       failedToSkipObjects: 'オブジェクトのスキップに失敗しました',
       failedToRereadRfid: 'RFIDの再読み取りに失敗しました',
@@ -449,6 +450,14 @@ export default {
       clickToEdit: '{{label}} - クリックして編集',
       clickToAddLabel: 'クリックしてラベルを追加',
     },
+    // Speed
+    speed: {
+      title: '印刷速度',
+      silent: 'サイレント (50%)',
+      standard: 'スタンダード (100%)',
+      sport: 'スポーツ (124%)',
+      ludicrous: 'ルディクラス (166%)',
+    },
     // Fans
     fans: {
       partCooling: 'パーツ冷却ファン',

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

@@ -284,6 +284,7 @@ export default {
       failedToPausePrint: 'Falha ao pausar impressão',
       failedToResumePrint: 'Falha ao retomar impressão',
       failedToControlChamberLight: 'Falha ao controlar a luz da câmara',
+      failedToSetSpeed: 'Falha ao definir a velocidade de impressão',
       failedToUpdateSetting: 'Falha ao atualizar configuração',
       failedToSkipObjects: 'Falha ao ignorar objetos',
       failedToRereadRfid: 'Falha ao reler RFID',
@@ -450,6 +451,14 @@ export default {
       clickToEdit: '{{label}} - Clique para editar',
       clickToAddLabel: 'Clique para adicionar etiqueta',
     },
+    // Speed
+    speed: {
+      title: 'Velocidade de impressão',
+      silent: 'Silencioso (50%)',
+      standard: 'Padrão (100%)',
+      sport: 'Sport (124%)',
+      ludicrous: 'Ludicrous (166%)',
+    },
     // Fans
     fans: {
       partCooling: 'Ventilador de resfriamento da peça',

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

@@ -284,6 +284,7 @@ export default {
       failedToPausePrint: '暂停打印失败',
       failedToResumePrint: '继续打印失败',
       failedToControlChamberLight: '控制腔室灯失败',
+      failedToSetSpeed: '设置打印速度失败',
       failedToUpdateSetting: '更新设置失败',
       failedToSkipObjects: '跳过对象失败',
       failedToRereadRfid: '重新读取 RFID 失败',
@@ -450,6 +451,14 @@ export default {
       clickToEdit: '{{label}} - 点击编辑',
       clickToAddLabel: '点击添加标签',
     },
+    // Speed
+    speed: {
+      title: '打印速度',
+      silent: '静音 (50%)',
+      standard: '标准 (100%)',
+      sport: '运动 (124%)',
+      ludicrous: '疯狂 (166%)',
+    },
     // Fans
     fans: {
       partCooling: '零件冷却风扇',

+ 82 - 0
frontend/src/pages/PrintersPage.tsx

@@ -46,6 +46,7 @@ import {
   Info,
   Cable,
   Flame,
+  Gauge,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -1580,6 +1581,7 @@ function PrinterCard({
   const [showHMSModal, setShowHMSModal] = useState(false);
   const [showStopConfirm, setShowStopConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
+  const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showUploadForPrint, setShowUploadForPrint] = useState(false);
@@ -1992,6 +1994,26 @@ function PrinterCard({
     },
   });
 
+  // Print speed mutation with optimistic update
+  const printSpeedMutation = useMutation({
+    mutationFn: (mode: number) => api.setPrintSpeed(printer.id, mode),
+    onMutate: async (mode) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
+      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);
+      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({
+        ...old,
+        speed_level: mode,
+      }));
+      return { previousStatus };
+    },
+    onError: (error: Error, _, context) => {
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);
+      }
+      showToast(error.message || t('printers.toast.failedToSetSpeed'), 'error');
+    },
+  });
+
   // Plate detection setting mutation
   const plateDetectionMutation = useMutation({
     mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),
@@ -2974,6 +2996,66 @@ function PrinterCard({
                         </span>
                       </div>
 
+                      {/* Separator */}
+                      <div className="w-px h-5 bg-bambu-gray/30" />
+
+                      {/* Print Speed */}
+                      {(() => {
+                        const speedLabels: Record<number, string> = { 1: '50%', 2: '100%', 3: '124%', 4: '166%' };
+                        const speedPct = speedLabels[status.speed_level] || '100%';
+                        return (
+                          <div className="relative">
+                            <button
+                              onClick={() => setShowSpeedMenu(showSpeedMenu === printer.id ? null : printer.id)}
+                              disabled={!isPrinting || !hasPermission('printers:control')}
+                              className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${
+                                isPrinting
+                                  ? 'bg-amber-500/10 hover:bg-amber-500/20'
+                                  : 'bg-bambu-dark cursor-not-allowed'
+                              }`}
+                              title={isPrinting ? t('printers.speed.title') : undefined}
+                            >
+                              <Gauge className={`w-3.5 h-3.5 ${
+                                isPrinting ? 'text-amber-400' : 'text-bambu-gray/50'
+                              }`} />
+                              <span className={`text-[10px] ${
+                                isPrinting ? 'text-amber-400' : 'text-bambu-gray/50'
+                              }`}>
+                                {speedPct}
+                              </span>
+                            </button>
+                            {showSpeedMenu === printer.id && (
+                              <>
+                                <div className="fixed inset-0 z-40" onClick={() => setShowSpeedMenu(null)} />
+                                <div className="absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]">
+                                  {([
+                                    { mode: 1, label: t('printers.speed.silent') },
+                                    { mode: 2, label: t('printers.speed.standard') },
+                                    { mode: 3, label: t('printers.speed.sport') },
+                                    { mode: 4, label: t('printers.speed.ludicrous') },
+                                  ] as const).map(({ mode, label }) => (
+                                    <button
+                                      key={mode}
+                                      onClick={() => {
+                                        printSpeedMutation.mutate(mode);
+                                        setShowSpeedMenu(null);
+                                      }}
+                                      className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
+                                        status.speed_level === mode
+                                          ? 'text-bambu-green bg-bambu-green/10'
+                                          : 'text-white hover:bg-bambu-dark-tertiary'
+                                      }`}
+                                    >
+                                      {label}
+                                    </button>
+                                  ))}
+                                </div>
+                              </>
+                            )}
+                          </div>
+                        );
+                      })()}
+
                     </div>
 
                     {/* Right: Print Control Buttons */}

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-BAXfE2V4.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-DS0IE7o1.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-hKLgfdEQ.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-CHMyQdwG.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-hKLgfdEQ.css">
+    <script type="module" crossorigin src="/assets/index-BAXfE2V4.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DS0IE7o1.css">
   </head>
   <body>
     <div id="root"></div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini