Browse Source

Ask which nozzle to feed when a Filament Track Switch is fitted

    Load and Unload in the AMS slot menu did nothing on an H2C with the switch
    fitted. The ams_change_filament command carries an optional extruder_id and
    Bambuddy never sent it. That is correct on every printer without the switch,
    and is what BambuStudio does there too -- each AMS is wired to one hotend, so
    the firmware works the target out for itself and an explicit value would only
    be a guess at something it already knows. Fit the switch and every AMS is
    bound to one of its two inlets instead, either hotend is reachable from any
    slot, and a command naming neither leaves the firmware nothing to act on. It
    was discarded in silence.

    Load now asks which hotend to feed, on the same terms as Bambu Studio: no
    preselection, so a stray Enter cannot feed the wrong one, and the hotend
    already fed from that very slot greyed out. Printers without a switch send a
    byte-identical command and still load in one click. A switch fitted but not
    yet set up -- any AMS still unassigned to an inlet -- refuses the load up
    front rather than publishing one the firmware will drop, mirroring
    DevFilaSwitch::IsReady, which likewise demands a switcher position on every
    AMS.

    Unload was addressed at the same time. It was aimed with tray_now, a single
    value for the whole printer, so on any dual-nozzle machine with both hotends
    loaded it unloaded whichever that field happened to name regardless of which
    slot's menu was used. It now names the slot and resolves the holding hotend
    from device.extruder.info, previously read for temperatures only. That
    resolution is gated on the printer having reported two extruders:
    single-nozzle machines do send the block, but nobody has read a single-nozzle
    snow value off the wire, and staking every X1C, P1S and A1 unload on an
    unverified encoding buys nothing where tray_now is already unambiguous.

    Both new state fields ride the WebSocket and are in the broadcast key, and
    both are computed in the REST status route as well -- that response is what
    the page has before any push arrives, and leaving them at their defaults
    would have told a correctly set-up machine that its switch was not set up.

    Verified on H2C-1, AMS-A slot 3: loaded and unloaded from each hotend in
    turn, all four correct. Covered by 18 MQTT unit tests, 4 status-dict tests,
    7 integration tests and 6 component tests.

    Two known stragglers, both deliberately left alone. Load on an AMS-HT slot
    has never worked -- an HT unit is addressed by its unit id rather than
    ams*4+slot, which these endpoints do not accept -- so unload there keeps the
    printer-wide form it always used instead of gaining a slot it cannot name.
    And a slot's K-profile still follows the AMS's plumbing rather than the
    nozzle just loaded, so loading to the far hotend leaves the other one's
    calibration bound; that is the same per-nozzle problem the filament and
    K-profile redesign is scoped to fix.
MartinNYHC 1 week ago
parent
commit
03d8310cf0
33 changed files with 1100 additions and 32 deletions
  1. 0 0
      CHANGELOG.md
  2. 63 9
      backend/app/api/routes/printers.py
  3. 13 0
      backend/app/main.py
  4. 23 0
      backend/app/schemas/printer.py
  5. 145 7
      backend/app/services/bambu_mqtt.py
  6. 28 0
      backend/app/services/printer_manager.py
  7. 142 4
      backend/tests/integration/test_printers_api.py
  8. 152 0
      backend/tests/unit/services/test_bambu_mqtt.py
  9. 46 0
      backend/tests/unit/services/test_printer_manager.py
  10. 5 0
      backend/tests/unit/test_printer_kill_switch.py
  11. 1 0
      backend/tests/unit/test_printer_manager_status_broadcast.py
  12. 1 0
      backend/tests/unit/test_printer_offline_notification.py
  13. 1 0
      backend/tests/unit/test_status_broadcast_ams_slot_config.py
  14. 57 0
      frontend/src/__tests__/api/amsLoadUnloadParams.test.ts
  15. 102 0
      frontend/src/__tests__/components/FeedDirectionModal.test.tsx
  16. 29 5
      frontend/src/api/client.ts
  17. 139 0
      frontend/src/components/FeedDirectionModal.tsx
  18. 6 0
      frontend/src/i18n/locales/de.ts
  19. 6 0
      frontend/src/i18n/locales/en.ts
  20. 6 0
      frontend/src/i18n/locales/es.ts
  21. 6 0
      frontend/src/i18n/locales/fr.ts
  22. 6 0
      frontend/src/i18n/locales/it.ts
  23. 6 0
      frontend/src/i18n/locales/ja.ts
  24. 7 1
      frontend/src/i18n/locales/ko.ts
  25. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 6 0
      frontend/src/i18n/locales/ru.ts
  27. 6 0
      frontend/src/i18n/locales/tr.ts
  28. 6 0
      frontend/src/i18n/locales/uk.ts
  29. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 73 5
      frontend/src/pages/PrintersPage.tsx
  32. 0 0
      static/assets/index-DL1B9Y3L.js
  33. 1 1
      static/index.html

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 63 - 9
backend/app/api/routes/printers.py

@@ -32,6 +32,7 @@ from backend.app.schemas.printer import (
     AMSTray,
     AMSTray,
     AMSUnit,
     AMSUnit,
     DiagnosticRequest,
     DiagnosticRequest,
+    ExtruderSlotResponse,
     FilaSwitchResponse,
     FilaSwitchResponse,
     HmsActionBody,
     HmsActionBody,
     HMSErrorResponse,
     HMSErrorResponse,
@@ -805,6 +806,17 @@ async def get_printer_status(
         # empty anyway, but gating it keeps a stale binding from outliving the
         # empty anyway, but gating it keeps a stale binding from outliving the
         # accessory being unplugged.
         # accessory being unplugged.
         ams_switch_inlet=(dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
         ams_switch_inlet=(dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
+        # Which hotend holds which slot. Same first-load reasoning as
+        # fila_switch.ready below — the AMS slot menu reads it to decide which
+        # hotend it may offer, and an empty default would offer both.
+        extruder_slots={
+            str(ext_id): ExtruderSlotResponse(
+                ams_id=slot.ams_id,
+                slot_id=slot.slot_id,
+                has_filament=slot.has_filament,
+            )
+            for ext_id, slot in state.extruder_slots.items()
+        },
         tray_now=tray_now,
         tray_now=tray_now,
         # Runout guidance (#2587): resolve the firmware's target/previous slot to a
         # Runout guidance (#2587): resolve the firmware's target/previous slot to a
         # global tray ID, but only while PAUSED — the moment the operator needs it.
         # global tray ID, but only while PAUSED — the moment the operator needs it.
@@ -854,6 +866,12 @@ async def get_printer_status(
                 out_extruders=list(state.fila_switch.out_extruders),
                 out_extruders=list(state.fila_switch.out_extruders),
                 stat=state.fila_switch.stat,
                 stat=state.fila_switch.stat,
                 info=state.fila_switch.info,
                 info=state.fila_switch.info,
+                # Must be computed here as well as in printer_state_to_dict: this
+                # is what the page gets on its first load, and the WebSocket only
+                # corrects it on the next push. Defaulting it to False instead
+                # would tell every correctly set-up machine that its switch is
+                # not set up, until a push happened to arrive.
+                ready=all(str(u.id) in state.ams_switch_inlet for u in ams_units),
             )
             )
             if state.fila_switch and state.fila_switch.installed
             if state.fila_switch and state.fila_switch.installed
             else None
             else None
@@ -4317,10 +4335,32 @@ 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)
 
 
 
 
+# 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
+# a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
+_LOAD_TRAY_ID_ERROR = "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+
+
+def _is_valid_load_tray_id(tray_id: int) -> bool:
+    """Whether ``tray_id`` names a slot the load/unload commands can address."""
+    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in (254, 255)
+
+
 @router.post("/{printer_id}/ams/load")
 @router.post("/{printer_id}/ams/load")
 async def ams_load(
 async def ams_load(
     printer_id: int,
     printer_id: int,
     tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
     tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
+    extruder_id: int | None = Query(
+        None,
+        ge=0,
+        le=1,
+        description=(
+            "Hotend to feed: 0 = right/main, 1 = left/deputy. Only meaningful "
+            "on a printer with a Filament Track Switch fitted, where the AMS is "
+            "bound to a switch inlet rather than a hotend and the firmware "
+            "cannot work the target out for itself. Omit on every other printer "
+            "— the field is absent from BambuStudio's own command there too."
+        ),
+    ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -4331,12 +4371,8 @@ async def ams_load(
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     - 255: Ext-R on dual-nozzle H2D
     """
     """
-    # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
-    # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
-    if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
-        raise HTTPException(
-            400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
-        )
+    if not _is_valid_load_tray_id(tray_id):
+        raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
 
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -4347,7 +4383,7 @@ async def ams_load(
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
-    success = client.ams_load_filament(tray_id)
+    success = client.ams_load_filament(tray_id, extruder_id=extruder_id)
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send load command")
         raise HTTPException(500, "Failed to send load command")
 
 
@@ -4363,10 +4399,23 @@ async def ams_load(
 @router.post("/{printer_id}/ams/unload")
 @router.post("/{printer_id}/ams/unload")
 async def ams_unload(
 async def ams_unload(
     printer_id: int,
     printer_id: int,
+    tray_id: int | None = Query(
+        None,
+        description=(
+            "Tray ID of the slot to unload, same encoding as the load endpoint. "
+            "Identifies which hotend to unload on a dual-nozzle printer, where "
+            "both can hold filament at once and the printer's single tray_now "
+            "field names only one of them. Omit to unload whatever tray_now "
+            "names, which is the only option a single-nozzle printer has."
+        ),
+    ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Unload the currently loaded filament."""
+    """Unload the filament in a given slot, or the currently loaded one."""
+    if tray_id is not None and not _is_valid_load_tray_id(tray_id):
+        raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
+
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
@@ -4376,8 +4425,13 @@ async def ams_unload(
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
-    success = client.ams_unload_filament()
+    success = client.ams_unload_filament(tray_id)
     if not success:
     if not success:
+        # A named slot that no hotend is fed from is a no-op, not a fault: the
+        # menu is per-slot and the operator may well have clicked one that is
+        # not loaded. Say so instead of returning a 500 they cannot act on.
+        if tray_id is not None:
+            raise HTTPException(409, "No hotend is loaded from that slot")
         raise HTTPException(500, "Failed to send unload command")
         raise HTTPException(500, "Failed to send unload command")
 
 
     return {"success": True, "message": "Unloading filament"}
     return {"success": True, "message": "Unloading filament"}

+ 13 - 0
backend/app/main.py

@@ -1551,6 +1551,19 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     fts_key = (
     fts_key = (
         state.fila_switch.installed if state.fila_switch else False,
         state.fila_switch.installed if state.fila_switch else False,
         tuple(sorted(state.ams_switch_inlet.items())),
         tuple(sorted(state.ams_switch_inlet.items())),
+        # Which hotend holds which slot. Unlike the two above this does move
+        # mid-print, on every filament change — but only between discrete slots,
+        # so it adds a push per toolchange, not a stream. The AMS slot menu needs
+        # it live: it decides which hotend the Load dialog may offer and whether
+        # Unload has anything to act on.
+        tuple(
+            sorted(
+                ((ext, slot.ams_id, slot.slot_id, slot.has_filament) for ext, slot in state.extruder_slots.items()),
+                # Sort on the extruder id alone: the other members are nullable
+                # and comparing None with an int raises.
+                key=lambda entry: entry[0],
+            )
+        ),
     )
     )
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"

+ 23 - 0
backend/app/schemas/printer.py

@@ -276,6 +276,25 @@ class FilaSwitchResponse(BaseModel):
     out_extruders: list[int] = []
     out_extruders: list[int] = []
     stat: int = 0
     stat: int = 0
     info: int = 0
     info: int = 0
+    # Whether the switch is set up: every AMS bound to one of its two inlets.
+    # A load cannot be routed until it is, so the UI blocks on this rather than
+    # sending a command the firmware will drop.
+    ready: bool = False
+
+
+class ExtruderSlotResponse(BaseModel):
+    """Which AMS slot one hotend is currently fed from.
+
+    From ``device.extruder.info[i].snow``. Needed because ``tray_now`` is a
+    single printer-wide value: on a dual-nozzle machine with both hotends
+    loaded it names only one of them, so it cannot say which hotend holds a
+    given slot.
+    """
+
+    # None when the hotend is not fed from any slot.
+    ams_id: int | None = None
+    slot_id: int | None = None
+    has_filament: bool = False
 
 
 
 
 class PrintOptionsResponse(BaseModel):
 class PrintOptionsResponse(BaseModel):
@@ -353,6 +372,10 @@ class PrinterStatus(BaseModel):
     # an FTS-bound AMS reaches BOTH nozzles, so it has no entry in
     # an FTS-bound AMS reaches BOTH nozzles, so it has no entry in
     # ams_extruder_map and must not be labelled left or right.
     # ams_extruder_map and must not be labelled left or right.
     ams_switch_inlet: dict[str, str] = {}
     ams_switch_inlet: dict[str, str] = {}
+    # Which AMS slot each hotend is fed from, keyed by extruder id as a string
+    # ("0" = right/main, "1" = left/deputy). Empty on printers that do not
+    # report ``device.extruder.info``.
+    extruder_slots: dict[str, ExtruderSlotResponse] = {}
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
     tray_now: int = 255
     # Runout / filament-replacement guidance (#2587). Populated only while the
     # Runout / filament-replacement guidance (#2587). Populated only while the

+ 145 - 7
backend/app/services/bambu_mqtt.py

@@ -749,6 +749,37 @@ class FilaSwitchState:
         return (raw >> 8) & 0xFF, raw & 0xFF
         return (raw >> 8) & 0xFF, raw & 0xFF
 
 
 
 
+# ``snow``/``spre``/``star`` all use this sentinel for "nothing here". Studio
+# only special-cases it on single-extruder machines, but 0xFFFF decodes to AMS
+# 255 slot 255 and slot 255 is not a real slot on any machine, so treating it
+# as empty everywhere is strictly safer than reading it as the external spool.
+_EXTRUDER_SLOT_EMPTY = 0xFFFF
+
+
+@dataclass
+class ExtruderSlot:
+    """Which AMS slot an extruder is currently fed from.
+
+    Parsed from ``print.device.extruder.info[i]`` — ``snow`` is snow-encoded
+    exactly like ``fila_switch.in`` (bits 8-15 = AMS id, bits 0-7 = slot), and
+    bit 1 of ``info`` says whether the extruder actually holds filament. Field
+    semantics from BambuStudio's ``DevExtruderSystem::ParseExtruderInfo``.
+
+    ``state.tray_now`` cannot answer this: it is a single value for the whole
+    printer, so on a dual-nozzle machine with both hotends loaded it names only
+    one of them. Unloading a specific slot needs to know which extruder is
+    holding it, which is what this is for.
+    """
+
+    ams_id: int | None = None
+    slot_id: int | None = None
+    has_filament: bool = False
+
+    def holds(self, ams_id: int, slot_id: int) -> bool:
+        """True when this extruder is fed from exactly ``(ams_id, slot_id)``."""
+        return self.ams_id == ams_id and self.slot_id == slot_id
+
+
 @dataclass
 @dataclass
 class PrintOptions:
 class PrintOptions:
     """AI detection and print options from xcam data."""
     """AI detection and print options from xcam data."""
@@ -873,6 +904,11 @@ class PrinterState:
     # Setup" screen. Only populated when an FTS is installed — without one an AMS
     # Setup" screen. Only populated when an FTS is installed — without one an AMS
     # is bound to an extruder instead and this stays empty. See FilaSwitchState.
     # is bound to an extruder instead and this stays empty. See FilaSwitchState.
     ams_switch_inlet: dict = field(default_factory=dict)
     ams_switch_inlet: dict = field(default_factory=dict)
+    # Which AMS slot each extruder is fed from: {extruder_id: ExtruderSlot}.
+    # Only populated by printers that report ``device.extruder.info`` (H2/X2
+    # series). Empty elsewhere, which every reader has to tolerate — see
+    # ExtruderSlot for why tray_now cannot stand in for it.
+    extruder_slots: dict = field(default_factory=dict)
     # Plate dispatched by Bambuddy for the current print. Some firmware versions
     # Plate dispatched by Bambuddy for the current print. Some firmware versions
     # (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
     # (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
     # regex used to derive the plate number from the path always falls back to
     # regex used to derive the plate number from the path always falls back to
@@ -2836,6 +2872,44 @@ class BambuMQTTClient:
             info=int(fs_data.get("info", 0) or 0),
             info=int(fs_data.get("info", 0) or 0),
         )
         )
 
 
+    def _parse_extruder_slots(self, data: dict) -> None:
+        """Read which AMS slot each extruder is fed from — ``device.extruder.info``.
+
+        Absent on printers that do not report the block, in which case the
+        previous answer is kept rather than cleared: a partial payload carrying
+        only temperatures must not look like "both hotends are now empty".
+        """
+        device = data.get("device")
+        if not isinstance(device, dict):
+            return
+        info = device.get("extruder", {}).get("info") if isinstance(device.get("extruder"), dict) else None
+        if not isinstance(info, list) or not info:
+            return
+
+        slots: dict[int, ExtruderSlot] = {}
+        for entry in info:
+            if not isinstance(entry, dict) or "id" not in entry:
+                continue
+            try:
+                ext_id = int(entry["id"])
+                snow = int(entry.get("snow", _EXTRUDER_SLOT_EMPTY))
+                flags = int(entry.get("info", 0) or 0)
+            except (TypeError, ValueError):
+                continue
+            if snow == _EXTRUDER_SLOT_EMPTY or snow < 0:
+                ams_id = slot_id = None
+            else:
+                ams_id = (snow >> 8) & 0xFF
+                slot_id = snow & 0xFF
+            slots[ext_id] = ExtruderSlot(
+                ams_id=ams_id,
+                slot_id=slot_id,
+                has_filament=bool(flags & 0b10),
+            )
+
+        if slots:
+            self.state.extruder_slots = slots
+
     def _handle_ams_data(self, ams_data):
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
         """Handle AMS data changes for Spoolman integration.
 
 
@@ -4178,6 +4252,7 @@ class BambuMQTTClient:
         # it first. Repeated here so _update_state stays a complete "absorb this
         # it first. Repeated here so _update_state stays a complete "absorb this
         # payload" step for any other caller; re-parsing the same block is free.
         # payload" step for any other caller; re-parsing the same block is free.
         self._parse_fila_switch(data)
         self._parse_fila_switch(data)
+        self._parse_extruder_slots(data)
 
 
         if "bed_temper" in data:
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
             temps["bed"] = float(data["bed_temper"])
@@ -7265,7 +7340,16 @@ class BambuMQTTClient:
             tray_id: Global tray ID — 0..15 for AMS slots, 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),
                 (single-external printers and Ext-L on dual-nozzle H2D),
                 255 for Ext-R on dual-nozzle H2D.
                 255 for Ext-R on dual-nozzle H2D.
-            extruder_id: Unused - kept for API compatibility
+            extruder_id: Which hotend to feed (0 = right/main, 1 = left/deputy).
+                Sent only when given, matching BambuStudio: ``extruder_id`` is
+                an optional field on ``ams_change_filament``
+                (``DeviceManager::command_ams_change_filament``) and Studio
+                omits it unless a Filament Track Switch is installed. Without a
+                switch the firmware derives the hotend from the AMS's own
+                extruder binding and an explicit value is redundant; *with* one
+                every AMS reports 0xE and is bound to a switch inlet instead, so
+                the firmware has nothing to derive from and the load silently
+                does nothing until we name the hotend.
 
 
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
@@ -7320,6 +7404,8 @@ class BambuMQTTClient:
                 "tar_temp": tar_temp,
                 "tar_temp": tar_temp,
             }
             }
         }
         }
+        if extruder_id is not None:
+            command["print"]["extruder_id"] = int(extruder_id)
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
         logger.info("[%s] Publishing ams_change_filament command: %s", self.serial_number, command_json)
         logger.info("[%s] Publishing ams_change_filament command: %s", self.serial_number, command_json)
@@ -7334,8 +7420,21 @@ class BambuMQTTClient:
 
 
         return True
         return True
 
 
-    def ams_unload_filament(self) -> bool:
-        """Unload the currently loaded filament.
+    def ams_unload_filament(self, tray_id: int | None = None) -> bool:
+        """Unload filament, optionally naming the slot to unload.
+
+        Args:
+            tray_id: Global tray ID of the slot being unloaded. When given, the
+                command is addressed to that slot's AMS and is only sent if an
+                extruder is actually fed from it — BambuStudio does the same
+                (``StatusPanel::on_ams_unload`` walks the extruders and sends
+                nothing when none matches). When omitted, the pre-existing
+                behaviour is kept: unload whatever ``tray_now`` names.
+
+        ``tray_now`` is a single value for the whole printer, so on a dual-nozzle
+        machine with both hotends loaded it names only one of them and an
+        unaddressed unload picks that one regardless of which slot the operator
+        clicked. Passing the slot is what makes the two hotends distinguishable.
 
 
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
@@ -7346,15 +7445,54 @@ class BambuMQTTClient:
 
 
         # Get the currently loaded tray info
         # Get the currently loaded tray info
         tray_now = self.state.tray_now
         tray_now = self.state.tray_now
-        logger.info("[%s] Unload requested, tray_now=%s", self.serial_number, tray_now)
+        source_tray = tray_now if tray_id is None else tray_id
+        logger.info("[%s] Unload requested, tray_now=%s, tray_id=%s", self.serial_number, tray_now, tray_id)
 
 
         # Determine source ams_id for the unload command
         # Determine source ams_id for the unload command
-        if tray_now == 255 or tray_now == 254:
+        if source_tray == 255 or source_tray == 254:
             ams_id = 255  # No filament or external spool
             ams_id = 255  # No filament or external spool
-        elif (_a2l := a2l_lite_wire_ids(tray_now // 4, tray_now)) is not None:
+        elif (_a2l := a2l_lite_wire_ids(source_tray // 4, source_tray)) is not None:
             ams_id = _a2l[0]  # A2L AMS-Lite: normalised 6 -> physical 16
             ams_id = _a2l[0]  # A2L AMS-Lite: normalised 6 -> physical 16
         else:
         else:
-            ams_id = tray_now // 4  # Source AMS
+            ams_id = source_tray // 4  # Source AMS
+
+        # Refuse an addressed unload of a slot no hotend is holding — but only on
+        # a printer that has more than one hotend, which is the only case the
+        # check exists for. With one hotend there is nothing to disambiguate:
+        # tray_now already names the loaded slot exactly, and running the check
+        # anyway would stake unload on `snow` meaning ams*4+slot there too. It
+        # very likely does, but single-nozzle machines do report the block —
+        # BambuStudio has a dedicated branch for `m_total_extder_count == 1` and
+        # an X1C on the maintainer's own network sends `device.extruder` — and
+        # nobody has read a single-nozzle `snow` off the wire. Guessing wrong
+        # would 409 every unload on every X1C, P1S and A1.
+        #
+        # Gated on the runtime flag rather than on len(extruder_slots), which is
+        # rebuilt from each payload's array and would flip the check off for any
+        # frame that carried a short one; and deliberately not on
+        # ``is_dual_nozzle_model``, whose model-name fallback reports at least
+        # one single-nozzle machine as dual (#1386) — the false positive there is
+        # exactly the case this gate exists to keep out.
+        #
+        # The external spool is excluded for a different reason: 254/255 are not
+        # ams*4+slot, so the local-slot arithmetic below cannot describe them.
+        if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
+            local_slot = _a2l[1] if (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None else tray_id % 4
+            holder = next(
+                (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
+                None,
+            )
+            if holder is None:
+                logger.info(
+                    "[%s] Unload skipped: no extruder is fed from AMS %s slot %s",
+                    self.serial_number,
+                    ams_id,
+                    local_slot,
+                )
+                return False
+            logger.info(
+                "[%s] Unloading AMS %s slot %s from extruder %s", self.serial_number, ams_id, local_slot, holder
+            )
 
 
         # Command format from BambuStudio traffic capture:
         # Command format from BambuStudio traffic capture:
         # - No extruder_id field
         # - No extruder_id field

+ 28 - 0
backend/app/services/printer_manager.py

@@ -1590,6 +1590,22 @@ def printer_state_to_dict(
                 "out_extruders": list(state.fila_switch.out_extruders),
                 "out_extruders": list(state.fila_switch.out_extruders),
                 "stat": state.fila_switch.stat,
                 "stat": state.fila_switch.stat,
                 "info": state.fila_switch.info,
                 "info": state.fila_switch.info,
+                # Mirrors BambuStudio's DevFilaSwitch::IsReady — every AMS has to
+                # be bound to an inlet before the switch can route anything. Until
+                # the operator has done that on the printer's Manual AMS Setup
+                # screen, Studio refuses a load outright rather than sending a
+                # command the firmware cannot act on, and so do we.
+                # An empty AMS list is "ready", as it is in Studio: there is then
+                # no slot to load from, so nothing can reach the check anyway, and
+                # reporting not-ready would only mean a confusing toast on a
+                # payload that has not carried the AMS block yet.
+                #
+                # An AMS still reporting a real extruder id rather than 0xE has no
+                # inlet entry, so a machine with one hard-wired unit reads as not
+                # ready. That looks harsh but is exactly Studio's own rule —
+                # IsReady() requires a switcher position on *every* AMS, and only
+                # the 0xE branch ever sets one (DevFilaSystem.cpp:596-615).
+                "ready": all(str(u["id"]) in state.ams_switch_inlet for u in ams_units),
             }
             }
             if state.fila_switch and state.fila_switch.installed
             if state.fila_switch and state.fila_switch.installed
             else None
             else None
@@ -1597,6 +1613,18 @@ def printer_state_to_dict(
         # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Gated on the accessory
         # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Gated on the accessory
         # so a stale binding cannot outlive it being unplugged.
         # so a stale binding cannot outlive it being unplugged.
         "ams_switch_inlet": (dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
         "ams_switch_inlet": (dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
+        # Which AMS slot each hotend is fed from: {extruder_id: {...}}. Travels on
+        # the WebSocket for the same reason as fila_switch above — the frontend
+        # shallow-merges pushes over its cached status, so an absent field keeps a
+        # stale value forever. Empty on printers that do not report it.
+        "extruder_slots": {
+            str(ext_id): {
+                "ams_id": slot.ams_id,
+                "slot_id": slot.slot_id,
+                "has_filament": slot.has_filament,
+            }
+            for ext_id, slot in state.extruder_slots.items()
+        },
         # WiFi signal strength
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
         "wifi_signal": state.wifi_signal,
         "wired_network": state.wired_network,
         "wired_network": state.wired_network,

+ 142 - 4
backend/tests/integration/test_printers_api.py

@@ -659,6 +659,69 @@ class TestPrintersAPI:
         assert result["fila_switch"]["stat"] == 0
         assert result["fila_switch"]["stat"] == 0
         assert result["fila_switch"]["info"] == 2
         assert result["fila_switch"]["info"] == 2
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_reports_switch_readiness_on_the_first_load(self, async_client: AsyncClient, printer_factory):
+        """``ready`` has to be computed by the REST route, not just the WebSocket.
+
+        This response is what the page has before any push arrives. Leaving the
+        field at its default would tell a correctly set-up machine that its
+        switch is not set up, and the AMS menu refuses Load on that.
+        """
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
+
+        printer = await printer_factory()
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "IDLE"
+        state.fila_switch = FilaSwitchState(installed=True)
+        state.raw_data = {"ams": [{"id": "0", "tray": []}, {"id": "1", "tray": []}]}
+        state.ams_switch_inlet = {"0": "A"}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+
+            unbound = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+            state.ams_switch_inlet = {"0": "A", "1": "B"}
+            bound = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert unbound.json()["fila_switch"]["ready"] is False
+        assert bound.json()["fila_switch"]["ready"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_reports_which_hotend_holds_which_slot(self, async_client: AsyncClient, printer_factory):
+        """Also needed on the first load: it decides which hotend Load may offer."""
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_mqtt import ExtruderSlot, PrinterState
+
+        printer = await printer_factory()
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "IDLE"
+        state.extruder_slots = {
+            0: ExtruderSlot(ams_id=0, slot_id=2, has_filament=True),
+            1: ExtruderSlot(),
+        }
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.json()["extruder_slots"] == {
+            "0": {"ams_id": 0, "slot_id": 2, "has_filament": True},
+            "1": {"ams_id": None, "slot_id": None, "has_filament": False},
+        }
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
     async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
@@ -1324,7 +1387,7 @@ class TestAMSLoadUnloadAPI:
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
 
 
             assert response.status_code == 200
             assert response.status_code == 200
-            mock_client.ams_load_filament.assert_called_once_with(5)
+            mock_client.ams_load_filament.assert_called_once_with(5, extruder_id=None)
             assert "AMS 1" in response.json()["message"]
             assert "AMS 1" in response.json()["message"]
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -1342,7 +1405,7 @@ class TestAMSLoadUnloadAPI:
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
 
 
             assert response.status_code == 200
             assert response.status_code == 200
-            mock_client.ams_load_filament.assert_called_once_with(254)
+            mock_client.ams_load_filament.assert_called_once_with(254, extruder_id=None)
             assert "external" in response.json()["message"].lower()
             assert "external" in response.json()["message"].lower()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -1360,7 +1423,7 @@ class TestAMSLoadUnloadAPI:
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
 
 
             assert response.status_code == 200
             assert response.status_code == 200
-            mock_client.ams_load_filament.assert_called_once_with(255)
+            mock_client.ams_load_filament.assert_called_once_with(255, extruder_id=None)
             assert "Ext-R" in response.json()["message"]
             assert "Ext-R" in response.json()["message"]
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -1379,6 +1442,33 @@ class TestAMSLoadUnloadAPI:
             assert response.status_code == 500
             assert response.status_code == 500
             assert "failed" in response.json()["detail"].lower()
             assert "failed" in response.json()["detail"].lower()
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_forwards_the_chosen_hotend(self, async_client: AsyncClient, printer_factory):
+        """A printer with a Filament Track Switch has to name the hotend to feed."""
+        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&extruder_id=1")
+
+            assert response.status_code == 200
+            mock_client.ams_load_filament.assert_called_once_with(5, extruder_id=1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_rejects_a_hotend_that_does_not_exist(self, async_client: AsyncClient, printer_factory):
+        """Only 0 and 1 are real hotends; anything else is a client bug."""
+        printer = await printer_factory(name="P")
+
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5&extruder_id=2")
+
+        assert response.status_code == 422
+
     # ── unload ───────────────────────────────────────────────────────────────
     # ── unload ───────────────────────────────────────────────────────────────
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -1414,9 +1504,57 @@ class TestAMSLoadUnloadAPI:
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
 
 
             assert response.status_code == 200
             assert response.status_code == 200
-            mock_client.ams_unload_filament.assert_called_once_with()
+            mock_client.ams_unload_filament.assert_called_once_with(None)
             assert response.json()["success"] is True
             assert response.json()["success"] is True
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_forwards_the_slot(self, async_client: AsyncClient, printer_factory):
+        """The slot is what tells a dual-nozzle printer which hotend to unload."""
+        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?tray_id=2")
+
+            assert response.status_code == 200
+            mock_client.ams_unload_filament.assert_called_once_with(2)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_of_an_unloaded_slot_is_a_conflict_not_a_fault(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Clicking Unload on an idle slot is a no-op the operator can understand.
+
+        A 500 would read as a broken printer; the menu is per-slot and picking a
+        slot no hotend is fed from is an ordinary mistake.
+        """
+        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?tray_id=2")
+
+            assert response.status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unload_rejects_an_invalid_slot(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P")
+
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload?tray_id=99")
+
+        assert response.status_code == 400
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
     async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):

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

@@ -6117,6 +6117,158 @@ class TestAmsLoadFilamentEncoding:
         assert mqtt_client.ams_load_filament(0) is False
         assert mqtt_client.ams_load_filament(0) is False
         mqtt_client._client.publish.assert_not_called()
         mqtt_client._client.publish.assert_not_called()
 
 
+    def test_extruder_id_is_absent_unless_asked_for(self, mqtt_client):
+        """The field must not appear on a printer that did not ask for it.
+
+        BambuStudio only sets it when a Filament Track Switch is installed
+        (``command_ams_change_filament`` takes it as std::optional). Everywhere
+        else the firmware derives the hotend from the AMS binding, and sending a
+        value would be us guessing at something it already knows.
+        """
+        assert mqtt_client.ams_load_filament(5) is True
+        assert "extruder_id" not in self._published(mqtt_client)["print"]
+
+    @pytest.mark.parametrize("extruder_id", [0, 1])
+    def test_extruder_id_rides_along_when_given(self, mqtt_client, extruder_id):
+        """With a switch fitted the hotend has to be named, or nothing happens."""
+        assert mqtt_client.ams_load_filament(5, extruder_id=extruder_id) is True
+        assert self._published(mqtt_client)["print"]["extruder_id"] == extruder_id
+
+
+class TestAmsUnloadFilamentTargeting:
+    """Which hotend an unload acts on, for dual-nozzle printers.
+
+    ``tray_now`` is a single printer-wide value, so with both hotends loaded it
+    names only one of them. BambuStudio addresses the unload by walking its
+    extruders for the one fed from the clicked slot
+    (``StatusPanel::on_ams_unload``) and publishes nothing when none matches.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient, ExtruderSlot
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        # The check only runs once the printer has reported two extruders; a
+        # single-nozzle machine has nothing to disambiguate.
+        client._is_dual_nozzle = True
+        # Right hotend fed from AMS 1 slot 1 (global tray 5), left from AMS 0
+        # slot 2 (global tray 2). tray_now names only the first of the two.
+        client.state.tray_now = 5
+        client.state.extruder_slots = {
+            0: ExtruderSlot(ams_id=1, slot_id=1, has_filament=True),
+            1: ExtruderSlot(ams_id=0, slot_id=2, has_filament=True),
+        }
+        return client
+
+    @staticmethod
+    def _published(client) -> dict:
+        last_call = client._client.publish.call_args_list[-1]
+        _topic, payload, *_ = last_call.args
+        return json.loads(payload)
+
+    def test_an_addressed_unload_targets_that_slots_ams(self, mqtt_client):
+        """Tray 2 is on the left hotend, which tray_now does not name."""
+        assert mqtt_client.ams_unload_filament(2) is True
+        cmd = self._published(mqtt_client)["print"]
+        assert cmd["ams_id"] == 0
+        assert cmd["slot_id"] == 255
+        assert cmd["target"] == 255
+
+    def test_an_unaddressed_unload_still_follows_tray_now(self, mqtt_client):
+        """Single-nozzle printers have no slot to pass; that path is unchanged."""
+        assert mqtt_client.ams_unload_filament() is True
+        assert self._published(mqtt_client)["print"]["ams_id"] == 1
+
+    def test_a_slot_no_hotend_holds_publishes_nothing(self, mqtt_client):
+        """Clicking Unload on an idle slot is a no-op, not a command."""
+        assert mqtt_client.ams_unload_filament(7) is False
+        mqtt_client._client.publish.assert_not_called()
+
+    def test_the_check_is_skipped_when_the_printer_reports_no_extruder_slots(self, mqtt_client):
+        """An empty map means "did not say", never "no hotend holds it".
+
+        Every printer outside the H2/X2 series omits ``device.extruder.info``,
+        and reading that silence as a negative would break unload on all of them.
+        """
+        mqtt_client.state.extruder_slots = {}
+        assert mqtt_client.ams_unload_filament(7) is True
+        assert self._published(mqtt_client)["print"]["ams_id"] == 1
+
+    def test_a_single_nozzle_printer_is_never_gated_on_snow(self, mqtt_client):
+        """One hotend means tray_now is already unambiguous.
+
+        Single-nozzle machines do report ``device.extruder.info`` — BambuStudio
+        has a branch for it and an X1C sends the block — but nobody has read a
+        single-nozzle ``snow`` off the wire. Running the match there would stake
+        every X1C/P1S/A1 unload on an unverified encoding, so it is not run.
+        """
+        mqtt_client._is_dual_nozzle = False
+
+        assert mqtt_client.ams_unload_filament(7) is True
+        assert self._published(mqtt_client)["print"]["ams_id"] == 1
+
+    def test_the_external_spool_is_not_matched_against_ams_slots(self, mqtt_client):
+        """254/255 are not ams*4+slot, so the slot arithmetic cannot describe them."""
+        assert mqtt_client.ams_unload_filament(254) is True
+        assert self._published(mqtt_client)["print"]["ams_id"] == 255
+
+
+class TestParseExtruderSlots:
+    """Decoding ``device.extruder.info`` into per-hotend loaded slots."""
+
+    @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",
+        )
+        client._client = MagicMock()
+        return client
+
+    def test_snow_decodes_to_ams_and_slot(self, mqtt_client):
+        """snow is bits 8-15 = AMS id, bits 0-7 = slot — same shape as fila_switch.in."""
+        mqtt_client._parse_extruder_slots({"device": {"extruder": {"info": [{"id": 0, "snow": 0x0102, "info": 0b10}]}}})
+
+        slot = mqtt_client.state.extruder_slots[0]
+        assert (slot.ams_id, slot.slot_id) == (1, 2)
+        assert slot.has_filament is True
+
+    def test_the_empty_sentinel_is_not_read_as_a_slot(self, mqtt_client):
+        """0xFFFF would otherwise decode to the nonexistent AMS 255 slot 255."""
+        mqtt_client._parse_extruder_slots({"device": {"extruder": {"info": [{"id": 1, "snow": 0xFFFF, "info": 0}]}}})
+
+        slot = mqtt_client.state.extruder_slots[1]
+        assert slot.ams_id is None and slot.slot_id is None
+        assert slot.has_filament is False
+
+    def test_a_payload_without_the_block_keeps_the_last_answer(self, mqtt_client):
+        """A temperatures-only frame must not read as "both hotends are empty"."""
+        mqtt_client._parse_extruder_slots({"device": {"extruder": {"info": [{"id": 0, "snow": 0x0003, "info": 0b10}]}}})
+        mqtt_client._parse_extruder_slots({"bed_temper": 60})
+
+        assert mqtt_client.state.extruder_slots[0].ams_id == 0
+
+    def test_holds_answers_for_one_slot_only(self, mqtt_client):
+        from backend.app.services.bambu_mqtt import ExtruderSlot
+
+        assert ExtruderSlot(ams_id=1, slot_id=2).holds(1, 2) is True
+        assert ExtruderSlot(ams_id=1, slot_id=2).holds(1, 3) is False
+        assert ExtruderSlot().holds(0, 0) is False
+
 
 
 class TestAmsFilamentSettingExternalSpoolEncoding:
 class TestAmsFilamentSettingExternalSpoolEncoding:
     """Encoding of `ams_filament_setting` / `reset_ams_slot` for the external spool.
     """Encoding of `ams_filament_setting` / `reset_ams_slot` for the external spool.

+ 46 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -849,6 +849,7 @@ class TestPrinterStateToDict:
         state.raw_data = {}
         state.raw_data = {}
         state.stg_cur = -1  # No calibration stage active
         state.stg_cur = -1  # No calibration stage active
         state.firmware_version = None
         state.firmware_version = None
+        state.extruder_slots = {}
         return state
         return state
 
 
     def test_fila_switch_and_inlets_ride_the_websocket(self, mock_state):
     def test_fila_switch_and_inlets_ride_the_websocket(self, mock_state):
@@ -874,8 +875,53 @@ class TestPrinterStateToDict:
             "out_extruders": [1, 0],
             "out_extruders": [1, 0],
             "stat": 0,
             "stat": 0,
             "info": 1,
             "info": 1,
+            "ready": True,
         }
         }
 
 
+    def test_a_switch_is_not_ready_until_every_ams_has_an_inlet(self, mock_state):
+        """An AMS with no inlet binding means the switch cannot route a load.
+
+        The load dialog blocks on this rather than publishing a command the
+        firmware will drop, the same way BambuStudio's DevFilaSwitch::IsReady
+        gates its own dialog.
+        """
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        mock_state.fila_switch = FilaSwitchState(installed=True)
+        mock_state.raw_data = {"ams": [{"id": "0", "tray": []}, {"id": "1", "tray": []}]}
+        mock_state.ams_switch_inlet = {"0": "A"}
+
+        assert printer_state_to_dict(mock_state)["fila_switch"]["ready"] is False
+
+        mock_state.ams_switch_inlet = {"0": "A", "1": "B"}
+
+        assert printer_state_to_dict(mock_state)["fila_switch"]["ready"] is True
+
+    def test_extruder_slots_ride_the_websocket(self, mock_state):
+        """Which hotend holds which slot has to travel with every push.
+
+        The AMS slot menu decides from it which hotend the load dialog may
+        offer, and tray_now cannot stand in: it is one value for the whole
+        printer, so with both hotends loaded it names only one of them.
+        """
+        from backend.app.services.bambu_mqtt import ExtruderSlot
+
+        mock_state.extruder_slots = {
+            0: ExtruderSlot(ams_id=0, slot_id=2, has_filament=True),
+            1: ExtruderSlot(ams_id=None, slot_id=None, has_filament=False),
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["extruder_slots"] == {
+            "0": {"ams_id": 0, "slot_id": 2, "has_filament": True},
+            "1": {"ams_id": None, "slot_id": None, "has_filament": False},
+        }
+
+    def test_extruder_slots_are_empty_when_unreported(self, mock_state):
+        """Printers outside the H2/X2 series never send the block."""
+        assert printer_state_to_dict(mock_state)["extruder_slots"] == {}
+
     def test_inlets_are_dropped_without_a_switch(self, mock_state):
     def test_inlets_are_dropped_without_a_switch(self, mock_state):
         """A binding must not outlive the accessory being unplugged."""
         """A binding must not outlive the accessory being unplugged."""
         from backend.app.services.bambu_mqtt import FilaSwitchState
         from backend.app.services.bambu_mqtt import FilaSwitchState

+ 5 - 0
backend/tests/unit/test_printer_kill_switch.py

@@ -83,6 +83,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -174,6 +175,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -252,6 +254,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -280,6 +283,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -362,6 +366,7 @@ async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,

+ 1 - 0
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -106,6 +106,7 @@ def _fake_state(**overrides):
         # printer_state_to_dict gates both of these on.
         # printer_state_to_dict gates both of these on.
         "fila_switch": None,
         "fila_switch": None,
         "ams_switch_inlet": {},
         "ams_switch_inlet": {},
+        "extruder_slots": {},
     }
     }
     base.update(overrides)
     base.update(overrides)
     return SimpleNamespace(**base)
     return SimpleNamespace(**base)

+ 1 - 0
backend/tests/unit/test_printer_offline_notification.py

@@ -60,6 +60,7 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=0,
         cooling_fan_speed=0,
         big_fan1_speed=0,
         big_fan1_speed=0,
         big_fan2_speed=0,
         big_fan2_speed=0,

+ 1 - 0
backend/tests/unit/test_status_broadcast_ams_slot_config.py

@@ -71,6 +71,7 @@ def _state(trays: list[dict]) -> SimpleNamespace:
         # key reads them so a Filament Track Switch rebind reaches the card.
         # key reads them so a Filament Track Switch rebind reaches the card.
         fila_switch=None,
         fila_switch=None,
         ams_switch_inlet={},
         ams_switch_inlet={},
+        extruder_slots={},
         cooling_fan_speed=0,
         cooling_fan_speed=0,
         big_fan1_speed=0,
         big_fan1_speed=0,
         big_fan2_speed=0,
         big_fan2_speed=0,

+ 57 - 0
frontend/src/__tests__/api/amsLoadUnloadParams.test.ts

@@ -0,0 +1,57 @@
+/**
+ * The load / unload endpoints take optional parameters, and "optional" has to
+ * mean absent rather than the string "undefined": the backend validates
+ * extruder_id as 0-1 and tray_id as an addressable slot, so a stray literal is
+ * a 422 on an action that used to work.
+ */
+
+import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { api } from '../../api/client';
+
+let lastUrl = '';
+const server = setupServer(
+  http.post('*/printers/:id/ams/load', ({ request }) => {
+    lastUrl = new URL(request.url).search;
+    return HttpResponse.json({ success: true, message: 'ok' });
+  }),
+  http.post('*/printers/:id/ams/unload', ({ request }) => {
+    lastUrl = new URL(request.url).search;
+    return HttpResponse.json({ success: true, message: 'ok' });
+  })
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => {
+  server.resetHandlers();
+  lastUrl = '';
+});
+afterAll(() => server.close());
+
+describe('AMS load/unload query parameters', () => {
+  it('omits extruder_id entirely when no hotend was chosen', async () => {
+    await api.loadAmsTray(1, 5);
+    expect(lastUrl).toBe('?tray_id=5');
+  });
+
+  it('sends extruder_id when a hotend was chosen', async () => {
+    await api.loadAmsTray(1, 5, 1);
+    expect(lastUrl).toBe('?tray_id=5&extruder_id=1');
+  });
+
+  it('sends extruder_id 0 rather than dropping it as falsy', async () => {
+    await api.loadAmsTray(1, 5, 0);
+    expect(lastUrl).toBe('?tray_id=5&extruder_id=0');
+  });
+
+  it('omits tray_id on an unaddressed unload', async () => {
+    await api.unloadAms(1);
+    expect(lastUrl).toBe('');
+  });
+
+  it('sends tray_id 0 on an unload of the first slot', async () => {
+    await api.unloadAms(1, 0);
+    expect(lastUrl).toBe('?tray_id=0');
+  });
+});

+ 102 - 0
frontend/src/__tests__/components/FeedDirectionModal.test.tsx

@@ -0,0 +1,102 @@
+/**
+ * Tests for FeedDirectionModal — the "which hotend?" question a Filament Track
+ * Switch forces onto every load.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FeedDirectionModal } from '../../components/FeedDirectionModal';
+import type { ExtruderSlot } from '../../api/client';
+
+const empty: ExtruderSlot = { ams_id: null, slot_id: null, has_filament: false };
+
+describe('FeedDirectionModal', () => {
+  const defaultProps = {
+    slotLabel: 'A3',
+    amsId: 0,
+    slotId: 2,
+    extruderSlots: { '0': empty, '1': empty },
+    onConfirm: vi.fn(),
+    onCancel: vi.fn(),
+  };
+
+  afterEach(() => {
+    cleanup();
+    vi.clearAllMocks();
+  });
+
+  it('names the slot being loaded', () => {
+    render(<FeedDirectionModal {...defaultProps} />);
+    expect(screen.getByText('Load A3 to which nozzle?')).toBeInTheDocument();
+  });
+
+  it('will not confirm until a hotend is picked', async () => {
+    const user = userEvent.setup();
+    render(<FeedDirectionModal {...defaultProps} />);
+
+    // No default selection: an unattended Enter must not feed an arbitrary
+    // hotend, which is why BambuStudio starts with neither radio checked.
+    expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
+
+    await user.click(screen.getByRole('button', { name: /Left nozzle/ }));
+
+    expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();
+  });
+
+  it('reports the left hotend as extruder 1 and the right as 0', async () => {
+    const user = userEvent.setup();
+    const onConfirm = vi.fn();
+    render(<FeedDirectionModal {...defaultProps} onConfirm={onConfirm} />);
+
+    await user.click(screen.getByRole('button', { name: /Left nozzle/ }));
+    await user.click(screen.getByRole('button', { name: 'Confirm' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(1);
+
+    await user.click(screen.getByRole('button', { name: /Right nozzle/ }));
+    await user.click(screen.getByRole('button', { name: 'Confirm' }));
+
+    expect(onConfirm).toHaveBeenLastCalledWith(0);
+  });
+
+  it('disables a hotend already fed from this very slot', () => {
+    render(
+      <FeedDirectionModal
+        {...defaultProps}
+        extruderSlots={{
+          '0': { ams_id: 0, slot_id: 2, has_filament: true },
+          '1': empty,
+        }}
+      />
+    );
+
+    expect(screen.getByRole('button', { name: /Right nozzle/ })).toBeDisabled();
+    expect(screen.getByRole('button', { name: /Left nozzle/ })).toBeEnabled();
+  });
+
+  it('leaves both hotends offered when the loaded slot is a different one', () => {
+    render(
+      <FeedDirectionModal
+        {...defaultProps}
+        extruderSlots={{
+          '0': { ams_id: 1, slot_id: 2, has_filament: true },
+          '1': empty,
+        }}
+      />
+    );
+
+    expect(screen.getByRole('button', { name: /Right nozzle/ })).toBeEnabled();
+  });
+
+  it('cancels on Escape', async () => {
+    const onCancel = vi.fn();
+    const user = userEvent.setup();
+    render(<FeedDirectionModal {...defaultProps} onCancel={onCancel} />);
+
+    await user.keyboard('{Escape}');
+
+    expect(onCancel).toHaveBeenCalled();
+  });
+});

+ 29 - 5
frontend/src/api/client.ts

@@ -508,6 +508,20 @@ export interface FilaSwitchState {
   out_extruders: number[];
   out_extruders: number[];
   stat: number;
   stat: number;
   info: number;
   info: number;
+  // Whether every AMS is bound to one of the switch's two inlets. Until it is,
+  // the switch cannot route a load anywhere and the printer has to be set up
+  // first ("Manual AMS Setup" on its screen).
+  ready: boolean;
+}
+
+// Which AMS slot one hotend is currently fed from. ams_id/slot_id are null when
+// the hotend holds nothing. Keyed by extruder id ('0' = right, '1' = left).
+// tray_now cannot answer this — it is one value for the whole printer, so on a
+// dual-nozzle machine it names only one of the two loaded hotends.
+export interface ExtruderSlot {
+  ams_id: number | null;
+  slot_id: number | null;
+  has_filament: boolean;
 }
 }
 
 
 // Which FTS inlet an AMS is plumbed into: 'A' | 'B'.
 // Which FTS inlet an AMS is plumbed into: 'A' | 'B'.
@@ -583,6 +597,9 @@ export interface PrinterStatus {
   // an entry here reaches BOTH nozzles through the switch, which is why it has
   // an entry here reaches BOTH nozzles through the switch, which is why it has
   // no ams_extruder_map entry and must not be badged left or right.
   // no ams_extruder_map entry and must not be badged left or right.
   ams_switch_inlet: Record<string, FtsInlet>;
   ams_switch_inlet: Record<string, FtsInlet>;
+  // Which AMS slot each hotend is fed from, keyed by extruder id as a string.
+  // Empty on printers that don't report it (everything but the H2/X2 series).
+  extruder_slots: Record<string, ExtruderSlot>;
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   tray_now: number;
   tray_now: number;
   // Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
   // Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
@@ -4733,16 +4750,23 @@ export const api = {
     ),
     ),
 
 
   // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
   // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
-  loadAmsTray: (printerId: number, trayId: number) =>
+  // extruderId (0 = right, 1 = left) names the hotend to feed. Pass it only on a
+  // printer with a Filament Track Switch — there the AMS is bound to a switch
+  // inlet rather than a hotend, so the firmware cannot work the target out and
+  // drops the command. Omit it everywhere else, as BambuStudio does.
+  loadAmsTray: (printerId: number, trayId: number, extruderId?: number) =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(
-      `/printers/${printerId}/ams/load?tray_id=${trayId}`,
+      `/printers/${printerId}/ams/load?tray_id=${trayId}` +
+        (extruderId !== undefined ? `&extruder_id=${extruderId}` : ''),
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
 
 
-  // Unload the currently loaded filament.
-  unloadAms: (printerId: number) =>
+  // Unload filament. trayId names the slot to unload, which is what tells a
+  // dual-nozzle printer which of its two hotends to act on; omit it to unload
+  // whatever the printer's single tray_now field names.
+  unloadAms: (printerId: number, trayId?: number) =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(
-      `/printers/${printerId}/ams/unload`,
+      `/printers/${printerId}/ams/unload` + (trayId !== undefined ? `?tray_id=${trayId}` : ''),
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
 
 

+ 139 - 0
frontend/src/components/FeedDirectionModal.tsx

@@ -0,0 +1,139 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Loader2 } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import type { ExtruderSlot } from '../api/client';
+
+// Hotend ids as the firmware numbers them. Mirrors BambuStudio's
+// MAIN_EXTRUDER_ID / DEPUTY_EXTRUDER_ID and `fts_routing.py` on the backend.
+const RIGHT_EXTRUDER = 0;
+const LEFT_EXTRUDER = 1;
+
+interface FeedDirectionModalProps {
+  // Human-readable name of the slot being loaded, e.g. "AMS-A 3".
+  slotLabel: string;
+  // The slot's own coordinates, used to spot the hotend already holding it.
+  amsId: number;
+  slotId: number;
+  extruderSlots: Record<string, ExtruderSlot>;
+  isLoading?: boolean;
+  onConfirm: (extruderId: number) => void;
+  onCancel: () => void;
+}
+
+/**
+ * Asks which hotend to feed a slot into, for printers with a Filament Track
+ * Switch fitted.
+ *
+ * Without a switch each AMS is wired to one hotend and the firmware works the
+ * target out for itself, so the load command carries no hotend at all. With one
+ * fitted, every AMS is bound to a switch *inlet* instead and either hotend is
+ * reachable — the firmware then has nothing to infer from and drops a command
+ * that does not name one. BambuStudio asks the same question in the same place
+ * (`FeedDirectionDialog`), including leaving Confirm disabled until a side is
+ * picked, so there is no default to accidentally act on.
+ */
+export function FeedDirectionModal({
+  slotLabel,
+  amsId,
+  slotId,
+  extruderSlots,
+  isLoading = false,
+  onConfirm,
+  onCancel,
+}: FeedDirectionModalProps) {
+  const { t } = useTranslation();
+  const [selected, setSelected] = useState<number | null>(null);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isLoading) onCancel();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onCancel, isLoading]);
+
+  // A hotend already fed from this exact slot cannot be loaded from it again.
+  const holdsThisSlot = (extruderId: number) => {
+    const slot = extruderSlots[String(extruderId)];
+    return slot?.ams_id === amsId && slot?.slot_id === slotId;
+  };
+
+  const options = [
+    { extruderId: LEFT_EXTRUDER, label: t('printers.ams.feedLeft'), taken: holdsThisSlot(LEFT_EXTRUDER) },
+    { extruderId: RIGHT_EXTRUDER, label: t('printers.ams.feedRight'), taken: holdsThisSlot(RIGHT_EXTRUDER) },
+  ];
+  const selectedIsTaken = options.some(o => o.extruderId === selected && o.taken);
+
+  // Status keeps arriving while the dialog is open, so a hotend can become the
+  // one holding this slot after it was picked — someone loading it from the
+  // printer's own screen. Drop the selection rather than leave Confirm armed on
+  // an option that has since been disabled.
+  useEffect(() => {
+    if (selectedIsTaken) setSelected(null);
+  }, [selectedIsTaken]);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
+      onClick={isLoading ? undefined : onCancel}
+    >
+      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-6">
+          <h3 className="text-lg font-semibold text-white mb-2">
+            {t('printers.ams.feedTitle', { slot: slotLabel })}
+          </h3>
+          <p className="text-bambu-gray text-sm">{t('printers.ams.feedPrompt')}</p>
+
+          <div className="grid grid-cols-2 gap-3 mt-4">
+            {options.map(({ extruderId, label, taken }) => {
+              const isSelected = selected === extruderId;
+              return (
+                <button
+                  key={extruderId}
+                  type="button"
+                  onClick={() => setSelected(extruderId)}
+                  disabled={taken || isLoading}
+                  title={taken ? t('printers.ams.feedAlreadyLoaded') : undefined}
+                  className={`p-3 rounded-lg border text-sm transition-colors ${
+                    taken
+                      ? 'border-transparent bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                      : isSelected
+                        ? 'border-bambu-green/50 bg-bambu-green/10 text-white'
+                        : 'border-transparent bg-bambu-dark text-white hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  <div className="font-medium">{label}</div>
+                  {taken && (
+                    <div className="text-xs mt-1">{t('printers.ams.feedAlreadyLoaded')}</div>
+                  )}
+                </button>
+              );
+            })}
+          </div>
+
+          <div className="flex gap-3 mt-6">
+            <Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
+              {t('common.cancel')}
+            </Button>
+            <Button
+              onClick={() => selected !== null && onConfirm(selected)}
+              className="flex-1"
+              disabled={selected === null || isLoading}
+            >
+              {isLoading ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {t('common.loading')}
+                </>
+              ) : (
+                t('common.confirm')
+              )}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

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

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: 'Laden',
       load: 'Laden',
       unload: 'Entladen',
       unload: 'Entladen',
+      feedTitle: '{{slot}} in welche Düse laden?',
+      feedPrompt: 'Der Filament Track Switch kann diesen Slot zu beiden Hotends leiten. Wähle, welches beschickt werden soll.',
+      feedLeft: 'Linke Düse',
+      feedRight: 'Rechte Düse',
+      feedAlreadyLoaded: 'Bereits geladen',
+      switchNotReady: 'Der Filament Track Switch ist noch nicht eingerichtet. Weise am Drucker jedem AMS einen Eingang zu und versuche es erneut.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'Verfahrwege werden bei manuellen Bewegungen nicht begrenzt – ein Firmware-Fehler von Bambu ignoriert die Software-Endschalter bei Remote-Befehlen. Bewegen Sie vorsichtig, um Kollisionen zu vermeiden.',
       limitWarning: 'Verfahrwege werden bei manuellen Bewegungen nicht begrenzt – ein Firmware-Fehler von Bambu ignoriert die Software-Endschalter bei Remote-Befehlen. Bewegen Sie vorsichtig, um Kollisionen zu vermeiden.',

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

@@ -523,6 +523,12 @@ export default {
     ams: {
     ams: {
       load: 'Load',
       load: 'Load',
       unload: 'Unload',
       unload: 'Unload',
+      feedTitle: 'Load {{slot}} to which nozzle?',
+      feedPrompt: 'The Filament Track Switch can route this slot to either hotend. Choose which one to feed.',
+      feedLeft: 'Left nozzle',
+      feedRight: 'Right nozzle',
+      feedAlreadyLoaded: 'Already loaded',
+      switchNotReady: 'The Filament Track Switch is not set up yet. Assign every AMS to an inlet on the printer, then try again.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'Travel limits are not enforced during manual moves — a Bambu firmware bug ignores software endstops for remote commands. Move carefully to avoid a collision.',
       limitWarning: 'Travel limits are not enforced during manual moves — a Bambu firmware bug ignores software endstops for remote commands. Move carefully to avoid a collision.',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: 'Cargar',
       load: 'Cargar',
       unload: 'Descargar',
       unload: 'Descargar',
+      feedTitle: '¿En qué boquilla cargar {{slot}}?',
+      feedPrompt: 'El Filament Track Switch puede dirigir esta ranura a cualquiera de los dos hotends. Elige cuál alimentar.',
+      feedLeft: 'Boquilla izquierda',
+      feedRight: 'Boquilla derecha',
+      feedAlreadyLoaded: 'Ya cargado',
+      switchNotReady: 'El Filament Track Switch aún no está configurado. Asigna cada AMS a una entrada en la impresora e inténtalo de nuevo.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'Los límites de recorrido no se aplican en los movimientos manuales: un error del firmware de Bambu ignora los finales de carrera por software en los comandos remotos. Muévelo con cuidado para evitar colisiones.',
       limitWarning: 'Los límites de recorrido no se aplican en los movimientos manuales: un error del firmware de Bambu ignora los finales de carrera por software en los comandos remotos. Muévelo con cuidado para evitar colisiones.',

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

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: 'Charger',
       load: 'Charger',
       unload: 'Décharger',
       unload: 'Décharger',
+      feedTitle: 'Charger {{slot}} vers quelle buse ?',
+      feedPrompt: 'Le Filament Track Switch peut diriger cet emplacement vers les deux buses. Choisissez celle à alimenter.',
+      feedLeft: 'Buse gauche',
+      feedRight: 'Buse droite',
+      feedAlreadyLoaded: 'Déjà chargé',
+      switchNotReady: 'Le Filament Track Switch n\'est pas encore configuré. Affectez chaque AMS à une entrée sur l\'imprimante, puis réessayez.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'Les limites de déplacement ne sont pas appliquées lors des mouvements manuels : un bug du firmware Bambu ignore les butées logicielles pour les commandes à distance. Déplacez avec précaution pour éviter une collision.',
       limitWarning: 'Les limites de déplacement ne sont pas appliquées lors des mouvements manuels : un bug du firmware Bambu ignore les butées logicielles pour les commandes à distance. Déplacez avec précaution pour éviter une collision.',

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

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: 'Carica',
       load: 'Carica',
       unload: 'Scarica',
       unload: 'Scarica',
+      feedTitle: 'Caricare {{slot}} in quale ugello?',
+      feedPrompt: 'Il Filament Track Switch può instradare questo slot verso entrambi gli hotend. Scegli quale alimentare.',
+      feedLeft: 'Ugello sinistro',
+      feedRight: 'Ugello destro',
+      feedAlreadyLoaded: 'Già caricato',
+      switchNotReady: 'Il Filament Track Switch non è ancora configurato. Assegna ogni AMS a un ingresso sulla stampante, poi riprova.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'I limiti di corsa non vengono applicati durante i movimenti manuali: un bug del firmware Bambu ignora i finecorsa software per i comandi remoti. Muovi con cautela per evitare collisioni.',
       limitWarning: 'I limiti di corsa non vengono applicati durante i movimenti manuali: un bug del firmware Bambu ignora i finecorsa software per i comandi remoti. Muovi con cautela per evitare collisioni.',

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

@@ -519,6 +519,12 @@ export default {
     ams: {
     ams: {
       load: 'ロード',
       load: 'ロード',
       unload: 'アンロード',
       unload: 'アンロード',
+      feedTitle: '{{slot}} をどちらのノズルにロードしますか?',
+      feedPrompt: 'Filament Track Switch はこのスロットをどちらのホットエンドにも送れます。送り先を選択してください。',
+      feedLeft: '左ノズル',
+      feedRight: '右ノズル',
+      feedAlreadyLoaded: 'ロード済み',
+      switchNotReady: 'Filament Track Switch がまだ設定されていません。プリンター側で各 AMS を入口に割り当ててから、もう一度お試しください。',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: '手動移動では可動範囲の制限が適用されません。Bambu のファームウェアの不具合により、リモートコマンドではソフトウェアリミットが無視されます。衝突しないよう注意して操作してください。',
       limitWarning: '手動移動では可動範囲の制限が適用されません。Bambu のファームウェアの不具合により、リモートコマンドではソフトウェアリミットが無視されます。衝突しないよう注意して操作してください。',

+ 7 - 1
frontend/src/i18n/locales/ko.ts

@@ -488,7 +488,13 @@ export default {
     },
     },
     ams: {
     ams: {
       load: '로드',
       load: '로드',
-      unload: '언로드'
+      unload: '언로드',
+      feedTitle: '{{slot}}을(를) 어느 노즐로 로드할까요?',
+      feedPrompt: 'Filament Track Switch는 이 슬롯을 양쪽 핫엔드로 보낼 수 있습니다. 공급할 쪽을 선택하세요.',
+      feedLeft: '왼쪽 노즐',
+      feedRight: '오른쪽 노즐',
+      feedAlreadyLoaded: '이미 로드됨',
+      switchNotReady: 'Filament Track Switch가 아직 설정되지 않았습니다. 프린터에서 각 AMS를 입구에 할당한 뒤 다시 시도하세요.'
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: '수동 이동 중에는 이동 한계가 적용되지 않습니다. Bambu 펌웨어 버그로 인해 원격 명령에서는 소프트웨어 엔드스톱이 무시됩니다. 충돌하지 않도록 주의해서 이동하세요.',
       limitWarning: '수동 이동 중에는 이동 한계가 적용되지 않습니다. Bambu 펌웨어 버그로 인해 원격 명령에서는 소프트웨어 엔드스톱이 무시됩니다. 충돌하지 않도록 주의해서 이동하세요.',

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

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: 'Carregar',
       load: 'Carregar',
       unload: 'Descarregar',
       unload: 'Descarregar',
+      feedTitle: 'Carregar {{slot}} em qual bico?',
+      feedPrompt: 'O Filament Track Switch pode direcionar este slot para qualquer um dos hotends. Escolha qual alimentar.',
+      feedLeft: 'Bico esquerdo',
+      feedRight: 'Bico direito',
+      feedAlreadyLoaded: 'Já carregado',
+      switchNotReady: 'O Filament Track Switch ainda não foi configurado. Atribua cada AMS a uma entrada na impressora e tente novamente.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'Os limites de curso não são aplicados durante movimentos manuais — um bug do firmware da Bambu ignora os fins de curso por software em comandos remotos. Mova com cuidado para evitar colisões.',
       limitWarning: 'Os limites de curso não são aplicados durante movimentos manuais — um bug do firmware da Bambu ignora os fins de curso por software em comandos remotos. Mova com cuidado para evitar colisões.',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -494,6 +494,12 @@ export default {
     ams: {
     ams: {
       load: "Загрузить",
       load: "Загрузить",
       unload: "Выгрузить",
       unload: "Выгрузить",
+      feedTitle: "В какое сопло загрузить {{slot}}?",
+      feedPrompt: "Filament Track Switch может направить этот слот в любой из хотэндов. Выберите, в какой подавать.",
+      feedLeft: "Левое сопло",
+      feedRight: "Правое сопло",
+      feedAlreadyLoaded: "Уже загружено",
+      switchNotReady: "Filament Track Switch ещё не настроен. Назначьте каждой AMS вход на принтере и повторите попытку.",
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: "При ручном перемещении ограничения хода не контролируются: из-за ошибки прошивки Bambu программные концевики игнорируются для удалённых команд. Перемещайте осторожно, чтобы избежать столкновения.",
       limitWarning: "При ручном перемещении ограничения хода не контролируются: из-за ошибки прошивки Bambu программные концевики игнорируются для удалённых команд. Перемещайте осторожно, чтобы избежать столкновения.",

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: 'Yükle',
       load: 'Yükle',
       unload: 'Çıkar',
       unload: 'Çıkar',
+      feedTitle: '{{slot}} hangi nozüle yüklensin?',
+      feedPrompt: 'Filament Track Switch bu yuvayı her iki hotend\'e de yönlendirebilir. Hangisinin besleneceğini seçin.',
+      feedLeft: 'Sol nozül',
+      feedRight: 'Sağ nozül',
+      feedAlreadyLoaded: 'Zaten yüklü',
+      switchNotReady: 'Filament Track Switch henüz kurulmadı. Yazıcıda her AMS\'yi bir girişe atayın ve tekrar deneyin.',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: 'Manuel hareketlerde hareket sınırları uygulanmaz — bir Bambu donanım yazılımı hatası, uzaktan komutlarda yazılım limit anahtarlarını yok sayar. Çarpışmayı önlemek için dikkatlice hareket ettirin.',
       limitWarning: 'Manuel hareketlerde hareket sınırları uygulanmaz — bir Bambu donanım yazılımı hatası, uzaktan komutlarda yazılım limit anahtarlarını yok sayar. Çarpışmayı önlemek için dikkatlice hareket ettirin.',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -523,6 +523,12 @@ export default {
     ams: {
     ams: {
       load: "Завантажити",
       load: "Завантажити",
       unload: "Вивантажити",
       unload: "Вивантажити",
+      feedTitle: "У яке сопло завантажити {{slot}}?",
+      feedPrompt: "Filament Track Switch може спрямувати цей слот до будь-якого з хотендів. Виберіть, у який подавати.",
+      feedLeft: "Ліве сопло",
+      feedRight: "Праве сопло",
+      feedAlreadyLoaded: "Уже завантажено",
+      switchNotReady: "Filament Track Switch ще не налаштовано. Призначте кожній AMS вхід на принтері й спробуйте ще раз.",
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: "Під час ручного переміщення межі ходу не контролюються: через помилку прошивки Bambu віддалені команди ігнорують програмні кінцеві обмежувачі. Переміщуйте стіл обережно, щоб уникнути зіткнення.",
       limitWarning: "Під час ручного переміщення межі ходу не контролюються: через помилку прошивки Bambu віддалені команди ігнорують програмні кінцеві обмежувачі. Переміщуйте стіл обережно, щоб уникнути зіткнення.",

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

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: '加载',
       load: '加载',
       unload: '卸载',
       unload: '卸载',
+      feedTitle: '将 {{slot}} 装载到哪个喷嘴?',
+      feedPrompt: 'Filament Track Switch 可以把该槽位送往任一热端。请选择要送入的一侧。',
+      feedLeft: '左喷嘴',
+      feedRight: '右喷嘴',
+      feedAlreadyLoaded: '已装载',
+      switchNotReady: 'Filament Track Switch 尚未设置。请在打印机上为每个 AMS 分配入口后重试。',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: '手动移动时不会强制执行行程限位——Bambu 固件存在缺陷,远程指令会忽略软件限位。请小心移动以避免碰撞。',
       limitWarning: '手动移动时不会强制执行行程限位——Bambu 固件存在缺陷,远程指令会忽略软件限位。请小心移动以避免碰撞。',

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

@@ -520,6 +520,12 @@ export default {
     ams: {
     ams: {
       load: '載入',
       load: '載入',
       unload: '卸載',
       unload: '卸載',
+      feedTitle: '要將 {{slot}} 載入哪個噴嘴?',
+      feedPrompt: 'Filament Track Switch 可以將此槽位送往任一熱端。請選擇要送入的一側。',
+      feedLeft: '左噴嘴',
+      feedRight: '右噴嘴',
+      feedAlreadyLoaded: '已載入',
+      switchNotReady: 'Filament Track Switch 尚未設定。請在印表機上為每個 AMS 指派入口後再試一次。',
     },
     },
     bedJog: {
     bedJog: {
       limitWarning: '手動移動時不會強制執行行程限位——Bambu 韌體存在缺陷,遠端指令會忽略軟體限位。請小心移動以避免碰撞。',
       limitWarning: '手動移動時不會強制執行行程限位——Bambu 韌體存在缺陷,遠端指令會忽略軟體限位。請小心移動以避免碰撞。',

+ 73 - 5
frontend/src/pages/PrintersPage.tsx

@@ -175,7 +175,8 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { FeedDirectionModal } from '../components/FeedDirectionModal';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
@@ -3166,17 +3167,20 @@ function PrinterCard({
 
 
   // AMS load/unload mutations (#891)
   // AMS load/unload mutations (#891)
   const loadAmsTrayMutation = useMutation({
   const loadAmsTrayMutation = useMutation({
-    mutationFn: ({ trayId }: { trayId: number }) => api.loadAmsTray(printer.id, trayId),
+    mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
+      api.loadAmsTray(printer.id, trayId, extruderId),
     onSuccess: (data) => {
     onSuccess: (data) => {
+      setFeedDirectionRequest(null);
       showToast(data.message || t('printers.toast.loadInitiated'));
       showToast(data.message || t('printers.toast.loadInitiated'));
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
+      setFeedDirectionRequest(null);
       showToast(error.message || t('printers.toast.failedToLoad'), 'error');
       showToast(error.message || t('printers.toast.failedToLoad'), 'error');
     },
     },
   });
   });
 
 
   const unloadAmsMutation = useMutation({
   const unloadAmsMutation = useMutation({
-    mutationFn: () => api.unloadAms(printer.id),
+    mutationFn: ({ trayId }: { trayId?: number }) => api.unloadAms(printer.id, trayId),
     onSuccess: (data) => {
     onSuccess: (data) => {
       showToast(data.message || t('printers.toast.unloadInitiated'));
       showToast(data.message || t('printers.toast.unloadInitiated'));
     },
     },
@@ -3185,6 +3189,48 @@ function PrinterCard({
     },
     },
   });
   });
 
 
+  // Pending "which hotend?" question, non-null only while the dialog is open.
+  // A Filament Track Switch makes both hotends reachable from every slot, so the
+  // load command has to name one — see FeedDirectionModal.
+  const [feedDirectionRequest, setFeedDirectionRequest] = useState<{
+    trayId: number;
+    amsId: number;
+    slotId: number;
+    slotLabel: string;
+  } | null>(null);
+
+  // Whether a load from this printer has to ask which hotend to feed.
+  const ftsNeedsFeedDirection = Boolean(status?.fila_switch?.installed);
+
+  const startAmsLoad = (amsId: number, slotId: number, trayId: number) => {
+    // The external holder needs no question: its two tray ids name the side
+    // outright (254 = Ext-L, 255 = Ext-R). A switch cannot be fitted alongside
+    // it anyway — Bambu's own guidance is to remove the switch to print from an
+    // external spool, because it would occupy an extruder channel permanently.
+    //
+    // An AMS-HT slot skips the question for a blunter reason: the id this menu
+    // carries does not address an HT unit, so the load is refused whatever the
+    // answer. Asking first would only put a dialog in front of the same error.
+    const isExternal = amsId === 255;
+    if (!ftsNeedsFeedDirection || isExternal || amsId >= 128) {
+      loadAmsTrayMutation.mutate({ trayId });
+      return;
+    }
+    // Nothing can be routed until every AMS is bound to an inlet, so refuse up
+    // front rather than sending a command the firmware will drop. BambuStudio
+    // shows the same message and returns without publishing anything.
+    if (!status?.fila_switch?.ready) {
+      showToast(t('printers.ams.switchNotReady'), 'warning');
+      return;
+    }
+    setFeedDirectionRequest({
+      trayId,
+      amsId,
+      slotId,
+      slotLabel: formatSlotLabel(amsId, slotId, false, false),
+    });
+  };
+
   // 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 }>;
@@ -3570,6 +3616,13 @@ function PrinterCard({
     includeRfid?: boolean;
     includeRfid?: boolean;
   }) => {
   }) => {
     const printerBusy = status?.state === 'RUNNING';
     const printerBusy = status?.state === 'RUNNING';
+    // An AMS-HT unit is addressed by its unit id alone (128-135), not by
+    // ams*4+slot, so the id this menu carries does not name it to the load and
+    // unload endpoints — they reject it. Sending no slot falls back to the
+    // printer-wide unload, which is what this menu did for every slot before
+    // the per-slot form existed. Load has never worked on an HT slot for the
+    // same addressing reason; fixing that is a separate change.
+    const unloadTrayId = amsId >= 128 && amsId !== 255 ? undefined : loadTrayId;
 
 
     return (
     return (
       <>
       <>
@@ -3601,7 +3654,7 @@ function PrinterCard({
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
             if (printerBusy || !hasPermission('printers:control')) return;
             if (printerBusy || !hasPermission('printers:control')) return;
-            loadAmsTrayMutation.mutate({ trayId: loadTrayId });
+            startAmsLoad(amsId, slotId, loadTrayId);
           }}
           }}
           disabled={printerBusy || !hasPermission('printers:control')}
           disabled={printerBusy || !hasPermission('printers:control')}
           title={printerBusy ? t('printers.bedJog.disabledWhilePrinting') : !hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
           title={printerBusy ? t('printers.bedJog.disabledWhilePrinting') : !hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
@@ -3618,7 +3671,7 @@ function PrinterCard({
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
             if (printerBusy || !hasPermission('printers:control')) return;
             if (printerBusy || !hasPermission('printers:control')) return;
-            unloadAmsMutation.mutate();
+            unloadAmsMutation.mutate({ trayId: unloadTrayId });
           }}
           }}
           disabled={printerBusy || !hasPermission('printers:control')}
           disabled={printerBusy || !hasPermission('printers:control')}
           title={printerBusy ? t('printers.bedJog.disabledWhilePrinting') : !hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
           title={printerBusy ? t('printers.bedJog.disabledWhilePrinting') : !hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
@@ -7152,6 +7205,21 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {/* Which hotend to feed — only asked on a printer with a Filament Track Switch */}
+      {feedDirectionRequest && (
+        <FeedDirectionModal
+          slotLabel={feedDirectionRequest.slotLabel}
+          amsId={feedDirectionRequest.amsId}
+          slotId={feedDirectionRequest.slotId}
+          extruderSlots={status?.extruder_slots ?? {}}
+          isLoading={loadAmsTrayMutation.isPending}
+          onConfirm={(extruderId) =>
+            loadAmsTrayMutation.mutate({ trayId: feedDirectionRequest.trayId, extruderId })
+          }
+          onCancel={() => setFeedDirectionRequest(null)}
+        />
+      )}
+
       {/* Edit Printer Modal */}
       {/* Edit Printer Modal */}
       {showEditModal && (
       {showEditModal && (
         <EditPrinterModal
         <EditPrinterModal

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DL1B9Y3L.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-9GYLTY6P.js"></script>
+    <script type="module" crossorigin src="/assets/index-DL1B9Y3L.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BzJRM4M1.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BzJRM4M1.css">
   </head>
   </head>
   <body>
   <body>

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