Browse Source

[Feature] Add spool rotation option for AMS drying

  Add a "Rotate spool during drying" checkbox to the manual drying popover
  for AMS 2 Pro and AMS-HT units. The firmware-level rotate_tray field was
  already sent (hardcoded to false) — this makes it user-configurable.
  The checkbox defaults to unchecked and resets each time the popover opens.
  Firmware silently disables rotation if filament is currently loaded.
maziggy 2 months ago
parent
commit
e82362ac65

+ 1 - 0
CHANGELOG.md

@@ -30,6 +30,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### 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 Rotation During AMS Drying** — Added a "Rotate spool during drying" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units. Rotates the spool for more even heat distribution. Off by default; resets when opening the popover for a different AMS unit. The firmware silently disables rotation if filament is currently loaded from the unit.
 - **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

@@ -91,7 +91,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
-- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets; automatic PSU detection and HMS power error reporting
+- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting
 - **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
 - **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)

+ 4 - 1
backend/app/api/routes/printers.py

@@ -1470,6 +1470,7 @@ async def start_drying(
     temp: int = 45,
     duration: int = 4,
     filament: str = "",
+    rotate_tray: bool = False,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1490,7 +1491,9 @@ async def start_drying(
     if duration < 1 or duration > 24:
         raise HTTPException(400, "Duration must be 1-24 hours")
 
-    success = printer_manager.send_drying_command(printer_id, ams_id, temp, duration, mode=1, filament=filament)
+    success = printer_manager.send_drying_command(
+        printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
+    )
     if not success:
         raise HTTPException(400, "Printer not connected")
     return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}

+ 5 - 2
backend/app/services/bambu_mqtt.py

@@ -2940,7 +2940,9 @@ class BambuMQTTClient:
         """Check if logging is enabled."""
         return self._logging_enabled
 
-    def send_drying_command(self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = ""):
+    def send_drying_command(
+        self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = "", rotate_tray: bool = False
+    ):
         """Send AMS drying start/stop command.
 
         Args:
@@ -2949,6 +2951,7 @@ class BambuMQTTClient:
             duration: Drying duration in hours
             mode: 1=start, 0=stop
             filament: Filament type string (e.g. "PLA", "PETG")
+            rotate_tray: Whether to rotate the spool during drying for even heat
         """
         if not self._client:
             return False
@@ -2963,7 +2966,7 @@ class BambuMQTTClient:
                 "duration": duration,
                 "humidity": 0,
                 "mode": mode,
-                "rotate_tray": False,
+                "rotate_tray": rotate_tray,
                 "filament": filament,
                 "close_power_conflict": False,
             }

+ 2 - 1
backend/app/services/printer_manager.py

@@ -452,11 +452,12 @@ class PrinterManager:
         duration: int,
         mode: int = 1,
         filament: str = "",
+        rotate_tray: bool = False,
     ) -> bool:
         """Send AMS drying command to printer."""
         if printer_id not in self._clients:
             return False
-        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament)
+        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament, rotate_tray)
 
     def request_status_update(self, printer_id: int) -> bool:
         """Request a full status update from the printer.

+ 73 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4,6 +4,8 @@ Tests for the BambuMQTTClient service.
 These tests focus on timelapse tracking during prints.
 """
 
+import json
+
 import pytest
 
 
@@ -2376,3 +2378,74 @@ class TestDeveloperModeDetection:
                 }
             )
         assert mqtt_client.state.developer_mode is False
+
+
+class TestSendDryingCommand:
+    """Tests for send_drying_command MQTT payload construction."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient with a mock MQTT client."""
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        return client
+
+    def test_rotate_tray_false_by_default(self, mqtt_client):
+        """Verify rotate_tray defaults to False in the MQTT payload."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        assert payload["print"]["rotate_tray"] is False
+
+    def test_rotate_tray_true_when_enabled(self, mqtt_client):
+        """Verify rotate_tray is True when explicitly enabled."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA", rotate_tray=True)
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        assert payload["print"]["rotate_tray"] is True
+
+    def test_rotate_tray_false_on_stop(self, mqtt_client):
+        """Verify rotate_tray is False when stopping drying (mode=0)."""
+        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        assert payload["print"]["rotate_tray"] is False
+
+    def test_all_required_fields_present(self, mqtt_client):
+        """Verify all required MQTT fields are present in the drying command."""
+        mqtt_client.send_drying_command(ams_id=128, temp=75, duration=8, mode=1, filament="ABS", rotate_tray=True)
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        cmd = payload["print"]
+        assert cmd["command"] == "ams_filament_drying"
+        assert cmd["ams_id"] == 128
+        assert cmd["temp"] == 75
+        assert cmd["duration"] == 8
+        assert cmd["mode"] == 1
+        assert cmd["rotate_tray"] is True
+        assert cmd["filament"] == "ABS"
+        assert cmd["cooling_temp"] == 20
+        assert cmd["humidity"] == 0
+        assert cmd["close_power_conflict"] is False
+        assert "sequence_id" in cmd
+
+    def test_publishes_with_qos_1(self, mqtt_client):
+        """Verify drying commands are published with QoS 1."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4)
+
+        call_args = mqtt_client._client.publish.call_args
+        # qos may be positional arg [2] or keyword
+        qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
+        assert qos == 1

+ 25 - 0
frontend/src/__tests__/pages/PrintersPageDrying.test.ts

@@ -194,3 +194,28 @@ describe('temperature clamping', () => {
     });
   });
 });
+
+describe('rotate tray option', () => {
+  it('defaults to false', () => {
+    // Mirrors the initial state: useState(false)
+    const defaultRotateTray = false;
+    expect(defaultRotateTray).toBe(false);
+  });
+
+  it('is included in the API URL when true', () => {
+    // Mirrors the API call construction from client.ts
+    const buildUrl = (rotateTray: boolean) =>
+      `/printers/1/drying/start?ams_id=0&temp=55&duration=4&filament=PLA&rotate_tray=${rotateTray}`;
+    expect(buildUrl(true)).toContain('rotate_tray=true');
+    expect(buildUrl(false)).toContain('rotate_tray=false');
+  });
+
+  it('resets to false when opening popover for a new AMS unit', () => {
+    // Mirrors the popover open logic: setDryingRotateTray(false) is called
+    // each time the popover opens for any AMS unit
+    let rotateTray = true; // user enabled it for previous AMS
+    // Simulates opening popover for a different AMS
+    rotateTray = false; // setDryingRotateTray(false)
+    expect(rotateTray).toBe(false);
+  });
+});

+ 2 - 2
frontend/src/api/client.ts

@@ -2424,9 +2424,9 @@ export const api = {
     }),
 
   // AMS Drying Control
-  startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '') =>
+  startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '', rotateTray: boolean = false) =>
     request<{ status: string; ams_id: number; temp: number; duration: number }>(
-      `/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}`,
+      `/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}&rotate_tray=${rotateTray}`,
       { method: 'POST' }
     ),
   stopDrying: (printerId: number, amsId: number) =>

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

@@ -419,6 +419,7 @@ export default {
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
       startingDrying: 'Trocknung wird gestartet...',
       stoppingDrying: 'Trocknung wird gestoppt...',
+      rotateTray: 'Spule während der Trocknung drehen',
     },
     // Filaments section
     filaments: 'Filamente',

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

@@ -419,6 +419,7 @@ export default {
       powerRequired: 'Connect AMS power adapter to enable drying',
       startingDrying: 'Starting drying...',
       stoppingDrying: 'Stopping drying...',
+      rotateTray: 'Rotate spool during drying',
     },
     // Filaments section
     filaments: 'Filaments',

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

@@ -419,6 +419,7 @@ export default {
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
       startingDrying: 'Démarrage du séchage...',
       stoppingDrying: 'Arrêt du séchage...',
+      rotateTray: 'Tourner la bobine pendant le séchage',
     },
     // Filaments section
     filaments: 'Filaments',

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

@@ -419,6 +419,7 @@ export default {
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
       startingDrying: 'Avvio essiccazione...',
       stoppingDrying: 'Arresto essiccazione...',
+      rotateTray: 'Ruota la bobina durante l\'essiccazione',
     },
     // Filaments section
     filaments: 'Filamenti',

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

@@ -418,6 +418,7 @@ export default {
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
       startingDrying: '乾燥を開始しています...',
       stoppingDrying: '乾燥を停止しています...',
+      rotateTray: '乾燥中にスプールを回転',
     },
     // Filaments section
     filaments: 'フィラメント',

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

@@ -419,6 +419,7 @@ export default {
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
       startingDrying: 'Iniciando secagem...',
       stoppingDrying: 'Parando secagem...',
+      rotateTray: 'Girar o carretel durante a secagem',
     },
     // Filaments section
     filaments: 'Filamentos',

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

@@ -419,6 +419,7 @@ export default {
       powerRequired: '连接AMS电源适配器以启用干燥',
       startingDrying: '正在启动干燥...',
       stoppingDrying: '正在停止干燥...',
+      rotateTray: '干燥时旋转料盘',
     },
     // Filaments section
     filaments: '耗材',

+ 16 - 3
frontend/src/pages/PrintersPage.tsx

@@ -1594,6 +1594,7 @@ function PrinterCard({
   const [dryingFilament, setDryingFilament] = useState('PLA');
   const [dryingTemp, setDryingTemp] = useState(50);
   const [dryingDuration, setDryingDuration] = useState(4);
+  const [dryingRotateTray, setDryingRotateTray] = useState(false);
   const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);
   const [isDraggingFile, setIsDraggingFile] = useState(false);
   const [isDropUploading, setIsDropUploading] = useState(false);
@@ -1894,8 +1895,8 @@ function PrinterCard({
 
   // AMS drying mutations
   const startDryingMutation = useMutation({
-    mutationFn: ({ amsId, temp, duration, filament }: { amsId: number; temp: number; duration: number; filament: string }) =>
-      api.startDrying(printer.id, amsId, temp, duration, filament),
+    mutationFn: ({ amsId, temp, duration, filament, rotateTray }: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean }) =>
+      api.startDrying(printer.id, amsId, temp, duration, filament, rotateTray),
     onSuccess: () => {
       setDryingPopoverAmsId(null);
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
@@ -3199,6 +3200,7 @@ function PrinterCard({
                                           setDryingFilament(filType);
                                           setDryingTemp(preset[moduleType] || preset.n3f);
                                           setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
+                                          setDryingRotateTray(false);
                                           setDryingPopoverModuleType(ams.module_type);
                                           setDryingPopoverAmsId(ams.id);
                                           const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -3607,6 +3609,7 @@ function PrinterCard({
                                         setDryingFilament(filType);
                                         setDryingTemp(preset[moduleType] || preset.n3f);
                                         setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
+                                        setDryingRotateTray(false);
                                         setDryingPopoverModuleType(ams.module_type);
                                         setDryingPopoverAmsId(ams.id);
                                         const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -4841,13 +4844,23 @@ function PrinterCard({
                     <span>24h</span>
                   </div>
                 </div>
+                {/* Rotate tray */}
+                <label className="flex items-center gap-2 cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={dryingRotateTray}
+                    onChange={e => setDryingRotateTray(e.target.checked)}
+                    className="w-3.5 h-3.5 accent-amber-500 rounded cursor-pointer"
+                  />
+                  <span className="text-[11px] text-bambu-gray">{t('printers.drying.rotateTray')}</span>
+                </label>
               </div>
               {/* Footer */}
               <div className="px-3 pb-3">
                 <button
                   onClick={() => {
                     if (dryingPopoverAmsId !== null) {
-                      startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament });
+                      startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament, rotateTray: dryingRotateTray });
                     }
                   }}
                   disabled={startDryingMutation.isPending}

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Ca5aA-MZ.js"></script>
+    <script type="module" crossorigin src="/assets/index-Ce8mhR3n.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-hPK_4Ftq.css">
   </head>
   <body>

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