Ver código fonte

Track which nozzle each AMS feeds through the Filament Track Switch

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. Bambuddy 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 binding needed no new telemetry. BambuStudio reads it out of bits
24-27 of the same AMS info string we already parse for the type and the
extruder id -- 0 is In-B, 1 is In-A -- and it only means anything when a
switch is installed, because without one 0xE really does mean an
uninitialised unit. That gates the read, which 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.

The badge letters stay L and R, In-A reading as L, with the inlet named
in full in the tooltip -- the letter is the inlet's position and not a
claim about which nozzle that AMS feeds, since the switch can route
either inlet to either outlet. Both views update live now. The switch
fields were missing from the WebSocket payload, and the frontend
shallow-merges each push over its cached status, so an absent field kept
whatever the last full fetch left behind. The broadcast dedup key had no
term for them either, so "Join IN-B" on the printer screen moved nothing:
the binding is not in the tray component of that key, and not in the AMS
change-hash, which covers tray fields only and must stay that way because
it drives Spoolman sync.

The rest of this is the calibration half, which is where it actually
bites. K-profiles are calibrated per nozzle and the printer numbers its
calibration table per nozzle too, so entry 16 exists on both hotends and
means a different profile on each. A tray holds exactly one index. Move
an AMS to the other inlet and every configured slot in it silently keeps
pointing at the old hotend's table -- measured on the maintainer's H2C, a
black PLA calibrated 0.018 left and 0.020 right stayed on the left
profile after the move, and a manual RFID re-read only re-asserted the
same wrong one. Bambu has not solved this either; their AMS dialog
carries "TODO: fila_switcher broken the connection of ams->extruder"
above the line that decides which nozzle's profiles to offer.

Three copies of "which extruder is this slot on" each ended in else 0,
which on a switch machine filed every profile under the right-hand
nozzle. They now share one resolver that returns None for "unknown",
because unknown and extruder 0 are very different answers on a
dual-nozzle machine and conflating them is what bound a left-nozzle
profile to a slot sitting on the right. The per-slot K value on the
printer card had the same confusion from the other direction: its lookup
was keyed on cali_idx alone, so one nozzle's K silently overwrote the
other's.

Moving an AMS now re-selects each configured slot's counterpart profile
for the nozzle it has arrived on. Only the calibration binding moves, and
only for slots whose spool already has a profile for that nozzle:
configuring a slot is a deliberate preparation step, so a slot we know
nothing about, or a spool calibrated on one hotend only, is left exactly
as the operator set it. Nothing fires on the first sighting of a binding
either, since every reconnect learns them afresh and re-applying there
would overwrite a choice made by hand.

Configure Slot resolves against the slot's own nozzle throughout. Option
identity carries the extruder, so a filament calibrated on both hotends
gives two distinguishable entries instead of two that collapse into
whichever the printer listed first; options name the hotend; matches are
scoped to the nozzle the slot feeds, with the other hotend's profiles
still reachable under Other K profiles; and the slot's active index is
resolved as a pair rather than followed into the wrong table.

Inlet to nozzle is one table, In-A to the left hotend and In-B to the
right, measured rather than assumed -- fila_switch.out cannot be used for
it, reporting [1, 1] unchanged across a 90-second capture, both outlets
claiming the same extruder. The print dialog picks up the same inlet
labelling in its slot dropdown, replacing a left/right hint that never
rendered because it matched snow-encoded values against global tray ids;
decoding it correctly would not have saved it, since the firmware never
reports which inlet is currently paired with which outlet. The dialog
also notes when every filament a print needs sits behind one inlet, which
is legal but slow -- a change between two filaments on the same inlet
retracts the outgoing spool all the way back to its AMS, where a change
across the two only retracts as far as the switch.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
that binding and has no command to write it, so there is no wire format
to copy.
maziggy 3 semanas atrás
pai
commit
c5e0055864

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **K-profiles follow an AMS when it moves between Filament Track Switch inlets** — K-profiles are calibrated per nozzle, and the printer numbers its calibration table per nozzle too, so entry 16 exists on both hotends and means a different profile on each. An AMS tray, however, holds exactly one index. Move an AMS to the switch's other inlet and every configured slot in it silently keeps pointing at the old hotend's table: on the maintainer's H2C a black PLA calibrated 0.018 on the left and 0.020 on the right stayed on the left profile after the move, and a manual RFID re-read only re-asserted the same wrong one. Bambuddy already stores both profiles for a spool, so the move now re-selects the counterpart for the nozzle that AMS actually feeds. Only the calibration binding changes and only for slots whose spool already has a profile for the new nozzle — configuring a slot is a deliberate preparation step, so a slot Bambuddy knows nothing about, or a spool calibrated on one hotend only, is left exactly as you set it. Nothing is re-applied on the first sighting of a binding either, or every reconnect would overwrite a choice made by hand.
+- **Configure Slot picks the K-profile for the slot's own nozzle** — The profile dropdown identified an entry by name and K value alone, with nothing naming the hotend, so a filament calibrated on both appeared twice with no way to tell them apart, and two that happened to share a K value collapsed into whichever the printer listed first. The tie-break meant to prefer the slot's own nozzle was gated on a value that is never set on a machine with a Filament Track Switch, where no AMS reports a nozzle at all — so the pick was arbitrary, and the fallback for an unrecognised binding was simply the first profile in the list. Options now carry the hotend, matches are scoped to the nozzle the slot actually feeds (the other hotend's profiles remain available under **Other K profiles**), and the slot's active index is resolved against its own nozzle rather than followed into the wrong table. The K value shown per slot on the printer card was affected by the same confusion and is now resolved the same way. Covered by backend and frontend tests.
 - **Auto K-profile calibration no longer leaves an archive behind** — When flow dynamics calibration is on, the printer lays down a pressure-advance line before the print itself. It announces that over MQTT through the same print-start event a real print uses, so Bambuddy archived it: a row named `auto_pa_line_calib_mode`, marked as having no 3MF, in among your actual prints, plus a "Print started" and a "Print completed" notification for each one. Bambuddy has always skipped the printer's other internal jobs, but only by spotting the `/usr/` path they carry — and this one arrives as a bare subtask name with no path at all, so it went straight past. It is now recognised by name, from either field the printer might report it in, and matched exactly so that a file you have deliberately named after the calibration is still your file. The completion is quiet too, which matters more than the noise: with no archive to close, it would have fallen into the path that attributes an unmatched completion to any job the printer finished in the last five minutes — and a calibration that runs alongside a real print would have told that print's owner it was done, early. Skipping the run early also saves the pointless FTP sweep for a 3MF that cannot exist, roughly a hundred connections to a printer that is mid-calibration. Covered by backend tests.
 - **A printer being busy or offline no longer blocks printing from its card (#2849)** — Dragging a sliced file onto a printer card refused the drop unless the printer was connected and neither printing nor paused, showing a red "Printer busy" and discarding the file; the card's **Print** button disappeared under the same condition. The workaround was to upload to the File Manager and queue it from there by hand, which is the same thing with more steps. It was never a real restriction: every print Bambuddy sends becomes a queue item, and dropping onto an idle printer only looks instant because the scheduler dispatches it right away. Both routes now work whatever the printer is doing, and a busy one simply queues the job. Offline printers are included, so you can queue work for a machine that is powered down and have it dispatch when it reconnects. The overlay says which you are getting — "Drop to print" when the job would start immediately, "Drop to queue" when it would wait, covering a print in progress, a paused job, an AMS mid-drying and a plate not yet cleared. That wording comes from the same check the Print Modal uses for its own "will start later" notice, so the card and the modal cannot disagree. The drop is also now gated on the permissions the flow actually uses, **Library Upload** and **Queue Create**, matching the Print button beside it; it previously checked Printer Control, which meant someone holding that but neither of the others got the file uploaded and then rejected by the queue, leaving it stranded in the library. Translated in all locales; wiki updated. Covered by frontend tests.
 - **Drying an AMS no longer sends an hourly "temperature high" alert for the whole cycle (#1802)** — The AMS temperature alert compares against the same threshold that colours the printer card, which defaults to 35 C, while drying deliberately runs at 45 C for PLA, 65 C for PETG and up to 85 C on an AMS-HT. The alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature you asked for, and then kept sending them while the unit cooled back down. The alert is now held back for the length of a cycle and through the cool-down that follows it, using the drying state the firmware already reports rather than anything you have to configure. Suppression lifts as soon as the unit reads back at or below your threshold, so a 65 C cycle in a cold basement and a 45 C one in a warm room each get exactly the cool-down they need instead of a fixed guess. Two things are deliberately left alone: the humidity alert, which is the one you want during drying because the number falling is the point, and a unit reporting `HeatOutOfControl`, where an AMS that has lost thermal control is precisely when the alert should still reach you. Because a cycle plus its cool-down can outlast a restart, the suppression is stored rather than held in memory. No new setting — the alert simply stops firing for heat you asked for. Wiki updated. Covered by backend tests.

+ 50 - 27
backend/app/api/routes/printers.py

@@ -68,6 +68,7 @@ from backend.app.services.printer_manager import (
     uniform_tray_filament_hint,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
+from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
 
@@ -495,16 +496,32 @@ async def get_printer_status(
     ams_exists = False
     raw_data = state.raw_data or {}
 
-    # Build K-profile lookup map: cali_idx -> k_value
-    # This allows looking up the calibrated K value for each AMS slot
-    kprofile_map: dict[int, float] = {}
+    # Build K-profile lookup map: (extruder_id, cali_idx) -> k_value.
+    #
+    # Keyed on the pair, not on cali_idx alone: the printer numbers its
+    # calibration table per nozzle, so entry 16 exists on both and means a
+    # different profile on each. A cali_idx-only map let whichever profile the
+    # printer happened to list last overwrite the other, and the slot then
+    # displayed the wrong nozzle's K — on the maintainer's H2C, 0.018 and 0.020
+    # for the same spool.
+    kprofile_map: dict[tuple[int, int], float] = {}
     for kp in state.kprofiles or []:
         if kp.slot_id is not None and kp.k_value:
             try:
-                kprofile_map[kp.slot_id] = float(kp.k_value)
+                kprofile_map[(int(kp.extruder_id or 0), kp.slot_id)] = float(kp.k_value)
             except (ValueError, TypeError):
                 pass  # Skip K-profile entries with unparseable values
 
+    def _kprofile_k(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
+        """K value for a slot's bound profile, resolved against its own nozzle."""
+        if cali_idx is None:
+            return None
+        extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
+        if extruder is not None:
+            return kprofile_map.get((extruder, cali_idx))
+        # Single-nozzle printers report everything under extruder 0.
+        return kprofile_map.get((0, cali_idx))
+
     # Cached active-cycle drying params (filament + target temp) we sent
     # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
     # needs the cache to render "<filament> @ <temp>°C".
@@ -529,8 +546,8 @@ async def get_printer_status(
                 # Get K value: first try tray's k field, then lookup from K-profiles
                 k_value = tray_data.get("k")
                 cali_idx = tray_data.get("cali_idx")
-                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
-                    k_value = kprofile_map[cali_idx]
+                if k_value is None:
+                    k_value = _kprofile_k(cali_idx, int(ams_data.get("id", 0)), int(tray_data.get("id", 0)))
 
                 trays.append(
                     AMSTray(
@@ -625,8 +642,11 @@ async def get_printer_status(
             # Get K value: first try tray's k field, then lookup from K-profiles
             vt_k_value = vt_data.get("k")
             vt_cali_idx = vt_data.get("cali_idx")
-            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-                vt_k_value = kprofile_map[vt_cali_idx]
+            if vt_k_value is None:
+                # External holder: id 254 is Ext-L, 255 is Ext-R. slot_extruder
+                # takes the 0/1 tray index, so normalise before asking.
+                vt_id = int(vt_data.get("id", 254))
+                vt_k_value = _kprofile_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
 
             tray_id = int(vt_data.get("id", 254))
             vt_tray.append(
@@ -2639,17 +2659,22 @@ async def configure_ams_slot(
             from backend.app.models.spoolman_k_profile import SpoolmanKProfile
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
-            # Resolve slot's extruder index for the K-profile match key. Same
-            # logic as _apply_pa_after_refresh: external slots invert tray→extruder,
-            # AMS slots come from ams_extruder_map. Falls back to 0 (single-nozzle).
+            # Resolve the slot's extruder for the K-profile match key. On a
+            # Filament Track Switch machine this comes from the AMS's inlet
+            # binding, because every unit reports extruder 0xE there — without
+            # that, the `else 0` below filed every profile under the right-hand
+            # nozzle and a left-nozzle calibration was stored as a right one.
             slot_state = printer_manager.get_status(printer_id)
-            slot_extruder: int | None = None
-            if slot_state and slot_state.ams_extruder_map:
-                if ams_id == 255:
-                    slot_extruder = 1 - tray_id
-                else:
-                    slot_extruder = slot_state.ams_extruder_map.get(str(ams_id))
-            kp_extruder = slot_extruder if slot_extruder is not None else 0
+            resolved_extruder = slot_extruder(
+                ams_id,
+                tray_id,
+                slot_state.ams_extruder_map if slot_state else None,
+                slot_state.ams_switch_inlet if slot_state else None,
+            )
+            # Still 0 when nothing is known, which is right for a single-nozzle
+            # printer — the resolver only returns None when it genuinely cannot
+            # tell, and on those machines extruder 0 is the only one there is.
+            kp_extruder = resolved_extruder if resolved_extruder is not None else 0
 
             # Spoolman SlotAssignment first — has UniqueConstraint, idempotent.
             sm_result = await db.execute(
@@ -3829,13 +3854,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
             if nd:
                 nozzle_diameter = nd
 
-        slot_extruder = None
-        if state.ams_extruder_map:
-            if ams_id == 255:
-                # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                slot_extruder = 1 - slot_id
-            else:
-                slot_extruder = state.ams_extruder_map.get(str(ams_id))
+        resolved_extruder = slot_extruder(ams_id, slot_id, state.ams_extruder_map, state.ams_switch_inlet)
 
         # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
         # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
@@ -3898,7 +3917,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
                 for kp in spool.k_profiles:
                     if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                         continue
-                    if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+                    if resolved_extruder is not None and kp.extruder is not None and kp.extruder == resolved_extruder:
                         exact_kp = kp
                         break
                     if fallback_kp is None:
@@ -3932,7 +3951,11 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
                     )
                     for kp in kp_result.scalars().all():
                         if kp.nozzle_diameter == nozzle_diameter:
-                            if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
+                            if (
+                                resolved_extruder is not None
+                                and kp.extruder is not None
+                                and kp.extruder != resolved_extruder
+                            ):
                                 continue
                             if kp.cali_idx is not None:
                                 matching_cali_idx = kp.cali_idx

+ 86 - 6
backend/app/main.py

@@ -117,6 +117,7 @@ from backend.app.services.printer_manager import (
     printer_state_to_dict,
     resolve_plate_id,
 )
+from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
     notify_missing_spool_assignments_on_print_start,
@@ -129,6 +130,7 @@ from backend.app.services.spoolman_tracking import (
 )
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
+from backend.app.utils.fts_routing import extruder_for_inlet, slot_extruder as resolve_slot_extruder
 from backend.app.utils.print_jobs import is_internal_printer_job
 
 
@@ -1794,6 +1796,83 @@ def _is_bambu_uuid(tray_uuid: str) -> bool:
     return bool(tray_uuid) and tray_uuid not in ("", "0" * len(tray_uuid))
 
 
+async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
+    """Re-point a moved AMS's K-profiles at the nozzle it now feeds.
+
+    K-profiles are per-nozzle and the printer's calibration table is numbered
+    per-nozzle, but a tray holds exactly one ``cali_idx``. Moving an AMS to the
+    switch's other inlet therefore silently invalidates every configured slot in
+    it: the index stays put and now resolves against the other nozzle's table.
+    Measured on the maintainer's H2C — one spool calibrated 0.018 on the left
+    and 0.020 on the right kept the left profile after the move, and a manual
+    RFID re-read only re-asserted the same wrong one.
+
+    Configuring a slot is a deliberate preparation step, so this re-selects
+    rather than re-configures: only the calibration binding moves, and only for
+    slots whose spool already has a stored profile for the new nozzle. A slot
+    Bambuddy knows nothing about is left exactly as the operator left it.
+    """
+    logger = logging.getLogger(__name__)
+
+    target_extruder = extruder_for_inlet(inlet)
+    if target_extruder is None:
+        return
+
+    client = printer_manager.get_client(printer_id)
+    state = printer_manager.get_status(printer_id)
+    if not client or not state or not state.raw_data:
+        return
+
+    nozzle_diameter = "0.4"
+    if state.nozzles and state.nozzles[0].nozzle_diameter:
+        nozzle_diameter = state.nozzles[0].nozzle_diameter
+
+    ams_raw = state.raw_data.get("ams")
+    ams_list = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    unit = next((u for u in ams_list if str(u.get("id")) == str(ams_id)), None)
+    if not unit:
+        return
+
+    try:
+        async with async_session() as db:
+            for tray in unit.get("tray", []):
+                tray_id = int(tray.get("id", -1))
+                if tray_id < 0 or not tray.get("tray_type"):
+                    continue
+                current_idx = tray.get("cali_idx")
+
+                profile = await find_slot_kprofile_for_extruder(
+                    db, printer_id, ams_id, tray_id, target_extruder, nozzle_diameter
+                )
+                if profile is None or profile.cali_idx is None:
+                    continue
+                if current_idx == profile.cali_idx:
+                    continue  # Already on the right one.
+
+                logger.info(
+                    "[Printer %s] AMS %s slot %s moved to inlet %s (nozzle %s): "
+                    "re-selecting K-profile %s (cali_idx %s -> %s, K=%s)",
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    inlet,
+                    target_extruder,
+                    profile.name,
+                    current_idx,
+                    profile.cali_idx,
+                    profile.k_value,
+                )
+                client.extrusion_cali_sel(
+                    ams_id=ams_id,
+                    tray_id=tray_id,
+                    cali_idx=profile.cali_idx,
+                    filament_id=profile.filament_id or tray.get("tray_info_idx", "") or "",
+                    nozzle_diameter=nozzle_diameter,
+                )
+    except Exception as e:
+        logger.warning("[Printer %s] Could not re-apply K-profiles after inlet move: %s", printer_id, e)
+
+
 async def on_ams_change(printer_id: int, ams_data: list):
     """Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
     logger = logging.getLogger(__name__)
@@ -2169,12 +2248,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         nd = state.nozzles[0].nozzle_diameter
                                         if nd:
                                             nozzle_diameter = nd
-                                    slot_extruder: int | None = None
-                                    if state and state.ams_extruder_map:
-                                        if ams_id == 255:
-                                            slot_extruder = 1 - tray_id
-                                        else:
-                                            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+                                    slot_extruder = resolve_slot_extruder(
+                                        ams_id,
+                                        tray_id,
+                                        state.ams_extruder_map if state else None,
+                                        state.ams_switch_inlet if state else None,
+                                    )
                                     # Prefer exact extruder match, fall back to
                                     # extruder-agnostic kp for the same printer +
                                     # nozzle. Avoids hard-skipping when the AMS is
@@ -7957,6 +8036,7 @@ async def lifespan(app: FastAPI):
     printer_manager.set_print_running_observed_callback(on_print_running_observed)
     printer_manager.set_finish_photo_moment_callback(on_finish_photo_moment)
     printer_manager.set_ams_change_callback(on_ams_change)
+    printer_manager.set_fts_inlet_change_callback(on_fts_inlet_change)
 
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
     await printer_manager.load_awaiting_plate_clear_from_db()

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

@@ -1105,6 +1105,7 @@ class BambuMQTTClient:
         on_finish_photo_moment: Callable[[dict], None] | None = None,
         on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
         on_tray_change: Callable[[int, int], None] | None = None,
+        on_fts_inlet_change: Callable[[int, str], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -1116,6 +1117,9 @@ class BambuMQTTClient:
         self.on_print_start = on_print_start
         self.on_print_complete = on_print_complete
         self.on_ams_change = on_ams_change
+        # Fired when an AMS is moved to the switch's other inlet, which changes
+        # the nozzle it feeds and so invalidates its slots' K-profile bindings.
+        self.on_fts_inlet_change = on_fts_inlet_change
         self.on_layer_change = on_layer_change
         # #2547: fired when `mc_percent` advances during a running print.
         # `on_layer_change` stops firing the instant the final layer starts, so
@@ -3214,6 +3218,7 @@ class BambuMQTTClient:
         # the full map. Merge into existing map to preserve entries from prior updates.
 
         fts_installed = self.state.fila_switch.installed
+        inlet_moves: list[tuple[int, str]] = []
         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:
@@ -3229,6 +3234,7 @@ class BambuMQTTClient:
                         if fts_installed:
                             inlet = {0: "B", 1: "A"}.get((info_val >> 24) & 0xF)
                             if inlet is not None:
+                                previous = ams_switch_inlet.get(str(ams_id))
                                 ams_switch_inlet[str(ams_id)] = inlet
                                 self._debug_on_change(
                                     f"ams_inlet:{ams_id}",
@@ -3239,6 +3245,18 @@ class BambuMQTTClient:
                                     info,
                                     inlet,
                                 )
+                                if previous is not None and previous != inlet:
+                                    # Only a genuine move, never the first sighting:
+                                    # re-applying K-profiles on every reconnect would
+                                    # fight a binding the operator set deliberately.
+                                    logger.info(
+                                        "[%s] AMS %s moved to FTS inlet %s (was %s)",
+                                        self.serial_number,
+                                        ams_id,
+                                        inlet,
+                                        previous,
+                                    )
+                                    inlet_moves.append((int(ams_id), inlet))
                         continue
                     ams_extruder_map[str(ams_id)] = extruder_id
                     self._debug_on_change(
@@ -3258,6 +3276,9 @@ class BambuMQTTClient:
             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
+        for moved_ams_id, moved_inlet in inlet_moves:
+            if self.on_fts_inlet_change:
+                self.on_fts_inlet_change(moved_ams_id, moved_inlet)
 
         # Extract drying status from info hex string and dry_sf_reason per AMS unit
         # BambuStudio DevFilaSystem.cpp parses info bits:

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

@@ -404,6 +404,7 @@ class PrinterManager:
         self._on_finish_photo_moment: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
+        self._on_fts_inlet_change: Callable[[int, int, str], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_print_progress: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
@@ -626,6 +627,14 @@ class PrinterManager:
         """Set callback for AMS data change events."""
         self._on_ams_change = callback
 
+    def set_fts_inlet_change_callback(self, callback: Callable[[int, int, str], None]):
+        """Set callback for Filament Track Switch inlet moves.
+
+        Receives ``(printer_id, ams_id, inlet)``. Fired only when an AMS moves
+        between inlets, not on the first sighting of a binding.
+        """
+        self._on_fts_inlet_change = callback
+
     def set_layer_change_callback(self, callback: Callable[[int, int], None]):
         """Set callback for layer change events. Receives (printer_id, layer_num)."""
         self._on_layer_change = callback
@@ -720,6 +729,10 @@ class PrinterManager:
             if self._on_ams_change:
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
 
+        def on_fts_inlet_change(ams_id: int, inlet: str):
+            if self._on_fts_inlet_change:
+                self._schedule_async(self._on_fts_inlet_change(printer_id, ams_id, inlet))
+
         def on_layer_change(layer_num: int):
             if self._on_layer_change:
                 self._schedule_async(self._on_layer_change(printer_id, layer_num))
@@ -753,6 +766,7 @@ class PrinterManager:
             on_print_start=on_print_start,
             on_print_complete=on_print_complete,
             on_ams_change=on_ams_change,
+            on_fts_inlet_change=on_fts_inlet_change,
             on_layer_change=on_layer_change,
             on_print_progress=on_print_progress,
             on_bed_temp_update=on_bed_temp_update,

+ 132 - 0
backend/app/services/slot_kprofile.py

@@ -0,0 +1,132 @@
+"""Find the stored K-profile for an AMS slot on a particular nozzle.
+
+K-profiles are per-nozzle: Bambuddy already keeps one row per
+``(spool, printer, extruder)`` in ``spool_k_profile`` (and the Spoolman mirror
+in ``spoolman_k_profile``), each with its own ``cali_idx`` and K value. On the
+maintainer's H2C, one black PLA reads 0.018 on the left hotend and 0.020 on the
+right, stored as calibration indices 16 and 15.
+
+A tray, by contrast, holds exactly **one** ``cali_idx``. So whenever a slot's
+nozzle changes — which on a Filament Track Switch machine happens every time an
+AMS is moved between the switch's two inlets — the stored counterpart for the
+new nozzle has to be looked up and re-selected. This module is that lookup.
+"""
+
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+
+@dataclass(frozen=True)
+class SlotKProfile:
+    """A stored calibration profile, flattened across the two inventory backends."""
+
+    cali_idx: int | None
+    k_value: float | None
+    name: str | None
+    extruder: int
+    # The preset the profile was calibrated under. ``extrusion_cali_sel`` must
+    # carry this rather than the tray's RFID value, or the firmware mislinks it.
+    filament_id: str | None
+
+
+async def find_slot_kprofile_for_extruder(
+    db: AsyncSession,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    extruder: int,
+    nozzle_diameter: str,
+) -> SlotKProfile | None:
+    """Stored profile for whatever is in this slot, calibrated for ``extruder``.
+
+    Returns None when the slot holds no known spool, or when that spool has no
+    profile for this nozzle — an operator who calibrated only one side should
+    keep the binding they set by hand rather than have it swapped for a guess.
+
+    Local spools take priority over Spoolman, matching the rest of the
+    K-profile cascade.
+    """
+    assignment = (
+        await db.execute(
+            select(SpoolAssignment).where(
+                SpoolAssignment.printer_id == printer_id,
+                SpoolAssignment.ams_id == ams_id,
+                SpoolAssignment.tray_id == tray_id,
+            )
+        )
+    ).scalar_one_or_none()
+
+    if assignment is not None:
+        profile = (
+            (
+                await db.execute(
+                    select(SpoolKProfile).where(
+                        SpoolKProfile.spool_id == assignment.spool_id,
+                        SpoolKProfile.printer_id == printer_id,
+                        SpoolKProfile.extruder == extruder,
+                        SpoolKProfile.nozzle_diameter == nozzle_diameter,
+                    )
+                )
+            )
+            .scalars()
+            .first()
+        )
+        if profile is not None:
+            spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
+            return SlotKProfile(
+                cali_idx=profile.cali_idx,
+                k_value=profile.k_value,
+                name=profile.name,
+                extruder=profile.extruder,
+                filament_id=(spool.slicer_filament if spool else None),
+            )
+        # A known spool with no profile for this nozzle is a deliberate stop:
+        # falling through to Spoolman would answer for a different spool.
+        return None
+
+    sm_assignment = (
+        await db.execute(
+            select(SpoolmanSlotAssignment).where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == ams_id,
+                SpoolmanSlotAssignment.tray_id == tray_id,
+            )
+        )
+    ).scalar_one_or_none()
+    if sm_assignment is None:
+        return None
+
+    sm_profile = (
+        (
+            await db.execute(
+                select(SpoolmanKProfile).where(
+                    SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
+                    SpoolmanKProfile.printer_id == printer_id,
+                    SpoolmanKProfile.extruder == extruder,
+                    SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
+                )
+            )
+        )
+        .scalars()
+        .first()
+    )
+    if sm_profile is None:
+        return None
+
+    # Spoolman rows carry no slicer preset; the caller falls back to the tray's
+    # own tray_info_idx for filament_id.
+    return SlotKProfile(
+        cali_idx=sm_profile.cali_idx,
+        k_value=sm_profile.k_value,
+        name=sm_profile.name,
+        extruder=sm_profile.extruder,
+        filament_id=None,
+    )

+ 76 - 0
backend/app/utils/fts_routing.py

@@ -0,0 +1,76 @@
+"""Which nozzle an AMS slot feeds, with or without a Filament Track Switch.
+
+K-profiles are per-nozzle, and the printer's calibration tables are numbered
+per-nozzle too: ``cali_idx: 16`` means "entry 16 of whichever nozzle feeds this
+tray". Without a switch that is unambiguous, because each AMS is wired to one
+extruder and says so in its ``info`` bits. With a switch installed every AMS
+reports 0xE instead and is bound to a switch *inlet*, so the answer has to come
+from the inlet binding.
+
+Every caller that resolves a slot to an extruder should go through
+``slot_extruder`` here. Three separate copies of that logic used to end in
+``else 0``, which on a switch machine silently filed every profile under the
+right-hand nozzle regardless of where the slot actually was.
+
+Kept as a leaf module with no imports of its own so the routes, the MQTT layer
+and the scheduler can all share one answer.
+"""
+
+# Which extruder each switch outlet terminates at. Measured on the maintainer's
+# H2C, 2026-08-16: Out-A is the left hotend, Out-B is the right one.
+#
+# We would rather read this than assert it, but it is not in the telemetry:
+# ``fila_switch.out`` reported ``[1, 1]`` unchanged across a 90-second capture,
+# i.e. both outlets claiming the same extruder, which cannot describe real
+# wiring. Whatever that field means, it is not outlet-to-nozzle.
+#
+# The inlet-to-outlet pairing then comes from the switch's un-crossed rest
+# position: In-A -> Out-A, In-B -> Out-B. The switch does cross the two during a
+# filament change, so this describes where a slot sits between prints, which is
+# what a manual "configure this slot" task needs. It is deliberately one table
+# to change if a machine turns up with its outlet tubes swapped.
+FTS_INLET_EXTRUDER: dict[str, int] = {
+    "A": 1,  # left / deputy
+    "B": 0,  # right / main
+}
+
+
+def extruder_for_inlet(inlet: str | None) -> int | None:
+    """Extruder fed by switch inlet ``"A"`` or ``"B"``; None for anything else."""
+    if not inlet:
+        return None
+    return FTS_INLET_EXTRUDER.get(inlet.upper())
+
+
+def slot_extruder(
+    ams_id: int,
+    tray_id: int,
+    ams_extruder_map: dict | None,
+    ams_switch_inlet: dict | None = None,
+) -> int | None:
+    """Resolve one AMS slot to the extruder it feeds, or None if unknowable.
+
+    Returns None rather than guessing. A single-nozzle printer has no map and no
+    switch, and there the caller's own default of extruder 0 is correct — but on
+    a dual-nozzle machine "I don't know" and "the right-hand nozzle" are very
+    different answers, and conflating them is what bound a left-nozzle K-profile
+    to a slot sitting on the right.
+
+    ``ams_id`` 255 is the external spool holder, where the tray id names the
+    side directly: tray 0 is Ext-L (extruder 1) and tray 1 is Ext-R (extruder 0).
+    """
+    if ams_id == 255:
+        return 1 - tray_id if tray_id in (0, 1) else None
+
+    # A real extruder id always wins. BambuStudio treats a non-0xE value as
+    # authoritative too, so an AMS wired straight to one nozzle keeps that
+    # binding even on a machine that has a switch fitted for its other units.
+    if ams_extruder_map:
+        mapped = ams_extruder_map.get(str(ams_id))
+        if mapped is not None:
+            return int(mapped)
+
+    if ams_switch_inlet:
+        return extruder_for_inlet(ams_switch_inlet.get(str(ams_id)))
+
+    return None

+ 230 - 0
backend/tests/unit/test_fts_kprofile_routing.py

@@ -0,0 +1,230 @@
+"""K-profiles follow an AMS when it moves between Filament Track Switch inlets.
+
+Measured on the maintainer's H2C, 2026-08-16. Spool 75, "HF Bambu PLA Matte
+Black", is calibrated on both hotends and Bambuddy stores both:
+
+    extruder 1 (left)   K 0.018   cali_idx 16
+    extruder 0 (right)  K 0.020   cali_idx 15
+
+A tray holds exactly one ``cali_idx``, and the printer's calibration table is
+numbered per nozzle — so index 16 exists on both hotends and means a different
+profile on each. Moving that AMS from In-A to In-B therefore leaves the slot
+bound to the *left* profile while it now feeds the right nozzle. The printer
+does not re-resolve it, and an RFID re-read only re-asserts the same wrong
+index. That is the state the live printer was in when this was written:
+
+    AMS 1  info=10002E03  inlet=IN-B   tray 0: PLA 000000  cali_idx=16
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.utils.fts_routing import FTS_INLET_EXTRUDER, extruder_for_inlet, slot_extruder
+
+
+class TestInletToNozzle:
+    def test_in_a_is_the_left_hotend(self):
+        assert extruder_for_inlet("A") == 1
+
+    def test_in_b_is_the_right_hotend(self):
+        assert extruder_for_inlet("B") == 0
+
+    @pytest.mark.parametrize("value", [None, "", "C", "left"])
+    def test_anything_else_is_unknown(self, value):
+        assert extruder_for_inlet(value) is None
+
+    def test_the_two_inlets_do_not_share_a_nozzle(self):
+        """A table that mapped both inlets to one hotend would silently bind
+        every slot to the same K-profile."""
+        assert sorted(FTS_INLET_EXTRUDER.values()) == [0, 1]
+
+
+class TestSlotExtruder:
+    def test_a_real_extruder_id_wins(self):
+        """A non-0xE binding is authoritative even on a machine with a switch,
+        which is how BambuStudio treats it too."""
+        assert slot_extruder(2, 0, {"2": 1}, {"2": "B"}) == 1
+
+    def test_falls_back_to_the_switch_inlet(self):
+        """The FTS case: every AMS reports 0xE, so the map is empty."""
+        assert slot_extruder(1, 0, {}, {"1": "B"}) == 0
+        assert slot_extruder(2, 3, {}, {"2": "A"}) == 1
+
+    def test_unknown_is_none_not_zero(self):
+        """The bug this replaced: three call sites ended in `else 0`, which on a
+        switch machine filed every profile under the right-hand nozzle."""
+        assert slot_extruder(1, 0, {}, {}) is None
+        assert slot_extruder(1, 0, None, None) is None
+
+    def test_external_slots_name_their_own_side(self):
+        assert slot_extruder(255, 0, {}, {}) == 1  # Ext-L
+        assert slot_extruder(255, 1, {}, {}) == 0  # Ext-R
+
+    def test_an_unmapped_ams_does_not_borrow_another_units_inlet(self):
+        assert slot_extruder(3, 0, {}, {"1": "A", "2": "B"}) is None
+
+    def test_the_maintainers_h2c(self):
+        """The live layout: AMS 0/1/128 on In-B, AMS 2 on In-A, empty map."""
+        inlets = {"0": "B", "1": "B", "2": "A", "128": "B"}
+        assert slot_extruder(0, 0, {}, inlets) == 0
+        assert slot_extruder(1, 0, {}, inlets) == 0
+        assert slot_extruder(2, 1, {}, inlets) == 1
+        assert slot_extruder(128, 0, {}, inlets) == 0
+
+
+def _profile(cali_idx: int, extruder: int, k: float):
+    return MagicMock(
+        cali_idx=cali_idx, extruder=extruder, k_value=k, name="HF Bambu PLA Matte Black", filament_id="GFA01"
+    )
+
+
+class TestReSelectOnInletMove:
+    """The callback that re-points a moved AMS's slots."""
+
+    @staticmethod
+    def _run(inlet, tray, profile, *, connected=True):
+        """Drive on_fts_inlet_change for one AMS holding one tray."""
+        from backend.app import main as main_module
+
+        client = MagicMock()
+        client.extrusion_cali_sel = MagicMock(return_value=True)
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": "1", "tray": [tray]}]}
+        state.nozzles = [MagicMock(nozzle_diameter="0.4")]
+
+        pm = MagicMock()
+        pm.get_client.return_value = client if connected else None
+        pm.get_status.return_value = state
+
+        session = AsyncMock()
+        session.__aenter__ = AsyncMock(return_value=session)
+        session.__aexit__ = AsyncMock()
+
+        with (
+            patch("backend.app.main.printer_manager", pm),
+            patch("backend.app.main.async_session", return_value=session),
+            patch(
+                "backend.app.main.find_slot_kprofile_for_extruder",
+                new=AsyncMock(return_value=profile),
+            ) as lookup,
+        ):
+            import asyncio
+
+            asyncio.run(main_module.on_fts_inlet_change(7, 1, inlet))
+        return client, lookup
+
+    def test_moving_to_in_b_selects_the_right_hotends_profile(self):
+        """The reported case, with the maintainer's real numbers."""
+        tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
+        client, lookup = self._run("B", tray, _profile(15, 0, 0.020))
+
+        assert lookup.await_args.args[4] == 0, "must look up the RIGHT hotend's profile"
+        client.extrusion_cali_sel.assert_called_once()
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
+
+    def test_moving_to_in_a_selects_the_left_hotends_profile(self):
+        tray = {"id": "0", "tray_type": "PLA", "cali_idx": 15, "tray_info_idx": "GFA01"}
+        client, lookup = self._run("A", tray, _profile(16, 1, 0.018))
+
+        assert lookup.await_args.args[4] == 1
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
+
+    def test_an_already_correct_slot_is_left_alone(self):
+        """No point re-sending a binding the printer already holds, and every
+        avoided write is one less chance of the firmware mislinking it."""
+        tray = {"id": "0", "tray_type": "PLA", "cali_idx": 15, "tray_info_idx": "GFA01"}
+        client, _ = self._run("B", tray, _profile(15, 0, 0.020))
+
+        client.extrusion_cali_sel.assert_not_called()
+
+    def test_a_spool_with_no_profile_for_that_nozzle_is_untouched(self):
+        """Calibrated on one hotend only: keep whatever the operator set by hand
+        rather than swapping it for a guess."""
+        tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
+        client, _ = self._run("B", tray, None)
+
+        client.extrusion_cali_sel.assert_not_called()
+
+    def test_an_empty_slot_is_skipped(self):
+        tray = {"id": "0", "tray_type": "", "cali_idx": -1}
+        client, lookup = self._run("B", tray, _profile(15, 0, 0.020))
+
+        lookup.assert_not_awaited()
+        client.extrusion_cali_sel.assert_not_called()
+
+    def test_a_disconnected_printer_is_a_no_op(self):
+        tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
+        client, _ = self._run("B", tray, _profile(15, 0, 0.020), connected=False)
+
+        client.extrusion_cali_sel.assert_not_called()
+
+    def test_an_unknown_inlet_is_a_no_op(self):
+        tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
+        client, lookup = self._run("C", tray, _profile(15, 0, 0.020))
+
+        lookup.assert_not_awaited()
+        client.extrusion_cali_sel.assert_not_called()
+
+
+class TestTheMoveIsDetected:
+    """The MQTT side: only a genuine move fires the callback."""
+
+    @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) -> str:
+        return f"{(inlet_bits << 24) | (0xE << 8) | 1:08X}"
+
+    def _push(self, client, inlet_bits):
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "IDLE",
+                    "device": {"fila_switch": {"in": [-1, -1], "out": [1, 1], "stat": 1, "info": 0}},
+                    "ams": {"ams": [{"id": "1", "info": self._info(inlet_bits), "tray": []}]},
+                }
+            }
+        )
+
+    def test_a_move_fires_once(self, mqtt_client):
+        seen = []
+        mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
+
+        self._push(mqtt_client, 1)  # In-A
+        self._push(mqtt_client, 0)  # In-B
+
+        assert seen == [(1, "B")]
+
+    def test_the_first_sighting_does_not_fire(self, mqtt_client):
+        """Every reconnect learns the bindings afresh. Re-applying K-profiles
+        there would fight a binding the operator set deliberately."""
+        seen = []
+        mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
+
+        self._push(mqtt_client, 1)
+
+        assert seen == []
+
+    def test_repeated_frames_do_not_fire(self, mqtt_client):
+        seen = []
+        mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
+
+        for _ in range(4):
+            self._push(mqtt_client, 1)
+
+        assert seen == []
+
+    def test_moving_back_fires_again(self, mqtt_client):
+        seen = []
+        mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
+
+        self._push(mqtt_client, 1)
+        self._push(mqtt_client, 0)
+        self._push(mqtt_client, 1)
+
+        assert seen == [(1, "B"), (1, "A")]

+ 129 - 3
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -686,8 +686,10 @@ describe('ConfigureAmsSlotModal', () => {
         expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
       });
       const marble = genericPlaProfiles.find(p => p.name === 'Marble')!;
+      // Option identity carries the extruder, so a filament calibrated on both
+      // hotends yields two distinguishable entries rather than one.
       fireEvent.change(screen.getByRole('combobox'), {
-        target: { value: `${marble.name}|${marble.k_value}` },
+        target: { value: `${marble.extruder_id}|${marble.name}|${marble.k_value}` },
       });
       fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
 
@@ -701,7 +703,9 @@ describe('ConfigureAmsSlotModal', () => {
 
     it('distinguishes two profiles that share a name but differ in K', async () => {
       // The picker used to key options by name alone, so same-named profiles
-      // were indistinguishable and the first always won.
+      // were indistinguishable and the first always won. The key now also
+      // carries the extruder, for the same reason one step further out: the
+      // printer numbers its calibration table per hotend.
       (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
         profiles: [
           { ...genericPlaProfiles[0], slot_id: 1, name: 'PLA', k_value: '0.020' },
@@ -718,7 +722,7 @@ describe('ConfigureAmsSlotModal', () => {
       await waitFor(() => {
         expect(screen.getByRole('option', { name: /K=0.045/ })).toBeInTheDocument();
       });
-      fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA|0.045' } });
+      fireEvent.change(screen.getByRole('combobox'), { target: { value: '0|PLA|0.045' } });
       fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
 
       await waitFor(() => {
@@ -763,3 +767,125 @@ describe('ConfigureAmsSlotModal', () => {
     expect(screen.queryByRole('option', { name: /should-not-appear/ })).not.toBeInTheDocument();
   });
 });
+
+describe('ConfigureAmsSlotModal — per-nozzle K-profiles', () => {
+  /**
+   * K-profiles are per-nozzle and the printer numbers its calibration table per
+   * nozzle too, so one filament calibrated on both hotends gives two profiles
+   * with the same name and the same index space. Measured on the maintainer's
+   * H2C: "HF Bambu PLA Matte Black" is K=0.018 at index 16 on the left hotend
+   * and K=0.020 at index 15 on the right.
+   *
+   * A tray holds exactly one cali_idx, so the picker has to resolve it against
+   * the slot's own nozzle. It used to match on the index alone and, failing
+   * that, take the first profile in the list — which on a Filament Track Switch
+   * machine (where no AMS reports an extruder at all) was arbitrary.
+   */
+  const matteBlack = (extruder: number, caliIdx: number, k: string) => ({
+    slot_id: caliIdx,
+    extruder_id: extruder,
+    nozzle_id: 'HH00-0.4',
+    nozzle_diameter: '0.4',
+    filament_id: 'GFL99',
+    name: 'HF Bambu PLA Matte Black',
+    k_value: k,
+    n_coef: '0',
+    ams_id: 0,
+    tray_id: 0,
+    setting_id: '',
+  });
+
+  const bothNozzles = [matteBlack(1, 16, '0.018'), matteBlack(0, 15, '0.020')];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    Element.prototype.scrollIntoView = vi.fn();
+    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
+    (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { filament_id: 'GFL99', name: 'Generic PLA', filament_type: 'PLA' },
+    ]);
+    (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({ profiles: bothNozzles });
+    (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
+  });
+
+  const slot = (extruderId: number | undefined, caliIdx: number) => ({
+    ...defaultProps.slotInfo,
+    savedPresetId: 'builtin_GFL99',
+    extruderId,
+    caliIdx,
+  });
+
+  it('preselects the right hotend profile for a slot on the right hotend', async () => {
+    // The reported bug, from the other side: the slot is bound to cali_idx 16,
+    // which is the LEFT hotend's entry. On a right-hotend slot the picker must
+    // land on 15, not follow the index into the wrong table.
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slot(0, 16)} />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('combobox')).toBeInTheDocument();
+    });
+    expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('0|HF Bambu PLA Matte Black|0.020');
+  });
+
+  it('preselects the left hotend profile for a slot on the left hotend', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slot(1, 16)} />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('combobox')).toBeInTheDocument();
+    });
+    expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('1|HF Bambu PLA Matte Black|0.018');
+  });
+
+  it('does not offer the other hotend profile as a match', async () => {
+    // Both are still reachable — the other lands in the "Other K profiles"
+    // group — but only one is a match for this slot.
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slot(0, 15)} />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /K=0.020/ })).toBeInTheDocument();
+    });
+    const matchGroup = screen.getByRole('combobox').querySelectorAll(':scope > option');
+    const matchValues = Array.from(matchGroup).map(o => (o as HTMLOptionElement).value).filter(Boolean);
+    expect(matchValues).toEqual(['0|HF Bambu PLA Matte Black|0.020']);
+  });
+
+  it('names the hotend on each option when the printer has two', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slot(0, 15)} />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /K=0.020/ })).toBeInTheDocument();
+    });
+    expect(screen.getByRole('option', { name: /K=0\.020.*Right/ })).toBeInTheDocument();
+    expect(screen.getByRole('option', { name: /K=0\.018.*Left/ })).toBeInTheDocument();
+  });
+
+  it('leaves single-nozzle printers unlabelled', async () => {
+    (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+      profiles: [matteBlack(0, 15, '0.020')],
+    });
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slot(0, 15)} />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /K=0.020/ })).toBeInTheDocument();
+    });
+    expect(screen.queryByRole('option', { name: /Right/ })).not.toBeInTheDocument();
+  });
+
+  it('sends the cali_idx of the nozzle-correct profile', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slot(0, 16)} />);
+
+    // Wait for the preselect to settle, not merely for the control to mount —
+    // the submit reads the selected profile, which lands an effect later.
+    await waitFor(() => {
+      expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
+        '0|HF Bambu PLA Matte Black|0.020'
+      );
+    });
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+    expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].cali_idx).toBe(15);
+  });
+});

+ 69 - 35
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -103,8 +103,41 @@ function parsePresetName(name: string): { material: string; brand: string; varia
 // Identity of a K-profile inside the picker. Both profile lists are
 // deduplicated on name+k_value, so this is unique across the whole option set
 // — unlike the bare name, which two profiles can share (#2710).
+// Option identity. The extruder has to be in here: the printer's calibration
+// table is numbered per nozzle, so one filament calibrated on both hotends gives
+// two profiles with the same name — and on a Filament Track Switch machine they
+// are both offered, because such a slot can reach either nozzle. Keying on
+// name+K alone made the two indistinguishable and, when their K happened to
+// match, silently collapsed them into whichever came first.
 function kProfileOptionValue(profile: KProfile): string {
-  return `${profile.name}|${profile.k_value}`;
+  return `${profile.extruder_id ?? 0}|${profile.name}|${profile.k_value}`;
+}
+
+/**
+ * The profile a slot's `cali_idx` points at.
+ *
+ * An index is only meaningful together with a nozzle: the printer numbers its
+ * calibration table per hotend, so entry 16 exists on both and means a
+ * different profile on each. On the maintainer's H2C, index 16 is the left
+ * hotend's black PLA at K=0.018 and index 15 is the right's at K=0.020.
+ * Matching on the index alone returns whichever the printer listed first.
+ */
+function findProfileByCaliIdx(
+  profiles: KProfile[],
+  caliIdx: number,
+  extruderId: number | undefined
+): KProfile | undefined {
+  if (extruderId !== undefined) {
+    return profiles.find(p => p.slot_id === caliIdx && (p.extruder_id ?? 0) === extruderId);
+  }
+  return profiles.find(p => p.slot_id === caliIdx);
+}
+
+// Suffix naming the hotend a profile was calibrated on, for printers that have
+// more than one. Empty on single-nozzle machines, where it would be noise.
+function kProfileNozzleSuffix(profile: KProfile, isDualNozzle: boolean, t: (k: string) => string): string {
+  if (!isDualNozzle) return '';
+  return ` \u00b7 ${profile.extruder_id === 1 ? t('common.left') : t('common.right')}`;
 }
 
 // Check if a preset is a user preset (not built-in)
@@ -858,6 +891,14 @@ export function ConfigureAmsSlotModal({
     });
   }, [colorCatalog, selectedPresetInfo]);
 
+  // Dual-nozzle if the printer reported calibration profiles for more than one
+  // extruder. Self-contained, and exactly the condition under which naming the
+  // hotend on each option is worth the space.
+  const isDualNozzleProfiles = useMemo(
+    () => new Set((kprofilesData?.profiles ?? []).map(p => p.extruder_id ?? 0)).size > 1,
+    [kprofilesData?.profiles]
+  );
+
   const matchingKProfiles = useMemo(() => {
     if (!kprofilesData?.profiles) return [];
     if (!selectedPresetInfo) {
@@ -869,10 +910,7 @@ export function ConfigureAmsSlotModal({
       // instead of dropping to default 0.020 (#1689 follow-up).
       const activeIdx = slotInfo.caliIdx;
       if (activeIdx != null && activeIdx > 0) {
-        const active = kprofilesData.profiles.find(
-          p => p.slot_id === activeIdx
-            && (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
-        );
+        const active = findProfileByCaliIdx(kprofilesData.profiles, activeIdx, slotInfo.extruderId);
         if (active) return [active];
       }
       return [];
@@ -955,18 +993,19 @@ export function ConfigureAmsSlotModal({
       return false;
     });
 
-    // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
-    // Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)
+    // Scope to the slot's own nozzle when it is known: a K-profile calibrated on
+    // the other hotend is not a match for this slot, and offering it as one is
+    // how the wrong K got bound. Those profiles are still reachable below under
+    // "Other", where the option label names the hotend.
+    const onThisNozzle = slotInfo.extruderId === undefined
+      ? filtered
+      : filtered.filter(p => (p.extruder_id ?? 0) === slotInfo.extruderId);
+
+    // Deduplicate genuine duplicates — same nozzle, same name, same K.
     const seen = new Map<string, KProfile>();
-    for (const profile of filtered) {
-      const key = `${profile.name}|${profile.k_value}`;
-      const existing = seen.get(key);
-      if (!existing) {
-        seen.set(key, profile);
-      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
-        // Replace with profile matching slot's extruder
-        seen.set(key, profile);
-      }
+    for (const profile of onThisNozzle) {
+      const key = kProfileOptionValue(profile);
+      if (!seen.has(key)) seen.set(key, profile);
     }
 
     const result = Array.from(seen.values());
@@ -979,10 +1018,7 @@ export function ConfigureAmsSlotModal({
     // card's hover-card correctly shows the active profile.
     const activeIdx = slotInfo.caliIdx;
     if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
-      const active = kprofilesData.profiles.find(
-        p => p.slot_id === activeIdx
-          && (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
-      );
+      const active = findProfileByCaliIdx(kprofilesData.profiles, activeIdx, slotInfo.extruderId);
       if (active) result.unshift(active);
     }
 
@@ -1006,15 +1042,10 @@ export function ConfigureAmsSlotModal({
     for (const profile of kprofilesData.profiles) {
       const key = kProfileOptionValue(profile);
       if (matched.has(key)) continue;
-      const existing = seen.get(key);
-      if (!existing) {
-        seen.set(key, profile);
-      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
-        seen.set(key, profile);
-      }
+      if (!seen.has(key)) seen.set(key, profile);
     }
     return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
-  }, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
+  }, [kprofilesData?.profiles, matchingKProfiles]);
 
   const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
 
@@ -1081,20 +1112,23 @@ export function ConfigureAmsSlotModal({
   // Auto-select best matching K profile when preset changes
   useEffect(() => {
     if (matchingKProfiles.length > 0) {
-      // Prefer the currently-active K-profile (by cali_idx) if available
+      // Prefer the currently-active K-profile, resolved against this slot's own
+      // nozzle — the index alone is ambiguous across hotends.
       if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
-        const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);
+        const active = findProfileByCaliIdx(matchingKProfiles, slotInfo.caliIdx, slotInfo.extruderId);
         if (active) {
           setSelectedKProfile(active);
           return;
         }
       }
-      // Fallback: first matching profile
+      // Fallback: the first match. matchingKProfiles is already scoped to this
+      // slot's nozzle when we know which one it is, so this cannot hand over a
+      // profile calibrated on the other hotend.
       setSelectedKProfile(matchingKProfiles[0]);
     } else {
       setSelectedKProfile(null);
     }
-  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);
+  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx, slotInfo.extruderId]);
 
   // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -1307,14 +1341,14 @@ export function ConfigureAmsSlotModal({
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         {matchingKProfiles.map((profile) => (
                           <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                            {profile.name} (K={profile.k_value})
+                            {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                           </option>
                         ))}
                         {otherKProfiles.length > 0 && (
                           <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                             {otherKProfiles.map((profile) => (
                               <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                                {profile.name} (K={profile.k_value})
+                                {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                               </option>
                             ))}
                           </optgroup>
@@ -1552,14 +1586,14 @@ export function ConfigureAmsSlotModal({
                       <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       {matchingKProfiles.map((profile) => (
                         <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                          {profile.name} (K={profile.k_value})
+                          {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                         </option>
                       ))}
                       {otherKProfiles.length > 0 && (
                         <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                           {otherKProfiles.map((profile) => (
                             <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                              {profile.name} (K={profile.k_value})
+                              {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                             </option>
                           ))}
                         </optgroup>

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

@@ -174,7 +174,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -5190,7 +5190,6 @@ function PrinterCard({
                   <div className="flex flex-wrap gap-2">
                     {/* Regular AMS units */}
                     {regularAms.map((ams) => {
-                      const mappedExtruderId = amsExtruderMap[String(ams.id)];
                       const sideBadge = amsSideBadge(ams.id, amsExtruderMap, amsSwitchInlet, ftsInstalled);
 
                       return (
@@ -5572,7 +5571,7 @@ function PrinterCard({
                                             trayColor: tray?.tray_color || undefined,
                                             traySubBrands: tray?.tray_sub_brands || undefined,
                                             trayInfoIdx: tray?.tray_info_idx || undefined,
-                                            extruderId: mappedExtruderId,
+                                            extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                             caliIdx: tray?.cali_idx,
                                             savedPresetId: slotPreset?.preset_id,
                                           }),
@@ -5595,7 +5594,7 @@ function PrinterCard({
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayCount: ams.tray.length,
-                                            extruderId: mappedExtruderId,
+                                            extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                           }),
                                         }}
                                         onAssignSpool={() => setAssignSpoolModal({
@@ -5623,7 +5622,6 @@ function PrinterCard({
                     })}
                     {/* HT AMS units */}
                     {htAms.map((ams) => {
-                      const mappedExtruderId = amsExtruderMap[String(ams.id)];
                       const sideBadge = amsSideBadge(ams.id, amsExtruderMap, amsSwitchInlet, ftsInstalled);
                       const tray = ams.tray[0];
                       const hasFillLevel = tray?.tray_type && tray.remain >= 0;
@@ -5960,7 +5958,7 @@ function PrinterCard({
                                         trayColor: tray?.tray_color || undefined,
                                         traySubBrands: tray?.tray_sub_brands || undefined,
                                         trayInfoIdx: tray?.tray_info_idx || undefined,
-                                        extruderId: mappedExtruderId,
+                                        extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                         caliIdx: tray?.cali_idx,
                                         savedPresetId: slotPreset?.preset_id,
                                       }),
@@ -5983,7 +5981,7 @@ function PrinterCard({
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayCount: ams.tray.length,
-                                        extruderId: mappedExtruderId,
+                                        extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                       }),
                                     }}
                                     onAssignSpool={() => setAssignSpoolModal({

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

@@ -42,6 +42,37 @@ export function normalizeColorForCompare(color: string | undefined): string {
  */
 export const FTS_INLET_SIDE = { A: 'L', B: 'R' } as const;
 
+/**
+ * Which extruder each switch inlet feeds. Out-A is the left hotend and Out-B the
+ * right one (measured on an H2C), and the inlet pairs with its own outlet in the
+ * switch's rest position. Mirrors `backend/app/utils/fts_routing.py`, which
+ * carries the full reasoning and the reason `fila_switch.out` cannot be used.
+ */
+const FTS_INLET_EXTRUDER: Record<string, number> = { A: 1, B: 0 };
+
+/**
+ * The extruder an AMS slot feeds, or undefined when it genuinely cannot be told.
+ *
+ * Undefined is not the same as extruder 0. K-profiles are per-nozzle, so a slot
+ * whose nozzle is unknown must not be silently treated as right-hand — that is
+ * what bound a left-nozzle profile to a slot sitting on the right.
+ */
+export function resolveSlotExtruder(
+  amsId: number,
+  trayId: number,
+  amsExtruderMap: Record<string, number> | undefined,
+  amsSwitchInlet: Record<string, string> | undefined
+): number | undefined {
+  // External holder: the tray id names the side. 254/Ext-L feeds extruder 1.
+  if (amsId === 255) return trayId === 0 || trayId === 1 ? 1 - trayId : undefined;
+
+  const mapped = amsExtruderMap?.[String(amsId)];
+  if (mapped !== undefined) return mapped;
+
+  const inlet = amsSwitchInlet?.[String(amsId)];
+  return inlet ? FTS_INLET_EXTRUDER[inlet.toUpperCase()] : undefined;
+}
+
 /**
  * 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

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-C2CTWRjb.js


+ 1 - 1
static/index.html

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

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff