Jelajahi Sumber

feat(printers): AMS slot Load / Unload from the printer card (#891)

  The ams_load_filament / ams_unload_filament MQTT primitives existed
  in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
  both as POST /printers/{id}/ams/load?tray_id={int} and
  POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.

  Wire them into the existing AMS slot popover (next to "Re-read RFID")
  and add a popover wrapper on the external spool slot which had none.
  Hidden while the printer is RUNNING, mirroring the RFID re-read
  gating. Both buttons enabled when permission is granted; the printer
  no-ops gracefully if there's nothing to do (matches BambuStudio).

  Dual-extruder H2D Ext-R support is the trickier piece. The existing
  ams_load_filament(254) capture came from a single-extruder printer
  and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
  BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
  extruder index, NOT a slot index), target=255, and curr/tar = the
  actual right-nozzle temp (read from state.temperatures["nozzle_2"],
  falling back to 215 °C if cold so the printer doesn't reject the
  command on a nonsensical temp). Added that as a new branch in
  ams_load_filament; the existing tray_id=254 branch is preserved
  verbatim — no risk of regression on single-external setups.
maziggy 3 minggu lalu
induk
melakukan
3320c7fd45

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -114,6 +114,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Resizable printer cards (S/M/L/XL)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read
+- **AMS slot Load / Unload from the printer card** — Hover any AMS slot or external spool, click the menu button, and load that tray or unload the currently-loaded one without going to the touchscreen; supports dual-extruder H2D (Ext-L / Ext-R drive their own nozzle)
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - 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 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, optional spool rotation; 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

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

@@ -2953,6 +2953,68 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
         logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
 
 
 
 
+@router.post("/{printer_id}/ams/load")
+async def ams_load(
+    printer_id: int,
+    tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Load filament from a specific AMS slot or external spool.
+
+    Tray ID encoding (matches Bambu firmware convention):
+    - 0..15: AMS slot, computed as ams_id * 4 + slot_id
+    - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
+    - 255: Ext-R on dual-nozzle H2D
+    """
+    if tray_id not in range(16) and tray_id not in (254, 255):
+        raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
+
+    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.ams_load_filament(tray_id)
+    if not success:
+        raise HTTPException(500, "Failed to send load command")
+
+    if tray_id == 254:
+        target = "external spool"
+    elif tray_id == 255:
+        target = "Ext-R"
+    else:
+        target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
+    return {"success": True, "message": f"Loading filament from {target}"}
+
+
+@router.post("/{printer_id}/ams/unload")
+async def ams_unload(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Unload the currently loaded filament."""
+    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.ams_unload_filament()
+    if not success:
+        raise HTTPException(500, "Failed to send unload command")
+
+    return {"success": True, "message": "Unloading filament"}
+
+
 @router.get("/{printer_id}/runtime-debug")
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
 async def get_runtime_debug(
     printer_id: int,
     printer_id: int,

+ 29 - 12
backend/app/services/bambu_mqtt.py

@@ -4313,7 +4313,9 @@ class BambuMQTTClient:
         """Load filament from a specific AMS tray.
         """Load filament from a specific AMS tray.
 
 
         Args:
         Args:
-            tray_id: Global tray ID (0-15 for AMS slots, or 254 for external spool)
+            tray_id: Global tray ID — 0..15 for AMS slots, 254 for external spool
+                (single-external printers and Ext-L on dual-nozzle H2D),
+                255 for Ext-R on dual-nozzle H2D.
             extruder_id: Unused - kept for API compatibility
             extruder_id: Unused - kept for API compatibility
 
 
         Returns:
         Returns:
@@ -4323,18 +4325,33 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
             logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
             return False
             return False
 
 
-        # Calculate ams_id and slot_id for logging
-        if tray_id == 254:
-            ams_id = 255  # External spool
+        # Build the ams_change_filament command. Encoding differs by target type:
+        #   - AMS slots (0..15): slot_id is the local slot, curr/tar_temp = -1.
+        #   - External spool (tray_id=254): legacy capture from a single-extruder
+        #     printer used slot_id=254, curr/tar_temp=-1; preserved here.
+        #   - Ext-R on dual-nozzle H2D (tray_id=255): captured shape from
+        #     BambuStudio uses slot_id=0 (extruder index, 0=right), and
+        #     curr_temp/tar_temp = the actual right-nozzle temp.  See #891.
+        self._sequence_id += 1
+        if tray_id == 255:
+            ams_id = 255
+            slot_id = 0  # extruder index for the right nozzle
+            right_temp = int(self.state.temperatures.get("nozzle_2", 0) or 0)
+            if right_temp < 180:
+                right_temp = 215  # Reasonable default if right nozzle is cold/unknown
+            curr_temp = right_temp
+            tar_temp = right_temp
+        elif tray_id == 254:
+            ams_id = 255
             slot_id = 254
             slot_id = 254
+            curr_temp = -1
+            tar_temp = -1
         else:
         else:
-            ams_id = tray_id // 4  # AMS unit (0, 1, 2, 3...)
-            slot_id = tray_id % 4  # Slot within AMS (0, 1, 2, 3)
+            ams_id = tray_id // 4
+            slot_id = tray_id % 4
+            curr_temp = -1
+            tar_temp = -1
 
 
-        # Command format from BambuStudio traffic capture:
-        # - No extruder_id field
-        # - curr_temp and tar_temp are -1 (not 0)
-        self._sequence_id += 1
         command = {
         command = {
             "print": {
             "print": {
                 "command": "ams_change_filament",
                 "command": "ams_change_filament",
@@ -4342,8 +4359,8 @@ class BambuMQTTClient:
                 "ams_id": ams_id,
                 "ams_id": ams_id,
                 "slot_id": slot_id,
                 "slot_id": slot_id,
                 "target": tray_id,
                 "target": tray_id,
-                "curr_temp": -1,
-                "tar_temp": -1,
+                "curr_temp": curr_temp,
+                "tar_temp": tar_temp,
             }
             }
         }
         }
 
 

+ 157 - 0
backend/tests/integration/test_printers_api.py

@@ -740,6 +740,163 @@ class TestAMSRefreshAPI:
             assert "unload" in response.json()["detail"].lower()
             assert "unload" in response.json()["detail"].lower()
 
 
 
 
+class TestAMSLoadUnloadAPI:
+    """Integration tests for AMS load / unload endpoints (#891)."""
+
+    # ── load ─────────────────────────────────────────────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_invalid_tray_id(self, async_client: AsyncClient, printer_factory):
+        """tray_id outside {0..15, 254, 255} is rejected."""
+        printer = await printer_factory(name="P")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/ams/load?tray_id=0")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Disconnected")
+
+        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}/ams/load?tray_id=0")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_ams_slot_success(self, async_client: AsyncClient, printer_factory):
+        """tray_id=5 → AMS 1 slot 2 (1-indexed in the message)."""
+        printer = await printer_factory(name="P")
+
+        mock_client = MagicMock()
+        mock_client.ams_load_filament.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}/ams/load?tray_id=5")
+
+            assert response.status_code == 200
+            mock_client.ams_load_filament.assert_called_once_with(5)
+            assert "AMS 1" in response.json()["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_external_left_success(self, async_client: AsyncClient, printer_factory):
+        """tray_id=254 → external spool / Ext-L."""
+        printer = await printer_factory(name="P")
+
+        mock_client = MagicMock()
+        mock_client.ams_load_filament.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}/ams/load?tray_id=254")
+
+            assert response.status_code == 200
+            mock_client.ams_load_filament.assert_called_once_with(254)
+            assert "external" in response.json()["message"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_external_right_success(self, async_client: AsyncClient, printer_factory):
+        """tray_id=255 → Ext-R on dual-nozzle H2D."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_load_filament.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}/ams/load?tray_id=255")
+
+            assert response.status_code == 200
+            mock_client.ams_load_filament.assert_called_once_with(255)
+            assert "Ext-R" in response.json()["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P")
+
+        mock_client = MagicMock()
+        mock_client.ams_load_filament.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}/ams/load?tray_id=0")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()
+
+    # ── unload ───────────────────────────────────────────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/ams/unload")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Disconnected")
+
+        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}/ams/unload")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_success(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P")
+
+        mock_client = MagicMock()
+        mock_client.ams_unload_filament.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}/ams/unload")
+
+            assert response.status_code == 200
+            mock_client.ams_unload_filament.assert_called_once_with()
+            assert response.json()["success"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P")
+
+        mock_client = MagicMock()
+        mock_client.ams_unload_filament.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}/ams/unload")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()
+
+
 class TestConfigureAMSSlotAPI:
 class TestConfigureAMSSlotAPI:
     """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
     """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
 
 

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

@@ -4536,3 +4536,89 @@ class TestFilamentTrackSwitchDetection:
         assert fs.installed is True
         assert fs.installed is True
         assert fs.in_slots == []
         assert fs.in_slots == []
         assert fs.out_extruders == []
         assert fs.out_extruders == []
+
+
+class TestAmsLoadFilamentEncoding:
+    """Per-target ams_change_filament command encoding (#891)."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        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",
+        )
+        # Pretend the MQTT layer is connected so the publish path is reached.
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    @staticmethod
+    def _published(client) -> dict:
+        """Return the JSON of the most recent publish() call."""
+        last_call = client._client.publish.call_args_list[-1]
+        topic, payload, *_ = last_call.args
+        return json.loads(payload)
+
+    def test_ams_slot_uses_local_index_and_minus_one_temps(self, mqtt_client):
+        """tray_id=5 → ams_id=1, slot_id=1, target=5, curr/tar=-1."""
+        assert mqtt_client.ams_load_filament(5) is True
+        cmd = self._published(mqtt_client)["print"]
+        assert cmd["command"] == "ams_change_filament"
+        assert cmd["ams_id"] == 1
+        assert cmd["slot_id"] == 1
+        assert cmd["target"] == 5
+        assert cmd["curr_temp"] == -1
+        assert cmd["tar_temp"] == -1
+
+    def test_external_left_keeps_legacy_encoding(self, mqtt_client):
+        """tray_id=254 → ams_id=255, slot_id=254, target=254, curr/tar=-1.
+
+        This is the original capture from a single-extruder printer; preserved
+        verbatim so existing single-external setups don't regress.
+        """
+        assert mqtt_client.ams_load_filament(254) is True
+        cmd = self._published(mqtt_client)["print"]
+        assert cmd["ams_id"] == 255
+        assert cmd["slot_id"] == 254
+        assert cmd["target"] == 254
+        assert cmd["curr_temp"] == -1
+        assert cmd["tar_temp"] == -1
+
+    def test_external_right_uses_extruder_index_and_actual_temp(self, mqtt_client):
+        """tray_id=255 → captured BambuStudio shape on dual-nozzle H2D:
+        ams_id=255, slot_id=0 (right extruder), target=255, curr/tar = right
+        nozzle temp.
+        """
+        # Simulate a heated right nozzle.
+        mqtt_client.state.temperatures["nozzle_2"] = 215.0
+
+        assert mqtt_client.ams_load_filament(255) is True
+        cmd = self._published(mqtt_client)["print"]
+        assert cmd["ams_id"] == 255
+        assert cmd["slot_id"] == 0
+        assert cmd["target"] == 255
+        assert cmd["curr_temp"] == 215
+        assert cmd["tar_temp"] == 215
+
+    def test_external_right_falls_back_when_nozzle_cold(self, mqtt_client):
+        """If the right nozzle reports < 180 °C, fall back to a sane default
+        so the printer accepts the command rather than rejecting it on a
+        nonsensical temperature.
+        """
+        mqtt_client.state.temperatures["nozzle_2"] = 25.0
+
+        assert mqtt_client.ams_load_filament(255) is True
+        cmd = self._published(mqtt_client)["print"]
+        assert cmd["curr_temp"] == 215
+        assert cmd["tar_temp"] == 215
+
+    def test_returns_false_when_disconnected(self, mqtt_client):
+        """Disconnected client must not publish anything."""
+        mqtt_client.state.connected = False
+        assert mqtt_client.ams_load_filament(0) is False
+        mqtt_client._client.publish.assert_not_called()

+ 220 - 0
frontend/src/__tests__/pages/PrintersPageAmsLoadUnload.test.tsx

@@ -0,0 +1,220 @@
+/**
+ * Tests for the AMS slot load / unload buttons on PrintersPage (#891).
+ *
+ * Verifies that the menu in each AMS slot popover exposes Load and Unload,
+ * that clicking them POSTs to the right endpoint with the right tray_id, and
+ * that the buttons are hidden while the printer is RUNNING.
+ */
+
+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 mockPrinter = {
+  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 baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_id_name: 'A00-R0',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 11,
+};
+
+const mockIdleStatusWithAms = {
+  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,
+  speed_level: 2,
+  vt_tray: [],
+  ams: [
+    {
+      id: 0,
+      humidity: 30,
+      temp: 25,
+      is_ams_ht: false,
+      serial_number: 'AMS00',
+      sw_ver: '1.0.0',
+      dry_time: 0,
+      dry_status: 0,
+      dry_sub_status: 0,
+      dry_sf_reason: [],
+      module_type: 'ams',
+      tray: [
+        { id: 0, ...baseTray },
+        { id: 1, ...baseTray, tray_color: '00FF00FF', tray_type: 'PETG' },
+        { id: 2, ...baseTray, tray_color: '0000FFFF', tray_type: 'ABS' },
+        { id: 3, ...baseTray, tray_color: 'FFFF00FF', tray_type: 'TPU' },
+      ],
+    },
+  ],
+};
+
+const mockRunningStatus = {
+  ...mockIdleStatusWithAms,
+  state: 'RUNNING',
+};
+
+describe('PrintersPage - AMS load/unload (#891)', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('Load posts to /ams/load with tray_id derived from amsId*4 + slot', async () => {
+    const user = userEvent.setup();
+    let captured: { tray_id: string | null } | null = null;
+
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockIdleStatusWithAms)),
+      http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
+        const url = new URL(request.url);
+        captured = { tray_id: url.searchParams.get('tray_id') };
+        return HttpResponse.json({ success: true, message: 'Loading filament from AMS 0 slot 3' });
+      }),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      // The slot menu button is hidden until we hover. Pull it directly out of the DOM.
+      expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
+    });
+
+    // Slot 2 (third one, slotIdx=2) → expected tray_id = 0*4 + 2 = 2
+    const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
+    await user.click(menuButtons[2]);
+
+    await waitFor(() => {
+      expect(screen.getByText('Load')).toBeInTheDocument();
+    });
+
+    await user.click(screen.getByText('Load'));
+
+    await waitFor(() => {
+      expect(captured).not.toBeNull();
+      expect(captured!.tray_id).toBe('2');
+    });
+  });
+
+  it('Unload posts to /ams/unload (no body, no params)', async () => {
+    const user = userEvent.setup();
+    let unloadCalled = false;
+
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockIdleStatusWithAms)),
+      http.post('/api/v1/printers/:id/ams/unload', () => {
+        unloadCalled = true;
+        return HttpResponse.json({ success: true, message: 'Unloading filament' });
+      }),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
+    });
+
+    const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
+    await user.click(menuButtons[0]);
+
+    await waitFor(() => {
+      expect(screen.getByText('Unload')).toBeInTheDocument();
+    });
+
+    await user.click(screen.getByText('Unload'));
+
+    await waitFor(() => {
+      expect(unloadCalled).toBe(true);
+    });
+  });
+
+  it('hides the slot menu while the printer is RUNNING', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockRunningStatus)),
+    );
+
+    render(<PrintersPage />);
+
+    // Wait for the page to render the printer card.
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+
+    // No "Slot options" menu trigger should be present at all while running.
+    expect(document.querySelectorAll('[title="Slot options"]').length).toBe(0);
+  });
+
+  it('external spool slot exposes Load and posts tray_id=254', async () => {
+    const user = userEvent.setup();
+    let captured: string | null = null;
+
+    server.use(
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({
+          ...mockIdleStatusWithAms,
+          ams: [], // external-only
+          vt_tray: [{ id: 254, ...baseTray, tray_type: 'PLA', tray_color: 'FFFFFFFF' }],
+        }),
+      ),
+      http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
+        captured = new URL(request.url).searchParams.get('tray_id');
+        return HttpResponse.json({ success: true, message: 'Loading filament from external spool' });
+      }),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
+    });
+
+    const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
+    await user.click(menuButtons[0]);
+
+    await waitFor(() => {
+      expect(screen.getByText('Load')).toBeInTheDocument();
+    });
+
+    await user.click(screen.getByText('Load'));
+
+    await waitFor(() => {
+      expect(captured).toBe('254');
+    });
+  });
+});

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

@@ -3095,6 +3095,20 @@ export const api = {
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
 
 
+  // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
+  loadAmsTray: (printerId: number, trayId: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/ams/load?tray_id=${trayId}`,
+      { method: 'POST' }
+    ),
+
+  // Unload the currently loaded filament.
+  unloadAms: (printerId: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/ams/unload`,
+      { method: 'POST' }
+    ),
+
   // MQTT Debug Logging
   // MQTT Debug Logging
   enableMQTTLogging: (printerId: number) =>
   enableMQTTLogging: (printerId: number) =>
     request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
     request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: 'Kalibrierung gespeichert!',
       calibrationSaved: 'Kalibrierung gespeichert!',
       calibrationFailed: 'Kalibrierung fehlgeschlagen',
       calibrationFailed: 'Kalibrierung fehlgeschlagen',
       rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
       rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
+      loadInitiated: 'Filament wird geladen…',
+      unloadInitiated: 'Filament wird entladen…',
+      failedToLoad: 'Filament konnte nicht geladen werden',
+      failedToUnload: 'Filament konnte nicht entladen werden',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: 'RFID neu lesen',
       reread: 'RFID neu lesen',
     },
     },
+    // AMS Laden/Entladen (#891)
+    ams: {
+      load: 'Laden',
+      unload: 'Entladen',
+    },
     bedJog: {
     bedJog: {
       title: 'Druckbett bewegen',
       title: 'Druckbett bewegen',
       bed: 'Bett',
       bed: 'Bett',

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: 'Calibration saved!',
       calibrationSaved: 'Calibration saved!',
       calibrationFailed: 'Calibration failed',
       calibrationFailed: 'Calibration failed',
       rfidRereadInitiated: 'RFID re-read initiated',
       rfidRereadInitiated: 'RFID re-read initiated',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: 'Re-read RFID',
       reread: 'Re-read RFID',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: 'Move build plate',
       title: 'Move build plate',
       bed: 'Bed',
       bed: 'Bed',

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: 'Calibration enregistrée !',
       calibrationSaved: 'Calibration enregistrée !',
       calibrationFailed: 'Échec de la calibration',
       calibrationFailed: 'Échec de la calibration',
       rfidRereadInitiated: 'Lecture RFID initiée',
       rfidRereadInitiated: 'Lecture RFID initiée',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: 'Relire RFID',
       reread: 'Relire RFID',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: 'Déplacer le plateau',
       title: 'Déplacer le plateau',
       bed: 'Plateau',
       bed: 'Plateau',

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: 'Calibrazione salvata!',
       calibrationSaved: 'Calibrazione salvata!',
       calibrationFailed: 'Calibrazione non riuscita',
       calibrationFailed: 'Calibrazione non riuscita',
       rfidRereadInitiated: 'Rilettura RFID avviata',
       rfidRereadInitiated: 'Rilettura RFID avviata',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: 'Rileggi RFID',
       reread: 'Rileggi RFID',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: 'Muovi il piano di stampa',
       title: 'Muovi il piano di stampa',
       bed: 'Piano',
       bed: 'Piano',

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

@@ -310,6 +310,10 @@ export default {
       calibrationSaved: 'キャリブレーションを保存しました!',
       calibrationSaved: 'キャリブレーションを保存しました!',
       calibrationFailed: 'キャリブレーションに失敗しました',
       calibrationFailed: 'キャリブレーションに失敗しました',
       rfidRereadInitiated: 'RFID再読み取りを開始しました',
       rfidRereadInitiated: 'RFID再読み取りを開始しました',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -333,6 +337,11 @@ export default {
     rfid: {
     rfid: {
       reread: 'RFID再読み取り',
       reread: 'RFID再読み取り',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: 'ビルドプレートを移動',
       title: 'ビルドプレートを移動',
       bed: 'ベッド',
       bed: 'ベッド',

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: 'Calibração salva!',
       calibrationSaved: 'Calibração salva!',
       calibrationFailed: 'Falha na calibração',
       calibrationFailed: 'Falha na calibração',
       rfidRereadInitiated: 'Releitura de RFID iniciada',
       rfidRereadInitiated: 'Releitura de RFID iniciada',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: 'Releitura de RFID',
       reread: 'Releitura de RFID',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: 'Mover a mesa de impressão',
       title: 'Mover a mesa de impressão',
       bed: 'Mesa',
       bed: 'Mesa',

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: '校准已保存!',
       calibrationSaved: '校准已保存!',
       calibrationFailed: '校准失败',
       calibrationFailed: '校准失败',
       rfidRereadInitiated: '已发起 RFID 重新读取',
       rfidRereadInitiated: '已发起 RFID 重新读取',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: '重新读取 RFID',
       reread: '重新读取 RFID',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: '移动热床',
       title: '移动热床',
       bed: '热床',
       bed: '热床',

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

@@ -311,6 +311,10 @@ export default {
       calibrationSaved: '校準已儲存!',
       calibrationSaved: '校準已儲存!',
       calibrationFailed: '校準失敗',
       calibrationFailed: '校準失敗',
       rfidRereadInitiated: '已發起 RFID 重新讀取',
       rfidRereadInitiated: '已發起 RFID 重新讀取',
+      loadInitiated: 'Loading filament…',
+      unloadInitiated: 'Unloading filament…',
+      failedToLoad: 'Failed to load filament',
+      failedToUnload: 'Failed to unload filament',
     },
     },
     // Connection status
     // Connection status
     connection: {
     connection: {
@@ -334,6 +338,11 @@ export default {
     rfid: {
     rfid: {
       reread: '重新讀取 RFID',
       reread: '重新讀取 RFID',
     },
     },
+    // AMS load/unload (#891)
+    ams: {
+      load: 'Load',
+      unload: 'Unload',
+    },
     bedJog: {
     bedJog: {
       title: '移動熱床',
       title: '移動熱床',
       bed: '熱床',
       bed: '熱床',

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

@@ -55,6 +55,8 @@ import {
   DoorOpen,
   DoorOpen,
   DoorClosed,
   DoorClosed,
   MoveVertical,
   MoveVertical,
+  LogIn,
+  LogOut,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
@@ -1997,6 +1999,27 @@ function PrinterCard({
     },
     },
   });
   });
 
 
+  // AMS load/unload mutations (#891)
+  const loadAmsTrayMutation = useMutation({
+    mutationFn: ({ trayId }: { trayId: number }) => api.loadAmsTray(printer.id, trayId),
+    onSuccess: (data) => {
+      showToast(data.message || t('printers.toast.loadInitiated'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('printers.toast.failedToLoad'), 'error');
+    },
+  });
+
+  const unloadAmsMutation = useMutation({
+    mutationFn: () => api.unloadAms(printer.id),
+    onSuccess: (data) => {
+      showToast(data.message || t('printers.toast.unloadInitiated'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('printers.toast.failedToUnload'), 'error');
+    },
+  });
+
   // Plate references state
   // Plate references state
   const [plateReferences, setPlateReferences] = useState<{
   const [plateReferences, setPlateReferences] = useState<{
     references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>;
     references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>;
@@ -3534,6 +3557,42 @@ function PrinterCard({
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           {t('printers.rfid.reread')}
                                           {t('printers.rfid.reread')}
                                         </button>
                                         </button>
+                                        <button
+                                          className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                            hasPermission('printers:control')
+                                              ? 'text-white hover:bg-bambu-dark-tertiary'
+                                              : 'text-bambu-gray/50 cursor-not-allowed'
+                                          }`}
+                                          onClick={(e) => {
+                                            e.stopPropagation();
+                                            if (!hasPermission('printers:control')) return;
+                                            loadAmsTrayMutation.mutate({ trayId: ams.id * 4 + slotIdx });
+                                            setAmsSlotMenu(null);
+                                          }}
+                                          disabled={!hasPermission('printers:control')}
+                                          title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
+                                        >
+                                          <LogIn className="w-3 h-3" />
+                                          {t('printers.ams.load')}
+                                        </button>
+                                        <button
+                                          className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                            hasPermission('printers:control')
+                                              ? 'text-white hover:bg-bambu-dark-tertiary'
+                                              : 'text-bambu-gray/50 cursor-not-allowed'
+                                          }`}
+                                          onClick={(e) => {
+                                            e.stopPropagation();
+                                            if (!hasPermission('printers:control')) return;
+                                            unloadAmsMutation.mutate();
+                                            setAmsSlotMenu(null);
+                                          }}
+                                          disabled={!hasPermission('printers:control')}
+                                          title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
+                                        >
+                                          <LogOut className="w-3 h-3" />
+                                          {t('printers.ams.unload')}
+                                        </button>
                                       </div>
                                       </div>
                                     )}
                                     )}
                                     {/* Hover card wraps only the visual content */}
                                     {/* Hover card wraps only the visual content */}
@@ -3840,6 +3899,42 @@ function PrinterCard({
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       {t('printers.rfid.reread')}
                                       {t('printers.rfid.reread')}
                                     </button>
                                     </button>
+                                    <button
+                                      className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                        hasPermission('printers:control')
+                                          ? 'text-white hover:bg-bambu-dark-tertiary'
+                                          : 'text-bambu-gray/50 cursor-not-allowed'
+                                      }`}
+                                      onClick={(e) => {
+                                        e.stopPropagation();
+                                        if (!hasPermission('printers:control')) return;
+                                        loadAmsTrayMutation.mutate({ trayId: ams.id * 4 + htSlotId });
+                                        setAmsSlotMenu(null);
+                                      }}
+                                      disabled={!hasPermission('printers:control')}
+                                      title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
+                                    >
+                                      <LogIn className="w-3 h-3" />
+                                      {t('printers.ams.load')}
+                                    </button>
+                                    <button
+                                      className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                        hasPermission('printers:control')
+                                          ? 'text-white hover:bg-bambu-dark-tertiary'
+                                          : 'text-bambu-gray/50 cursor-not-allowed'
+                                      }`}
+                                      onClick={(e) => {
+                                        e.stopPropagation();
+                                        if (!hasPermission('printers:control')) return;
+                                        unloadAmsMutation.mutate();
+                                        setAmsSlotMenu(null);
+                                      }}
+                                      disabled={!hasPermission('printers:control')}
+                                      title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
+                                    >
+                                      <LogOut className="w-3 h-3" />
+                                      {t('printers.ams.unload')}
+                                    </button>
                                   </div>
                                   </div>
                                 )}
                                 )}
                                 {/* Hover card wraps only the visual content */}
                                 {/* Hover card wraps only the visual content */}
@@ -4043,8 +4138,65 @@ function PrinterCard({
                                 </div>
                                 </div>
                               );
                               );
 
 
+                              const extMenuKey = 255 * 10 + slotTrayId; // unique slotId space for external menu state
+                              const isExtMenuOpen = amsSlotMenu?.amsId === 255 && amsSlotMenu?.slotId === extMenuKey;
+
                               return (
                               return (
                                 <div key={extTrayId} className="relative group">
                                 <div key={extTrayId} className="relative group">
+                                  {/* Menu button - appears on hover, hidden when printer busy */}
+                                  {status?.state !== 'RUNNING' && (
+                                    <button
+                                      onClick={(e) => {
+                                        e.stopPropagation();
+                                        setAmsSlotMenu(isExtMenuOpen ? null : { amsId: 255, slotId: extMenuKey });
+                                      }}
+                                      className="absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary"
+                                      title={t('printers.slotOptions')}
+                                    >
+                                      <MoreVertical className="w-2.5 h-2.5 text-bambu-gray" />
+                                    </button>
+                                  )}
+                                  {/* Dropdown menu */}
+                                  {status?.state !== 'RUNNING' && isExtMenuOpen && (
+                                    <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
+                                      <button
+                                        className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                          hasPermission('printers:control')
+                                            ? 'text-white hover:bg-bambu-dark-tertiary'
+                                            : 'text-bambu-gray/50 cursor-not-allowed'
+                                        }`}
+                                        onClick={(e) => {
+                                          e.stopPropagation();
+                                          if (!hasPermission('printers:control')) return;
+                                          loadAmsTrayMutation.mutate({ trayId: extTrayId });
+                                          setAmsSlotMenu(null);
+                                        }}
+                                        disabled={!hasPermission('printers:control')}
+                                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
+                                      >
+                                        <LogIn className="w-3 h-3" />
+                                        {t('printers.ams.load')}
+                                      </button>
+                                      <button
+                                        className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                          hasPermission('printers:control')
+                                            ? 'text-white hover:bg-bambu-dark-tertiary'
+                                            : 'text-bambu-gray/50 cursor-not-allowed'
+                                        }`}
+                                        onClick={(e) => {
+                                          e.stopPropagation();
+                                          if (!hasPermission('printers:control')) return;
+                                          unloadAmsMutation.mutate();
+                                          setAmsSlotMenu(null);
+                                        }}
+                                        disabled={!hasPermission('printers:control')}
+                                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
+                                      >
+                                        <LogOut className="w-3 h-3" />
+                                        {t('printers.ams.unload')}
+                                      </button>
+                                    </div>
+                                  )}
                                   {!isEmpty ? (
                                   {!isEmpty ? (
                                     <FilamentHoverCard
                                     <FilamentHoverCard
                                       data={extFilamentData}
                                       data={extFilamentData}

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


+ 1 - 1
static/index.html

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

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