Explorar o código

Show which Filament Track Switch inlet each AMS feeds

With a switch fitted, an AMS is not wired to a nozzle any more. It is
plumbed into one of the switch's two inlets and reaches both nozzles
through it, so every unit reports its extruder as "not fixed" (0xE) and
ams_extruder_map comes back empty on these machines.

The printer card had nothing to fall back on but the AMS unit number, so
AMS-A was badged R and AMS-B was badged L purely because their unit ids
are 0 and 1, a third unit got no badge at all, and every one of those
labels was wrong. The SpoolBuddy assign modal had the same fallback in a
worse form, mapping anything that was not extruder 1 to R.

The binding turned out to need no new telemetry. BambuStudio reads it out
of bits 24-27 of the same AMS info string we already parse for the AMS
type and the extruder id -- 0 is In-B, 1 is In-A -- and it is only
meaningful when a switch is installed, because without one 0xE really
does mean an uninitialised unit and those bits carry nothing. That gates
the read, which in turn forced the switch block to be parsed before the
AMS block: _handle_ams_data runs early in _process_message and
_update_state only much later, so the binding was lost on every frame
that carried both. _parse_fila_switch is split out and called first, and
left in _update_state as well so that stays a complete absorb step.

The badge keeps L and R rather than A and B, because the lettering is
familiar and matches the physical layout. It is a different colour from
the plain nozzle badge, and its tooltip names the inlet in full, since
the letter is the inlet's position and not a claim about which nozzle
that AMS feeds -- the switch can route either inlet to either outlet. An
AMS still reporting a real extruder id keeps its ordinary badge, which
BambuStudio also treats as authoritative over any switch binding, and a
switch that has been fitted but not yet set up on the printer shows
nothing rather than a guess.

The print dialog's slot dropdown gets the same label. It replaces a
left/right hint that never once rendered: ftsExtruderForSlot compared
snow-encoded in[] values against global tray ids and could not match.
Decoding it correctly would not have saved it -- the firmware reports
which slot sits in each inlet and which nozzle each outlet feeds, but
never which inlet is currently paired with which outlet, so no per-slot
nozzle can be derived. That function is gone rather than fixed.

The dialog also points out when every filament a print needs sits behind
one inlet. Bambu's own guidance is that this is legal but slow: a change
between two filaments on the same inlet retracts the outgoing spool all
the way back to its AMS before the next can be fed up the shared tube,
where a change across the two inlets only retracts as far as the switch.
All on one inlet means every change in the job takes the slow path, and
moving a single spool fixes it. So it advises, it does not block.

Both views update live. Two things were stopping that. fila_switch and
ams_switch_inlet were absent from printer_state_to_dict, and the frontend
shallow-merges each WebSocket push over its cached status, so a field the
push omits keeps whatever the last full fetch left behind. And the
broadcast dedup key had no term for either, so "Join IN-B" on the printer
screen moved nothing: the binding is not in the tray component of that
key, and it is not in the AMS change-hash either, which covers tray
fields only and must stay that way because it drives Spoolman sync.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
the binding and has no command to write it -- its switch class is parse
and getters only, and the recommended-arrangement popup draws and
publishes nothing -- so there is no wire format for us to copy.

Adding the two fields to PrinterState broke four test modules whose
SimpleNamespace stubs predate them. The stubs are fixed rather than the
production reads made defensive: the real dataclass always carries both,
and a getattr in the dedup key would silently stop tracking the field if
it were ever renamed.
maziggy hai 3 semanas
pai
achega
7a42e0a7e5
Modificáronse 34 ficheiros con 912 adicións e 72 borrados
  1. 0 0
      CHANGELOG.md
  2. 4 0
      backend/app/api/routes/printers.py
  3. 12 1
      backend/app/main.py
  4. 6 0
      backend/app/schemas/printer.py
  5. 96 21
      backend/app/services/bambu_mqtt.py
  6. 19 0
      backend/app/services/printer_manager.py
  7. 131 0
      backend/tests/unit/services/test_bambu_mqtt.py
  8. 37 0
      backend/tests/unit/services/test_printer_manager.py
  9. 20 0
      backend/tests/unit/test_printer_kill_switch.py
  10. 4 0
      backend/tests/unit/test_printer_manager_status_broadcast.py
  11. 4 0
      backend/tests/unit/test_printer_offline_notification.py
  12. 75 0
      backend/tests/unit/test_status_broadcast_ams_slot_config.py
  13. 107 9
      frontend/src/__tests__/components/FilamentMapping.test.tsx
  14. 199 0
      frontend/src/__tests__/pages/PrintersPageAmsSwitchInlet.test.tsx
  15. 14 2
      frontend/src/api/client.ts
  16. 48 20
      frontend/src/components/PrintModal/FilamentMapping.tsx
  17. 11 3
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  18. 2 0
      frontend/src/i18n/locales/de.ts
  19. 2 0
      frontend/src/i18n/locales/en.ts
  20. 2 0
      frontend/src/i18n/locales/es.ts
  21. 2 0
      frontend/src/i18n/locales/fr.ts
  22. 2 0
      frontend/src/i18n/locales/it.ts
  23. 2 0
      frontend/src/i18n/locales/ja.ts
  24. 2 0
      frontend/src/i18n/locales/ko.ts
  25. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 2 0
      frontend/src/i18n/locales/ru.ts
  27. 2 0
      frontend/src/i18n/locales/tr.ts
  28. 2 0
      frontend/src/i18n/locales/uk.ts
  29. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 87 15
      frontend/src/pages/PrintersPage.tsx
  32. 11 0
      frontend/src/utils/amsHelpers.ts
  33. 0 0
      static/assets/index-FP9eKiXB.js
  34. 1 1
      static/index.html

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
CHANGELOG.md


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

@@ -770,6 +770,10 @@ async def get_printer_status(
         active_extruder=state.active_extruder,
         active_extruder=state.active_extruder,
         ams_mapping=ams_mapping,
         ams_mapping=ams_mapping,
         ams_extruder_map=ams_extruder_map,
         ams_extruder_map=ams_extruder_map,
+        # Only meaningful alongside an installed switch; without one the map is
+        # empty anyway, but gating it keeps a stale binding from outliving the
+        # accessory being unplugged.
+        ams_switch_inlet=(dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
         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.

+ 12 - 1
backend/app/main.py

@@ -1483,13 +1483,24 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         if state.raw_data
         if state.raw_data
         else ()
         else ()
     )
     )
+    # Filament Track Switch: which inlet each AMS is bound to, and whether the
+    # accessory is fitted at all. Neither is in ams_tray_key (it is per-tray) nor
+    # in the AMS change-hash (tray fields only, and widening that would fire
+    # spurious Spoolman syncs), so without them a "Join IN-B" on the printer
+    # screen changed no key at all and the card's inlet badges sat stale until a
+    # reload. Like the filament-backup flag, these only move when someone
+    # reconfigures the machine, so they add no mid-print broadcast traffic.
+    fts_key = (
+        state.fila_switch.installed if state.fila_switch else False,
+        tuple(sorted(state.ams_switch_inlet.items())),
+    )
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
-        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}"
+        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}:{fts_key}"
     )
     )
 
 
     is_active_print = state.state in _ACTIVE_PRINT_STATES
     is_active_print = state.state in _ACTIVE_PRINT_STATES

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

@@ -338,6 +338,12 @@ class PrinterStatus(BaseModel):
     # Filament Track Switch (FTS) accessory — when installed, AMS reports
     # Filament Track Switch (FTS) accessory — when installed, AMS reports
     # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
     # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
     fila_switch: FilaSwitchResponse | None = None
     fila_switch: FilaSwitchResponse | None = None
+    # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}, from AMS info bits 24-27.
+    # Which of the switch's two inlets each AMS is plumbed into, as set on the
+    # printer's "Manual AMS Setup" screen. Empty unless an FTS is installed —
+    # 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_switch_inlet: dict[str, str] = {}
     # 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

+ 96 - 21
backend/app/services/bambu_mqtt.py

@@ -703,16 +703,43 @@ class FilaSwitchState:
     AMS and the printer's extruders. When installed, the AMS no longer has a
     AMS and the printer's extruders. When installed, the AMS no longer has a
     fixed extruder assignment — any slot can be routed to any extruder via the
     fixed extruder assignment — any slot can be routed to any extruder via the
     track switch. Detected from print.device.fila_switch in MQTT.
     track switch. Detected from print.device.fila_switch in MQTT.
+
+    The switch has two inlets (In-A, In-B) and two outlets (Out-A, Out-B), and
+    can pair any inlet with any outlet. Which AMS sits on which *inlet* is the
+    stable, operator-visible relationship — it is set on the printer's "Manual
+    AMS Setup" screen and read back from AMS ``info`` bits 24-27, not from here.
+
+    Field semantics below are taken from BambuStudio's own parser
+    (``DevFilaSwitch::ParseFilaSwitchInfo``), not inferred.
     """
     """
 
 
     installed: bool = False
     installed: bool = False
-    # in[track] = currently loaded slot for that track (-1 = empty). The slot
-    # value is reported as observed in MQTT (treated as a global tray ID).
+    # Raw ``in`` array, as it arrives. **Index 0 is In-B and index 1 is In-A** —
+    # the arrays are ordered B-then-A, which is the opposite of how they read.
+    # Each value is snow-encoded: bits 8-15 = AMS id, bits 0-7 = slot. -1 = the
+    # inlet is empty. Use `inlet_slot()` rather than indexing this directly.
     in_slots: list[int] = field(default_factory=list)
     in_slots: list[int] = field(default_factory=list)
-    # out[track] = extruder this track terminates at (0 = right/main, 1 = left)
+    # Raw ``out`` array, same B-then-A order. out[i] = the extruder that *outlet*
+    # terminates at (0 = right/main, 1 = left/deputy), or 0xE when unset. Note
+    # this is the outlet's static wiring, NOT the live inlet→outlet route: which
+    # inlet is currently paired with which outlet is not reported at all.
     out_extruders: list[int] = field(default_factory=list)
     out_extruders: list[int] = field(default_factory=list)
-    stat: int = 0  # status flags (0 = idle)
-    info: int = 0  # info flags
+    stat: int = 0  # CaliStatus: 0 = idle, 1 = calibration stepping
+    info: int = 0  # bit 0 = inlet has filament
+
+    def inlet_slot(self, inlet: str) -> tuple[int, int] | None:
+        """Decode ``in`` for inlet ``"A"`` or ``"B"`` into ``(ams_id, slot)``.
+
+        Returns None when the inlet is empty, unreported, or ``inlet`` is not
+        one of A/B.
+        """
+        index = {"A": 1, "B": 0}.get(inlet.upper())
+        if index is None or index >= len(self.in_slots):
+            return None
+        raw = self.in_slots[index]
+        if raw < 0:
+            return None
+        return (raw >> 8) & 0xFF, raw & 0xFF
 
 
 
 
 @dataclass
 @dataclass
@@ -834,6 +861,11 @@ class PrinterState:
     # Filament Track Switch (FTS) accessory — when installed, AMS info reports
     # Filament Track Switch (FTS) accessory — when installed, AMS info reports
     # bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
     # bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
     fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
     fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
+    # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Which of the switch's two
+    # filament inlets an AMS is plumbed into, as set on the printer's "Manual 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.
+    ams_switch_inlet: 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
@@ -1981,6 +2013,12 @@ class BambuMQTTClient:
                         self._is_dual_nozzle = True
                         self._is_dual_nozzle = True
                         logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
                         logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
 
 
+            # Must run before _handle_ams_data: the per-AMS inlet binding is read
+            # out of the AMS info bits, but only means anything once we know a
+            # switch is installed. Parsing them the other way round would lose
+            # the binding on every frame where the two arrive together.
+            self._parse_fila_switch(print_data)
+
             # Handle AMS data that comes inside print key
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
             if "ams" in print_data:
                 try:
                 try:
@@ -2630,6 +2668,28 @@ class BambuMQTTClient:
                     )
                     )
                 self._has_a2l_am_unit = True
                 self._has_a2l_am_unit = True
 
 
+    def _parse_fila_switch(self, data: dict) -> None:
+        """Read the Filament Track Switch block out of a print payload — #1162.
+
+        Presence of ``device.fila_switch`` means the accessory is installed. Kept
+        separate from the rest of the state update because ``_handle_ams_data``
+        needs the answer before it parses the AMS info bits, and that runs first.
+        """
+        if not isinstance(data.get("device"), dict):
+            return
+        fs_data = data["device"].get("fila_switch")
+        if not isinstance(fs_data, dict):
+            return
+        in_raw = fs_data.get("in")
+        out_raw = fs_data.get("out")
+        self.state.fila_switch = FilaSwitchState(
+            installed=True,
+            in_slots=list(in_raw) if isinstance(in_raw, list) else [],
+            out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
+            stat=int(fs_data.get("stat", 0) or 0),
+            info=int(fs_data.get("info", 0) or 0),
+        )
+
     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.
 
 
@@ -3138,13 +3198,24 @@ class BambuMQTTClient:
         # BambuStudio DevFilaSystem.cpp parses info as hex string:
         # BambuStudio DevFilaSystem.cpp parses info as hex string:
         #   type_id    = get_flag_bits(info, 0, 4)   // bits 0-3: AMS type
         #   type_id    = get_flag_bits(info, 0, 4)   // bits 0-3: AMS type
         #   extruder_id = get_flag_bits(info, 8, 4)  // bits 8-11: extruder assignment
         #   extruder_id = get_flag_bits(info, 8, 4)  // bits 8-11: extruder assignment
+        #   bind_switch_in = get_flag_bits(info, 24, 4)  // bits 24-27: FTS inlet
         # where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
         # where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
-        # extruder_id: 0=right/main, 1=left/deputy, 0xE=uninitialized (skip)
+        # extruder_id: 0=right/main, 1=left/deputy, 0xE=routing is not fixed
+        #
+        # 0xE does not mean "broken". On a Filament Track Switch machine it is the
+        # normal steady state: the AMS is bound to a switch *inlet* rather than to
+        # one extruder, and reaches both nozzles through it. Bits 24-27 then name
+        # that inlet — 0 = In-B, 1 = In-A (BambuStudio's SwitchPos enum, which is
+        # ordered B-then-A). Without an FTS, 0xE really is an uninitialised unit
+        # and bits 24-27 carry nothing, which is why the inlet read is gated on
+        # the switch being installed.
         #
         #
         # Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
         # Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
         # the full map. Merge into existing map to preserve entries from prior updates.
         # the full map. Merge into existing map to preserve entries from prior updates.
 
 
+        fts_installed = self.state.fila_switch.installed
         ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
         ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
+        ams_switch_inlet = dict(self.state.ams_switch_inlet) if self.state.ams_switch_inlet else {}
         for ams_unit in merged_ams:
         for ams_unit in merged_ams:
             ams_id = ams_unit.get("id")
             ams_id = ams_unit.get("id")
             info = ams_unit.get("info")
             info = ams_unit.get("info")
@@ -3155,7 +3226,19 @@ class BambuMQTTClient:
                     # Extract 4 bits starting at bit 8 for extruder assignment
                     # Extract 4 bits starting at bit 8 for extruder assignment
                     extruder_id = (info_val >> 8) & 0xF
                     extruder_id = (info_val >> 8) & 0xF
                     if extruder_id == 0xE:
                     if extruder_id == 0xE:
-                        # 0xE = uninitialized AMS, skip
+                        if fts_installed:
+                            inlet = {0: "B", 1: "A"}.get((info_val >> 24) & 0xF)
+                            if inlet is not None:
+                                ams_switch_inlet[str(ams_id)] = inlet
+                                self._debug_on_change(
+                                    f"ams_inlet:{ams_id}",
+                                    inlet,
+                                    "[%s] AMS %s info=0x%s -> FTS inlet %s",
+                                    self.serial_number,
+                                    ams_id,
+                                    info,
+                                    inlet,
+                                )
                         continue
                         continue
                     ams_extruder_map[str(ams_id)] = extruder_id
                     ams_extruder_map[str(ams_id)] = extruder_id
                     self._debug_on_change(
                     self._debug_on_change(
@@ -3173,6 +3256,8 @@ class BambuMQTTClient:
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map
             self.state.ams_extruder_map = ams_extruder_map
             self.state.ams_extruder_map = ams_extruder_map
             logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
             logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
+        if ams_switch_inlet:
+            self.state.ams_switch_inlet = ams_switch_inlet
 
 
         # Extract drying status from info hex string and dry_sf_reason per AMS unit
         # Extract drying status from info hex string and dry_sf_reason per AMS unit
         # BambuStudio DevFilaSystem.cpp parses info bits:
         # BambuStudio DevFilaSystem.cpp parses info bits:
@@ -3926,20 +4011,10 @@ class BambuMQTTClient:
                 if "cur" in ext_data:
                 if "cur" in ext_data:
                     logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
                     logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
 
 
-        # Filament Track Switch (FTS) detection — #1162. Presence of
-        # device.fila_switch in MQTT means the FTS accessory is installed.
-        if "device" in data and isinstance(data.get("device"), dict):
-            fs_data = data["device"].get("fila_switch")
-            if isinstance(fs_data, dict):
-                in_raw = fs_data.get("in")
-                out_raw = fs_data.get("out")
-                self.state.fila_switch = FilaSwitchState(
-                    installed=True,
-                    in_slots=list(in_raw) if isinstance(in_raw, list) else [],
-                    out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
-                    stat=int(fs_data.get("stat", 0) or 0),
-                    info=int(fs_data.get("info", 0) or 0),
-                )
+        # Also parsed earlier in _process_message, because _handle_ams_data needs
+        # 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.
+        self._parse_fila_switch(data)
 
 
         if "bed_temper" in data:
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
             temps["bed"] = float(data["bed_temper"])

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

@@ -1507,6 +1507,25 @@ def printer_state_to_dict(
         ),
         ),
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         "ams_extruder_map": ams_extruder_map,
         "ams_extruder_map": ams_extruder_map,
+        # Filament Track Switch. Both fields have to travel on the WebSocket, not
+        # only on the REST status: the frontend shallow-merges each push over its
+        # cached status, so a field that is absent here keeps whatever the last
+        # full fetch left behind. Omitting them meant the AMS inlet badges only
+        # ever changed on a page reload.
+        "fila_switch": (
+            {
+                "installed": True,
+                "in_slots": list(state.fila_switch.in_slots),
+                "out_extruders": list(state.fila_switch.out_extruders),
+                "stat": state.fila_switch.stat,
+                "info": state.fila_switch.info,
+            }
+            if state.fila_switch and state.fila_switch.installed
+            else None
+        ),
+        # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Gated on the accessory
+        # 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 {}),
         # 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,

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

@@ -5854,6 +5854,137 @@ class TestFilamentTrackSwitchDetection:
         assert fs.out_extruders == []
         assert fs.out_extruders == []
 
 
 
 
+class TestFilamentTrackSwitchInletBinding:
+    """Which FTS inlet each AMS is plumbed into, from AMS ``info`` bits 24-27.
+
+    With a switch installed an AMS is no longer bound to one extruder — it is
+    bound to one of the switch's two *inlets*, and reaches both nozzles through
+    it. That binding is what the printer's "Manual AMS Setup" screen sets, and
+    it is the only per-AMS side information there is: ``ams_extruder_map`` is
+    empty on these machines because bits 8-11 read 0xE for every unit.
+
+    Bit layout and the 0=In-B / 1=In-A ordering are BambuStudio's
+    (``DevFilaSystem.cpp``, ``DevFilaSwitch::SwitchPos``), not inferred.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _info(*, inlet_bits: int, extruder: int = 0xE, ams_type: int = 1) -> str:
+        """Build an AMS ``info`` hex string with the fields we read out of it."""
+        return f"{(inlet_bits << 24) | (extruder << 8) | ams_type:08X}"
+
+    @staticmethod
+    def _frame(client, ams_units: list[dict], *, fts: bool = True) -> None:
+        """Drive one push_status through the real entry point.
+
+        Deliberately goes through ``_process_message`` rather than calling the
+        AMS handler directly: the ordering between the switch block and the AMS
+        block is the thing most likely to break, and only this path exercises it.
+        """
+        print_data = {"gcode_state": "IDLE", "ams": {"ams": ams_units}}
+        if fts:
+            print_data["device"] = {"fila_switch": {"in": [-1, -1], "out": [1, 0], "stat": 0, "info": 0}}
+        client._process_message({"print": print_data})
+
+    def test_inlet_a_from_bits_24_27(self, mqtt_client):
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}])
+        assert mqtt_client.state.ams_switch_inlet == {"0": "A"}
+
+    def test_inlet_b_from_bits_24_27(self, mqtt_client):
+        """0 is In-B, not "unset" — the enum is ordered B first."""
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=0), "tray": []}])
+        assert mqtt_client.state.ams_switch_inlet == {"0": "B"}
+
+    def test_the_maintainers_layout(self, mqtt_client):
+        """Four units split across both inlets, as on the H2C in #1162's
+        follow-up: AMS-A and an AMS-HT on In-A, AMS-B and AMS-C on In-B."""
+        self._frame(
+            mqtt_client,
+            [
+                {"id": "0", "info": self._info(inlet_bits=1), "tray": []},
+                {"id": "1", "info": self._info(inlet_bits=0), "tray": []},
+                {"id": "2", "info": self._info(inlet_bits=0), "tray": []},
+                {"id": "128", "info": self._info(inlet_bits=1, ams_type=4), "tray": []},
+            ],
+        )
+        assert mqtt_client.state.ams_switch_inlet == {"0": "A", "1": "B", "2": "B", "128": "A"}
+        # Every unit reads 0xE, so the extruder map stays empty — which is
+        # exactly why a left/right label cannot be derived on these machines.
+        assert mqtt_client.state.ams_extruder_map == {}
+
+    def test_no_inlet_read_without_a_switch(self, mqtt_client):
+        """Without an FTS, 0xE means an uninitialised unit and bits 24-27 carry
+        nothing. Reading them anyway would invent inlet "B" for every AMS."""
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=0), "tray": []}], fts=False)
+        assert mqtt_client.state.ams_switch_inlet == {}
+
+    def test_a_normally_bound_ams_is_untouched(self, mqtt_client):
+        """An AMS that still reports a real extruder id keeps using it, switch
+        or no switch — only 0xE units are inlet-bound."""
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1, extruder=1), "tray": []}])
+        assert mqtt_client.state.ams_extruder_map == {"0": 1}
+        assert mqtt_client.state.ams_switch_inlet == {}
+
+    def test_binding_survives_a_frame_without_the_switch_block(self, mqtt_client):
+        """Partial push_status frames carry the AMS block without device.*.
+        The switch stays installed (sticky) so the binding must not be dropped."""
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}])
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "IDLE",
+                    "ams": {"ams": [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}]},
+                }
+            }
+        )
+        assert mqtt_client.state.ams_switch_inlet == {"0": "A"}
+
+    def test_a_rebind_is_picked_up(self, mqtt_client):
+        """ "Join IN-B" on the printer screen flips the bits; we must follow it
+        rather than keeping the first value we ever saw."""
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}])
+        self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=0), "tray": []}])
+        assert mqtt_client.state.ams_switch_inlet == {"0": "B"}
+
+    def test_unparseable_info_is_skipped(self, mqtt_client):
+        self._frame(mqtt_client, [{"id": "0", "info": "not-hex", "tray": []}])
+        assert mqtt_client.state.ams_switch_inlet == {}
+
+
+class TestFilamentTrackSwitchInletSlotDecode:
+    """``fila_switch.in`` decoding — snow-encoded, and ordered B before A."""
+
+    def test_decodes_ams_and_slot(self):
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        # 0x0102 = AMS 1 slot 2 on In-A; 0x0003 = AMS 0 slot 3 on In-B.
+        fs = FilaSwitchState(installed=True, in_slots=[0x0003, 0x0102])
+        assert fs.inlet_slot("A") == (1, 2)
+        assert fs.inlet_slot("B") == (0, 3)
+
+    def test_empty_inlet_is_none(self):
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        fs = FilaSwitchState(installed=True, in_slots=[-1, 0x0102])
+        assert fs.inlet_slot("B") is None
+        assert fs.inlet_slot("A") == (1, 2)
+
+    def test_missing_or_unknown_inlet_is_none(self):
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        assert FilaSwitchState(installed=True).inlet_slot("A") is None
+        assert FilaSwitchState(installed=True, in_slots=[0x0003, 0x0102]).inlet_slot("C") is None
+
+
 class TestAmsLoadFilamentEncoding:
 class TestAmsLoadFilamentEncoding:
     """Per-target ams_change_filament command encoding (#891)."""
     """Per-target ams_change_filament command encoding (#891)."""
 
 

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

@@ -851,6 +851,43 @@ class TestPrinterStateToDict:
         state.firmware_version = None
         state.firmware_version = None
         return state
         return state
 
 
+    def test_fila_switch_and_inlets_ride_the_websocket(self, mock_state):
+        """The FTS fields must be in the broadcast dict, not only the REST status.
+
+        The frontend shallow-merges each WebSocket push over its cached status,
+        so a field this dict omits keeps whatever the last full fetch left —
+        which is why the AMS inlet badges only ever changed on a page reload.
+        """
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        mock_state.fila_switch = FilaSwitchState(
+            installed=True, in_slots=[-1, 0x0102], out_extruders=[1, 0], stat=0, info=1
+        )
+        mock_state.ams_switch_inlet = {"0": "A", "1": "B"}
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams_switch_inlet"] == {"0": "A", "1": "B"}
+        assert result["fila_switch"] == {
+            "installed": True,
+            "in_slots": [-1, 0x0102],
+            "out_extruders": [1, 0],
+            "stat": 0,
+            "info": 1,
+        }
+
+    def test_inlets_are_dropped_without_a_switch(self, mock_state):
+        """A binding must not outlive the accessory being unplugged."""
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        mock_state.fila_switch = FilaSwitchState(installed=False)
+        mock_state.ams_switch_inlet = {"0": "A"}
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["fila_switch"] is None
+        assert result["ams_switch_inlet"] == {}
+
     def test_basic_conversion(self, mock_state):
     def test_basic_conversion(self, mock_state):
         """Verify basic state fields are converted."""
         """Verify basic state fields are converted."""
         result = printer_state_to_dict(mock_state)
         result = printer_state_to_dict(mock_state)

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

@@ -78,6 +78,10 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         temperatures={},
         temperatures={},
         raw_data={},
         raw_data={},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -164,6 +168,10 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
         temperatures={},
         temperatures={},
         raw_data={},
         raw_data={},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -237,6 +245,10 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         temperatures={},
         temperatures={},
         raw_data={},
         raw_data={},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -260,6 +272,10 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         temperatures={},
         temperatures={},
         raw_data={},
         raw_data={},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,
@@ -337,6 +353,10 @@ async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_
         temperatures={},
         temperatures={},
         raw_data={},
         raw_data={},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=None,
         cooling_fan_speed=None,
         big_fan1_speed=None,
         big_fan1_speed=None,
         big_fan2_speed=None,
         big_fan2_speed=None,

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

@@ -102,6 +102,10 @@ def _fake_state(**overrides):
         "wifi_signal": None,
         "wifi_signal": None,
         "wired_network": None,
         "wired_network": None,
         "ams_filament_backup": None,
         "ams_filament_backup": None,
+        # Filament Track Switch. None means "no accessory", which is what
+        # printer_state_to_dict gates both of these on.
+        "fila_switch": None,
+        "ams_switch_inlet": {},
     }
     }
     base.update(overrides)
     base.update(overrides)
     return SimpleNamespace(**base)
     return SimpleNamespace(**base)

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

@@ -55,6 +55,10 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
         temperatures={},
         temperatures={},
         raw_data={},
         raw_data={},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=0,
         cooling_fan_speed=0,
         big_fan1_speed=0,
         big_fan1_speed=0,
         big_fan2_speed=0,
         big_fan2_speed=0,

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

@@ -66,6 +66,10 @@ def _state(trays: list[dict]) -> SimpleNamespace:
         temperatures={},
         temperatures={},
         raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
         raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
         stg_cur=0,
         stg_cur=0,
+        # Real PrinterState always carries these; the status-broadcast dedup
+        # key reads them so a Filament Track Switch rebind reaches the card.
+        fila_switch=None,
+        ams_switch_inlet={},
         cooling_fan_speed=0,
         cooling_fan_speed=0,
         big_fan1_speed=0,
         big_fan1_speed=0,
         big_fan2_speed=0,
         big_fan2_speed=0,
@@ -245,3 +249,74 @@ class TestExistingBehaviourUnchanged:
                 await main_module.on_printer_status_change(1, state)
                 await main_module.on_printer_status_change(1, state)
 
 
         assert ws_mgr.send_printer_status.await_count == 1
         assert ws_mgr.send_printer_status.await_count == 1
+
+
+class TestFilamentTrackSwitchBroadcasts:
+    """Moving an AMS to the other switch inlet has to reach the printer card.
+
+    The inlet binding lives in AMS ``info`` bits, so it is in neither the tray
+    component of this key nor the AMS change-hash (which covers tray fields only
+    — widening that would fire spurious Spoolman syncs). Without its own term
+    here, "Join IN-B" on the printer's Manual AMS Setup screen moved no key at
+    all and the card's inlet badges stayed stale until a page reload.
+    """
+
+    @staticmethod
+    def _fts_state(inlets: dict[str, str], installed: bool = True):
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        state = _state([_tray()])
+        state.fila_switch = FilaSwitchState(installed=installed)
+        state.ams_switch_inlet = inlets
+        return state
+
+    async def _push_state(self, ws_mgr, state) -> None:
+        relay = MagicMock()
+        relay.on_printer_status = AsyncMock()
+        pm = MagicMock()
+        pm.get_printer.return_value = None
+        pm.get_model.return_value = ""
+        with (
+            patch("backend.app.main.ws_manager", ws_mgr),
+            patch("backend.app.main.mqtt_relay", relay),
+            patch("backend.app.main.printer_manager", pm),
+            _spawn_patch(),
+            patch("backend.app.main.printer_state_to_dict", return_value={}),
+        ):
+            await main_module.on_printer_status_change(1, state)
+
+    @pytest.mark.asyncio
+    async def test_a_rebind_broadcasts(self, ws_mgr):
+        await self._push_state(ws_mgr, self._fts_state({"0": "A", "1": "B"}))
+        assert ws_mgr.send_printer_status.await_count == 1
+
+        await self._push_state(ws_mgr, self._fts_state({"0": "B", "1": "B"}))
+
+        assert ws_mgr.send_printer_status.await_count == 2, (
+            "moving an AMS to the other inlet did not reach the frontend — the "
+            "card would keep showing the old inlet badge until a page reload"
+        )
+
+    @pytest.mark.asyncio
+    async def test_fitting_the_accessory_broadcasts(self, ws_mgr):
+        await self._push_state(ws_mgr, self._fts_state({}, installed=False))
+        await self._push_state(ws_mgr, self._fts_state({}, installed=True))
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_an_unchanged_binding_is_still_suppressed(self, ws_mgr):
+        """The binding only moves when someone reconfigures the machine, so it
+        must not add a broadcast to every push mid-print."""
+        for _ in range(3):
+            await self._push_state(ws_mgr, self._fts_state({"0": "A", "1": "B"}))
+
+        assert ws_mgr.send_printer_status.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_key_order_does_not_matter(self, ws_mgr):
+        """Dict iteration order must not masquerade as a rebind."""
+        await self._push_state(ws_mgr, self._fts_state({"0": "A", "1": "B"}))
+        await self._push_state(ws_mgr, self._fts_state({"1": "B", "0": "A"}))
+
+        assert ws_mgr.send_printer_status.await_count == 1

+ 107 - 9
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -74,6 +74,7 @@ describe('FilamentMapping — FTS routing', () => {
                 stat: 0,
                 stat: 0,
                 info: 2,
                 info: 2,
               },
               },
+              ams_switch_inlet: { '0': 'A' },
             }),
             }),
           ),
           ),
       ),
       ),
@@ -99,16 +100,45 @@ describe('FilamentMapping — FTS routing', () => {
     });
     });
     expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
     expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
 
 
-    // The slot currently fed into a track gets an [L]/[R] badge. AMS-0 slot 1
-    // (global tray ID 1) is in fila_switch.in_slots[1], whose track terminates
-    // at extruder 1 → the LEFT-nozzle short label appears in that option.
-    const petgOption = screen.getByText(/Bambu PETG/);
-    expect(petgOption.textContent).toMatch(/\[L\]/);
+    // Each slot is badged for the switch INLET its AMS is plumbed into, using
+    // the same L-for-In-A lettering as the printer card. Both slots are in
+    // AMS 0, which is on In-A.
+    expect(screen.getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
+    expect(screen.getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
+  });
+
+  it('does not badge slots whose AMS has no inlet binding yet', async () => {
+    // A switch that has been fitted but not set up on the printer's Manual AMS
+    // Setup screen reports no binding. Better a missing badge than a made-up one.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: { installed: true, in_slots: [-1, 1], out_extruders: [0, 1], stat: 0, info: 2 },
+              ams_switch_inlet: {},
+            }),
+          ),
+      ),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
 
 
-    // AMS-0 slot 0 (global tray ID 0) is NOT currently fed into any track —
-    // FTS routes it on demand, so no badge.
-    const plaOption = screen.getByText(/Bambu PLA/);
-    expect(plaOption.textContent).not.toMatch(/\[[LR]\]/);
+    await waitFor(() => {
+      expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+    });
+    expect(screen.getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
   });
   });
 
 
   it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
   it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
@@ -341,3 +371,71 @@ describe('FilamentMapping — FTS routing', () => {
     expect(grams.parentElement).toBe(name.parentElement);
     expect(grams.parentElement).toBe(name.parentElement);
   });
   });
 });
 });
+
+describe('FilamentMapping — FTS same-inlet advisory', () => {
+  // Bambu's own guidance: a change between two filaments on the SAME switch
+  // inlet has to retract the outgoing one all the way back to its AMS before
+  // the incoming one can be fed up the shared tube. A change across the two
+  // inlets only retracts as far as the switch. When every filament a job needs
+  // sits behind one inlet, every change in that job takes the slow path — the
+  // one arrangement worth telling the operator about, since moving a single
+  // spool fixes it.
+  const twoFilamentReqs = {
+    filaments: [
+      { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 20, used_meters: 7, nozzle_id: 0 },
+      { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 25, used_meters: 8.5, nozzle_id: 1 },
+    ],
+  };
+
+  // Two AMS units, one filament matching in each, so the pick is unambiguous.
+  const twoAmsStatus = (amsSwitchInlet: Record<string, 'A' | 'B'>): Partial<PrinterStatus> => ({
+    ams: [
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Bambu PLA' }] },
+      { id: 1, tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Bambu PETG' }] },
+    ],
+    fila_switch: { installed: true, in_slots: [-1, -1], out_extruders: [1, 0], stat: 0, info: 0 },
+    ams_switch_inlet: amsSwitchInlet,
+  } as Partial<PrinterStatus>);
+
+  const renderWith = (amsSwitchInlet: Record<string, 'A' | 'B'>) => {
+    server.use(
+      http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus(twoAmsStatus(amsSwitchInlet)))),
+    );
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={twoFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+  };
+
+  it('warns when every filament for the print is behind one inlet', async () => {
+    renderWith({ '0': 'A', '1': 'A' });
+    // Names the inlet, so the operator knows which spool to move.
+    expect(await screen.findByText(/on Filament Track Switch IN-A\./)).toBeInTheDocument();
+    expect(screen.getByText(/same inlet is slower/i)).toBeInTheDocument();
+  });
+
+  it('stays quiet when the filaments are split across both inlets', async () => {
+    renderWith({ '0': 'A', '1': 'B' });
+    await waitFor(() => {
+      expect(screen.getAllByText(/Bambu PETG/).length).toBeGreaterThan(0);
+    });
+    expect(screen.queryByText(/same inlet is slower/i)).not.toBeInTheDocument();
+  });
+
+  it('stays quiet when the bindings are not known', async () => {
+    // No advisory can be justified without knowing where the spools actually are.
+    renderWith({});
+    await waitFor(() => {
+      expect(screen.getAllByText(/Bambu PETG/).length).toBeGreaterThan(0);
+    });
+    expect(screen.queryByText(/same inlet is slower/i)).not.toBeInTheDocument();
+  });
+});

+ 199 - 0
frontend/src/__tests__/pages/PrintersPageAmsSwitchInlet.test.tsx

@@ -0,0 +1,199 @@
+/**
+ * The AMS card side badge on a Filament Track Switch machine.
+ *
+ * Without a switch, each AMS is wired to one nozzle and the card badges it L or
+ * R. With a switch fitted, the AMS is bound to one of the switch's two *inlets*
+ * instead and reaches BOTH nozzles through it — so every unit reports extruder
+ * 0xE and `ams_extruder_map` comes back empty.
+ *
+ * The card used to fall through to the AMS unit id in that case, which quietly
+ * labelled AMS 0 "R" and AMS 1 "L" from nothing but their unit numbers, gave a
+ * third unit no badge at all, and was wrong for every one of them. It now shows
+ * the inlet the printer's own "Manual AMS Setup" screen assigned — lettered L
+ * for In-A and R for In-B, with the tooltip naming the inlet outright so the
+ * letter is not mistaken for a claim about which nozzle the AMS feeds.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'H2C',
+  ip_address: '192.168.1.100',
+  serial_number: '31B8BP610600650',
+  access_code: '12345678',
+  model: 'H2C',
+  enabled: true,
+  nozzle_count: 2,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_id_name: 'A00-R0',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: 55,
+  drying_time: 8,
+  state: 3,
+};
+
+function amsUnit(id: number) {
+  return {
+    id,
+    humidity: 30,
+    temp: 33,
+    is_ams_ht: false,
+    serial_number: `AMS0${id}`,
+    sw_ver: '03.00.21.29',
+    module_type: 'n3f',
+    tray: [0, 1, 2, 3].map((t) => ({ id: t, ...baseTray })),
+  };
+}
+
+/** Three AMS units on a dual-nozzle printer, with the FTS fields under test. */
+function makeStatus(over: Record<string, unknown>) {
+  return {
+    connected: true,
+    state: 'IDLE',
+    progress: 0,
+    layer_num: 0,
+    total_layers: 0,
+    // Two nozzle readings are what marks the card as dual-nozzle, which is the
+    // precondition for any L/R badge appearing at all.
+    temperatures: { nozzle: 25, nozzle_2: 25, bed: 25, chamber: 25 },
+    remaining_time: 0,
+    filename: null,
+    wifi_signal: -29,
+    speed_level: 2,
+    vt_tray: [],
+    ams: [amsUnit(0), amsUnit(1), amsUnit(2)],
+    ams_extruder_map: {},
+    fila_switch: null,
+    ams_switch_inlet: {},
+    ...over,
+  };
+}
+
+function renderWith(over: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(over))),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+  );
+  render(<PrintersPage />);
+}
+
+const FTS_INSTALLED = { installed: true, in_slots: [-1, -1], out_extruders: [1, 0], stat: 0, info: 0 };
+
+describe('PrintersPage — AMS card side badge with a Filament Track Switch', () => {
+  beforeEach(() => {
+    server.use(http.get('/api/v1/queue/', () => HttpResponse.json([])));
+  });
+
+  it('badges each AMS with the switch inlet it is plumbed into', async () => {
+    renderWith({
+      fila_switch: FTS_INSTALLED,
+      ams_switch_inlet: { '0': 'A', '1': 'B', '2': 'B' },
+    });
+
+    await waitFor(() => {
+      expect(screen.getAllByTitle(/Filament Track Switch IN-A/).length).toBe(1);
+    });
+    expect(screen.getAllByTitle(/Filament Track Switch IN-B/).length).toBe(2);
+  });
+
+  it('letters In-A as L and In-B as R, and names the inlet in the tooltip', async () => {
+    renderWith({
+      fila_switch: FTS_INSTALLED,
+      ams_switch_inlet: { '0': 'A', '1': 'B', '2': 'B' },
+    });
+
+    // The letter is familiar; the tooltip carries what it actually means, since
+    // an AMS behind the switch reaches both nozzles and "L" is the inlet's
+    // position rather than the nozzle it feeds.
+    const inA = await screen.findByTitle(/IN-A/);
+    expect(inA.textContent).toBe('L');
+    expect(inA.title).toMatch(/\(L\)/);
+    expect(inA.title).toMatch(/both nozzles/i);
+    for (const inB of screen.getAllByTitle(/IN-B/)) {
+      expect(inB.textContent).toBe('R');
+      expect(inB.title).toMatch(/\(R\)/);
+    }
+  });
+
+  it('does not reuse the plain nozzle tooltip for an inlet badge', async () => {
+    // The two badges share a letter but never a tooltip: a bare "Left" would be
+    // the very claim the inlet badge exists to avoid making.
+    renderWith({
+      fila_switch: FTS_INSTALLED,
+      ams_switch_inlet: { '0': 'A', '1': 'B', '2': 'B' },
+    });
+
+    await waitFor(() => {
+      expect(screen.getAllByTitle(/IN-A/).length).toBeGreaterThan(0);
+    });
+    expect(screen.queryByTitle('Left')).toBeNull();
+    expect(screen.queryByTitle('Right')).toBeNull();
+  });
+
+  it('shows nothing rather than guessing when the switch is not set up yet', async () => {
+    // A switch fitted but not yet assigned on the printer screen reports no
+    // binding. This is the case the old unit-id fallback got wrong.
+    renderWith({ fila_switch: FTS_INSTALLED, ams_switch_inlet: {} });
+
+    // Wait for the AMS cards themselves, so the absence assertions below cannot
+    // pass trivially during the loading window.
+    await waitFor(() => {
+      expect(screen.getAllByText('AMS-C').length).toBeGreaterThan(0);
+    });
+    expect(screen.queryByTitle('Left')).toBeNull();
+    expect(screen.queryByTitle('Right')).toBeNull();
+    expect(screen.queryByTitle(/IN-[AB]/)).toBeNull();
+  });
+
+  it('still shows L/R on a dual-nozzle printer without a switch', async () => {
+    // Regression guard: the inlet work must not take the ordinary H2D badge
+    // down with it.
+    renderWith({ fila_switch: null, ams_extruder_map: { '0': 1, '1': 0, '2': 0 } });
+
+    await waitFor(() => {
+      expect(screen.getAllByTitle('Left').length).toBe(1);
+    });
+    expect(screen.getAllByTitle('Right').length).toBe(2);
+    expect(screen.queryByTitle(/IN-[AB]/)).toBeNull();
+  });
+
+  it('prefers a real extruder id over the unit-id guess even with a switch fitted', async () => {
+    // An AMS reporting a genuine extruder id is bound to that nozzle directly,
+    // switch or no switch — BambuStudio treats a non-0xE id as authoritative.
+    renderWith({
+      fila_switch: FTS_INSTALLED,
+      ams_extruder_map: { '2': 1 },
+      ams_switch_inlet: { '0': 'A', '1': 'B' },
+    });
+
+    await waitFor(() => {
+      expect(screen.getAllByTitle(/IN-A/).length).toBe(1);
+    });
+    expect(screen.getAllByTitle('Left').length).toBe(1);
+    expect(screen.queryByTitle('Right')).toBeNull();
+  });
+});

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

@@ -492,14 +492,21 @@ export interface PrintOptions {
 
 
 export interface FilaSwitchState {
 export interface FilaSwitchState {
   installed: boolean;
   installed: boolean;
-  // in[track] = currently loaded slot for that track (-1 = empty)
+  // Raw wire arrays, ordered **In-B first, then In-A** (BambuStudio's SwitchPos
+  // enum). in[] values are snow-encoded (bits 8-15 = AMS id, bits 0-7 = slot,
+  // -1 = empty); out[] values are the extruder each *outlet* terminates at, or
+  // 0xE when unset. Neither array says which inlet is currently routed to which
+  // outlet — that pairing is not reported. For per-AMS side information use
+  // PrinterStatus.ams_switch_inlet instead.
   in_slots: number[];
   in_slots: number[];
-  // out[track] = extruder this track terminates at (0 = right, 1 = left)
   out_extruders: number[];
   out_extruders: number[];
   stat: number;
   stat: number;
   info: number;
   info: number;
 }
 }
 
 
+// Which FTS inlet an AMS is plumbed into: 'A' | 'B'.
+export type FtsInlet = 'A' | 'B';
+
 export interface PrinterStatus {
 export interface PrinterStatus {
   id: number;
   id: number;
   name: string;
   name: string;
@@ -565,6 +572,11 @@ export interface PrinterStatus {
   // AMS slots aren't tied to a specific extruder; the FTS routes any slot to
   // AMS slots aren't tied to a specific extruder; the FTS routes any slot to
   // either extruder, so per-extruder slot filtering must be skipped.
   // either extruder, so per-extruder slot filtering must be skipped.
   fila_switch: FilaSwitchState | null;
   fila_switch: FilaSwitchState | null;
+  // Per-AMS FTS inlet binding, {ams_id: 'A' | 'B'}, as set on the printer's
+  // "Manual AMS Setup" screen. Empty unless a switch is installed. An AMS with
+  // 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.
+  ams_switch_inlet: Record<string, FtsInlet>;
   // 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.

+ 48 - 20
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
-import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
+import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
 import { useFilamentLabels } from './useFilamentLabels';
 import { useFilamentLabels } from './useFilamentLabels';
 import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
 import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
@@ -234,16 +234,39 @@ export function FilamentMapping({
 
 
   // Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
   // Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
   // (any slot can be routed to either extruder), so the per-nozzle dropdown
   // (any slot can be routed to either extruder), so the per-nozzle dropdown
-  // filter is suppressed. fila_switch.in_slots[track] = currently fed slot,
-  // fila_switch.out_extruders[track] = extruder that track terminates at. See #1162.
+  // filter is suppressed. See #1162.
+  //
+  // What a slot CAN be labelled with is the switch inlet its AMS is plumbed
+  // into (ams_switch_inlet, from AMS info bits 24-27). That is the stable
+  // relationship the printer's own "Manual AMS Setup" screen sets. The live
+  // inlet-to-outlet route is deliberately not shown: the firmware never reports
+  // which inlet is currently paired with which outlet, so any left/right label
+  // on a slot would be a guess.
   const ftsInstalled = printerStatus?.fila_switch?.installed === true;
   const ftsInstalled = printerStatus?.fila_switch?.installed === true;
-  const ftsExtruderForSlot = (globalTrayId: number): number | null => {
-    const fs = printerStatus?.fila_switch;
-    if (!fs?.installed) return null;
-    const track = fs.in_slots.indexOf(globalTrayId);
-    if (track < 0) return null;
-    return fs.out_extruders[track] ?? null;
-  };
+  const amsSwitchInlet = printerStatus?.ams_switch_inlet;
+  const ftsInletForAms = (amsId: number): 'A' | 'B' | null =>
+    (ftsInstalled && amsSwitchInlet?.[String(amsId)]) || null;
+
+  // Every filament for this print sitting behind one inlet is the case worth
+  // flagging. Bambu's own guidance: a change between two filaments on the same
+  // inlet has to retract the old one all the way back to its AMS before the new
+  // one can be fed through the shared tube, where a change across the two
+  // inlets only retracts as far as the switch. All-on-one-inlet means every
+  // single change in the job takes the slow path.
+  const sameInletWarning = useMemo(() => {
+    if (!ftsInstalled) return null;
+    const inlets = new Set<string>();
+    for (const item of filamentComparison) {
+      if (!item.loaded || item.loaded.isExternal) return null;
+      const inlet = ftsInletForAms(item.loaded.amsId);
+      if (!inlet) return null;
+      inlets.add(inlet);
+    }
+    if (filamentComparison.length < 2 || inlets.size !== 1) return null;
+    return [...inlets][0];
+    // ftsInletForAms is a stable closure over the two values already listed.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [ftsInstalled, amsSwitchInlet, filamentComparison]);
 
 
   // Don't render if no filament requirements
   // Don't render if no filament requirements
   if (!hasFilamentReqs) {
   if (!hasFilamentReqs) {
@@ -345,6 +368,12 @@ export function FilamentMapping({
               </button>
               </button>
             </div>
             </div>
           </div>
           </div>
+          {sameInletWarning && (
+            <div className="flex items-start gap-1.5 rounded border border-yellow-500/40 bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-700 dark:text-yellow-400">
+              <AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
+              <span>{t('printModal.ftsSameInletHint', { inlet: sameInletWarning })}</span>
+            </div>
+          )}
           {filamentComparison.map((item, idx) => {
           {filamentComparison.map((item, idx) => {
             // #1717: surface the same per-slot force-color-match checkbox here
             // #1717: surface the same per-slot force-color-match checkbox here
             // that FilamentOverride exposes for model-mode dispatch. The
             // that FilamentOverride exposes for model-mode dispatch. The
@@ -460,16 +489,15 @@ export function FilamentMapping({
                             defaultValue: ` - ${remainingWeight}g left`,
                             defaultValue: ` - ${remainingWeight}g left`,
                           })
                           })
                         : '';
                         : '';
-                      // FTS routing badge: if this slot is currently fed into an FTS
-                      // track, show the destination extruder. Idle (not-loaded) slots
-                      // get no badge — they can be routed to either extruder on demand.
-                      const ftsTargetExtruder = ftsInstalled
-                        ? ftsExtruderForSlot(f.globalTrayId)
-                        : null;
-                      const ftsBadge =
-                        ftsTargetExtruder == null
-                          ? ''
-                          : ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
+                      // FTS badge: which switch inlet this slot's AMS feeds. Not a
+                      // nozzle — the slot reaches both through the switch — but it
+                      // is what decides whether a change to the next filament is
+                      // the fast cross-inlet one or the slow same-inlet one.
+                      const ftsInlet = ftsInletForAms(f.amsId);
+                      // Same L/R lettering the printer card uses for inlets, so the
+                      // two views agree. Not translated: L and R are the letters on
+                      // the machine.
+                      const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
                       return (
                       return (
                         <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                         <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                           {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
                           {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}

+ 11 - 3
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -176,13 +176,21 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
     ? status.ams_extruder_map
     ? status.ams_extruder_map
     : cachedAmsExtruderMap.current;
     : cachedAmsExtruderMap.current;
 
 
+  const ftsInstalled = status?.fila_switch?.installed === true;
+
   const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
   const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
     if (!isDualNozzle) return null;
     if (!isDualNozzle) return null;
     const mappedExtruderId = amsExtruderMap[String(amsId)];
     const mappedExtruderId = amsExtruderMap[String(amsId)];
+    if (mappedExtruderId !== undefined) return mappedExtruderId === 1 ? 'L' : 'R';
+    // With a Filament Track Switch every AMS reports extruder 0xE and reaches
+    // both nozzles through the switch, so there is no side to show. The unit-id
+    // guess below would label them all "R" — it exists only for dual-nozzle
+    // printers that never sent a map at all. See PrintersPage.amsSideBadge,
+    // which shows the switch inlet in place of L/R on the printer card.
+    if (ftsInstalled) return null;
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
-    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
-    return extruderId === 1 ? 'L' : 'R';
-  }, [isDualNozzle, amsExtruderMap]);
+    return normalizedId === 1 ? 'L' : 'R';
+  }, [isDualNozzle, amsExtruderMap, ftsInstalled]);
 
 
   // Assign spool to AMS slot — single API call, backend handles both DB record
   // Assign spool to AMS slot — single API call, backend handles both DB record
   // AND MQTT auto-configuration. When the target slot is currently empty, the
   // AND MQTT auto-configuration. When the target slot is currently empty, the

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

@@ -416,6 +416,7 @@ export default {
     notInserted: 'Nicht eingelegt',
     notInserted: 'Nicht eingelegt',
     totalPrintHours: 'Druckstunden',
     totalPrintHours: 'Druckstunden',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
+    amsSwitchInletTooltip: 'Filamentweiche IN-{{inlet}} ({{side}}) — versorgt beide Düsen',
     nozzleRack: 'Düsenhalter',
     nozzleRack: 'Düsenhalter',
     nozzleDocked: 'Angedockt',
     nozzleDocked: 'Angedockt',
     nozzleMounted: 'Montiert',
     nozzleMounted: 'Montiert',
@@ -4951,6 +4952,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Linke Düse',
     leftNozzleTooltip: 'Linke Düse',
     rightNozzleTooltip: 'Rechte Düse',
     rightNozzleTooltip: 'Rechte Düse',
+    ftsSameInletHint: 'Alle Filamente für diesen Druck liegen an Filamentweiche IN-{{inlet}}. Wechsel zwischen Filamenten am selben Eingang dauern länger — verlege eines in ein AMS am anderen Eingang.',
     rackPosition: 'Wechslerposition',
     rackPosition: 'Wechslerposition',
     rackPositionTooltip: 'Welche Düse im Wechsler dieses Filament druckt. Die Positionen sind wie am Drucker nummeriert.',
     rackPositionTooltip: 'Welche Düse im Wechsler dieses Filament druckt. Die Positionen sind wie am Drucker nummeriert.',
     rackEmptyPosition: 'Diese Wechslerposition ist leer',
     rackEmptyPosition: 'Diese Wechslerposition ist leer',

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

@@ -419,6 +419,7 @@ export default {
     notInserted: 'Not inserted',
     notInserted: 'Not inserted',
     totalPrintHours: 'Print Hours',
     totalPrintHours: 'Print Hours',
     activeNozzle: 'Active: {{nozzle}} nozzle',
     activeNozzle: 'Active: {{nozzle}} nozzle',
+    amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — feeds both nozzles',
     nozzleRack: 'Nozzle Rack',
     nozzleRack: 'Nozzle Rack',
     nozzleDocked: 'Docked',
     nozzleDocked: 'Docked',
     nozzleMounted: 'Mounted',
     nozzleMounted: 'Mounted',
@@ -4995,6 +4996,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Left nozzle',
     leftNozzleTooltip: 'Left nozzle',
     rightNozzleTooltip: 'Right nozzle',
     rightNozzleTooltip: 'Right nozzle',
+    ftsSameInletHint: 'All filaments for this print are on Filament Track Switch IN-{{inlet}}. Switching between filaments on the same inlet is slower — move one to an AMS on the other inlet to speed it up.',
     rackPosition: 'Rack position',
     rackPosition: 'Rack position',
     rackPositionTooltip: 'Which nozzle on the rack prints this filament. Positions are numbered as on the printer.',
     rackPositionTooltip: 'Which nozzle on the rack prints this filament. Positions are numbered as on the printer.',
     rackEmptyPosition: 'This rack position is empty',
     rackEmptyPosition: 'This rack position is empty',

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

@@ -416,6 +416,7 @@ export default {
     notInserted: 'No insertada',
     notInserted: 'No insertada',
     totalPrintHours: 'Horas de impresión',
     totalPrintHours: 'Horas de impresión',
     activeNozzle: 'Activa: boquilla {{nozzle}}',
     activeNozzle: 'Activa: boquilla {{nozzle}}',
+    amsSwitchInletTooltip: 'Conmutador de ruta de filamento IN-{{inlet}} ({{side}}): alimenta ambas boquillas',
     nozzleRack: 'Soporte de boquillas',
     nozzleRack: 'Soporte de boquillas',
     nozzleDocked: 'Acoplada',
     nozzleDocked: 'Acoplada',
     nozzleMounted: 'Montada',
     nozzleMounted: 'Montada',
@@ -4958,6 +4959,7 @@ export default {
     rightNozzle: 'D',
     rightNozzle: 'D',
     leftNozzleTooltip: 'Boquilla izquierda',
     leftNozzleTooltip: 'Boquilla izquierda',
     rightNozzleTooltip: 'Boquilla derecha',
     rightNozzleTooltip: 'Boquilla derecha',
+    ftsSameInletHint: 'Todos los filamentos de esta impresión están en el conmutador de ruta de filamento IN-{{inlet}}. Cambiar entre filamentos de la misma entrada es más lento: mueve uno a un AMS de la otra entrada.',
     rackPosition: 'Posición del carro',
     rackPosition: 'Posición del carro',
     rackPositionTooltip: 'Qué boquilla del carro imprime este filamento. Las posiciones se numeran como en la impresora.',
     rackPositionTooltip: 'Qué boquilla del carro imprime este filamento. Las posiciones se numeran como en la impresora.',
     rackEmptyPosition: 'Esta posición del carro está vacía',
     rackEmptyPosition: 'Esta posición del carro está vacía',

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

@@ -416,6 +416,7 @@ export default {
     notInserted: 'Non insérée',
     notInserted: 'Non insérée',
     totalPrintHours: 'Heures d\'impression',
     totalPrintHours: 'Heures d\'impression',
     activeNozzle: 'Active : buse {{nozzle}}',
     activeNozzle: 'Active : buse {{nozzle}}',
+    amsSwitchInletTooltip: 'Sélecteur de chemin de filament IN-{{inlet}} ({{side}}) — alimente les deux buses',
     nozzleRack: 'Rack à buses',
     nozzleRack: 'Rack à buses',
     nozzleDocked: 'Rangée',
     nozzleDocked: 'Rangée',
     nozzleMounted: 'Montée',
     nozzleMounted: 'Montée',
@@ -4940,6 +4941,7 @@ export default {
     rightNozzle: 'D',
     rightNozzle: 'D',
     leftNozzleTooltip: 'Buse gauche',
     leftNozzleTooltip: 'Buse gauche',
     rightNozzleTooltip: 'Buse droite',
     rightNozzleTooltip: 'Buse droite',
+    ftsSameInletHint: 'Tous les filaments de cette impression sont sur le sélecteur de chemin de filament IN-{{inlet}}. Changer entre des filaments d\'une même entrée est plus lent — déplacez-en un vers un AMS de l\'autre entrée.',
     rackPosition: 'Position du rack',
     rackPosition: 'Position du rack',
     rackPositionTooltip: "Quelle buse du rack imprime ce filament. Les positions sont numérotées comme sur l'imprimante.",
     rackPositionTooltip: "Quelle buse du rack imprime ce filament. Les positions sont numérotées comme sur l'imprimante.",
     rackEmptyPosition: 'Cette position du rack est vide',
     rackEmptyPosition: 'Cette position du rack est vide',

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

@@ -416,6 +416,7 @@ export default {
     notInserted: 'Non inserita',
     notInserted: 'Non inserita',
     totalPrintHours: 'Ore di stampa',
     totalPrintHours: 'Ore di stampa',
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     activeNozzle: 'Attivo: ugello {{nozzle}}',
+    amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — alimenta entrambi gli ugelli',
     nozzleRack: 'Rack Ugelli',
     nozzleRack: 'Rack Ugelli',
     nozzleDocked: 'Agganciato',
     nozzleDocked: 'Agganciato',
     nozzleMounted: 'Montato',
     nozzleMounted: 'Montato',
@@ -4939,6 +4940,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Ugello sinistro',
     leftNozzleTooltip: 'Ugello sinistro',
     rightNozzleTooltip: 'Ugello destro',
     rightNozzleTooltip: 'Ugello destro',
+    ftsSameInletHint: 'Tutti i filamenti di questa stampa sono sul Filament Track Switch IN-{{inlet}}. Il cambio tra filamenti sullo stesso ingresso è più lento: spostane uno su un AMS dell\'altro ingresso.',
     rackPosition: 'Posizione nel rack',
     rackPosition: 'Posizione nel rack',
     rackPositionTooltip: 'Quale ugello del rack stampa questo filamento. Le posizioni sono numerate come sulla stampante.',
     rackPositionTooltip: 'Quale ugello del rack stampa questo filamento. Le posizioni sono numerate come sulla stampante.',
     rackEmptyPosition: 'Questa posizione del rack è vuota',
     rackEmptyPosition: 'Questa posizione del rack è vuota',

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

@@ -415,6 +415,7 @@ export default {
     notInserted: '未挿入',
     notInserted: '未挿入',
     totalPrintHours: '印刷時間',
     totalPrintHours: '印刷時間',
     activeNozzle: 'アクティブ: {{nozzle}}ノズル',
     activeNozzle: 'アクティブ: {{nozzle}}ノズル',
+    amsSwitchInletTooltip: 'フィラメント経路切替器 IN-{{inlet}}({{side}})— 両方のノズルに供給できます',
     nozzleRack: 'ノズルラック',
     nozzleRack: 'ノズルラック',
     nozzleDocked: 'ドッキング中',
     nozzleDocked: 'ドッキング中',
     nozzleMounted: 'マウント中',
     nozzleMounted: 'マウント中',
@@ -4951,6 +4952,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: '左ノズル',
     leftNozzleTooltip: '左ノズル',
     rightNozzleTooltip: '右ノズル',
     rightNozzleTooltip: '右ノズル',
+    ftsSameInletHint: 'この印刷で使うフィラメントはすべてフィラメント経路切替器 IN-{{inlet}} にあります。同じ入口のフィラメント同士の交換は遅くなります。1 つをもう一方の入口の AMS に移すと速くなります。',
     rackPosition: 'ラック位置',
     rackPosition: 'ラック位置',
     rackPositionTooltip: 'このフィラメントを印刷するラック上のノズル。位置番号はプリンター本体と同じです。',
     rackPositionTooltip: 'このフィラメントを印刷するラック上のノズル。位置番号はプリンター本体と同じです。',
     rackEmptyPosition: 'このラック位置は空です',
     rackEmptyPosition: 'このラック位置は空です',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -393,6 +393,7 @@ export default {
     notInserted: '삽입되지 않음',
     notInserted: '삽입되지 않음',
     totalPrintHours: '인쇄 시간',
     totalPrintHours: '인쇄 시간',
     activeNozzle: '활성: {{nozzle}} 노즐',
     activeNozzle: '활성: {{nozzle}} 노즐',
+    amsSwitchInletTooltip: '필라멘트 트랙 스위치 IN-{{inlet}}({{side}}) — 양쪽 노즐에 공급',
     nozzleRack: '노즐 랙',
     nozzleRack: '노즐 랙',
     nozzleDocked: '도킹됨',
     nozzleDocked: '도킹됨',
     nozzleMounted: '장착됨',
     nozzleMounted: '장착됨',
@@ -4721,6 +4722,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: '왼쪽 노즐',
     leftNozzleTooltip: '왼쪽 노즐',
     rightNozzleTooltip: '오른쪽 노즐',
     rightNozzleTooltip: '오른쪽 노즐',
+    ftsSameInletHint: '이 출력에 사용할 필라멘트가 모두 필라멘트 트랙 스위치 IN-{{inlet}}에 있습니다. 같은 입구의 필라멘트끼리 교체하면 느립니다. 하나를 반대쪽 입구의 AMS로 옮기면 빨라집니다.',
     rackPosition: '랙 위치',
     rackPosition: '랙 위치',
     rackPositionTooltip: '이 필라멘트를 출력할 랙의 노즐입니다. 위치 번호는 프린터와 동일합니다.',
     rackPositionTooltip: '이 필라멘트를 출력할 랙의 노즐입니다. 위치 번호는 프린터와 동일합니다.',
     rackEmptyPosition: '이 랙 위치는 비어 있습니다',
     rackEmptyPosition: '이 랙 위치는 비어 있습니다',

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

@@ -416,6 +416,7 @@ export default {
     notInserted: 'Não inserido',
     notInserted: 'Não inserido',
     totalPrintHours: 'Horas de impressão',
     totalPrintHours: 'Horas de impressão',
     activeNozzle: 'Ativo: {{nozzle}} bico',
     activeNozzle: 'Ativo: {{nozzle}} bico',
+    amsSwitchInletTooltip: 'Comutador de trajeto de filamento IN-{{inlet}} ({{side}}) — alimenta os dois bicos',
     nozzleRack: 'Suporte de bicos',
     nozzleRack: 'Suporte de bicos',
     nozzleDocked: 'Acoplado',
     nozzleDocked: 'Acoplado',
     nozzleMounted: 'Montado',
     nozzleMounted: 'Montado',
@@ -4939,6 +4940,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Bico esquerdo',
     leftNozzleTooltip: 'Bico esquerdo',
     rightNozzleTooltip: 'Bico direito',
     rightNozzleTooltip: 'Bico direito',
+    ftsSameInletHint: 'Todos os filamentos desta impressão estão no comutador de trajeto de filamento IN-{{inlet}}. Trocar entre filamentos da mesma entrada é mais lento — mova um para um AMS da outra entrada.',
     rackPosition: 'Posição no rack',
     rackPosition: 'Posição no rack',
     rackPositionTooltip: 'Qual bico do rack imprime este filamento. As posições são numeradas como na impressora.',
     rackPositionTooltip: 'Qual bico do rack imprime este filamento. As posições são numeradas como na impressora.',
     rackEmptyPosition: 'Esta posição do rack está vazia',
     rackEmptyPosition: 'Esta posição do rack está vazia',

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

@@ -398,6 +398,7 @@ export default {
     notInserted: "Не установлена",
     notInserted: "Не установлена",
     totalPrintHours: "Часов печати",
     totalPrintHours: "Часов печати",
     activeNozzle: "Активное сопло: {{nozzle}}",
     activeNozzle: "Активное сопло: {{nozzle}}",
+    amsSwitchInletTooltip: "Переключатель тракта филамента IN-{{inlet}} ({{side}}) — подаёт на оба сопла",
     nozzleRack: "Держатель сопел",
     nozzleRack: "Держатель сопел",
     nozzleDocked: "В держателе",
     nozzleDocked: "В держателе",
     nozzleMounted: "Установлено",
     nozzleMounted: "Установлено",
@@ -4710,6 +4711,7 @@ export default {
     rightNozzle: "П",
     rightNozzle: "П",
     leftNozzleTooltip: "Левое сопло",
     leftNozzleTooltip: "Левое сопло",
     rightNozzleTooltip: "Правое сопло",
     rightNozzleTooltip: "Правое сопло",
+    ftsSameInletHint: "Все филаменты для этой печати подключены к переключателю тракта филамента IN-{{inlet}}. Смена между филаментами на одном входе идёт медленнее — переставьте один в AMS на другом входе.",
     rackPosition: "Позиция в стойке",
     rackPosition: "Позиция в стойке",
     rackPositionTooltip: "Каким соплом из стойки печатать этот филамент. Позиции нумеруются так же, как на принтере.",
     rackPositionTooltip: "Каким соплом из стойки печатать этот филамент. Позиции нумеруются так же, как на принтере.",
     rackEmptyPosition: "Эта позиция в стойке пуста",
     rackEmptyPosition: "Эта позиция в стойке пуста",

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

@@ -416,6 +416,7 @@ export default {
     notInserted: 'Takılı değil',
     notInserted: 'Takılı değil',
     totalPrintHours: 'Baskı Saatleri',
     totalPrintHours: 'Baskı Saatleri',
     activeNozzle: 'Aktif: {{nozzle}} nozul',
     activeNozzle: 'Aktif: {{nozzle}} nozul',
+    amsSwitchInletTooltip: 'Filament Hattı Değiştirici IN-{{inlet}} ({{side}}) — her iki nozulu besler',
     nozzleRack: 'Nozul Rafı',
     nozzleRack: 'Nozul Rafı',
     nozzleDocked: 'Yuvalanmış',
     nozzleDocked: 'Yuvalanmış',
     nozzleMounted: 'Takılı',
     nozzleMounted: 'Takılı',
@@ -4928,6 +4929,7 @@ export default {
     rightNozzle: 'R',
     rightNozzle: 'R',
     leftNozzleTooltip: 'Sol nozul',
     leftNozzleTooltip: 'Sol nozul',
     rightNozzleTooltip: 'Sağ nozul',
     rightNozzleTooltip: 'Sağ nozul',
+    ftsSameInletHint: 'Bu baskıdaki tüm filamentler Filament Hattı Değiştirici IN-{{inlet}} girişinde. Aynı girişteki filamentler arasında geçiş daha yavaştır — birini diğer girişteki bir AMS\'e taşıyın.',
     rackPosition: 'Rack konumu',
     rackPosition: 'Rack konumu',
     rackPositionTooltip: 'Bu filamenti rack üzerindeki hangi nozulun basacağı. Konumlar yazıcıdaki gibi numaralandırılmıştır.',
     rackPositionTooltip: 'Bu filamenti rack üzerindeki hangi nozulun basacağı. Konumlar yazıcıdaki gibi numaralandırılmıştır.',
     rackEmptyPosition: 'Bu rack konumu boş',
     rackEmptyPosition: 'Bu rack konumu boş',

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

@@ -419,6 +419,7 @@ export default {
     notInserted: "Не вставлено",
     notInserted: "Не вставлено",
     totalPrintHours: "Загальний час друку",
     totalPrintHours: "Загальний час друку",
     activeNozzle: "Активне сопло: {{nozzle}}",
     activeNozzle: "Активне сопло: {{nozzle}}",
+    amsSwitchInletTooltip: "Перемикач тракту філаменту IN-{{inlet}} ({{side}}) — подає на обидва сопла",
     nozzleRack: "Стійка сопел",
     nozzleRack: "Стійка сопел",
     nozzleDocked: "У док-станції",
     nozzleDocked: "У док-станції",
     nozzleMounted: "Установлено",
     nozzleMounted: "Установлено",
@@ -4993,6 +4994,7 @@ export default {
     rightNozzle: "Р",
     rightNozzle: "Р",
     leftNozzleTooltip: "Ліве сопло",
     leftNozzleTooltip: "Ліве сопло",
     rightNozzleTooltip: "Праве сопло",
     rightNozzleTooltip: "Праве сопло",
+    ftsSameInletHint: "Усі філаменти для цього друку підключені до перемикача тракту філаменту IN-{{inlet}}. Зміна між філаментами на одному вході повільніша — перемістіть один в AMS на іншому вході.",
     rackPosition: "Позиція у стійці",
     rackPosition: "Позиція у стійці",
     rackPositionTooltip: "Яким соплом зі стійки друкувати цей філамент. Позиції нумеруються так само, як на принтері.",
     rackPositionTooltip: "Яким соплом зі стійки друкувати цей філамент. Позиції нумеруються так само, як на принтері.",
     rackEmptyPosition: "Ця позиція у стійці порожня",
     rackEmptyPosition: "Ця позиція у стійці порожня",

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

@@ -416,6 +416,7 @@ export default {
     notInserted: '未插入',
     notInserted: '未插入',
     totalPrintHours: '打印时长',
     totalPrintHours: '打印时长',
     activeNozzle: '当前:{{nozzle}} 喷嘴',
     activeNozzle: '当前:{{nozzle}} 喷嘴',
+    amsSwitchInletTooltip: '耗材变轨器 IN-{{inlet}}({{side}})— 可向两个喷嘴供料',
     nozzleRack: '喷嘴架',
     nozzleRack: '喷嘴架',
     nozzleDocked: '已停靠',
     nozzleDocked: '已停靠',
     nozzleMounted: '已安装',
     nozzleMounted: '已安装',
@@ -4939,6 +4940,7 @@ export default {
     rightNozzle: '右',
     rightNozzle: '右',
     leftNozzleTooltip: '左喷嘴',
     leftNozzleTooltip: '左喷嘴',
     rightNozzleTooltip: '右喷嘴',
     rightNozzleTooltip: '右喷嘴',
+    ftsSameInletHint: '本次打印的所有耗材都在耗材变轨器 IN-{{inlet}} 上。同一入口的耗材之间切换较慢,可将其中一卷移到另一入口的 AMS。',
     rackPosition: '刀架位置',
     rackPosition: '刀架位置',
     rackPositionTooltip: '由刀架上的哪个喷嘴打印此耗材。位置编号与打印机上一致。',
     rackPositionTooltip: '由刀架上的哪个喷嘴打印此耗材。位置编号与打印机上一致。',
     rackEmptyPosition: '此刀架位置为空',
     rackEmptyPosition: '此刀架位置为空',

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

@@ -416,6 +416,7 @@ export default {
     notInserted: '未插入',
     notInserted: '未插入',
     totalPrintHours: '列印時長',
     totalPrintHours: '列印時長',
     activeNozzle: '目前:{{nozzle}} 噴嘴',
     activeNozzle: '目前:{{nozzle}} 噴嘴',
+    amsSwitchInletTooltip: '耗材變軌器 IN-{{inlet}}({{side}})— 可向兩個噴嘴供料',
     nozzleRack: '噴嘴架',
     nozzleRack: '噴嘴架',
     nozzleDocked: '已停靠',
     nozzleDocked: '已停靠',
     nozzleMounted: '已安裝',
     nozzleMounted: '已安裝',
@@ -4939,6 +4940,7 @@ export default {
     rightNozzle: '右',
     rightNozzle: '右',
     leftNozzleTooltip: '左噴嘴',
     leftNozzleTooltip: '左噴嘴',
     rightNozzleTooltip: '右噴嘴',
     rightNozzleTooltip: '右噴嘴',
+    ftsSameInletHint: '本次列印的所有耗材都在耗材變軌器 IN-{{inlet}} 上。同一入口的耗材之間切換較慢,可將其中一捲移到另一入口的 AMS。',
     rackPosition: '刀架位置',
     rackPosition: '刀架位置',
     rackPositionTooltip: '由刀架上的哪個噴嘴列印此耗材。位置編號與印表機上一致。',
     rackPositionTooltip: '由刀架上的哪個噴嘴列印此耗材。位置編號與印表機上一致。',
     rackEmptyPosition: '此刀架位置為空',
     rackEmptyPosition: '此刀架位置為空',

+ 87 - 15
frontend/src/pages/PrintersPage.tsx

@@ -174,7 +174,7 @@ 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 } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, 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';
@@ -217,18 +217,66 @@ function formatKValue(k: number | null | undefined): string {
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   const { mode } = useTheme();
   const { mode } = useTheme();
+  const { t } = useTranslation();
   // Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green)
   // Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green)
   const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9';
   const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9';
   return (
   return (
     <span
     <span
       className="inline-flex items-center justify-center w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)] text-[length:var(--pc-t10,10px)] font-bold rounded"
       className="inline-flex items-center justify-center w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)] text-[length:var(--pc-t10,10px)] font-bold rounded"
       style={{ backgroundColor: bgColor, color: '#00ae42' }}
       style={{ backgroundColor: bgColor, color: '#00ae42' }}
+      title={side === 'L' ? t('common.left') : t('common.right')}
     >
     >
       {side}
       {side}
     </span>
     </span>
   );
   );
 }
 }
 
 
+// Filament Track Switch inlet indicator. Same L/R lettering as NozzleBadge but
+// deliberately a different colour, because it means something different: this
+// AMS is plumbed into one switch inlet and reaches BOTH nozzles through it.
+function InletBadge({ inlet, title }: { inlet: 'A' | 'B'; title: string }) {
+  const { mode } = useTheme();
+  const bgColor = mode === 'dark' ? '#1e3a5f' : '#e3f0fb';
+  return (
+    <span
+      className="inline-flex items-center justify-center w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)] text-[length:var(--pc-t10,10px)] font-bold rounded"
+      style={{ backgroundColor: bgColor, color: '#3b82f6' }}
+      title={title}
+    >
+      {FTS_INLET_SIDE[inlet]}
+    </span>
+  );
+}
+
+/**
+ * Which side indicator, if any, belongs on an AMS card header.
+ *
+ * Three sources, in descending authority:
+ *   1. A Filament Track Switch inlet binding — the AMS feeds both nozzles
+ *      through the switch, so the inlet is the only meaningful label.
+ *   2. A real extruder id from ams_extruder_map.
+ *   3. The AMS unit id, as a last-resort guess for dual-nozzle printers that
+ *      never reported a map. This one is only a guess, and it is suppressed
+ *      when a switch is installed: with an FTS every unit reports extruder
+ *      0xE, so the fallback would silently label AMS 0 "R" and AMS 1 "L" from
+ *      nothing but their unit numbers.
+ */
+function amsSideBadge(
+  amsId: number,
+  amsExtruderMap: Record<string, number>,
+  amsSwitchInlet: Record<string, 'A' | 'B'>,
+  ftsInstalled: boolean
+): { kind: 'inlet'; inlet: 'A' | 'B' } | { kind: 'nozzle'; side: 'L' | 'R' } | null {
+  const inlet = amsSwitchInlet[String(amsId)];
+  if (inlet) return { kind: 'inlet', inlet };
+
+  const mapped = amsExtruderMap[String(amsId)];
+  const extruderId = mapped !== undefined ? mapped : ftsInstalled ? undefined : amsId >= 128 ? amsId - 128 : amsId;
+  if (extruderId === 1) return { kind: 'nozzle', side: 'L' };
+  if (extruderId === 0) return { kind: 'nozzle', side: 'R' };
+  return null;
+}
+
 // Expand nozzle type codes to material names
 // Expand nozzle type codes to material names
 // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
 // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
 // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
 // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
@@ -2419,6 +2467,20 @@ function PrinterCard({
     ? status.ams_extruder_map
     ? status.ams_extruder_map
     : cachedAmsExtruderMap.current;
     : cachedAmsExtruderMap.current;
 
 
+  // Same caching for the Filament Track Switch inlet bindings, for the same
+  // reason: a partial MQTT frame briefly empties the map and the badges would
+  // otherwise blink out.
+  const cachedAmsSwitchInlet = useRef<Record<string, 'A' | 'B'>>({});
+  useEffect(() => {
+    if (status?.ams_switch_inlet && Object.keys(status.ams_switch_inlet).length > 0) {
+      cachedAmsSwitchInlet.current = status.ams_switch_inlet;
+    }
+  }, [status?.ams_switch_inlet]);
+  const ftsInstalled = status?.fila_switch?.installed === true;
+  const amsSwitchInlet = (status?.ams_switch_inlet && Object.keys(status.ams_switch_inlet).length > 0)
+    ? status.ams_switch_inlet
+    : cachedAmsSwitchInlet.current;
+
   // Cache AMS data to prevent it disappearing on idle/offline printers
   // Cache AMS data to prevent it disappearing on idle/offline printers
   const cachedAmsData = useRef<AMSUnit[]>([]);
   const cachedAmsData = useRef<AMSUnit[]>([]);
   useEffect(() => {
   useEffect(() => {
@@ -5129,10 +5191,7 @@ function PrinterCard({
                     {/* Regular AMS units */}
                     {/* Regular AMS units */}
                     {regularAms.map((ams) => {
                     {regularAms.map((ams) => {
                       const mappedExtruderId = amsExtruderMap[String(ams.id)];
                       const mappedExtruderId = amsExtruderMap[String(ams.id)];
-                      const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
-                      const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
-                      const isLeftNozzle = extruderId === 1;
-                      const isRightNozzle = extruderId === 0;
+                      const sideBadge = amsSideBadge(ams.id, amsExtruderMap, amsSwitchInlet, ftsInstalled);
 
 
                       return (
                       return (
                         <div key={ams.id} style={getAmsCardStyle(4)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
                         <div key={ams.id} style={getAmsCardStyle(4)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
@@ -5152,9 +5211,17 @@ function PrinterCard({
                                     {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
                                     {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
                                   </span>
                                   </span>
                                 </AmsNameHoverCard>
                                 </AmsNameHoverCard>
-                                {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
-                                  <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
-                                )}
+                                {sideBadge?.kind === 'inlet' ? (
+                                  <InletBadge
+                                    inlet={sideBadge.inlet}
+                                    title={t('printers.amsSwitchInletTooltip', {
+                                      inlet: sideBadge.inlet,
+                                      side: FTS_INLET_SIDE[sideBadge.inlet],
+                                    })}
+                                  />
+                                ) : isDualNozzle && sideBadge?.kind === 'nozzle' ? (
+                                  <NozzleBadge side={sideBadge.side} />
+                                ) : null}
                               </div>
                               </div>
                               {(ams.humidity != null || ams.temp != null) && (
                               {(ams.humidity != null || ams.temp != null) && (
                                 <div className="flex shrink-0 items-center gap-1.5">
                                 <div className="flex shrink-0 items-center gap-1.5">
@@ -5557,10 +5624,7 @@ function PrinterCard({
                     {/* HT AMS units */}
                     {/* HT AMS units */}
                     {htAms.map((ams) => {
                     {htAms.map((ams) => {
                       const mappedExtruderId = amsExtruderMap[String(ams.id)];
                       const mappedExtruderId = amsExtruderMap[String(ams.id)];
-                      const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
-                      const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
-                      const isLeftNozzle = extruderId === 1;
-                      const isRightNozzle = extruderId === 0;
+                      const sideBadge = amsSideBadge(ams.id, amsExtruderMap, amsSwitchInlet, ftsInstalled);
                       const tray = ams.tray[0];
                       const tray = ams.tray[0];
                       const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                       const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                       const isEmpty = !tray?.tray_type;
                       const isEmpty = !tray?.tray_type;
@@ -5722,9 +5786,17 @@ function PrinterCard({
                                     {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
                                     {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
                                   </span>
                                   </span>
                                 </AmsNameHoverCard>
                                 </AmsNameHoverCard>
-                                {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
-                                  <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
-                                )}
+                                {sideBadge?.kind === 'inlet' ? (
+                                  <InletBadge
+                                    inlet={sideBadge.inlet}
+                                    title={t('printers.amsSwitchInletTooltip', {
+                                      inlet: sideBadge.inlet,
+                                      side: FTS_INLET_SIDE[sideBadge.inlet],
+                                    })}
+                                  />
+                                ) : isDualNozzle && sideBadge?.kind === 'nozzle' ? (
+                                  <NozzleBadge side={sideBadge.side} />
+                                ) : null}
                               </div>
                               </div>
                               {/* Drying button for HT AMS */}
                               {/* Drying button for HT AMS */}
                               {(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
                               {(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (

+ 11 - 0
frontend/src/utils/amsHelpers.ts

@@ -31,6 +31,17 @@ export function normalizeColorForCompare(color: string | undefined): string {
   return color.replace('#', '').toLowerCase().substring(0, 6);
   return color.replace('#', '').toLowerCase().substring(0, 6);
 }
 }
 
 
+/**
+ * Which side letter stands for a Filament Track Switch inlet: In-A reads as L,
+ * In-B as R.
+ *
+ * This labels the inlet's position, not the nozzle it feeds — the switch can
+ * route either inlet to either nozzle, and it never reports which pairing is
+ * live. Anywhere this letter is shown next to a hover target, the tooltip names
+ * the inlet outright so the two cannot be confused.
+ */
+export const FTS_INLET_SIDE = { A: 'L', B: 'R' } as const;
+
 /**
 /**
  * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
  * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-FP9eKiXB.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-Cxc_iG-w.js"></script>
+    <script type="module" crossorigin src="/assets/index-FP9eKiXB.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
     <link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
   </head>
   </head>
   <body>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio