Parcourir la source

H3C: review fixes (#3442)

- VP queue mode keeps an H3C's per-PrintTool external inputs in the
  slicer's mapping (target printer's tray layout, else the VP model)
- Low-filament alerts name an H3C's external inputs "Ext-n"
- Configure dialog names an H3C external input "Ext-n"
- An empty H3C external input with a spool assigned keeps its tile
- A partial {id, state} push no longer clears an AMS 3H slot
- Test that startup runs the H3C queue mapping migration
maziggy il y a 2 jours
Parent
commit
f533e608d2
32 fichiers modifiés avec 247 ajouts et 19 suppressions
  1. 6 1
      backend/app/services/bambu_mqtt.py
  2. 6 3
      backend/app/services/print_scheduler.py
  3. 5 1
      backend/app/services/spool_assignment_notifications.py
  4. 29 6
      backend/app/services/virtual_printer/manager.py
  5. 33 0
      backend/tests/unit/services/test_filament_low_producer_2913.py
  6. 77 0
      backend/tests/unit/services/test_vp_external_spool_mapping_3237.py
  7. 11 0
      backend/tests/unit/test_h3c_ams3h.py
  8. 9 0
      backend/tests/unit/test_h3c_queue_tray_ids_migration.py
  9. 9 0
      frontend/src/__tests__/components/ConfigureAmsSlotModalWiring.test.tsx
  10. 38 0
      frontend/src/__tests__/pages/PrintersPageAms3h.test.tsx
  11. 5 2
      frontend/src/components/ConfigureAmsSlotModal.tsx
  12. 13 2
      frontend/src/pages/PrintersPage.tsx
  13. 4 2
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  14. 0 0
      static/assets/ArchivesPage-qSJV8uOK.js
  15. 0 0
      static/assets/CamWallPage-A9bP7vOU.js
  16. 0 0
      static/assets/CameraPage-dm3U0Y6c.js
  17. 0 0
      static/assets/ConfigureAmsSlotModal-BWcNYhGG.js
  18. 0 0
      static/assets/FileManagerPage-Ck_MMEXg.js
  19. 0 0
      static/assets/InventoryPage-CfPfP-hJ.js
  20. 0 0
      static/assets/ModelSourcesPage-DOAQ1GZZ.js
  21. 0 0
      static/assets/PrintersPage-Cmk_JN09.js
  22. 0 0
      static/assets/SettingsPage-b7vUJPym.js
  23. 0 0
      static/assets/SliceModal-lVk7tSIf.js
  24. 0 0
      static/assets/SpoolBuddyAmsPage-CAbowbzj.js
  25. 0 0
      static/assets/SpoolBuddyAmsPage-CWj6Z6Ol.js
  26. 0 0
      static/assets/SpoolBuddyInventoryPage-puFUedIS.js
  27. 1 1
      static/assets/SpoolBuddySettingsPage-ObLogd44.js
  28. 0 0
      static/assets/index-C_dtkeDr.js
  29. 0 0
      static/assets/locationSensorDefaults-C4rloVcw.js
  30. 0 0
      static/assets/spoolCost-CYKsptkg.js
  31. 0 0
      static/assets/usePageFileDrop-BARgRaek.js
  32. 1 1
      static/index.html

+ 6 - 1
backend/app/services/bambu_mqtt.py

@@ -3990,8 +3990,13 @@ class BambuMQTTClient:
                             # heuristic for HT units — a genuine HT spool removal still
                             # clears via the explicit tray_type=="" case above and the
                             # tray_exist_bits cleanup below.
+                            #
+                            # A rack AMS (the H3C's AMS 3H) reports a loaded slot
+                            # as state=27, so it is skipped the same way.
                             try:
-                                _is_ht_unit = int(ams_id) >= 128
+                                _is_ht_unit = int(ams_id) >= 128 or is_rack_ams(
+                                    unit_ams_type(int(ams_id), ams_unit.get("info") or existing_unit.get("info"))
+                                )
                             except (TypeError, ValueError):
                                 _is_ht_unit = False
                             tray_state = new_tray.get("state")

+ 6 - 3
backend/app/services/print_scheduler.py

@@ -103,7 +103,7 @@ from backend.app.utils.tray_ids import (
 logger = logging.getLogger(__name__)
 
 
-def _ams_slot_label(ams_id: int, tray_id: int) -> str:
+def _ams_slot_label(ams_id: int, tray_id: int, layout: TrayLayout | None = None) -> str:
     """Human slot name for an assignment tuple, in the spelling already in use.
 
     Composed from the notification side's own pair rather than spelled out again
@@ -117,8 +117,11 @@ def _ams_slot_label(ams_id: int, tray_id: int) -> str:
     Naming the same slot two ways is its own defect once an alert and the
     Inventory page are meant to be talking about the same tray, so this defers
     rather than adding a fifth spelling.
+
+    ``layout`` is the printer's (``printer_tray_layout``): an H3C names its
+    per-hotend external inputs "Ext-n".
     """
-    return _slot_label_from_global_tray(_global_tray_from_assignment(ams_id, tray_id))
+    return _slot_label_from_global_tray(_global_tray_from_assignment(ams_id, tray_id, layout), layout)
 
 
 # Minimum seconds between low-filament checks. Matches the scheduler's idle
@@ -5790,7 +5793,7 @@ class PrintScheduler:
                     continue
                 printers[printer_id] = printer
             printer = printers[printer_id]
-            slot = _ams_slot_label(ams_id, tray_id)
+            slot = _ams_slot_label(ams_id, tray_id, printer_tray_layout(printer_id, printer_manager))
             if send_notification:
                 try:
                     await notification_service.on_filament_low(

+ 5 - 1
backend/app/services/spool_assignment_notifications.py

@@ -11,6 +11,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager, printer_tray_layout
 from backend.app.utils.tray_ids import (
     EXTERNAL_DEPUTY_ID,
+    RACK_EXTERNAL_IDS,
     AmsType,
     TrayLayout,
     decode_wire_slot,
@@ -29,8 +30,11 @@ def _global_tray_from_assignment(ams_id: int, tray_id: int, layout: TrayLayout |
 def _slot_label_from_global_tray(global_tray_id: int, layout: TrayLayout | None = None) -> str:
     """Return a human-readable slot label from a global tray ID."""
     layout = layout or TrayLayout()
-    if layout.rack and layout.is_external(global_tray_id):
+    if (layout.rack and layout.is_external(global_tray_id)) or (
+        RACK_EXTERNAL_IDS.start <= global_tray_id < EXTERNAL_DEPUTY_ID
+    ):
         # One external input per hotend, numbered like Bambu Studio's "Ext-n".
+        # 247-253 are only ever such inputs, so they read so without a layout.
         return f"Ext-{external_hotend(global_tray_id) + 1}"
     if global_tray_id == 254:
         return "Ext-L"

+ 29 - 6
backend/app/services/virtual_printer/manager.py

@@ -28,7 +28,8 @@ from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
-from backend.app.utils.printer_models import is_nozzle_rack_model
+from backend.app.utils.printer_models import has_ams3h, is_nozzle_rack_model
+from backend.app.utils.tray_ids import TrayLayout
 
 if TYPE_CHECKING:
     from backend.app.services.printer_manager import PrinterManager
@@ -157,7 +158,9 @@ def _tristate_from_slicer(data: dict, bool_field: str | None, int_field: str) ->
     return None
 
 
-def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_nozzle: bool = False) -> str | None:
+def _extract_slicer_ams_mapping_json(
+    data: dict, log_prefix: str, *, is_dual_nozzle: bool = False, layout: TrayLayout | None = None
+) -> str | None:
     """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
 
     BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
@@ -192,7 +195,8 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_noz
     turns that back into the external-spool entry. Kept as ``-1`` it dispatched
     as unmapped and the printer stopped with 0700-8012 (#3237).
     ``is_dual_nozzle`` is the target printer's, since that decides which
-    global tray an external spool is.
+    global tray an external spool is. So is ``layout``: on a rack printer
+    (H3C) each hotend has its own external input, 247-255.
 
     Returns ``None`` when the field is absent, unparsable, or the classic
     "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
@@ -218,7 +222,7 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_noz
             detail = json.loads(detail)
         except json.JSONDecodeError:
             detail = None
-    raw = resolve_external_spools_in_mapping(raw, detail, is_dual_nozzle)
+    raw = resolve_external_spools_in_mapping(raw, detail, is_dual_nozzle, layout)
     if all(v < 0 for v in raw):
         # #2589 sentinel — every slot unresolved. Let the scheduler compute a
         # fresh mapping from live AMS state instead of trusting this.
@@ -456,6 +460,19 @@ class VirtualPrinterInstance:
             )
         return is_dual_nozzle_model(self.model)
 
+    def _target_tray_layout(self) -> TrayLayout:
+        """How the target printer numbers its trays, which places an H3C's
+        per-hotend external inputs in the slicer's mapping.
+
+        The target's live AMS data, else its model; without a target printer
+        ("any available" queue mode), the VP's own model.
+        """
+        if self._printer_manager is not None and self.target_printer_id is not None:
+            from backend.app.services.printer_manager import printer_tray_layout
+
+            return printer_tray_layout(self.target_printer_id, self._printer_manager)
+        return TrayLayout.ams3h() if has_ams3h(self.model) else TrayLayout()
+
     async def on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT.
 
@@ -602,7 +619,10 @@ class VirtualPrinterInstance:
         # #1766 backup gate).
         ams_mapping_json = (
             _extract_slicer_ams_mapping_json(
-                data, f"[VP {self.name}] Late MQTT", is_dual_nozzle=self._target_is_dual_nozzle()
+                data,
+                f"[VP {self.name}] Late MQTT",
+                is_dual_nozzle=self._target_is_dual_nozzle(),
+                layout=self._target_tray_layout(),
             )
             if self.target_printer_id is not None and self.save_ams_mapping
             else None
@@ -1074,7 +1094,10 @@ class VirtualPrinterInstance:
                 ams_mapping_json: str | None = None
                 if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
                     ams_mapping_json = _extract_slicer_ams_mapping_json(
-                        slicer_opts, f"[VP {self.name}]", is_dual_nozzle=self._target_is_dual_nozzle()
+                        slicer_opts,
+                        f"[VP {self.name}]",
+                        is_dual_nozzle=self._target_is_dual_nozzle(),
+                        layout=self._target_tray_layout(),
                     )
 
                 # `Force color match` is the user asking Bambuddy to do the

+ 33 - 0
backend/tests/unit/services/test_filament_low_producer_2913.py

@@ -27,6 +27,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.print_scheduler import PrintScheduler, _ams_slot_label, _remaining_percent
 from backend.app.services.spoolman import SpoolmanUnavailableError
+from backend.app.utils.tray_ids import TrayLayout
 
 
 async def _printer(db, name: str = "X2D") -> Printer:
@@ -95,6 +96,21 @@ def test_slot_labels_are_the_ones_the_notification_side_already_uses():
     assert _ams_slot_label(6, 0) == "Lite-1"
 
 
+def test_slot_label_names_an_h3c_external_input_ext_n():
+    """An H3C's external inputs are one per hotend, "Ext-n" like Bambu Studio.
+
+    Without the printer's layout, 247-253 read as "HT-" plus a Latin-1 character.
+    """
+    h3c = TrayLayout.ams3h()
+    assert _ams_slot_label(255, 0, h3c) == "Ext-2"
+    assert _ams_slot_label(255, 1, h3c) == "Ext-1"
+    assert _ams_slot_label(255, 2, h3c) == "Ext-9"
+    assert _ams_slot_label(255, 8, h3c) == "Ext-3"
+    assert _ams_slot_label(32, 4, h3c) == "3H-5"
+    # 247-253 are only ever such inputs, so they read so without a layout too.
+    assert _ams_slot_label(255, 4) == "Ext-7"
+
+
 # -- the producer -----------------------------------------------------------
 
 
@@ -145,6 +161,23 @@ async def test_fires_when_an_assigned_spool_is_below_the_threshold(db_session, s
     assert args[3] == 15
 
 
+@pytest.mark.asyncio
+async def test_an_h3c_external_input_alert_names_the_hotends_input(db_session, scheduler, notify):
+    """The alert names the slot with the printer's own layout: an H3C's input (255, 1) is "Ext-1", not "Ext-R"."""
+    printer = await _printer(db_session)
+    await _assigned_spool(db_session, printer, weight_used=850.0, ams=255, tray=1)
+    await db_session.commit()
+
+    with patch(
+        "backend.app.services.print_scheduler.printer_tray_layout",
+        return_value=TrayLayout.ams3h(),
+    ):
+        await _pass(scheduler, db_session)
+
+    notify.on_filament_low.assert_awaited_once()
+    assert notify.on_filament_low.await_args.args[2] == "Ext-1"
+
+
 @pytest.mark.asyncio
 async def test_the_spool_colour_is_passed_for_the_template(db_session, scheduler, notify):
     """on_filament_low has always accepted color= and the filament_low template

+ 77 - 0
backend/tests/unit/services/test_vp_external_spool_mapping_3237.py

@@ -143,3 +143,80 @@ async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
     assert len(added_items) == 1
     assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
     assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [254, 3, 2]
+
+
+# An H3C job: AMS 3H slot 1, then the external inputs of hotends 0, 1 and 5.
+H3C_PAYLOAD = {
+    "command": "project_file",
+    "ams_mapping": [28, -1, -1, -1],
+    "ams_mapping2": [
+        {"ams_id": 32, "slot_id": 0},
+        {"ams_id": 255, "slot_id": 0},
+        {"ams_id": 254, "slot_id": 0},
+        {"ams_id": 250, "slot_id": 0},
+    ],
+}
+
+
+class TestRackPrinterExternalInputs:
+    """An H3C has one external input per hotend (247-255); each keeps its own id."""
+
+    def test_rack_layout_keeps_each_input(self):
+        from backend.app.utils.tray_ids import TrayLayout
+
+        raw = _extract_slicer_ams_mapping_json(H3C_PAYLOAD, "[test]", layout=TrayLayout.ams3h())
+        assert json.loads(raw) == [28, 255, 254, 250]
+
+    def test_target_h3c_layout_from_its_model(self, tmp_path):
+        manager = _manager_with(None)
+        manager.get_status = MagicMock(return_value=None)
+        manager.get_model = MagicMock(return_value="H3C")
+        assert _instance(tmp_path, model="N10", printer_manager=manager)._target_tray_layout().rack
+
+    def test_any_available_h3c_uses_the_vp_model(self, tmp_path):
+        inst = _instance(tmp_path, model="N10")
+        inst.target_printer_id = None
+        assert inst._target_tray_layout().rack
+
+    def test_other_targets_are_not_rack(self, tmp_path):
+        manager = _manager_with(None)
+        manager.get_status = MagicMock(return_value=None)
+        manager.get_model = MagicMock(return_value="H2C")
+        assert not _instance(tmp_path, printer_manager=manager)._target_tray_layout().rack
+        inst = _instance(tmp_path, model="BL-P001")
+        inst.target_printer_id = None
+        assert not inst._target_tray_layout().rack
+
+    @pytest.mark.asyncio
+    async def test_queue_item_keeps_h3c_external_inputs(self, tmp_path):
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        session_ctx = AsyncMock()
+        session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        session_ctx.__aexit__ = AsyncMock(return_value=False)
+        session_factory = MagicMock(return_value=session_ctx)
+
+        manager = _manager_with(MagicMock(_is_dual_nozzle=False, model="N10"))
+        manager.get_status = MagicMock(return_value=None)
+        manager.get_model = MagicMock(return_value="N10")
+        inst = _instance(tmp_path, model="N10", printer_manager=manager, session_factory=session_factory)
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+        await inst.on_print_command(file_path.name, dict(H3C_PAYLOAD))
+        mock_archive = MagicMock(id=1, print_name="test")
+        with (
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ) as archive_print,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert json.loads(added_items[0].ams_mapping) == [28, 255, 254, 250]
+        assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [28, 255, 254, 250]

+ 11 - 0
backend/tests/unit/test_h3c_ams3h.py

@@ -314,6 +314,17 @@ class TestIngest:
         client._process_message({"print": {"ams": {"tray_now": "2"}}})
         assert client.state.tray_now == 30
 
+    def test_a_partial_state_27_push_keeps_the_slots_spool(self):
+        # The AMS 3H reports a loaded slot as state 27. The "state != 11 is not
+        # loaded" rule for partial {id, state} pushes (#784) must not empty it,
+        # as it must not for an AMS-HT (#2594).
+        client = _wired(_ingested())
+        client._process_message(
+            {"print": {"command": "push_status", "ams": {"ams": [{"id": "32", "tray": [{"id": "3", "state": 27}]}]}}}
+        )
+        assert _trays_by_id(_unit(client))[3]["tray_type"] == "PLA"
+        assert client.ams_load_filament(31)
+
     def test_every_full_push_is_globalised_not_just_the_first(self):
         # The H3C sends the unit list with every push. The push's units plus the
         # ones already known used to list unit 32 twice, which read as two rack

+ 9 - 0
backend/tests/unit/test_h3c_queue_tray_ids_migration.py

@@ -97,3 +97,12 @@ async def test_it_runs_once(test_engine):
     await _run(test_engine)
 
     assert (await _mappings(test_engine))[("q", ids["h3c"])] == [129]
+
+
+def test_runs_at_startup():
+    """The rewrite only helps if startup runs it."""
+    import inspect
+
+    from backend.app.core.database import _run_migrations
+
+    assert "await _migrate_h3c_queue_tray_ids(conn)" in inspect.getsource(_run_migrations)

+ 9 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModalWiring.test.tsx

@@ -88,6 +88,15 @@ describe('ConfigureAmsSlotModal reset wiring', () => {
     await waitFor(() => expect(onSuccess).toHaveBeenCalled());
   });
 
+  it.each([false, true])('shows the caller\'s slot label (fullScreen %s)', async (fullScreen) => {
+    // An H3C's per-hotend external input, stored as (255, 5): "Ext-6", not "External Slot 6".
+    const base = props(255, 5);
+    render(<ConfigureAmsSlotModal {...base} fullScreen={fullScreen} slotInfo={{ ...base.slotInfo, label: 'Ext-6' }} />);
+
+    expect(await screen.findByText('Ext-6')).toBeInTheDocument();
+    expect(screen.queryByText(/External Slot/)).not.toBeInTheDocument();
+  });
+
   it('Cancel closes without resetting or configuring', async () => {
     const user = userEvent.setup();
     render(<ConfigureAmsSlotModal {...props(1, 1)} />);

+ 38 - 0
frontend/src/__tests__/pages/PrintersPageAms3h.test.tsx

@@ -152,4 +152,42 @@ describe('PrintersPage - H3C AMS 3H', () => {
     expect(slots[8]).toHaveTextContent('6');
     expect(slots[8]).toHaveTextContent('PETG');
   });
+
+  it('names an external input Ext-n in its Configure dialog', async () => {
+    const inputs = emptyInputs.map((vt) => (vt.id === 250 ? { ...tray(250, 'FF0000FF'), tray_type: 'PETG' } : vt));
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...h3cStatus, vt_tray: inputs })));
+    const user = userEvent.setup();
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('filament-slot')).toHaveLength(9);
+    });
+    fireEvent.mouseEnter(screen.getAllByTestId('filament-slot')[8]);
+    await user.click(await screen.findByRole('button', { name: 'Configure' }));
+
+    // Input 250 is stored as (255, 5); the generic label read "External Slot 6".
+    await waitFor(() => {
+      expect(screen.getAllByText('Ext-6').length).toBeGreaterThan(0);
+    });
+    expect(screen.queryByText(/External Slot/)).not.toBeInTheDocument();
+  });
+
+  it('keeps a tile for an empty external input with a spool assigned', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(h3cStatus)),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([{
+        id: 1, spool_id: 42, printer_id: 2, ams_id: 255, tray_id: 5, printer_name: 'Workshop H3C', ams_label: null,
+        spool: { id: 42, material: 'PLA', brand: 'Generic', color_name: 'Red', label_weight: 1000, weight_used: 0, rgba: 'FF0000FF' },
+      }])),
+    );
+
+    render(<PrintersPage />);
+
+    // AMS 3H slot 8 and the assigned input 250 (stored as (255, 5)).
+    await waitFor(() => {
+      expect(screen.getAllByTestId('empty-slot')).toHaveLength(2);
+    });
+    expect(screen.queryByTestId('rack-externals-empty')).not.toBeInTheDocument();
+  });
 });

+ 5 - 2
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -26,6 +26,9 @@ interface SlotInfo {
   extruderId?: number;
   caliIdx?: number | null;
   savedPresetId?: string;
+  // The caller's own name for the slot, when the "AMS Slot n" form doesn't fit:
+  // an H3C's per-hotend external inputs are "Ext-n".
+  label?: string;
 }
 
 
@@ -1292,7 +1295,7 @@ export function ConfigureAmsSlotModal({
                   />
                 )}
                 <span className="text-white/70">
-                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId), slot: slotInfo.trayId + 1 })}
+                  {slotInfo.label ?? t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId), slot: slotInfo.trayId + 1 })}
                 </span>
                 {slotInfo.traySubBrands && (
                   <span>({slotInfo.traySubBrands})</span>
@@ -1333,7 +1336,7 @@ export function ConfigureAmsSlotModal({
                   />
                 )}
                 <span className="text-white font-medium">
-                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId), slot: slotInfo.trayId + 1 })}
+                  {slotInfo.label ?? t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId), slot: slotInfo.trayId + 1 })}
                 </span>
                 {slotInfo.traySubBrands && (
                   <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>

+ 13 - 2
frontend/src/pages/PrintersPage.tsx

@@ -2299,6 +2299,7 @@ function PrinterCard({
     extruderId?: number;
     caliIdx?: number | null;
     savedPresetId?: string;
+    label?: string;
   } | null>(null);
   const [showFirmwareModal, setShowFirmwareModal] = useState(false);
   const [plateCheckResult, setPlateCheckResult] = useState<{
@@ -6413,10 +6414,18 @@ function PrinterCard({
                       })}
                       {/* External spool(s) - grouped in one card like regular AMS. A
                           rack printer's nine per-hotend inputs: only loaded ones get a
-                          tile, the empty ones one line. */}
+                          tile, the empty ones one line. An empty input with a spool
+                          assigned keeps its tile, so the assignment stays visible and
+                          can be undone. */}
                       {status.vt_tray.length > 0 && showExternalSpool && (() => {
+                        const rackExternalAssigned = (vt: AMSTray) => {
+                          const slot = externalSlotIndex(vt.id ?? 254);
+                          return spoolmanEnabled
+                            ? !!spoolmanSlotAssignments?.some(a => a.printer_id === printer.id && a.ams_id === 255 && a.tray_id === slot)
+                            : !!onGetAssignment?.(printer.id, 255, slot);
+                        };
                         const shownExternals = trayLayout.rack
-                          ? [...status.vt_tray].filter((vt) => vt.tray_type).sort((a, b) => (b.id ?? 254) - (a.id ?? 254))
+                          ? [...status.vt_tray].filter((vt) => vt.tray_type || rackExternalAssigned(vt)).sort((a, b) => (b.id ?? 254) - (a.id ?? 254))
                           : [...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254));
                         return (
                         <div style={getAmsCardStyle(Math.max(1, shownExternals.length))} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
@@ -6642,6 +6651,7 @@ function PrinterCard({
                                           extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
                                           caliIdx: extTray.cali_idx,
                                           savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx) ? extSlotPreset?.preset_id : undefined,
+                                          label: trayLayout.rack ? extLabel : undefined,
                                         }),
                                       }}
                                     >
@@ -6658,6 +6668,7 @@ function PrinterCard({
                                           amsId: 255,
                                           trayId: slotTrayId,
                                           extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
+                                          label: trayLayout.rack ? extLabel : undefined,
                                         }),
                                       }}
                                       onAssignSpool={() => setAssignSpoolModal({

+ 4 - 2
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -248,6 +248,7 @@ export function SpoolBuddyAmsPage() {
     extruderId?: number;
     caliIdx?: number | null;
     savedPresetId?: string;
+    label?: string;
   } | null>(null);
 
   // Slot action picker: shown before opening configure or assign modal
@@ -425,8 +426,9 @@ export function SpoolBuddyAmsPage() {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const { tray, location, ...configData } = slotActionPicker;
     setSlotActionPicker(null);
-    setConfigureSlotModal(configData);
-  }, [slotActionPicker]);
+    // An H3C's per-hotend external input keeps its "Ext-n" name in the dialog.
+    setConfigureSlotModal({ ...configData, label: configData.amsId === 255 && trayLayout.rack ? location : undefined });
+  }, [slotActionPicker, trayLayout.rack]);
 
   const openAssignFromPicker = useCallback(() => {
     if (!slotActionPicker || !selectedPrinterId) return;

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchivesPage-qSJV8uOK.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CamWallPage-A9bP7vOU.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraPage-dm3U0Y6c.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ConfigureAmsSlotModal-BWcNYhGG.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/FileManagerPage-Ck_MMEXg.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/InventoryPage-CfPfP-hJ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ModelSourcesPage-DOAQ1GZZ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PrintersPage-Cmk_JN09.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SettingsPage-b7vUJPym.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SliceModal-lVk7tSIf.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CAbowbzj.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CWj6Z6Ol.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-puFUedIS.js


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
static/assets/SpoolBuddySettingsPage-ObLogd44.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-C_dtkeDr.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/locationSensorDefaults-C4rloVcw.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/spoolCost-CYKsptkg.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/usePageFileDrop-BARgRaek.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CLHXpdAp.js"></script>
+    <script type="module" crossorigin src="/assets/index-C_dtkeDr.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-MyvT0tak.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-hYJuSgam.js">

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff