Procházet zdrojové kódy

feat(printer): support Filament Track Switch (FTS) accessory in print modal (#1162)

  The FTS routes any AMS slot to either extruder, so AMS info reports
  bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
  The print modal's per-nozzle dropdown filter then hides every loaded
  slot, leaving the user with an empty filament dropdown.

  Detection: parse print.device.fila_switch from MQTT push_status into a
  new FilaSwitchState dataclass on PrinterState; surface it through the
  GET /printers/{id}/status response as a nullable FilaSwitchResponse.

  Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
  filter when fila_switch.installed is true. Slots currently fed into a
  track display an [L]/[R] routing badge in the dropdown so the user
  can see where the FTS is currently routing them.

  Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
  integration (status route), 2 hook regression, 2 component regression.
maziggy před 4 týdny
rodič
revize
b45ca2a662

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
CHANGELOG.md


+ 1 - 1
README.md

@@ -92,7 +92,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Duplicate detection & full-text search
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music) with automatic AVI-to-MP4 conversion for P1-series printers, manual upload & remove
 - Timelapse editor (trim, speed, music) with automatic AVI-to-MP4 conversion for P1-series printers, manual upload & remove
-- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro, **Filament Track Switch (FTS) support** — when the FTS accessory is installed the per-nozzle filter is suppressed since the FTS routes any AMS slot to either extruder)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
 - Tag management (rename/delete across all archives)

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

@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
     AmsLabelBody,
     AmsLabelBody,
     AMSTray,
     AMSTray,
     AMSUnit,
     AMSUnit,
+    FilaSwitchResponse,
     HMSErrorResponse,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
     NozzleRackSlot,
@@ -635,6 +636,17 @@ async def get_printer_status(
         supports_drying=supports_drying(printer.model, state.firmware_version),
         supports_drying=supports_drying(printer.model, state.firmware_version),
         current_archive_id=current_archive_id,
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
         current_plate_id=current_plate_id,
+        fila_switch=(
+            FilaSwitchResponse(
+                installed=state.fila_switch.installed,
+                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
+        ),
     )
     )
 
 
 
 

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

@@ -179,6 +179,24 @@ class AmsLabelBody(BaseModel):
     ams_serial: str = Field(default="", max_length=50)
     ams_serial: str = Field(default="", max_length=50)
 
 
 
 
+class FilaSwitchResponse(BaseModel):
+    """Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
+
+    When installed, the AMS info field reports bits 8-11 = 0xE (uninitialized)
+    because slots are dynamically routed via the FTS rather than tied to a
+    specific extruder. Frontend uses `installed` to suppress the per-extruder
+    slot filter in the print modal. See #1162.
+    """
+
+    installed: bool = False
+    # in[track] = currently loaded slot for that track (-1 = empty)
+    in_slots: list[int] = []
+    # out[track] = extruder this track terminates at (0 = right, 1 = left)
+    out_extruders: list[int] = []
+    stat: int = 0
+    info: int = 0
+
+
 class PrintOptionsResponse(BaseModel):
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
     """AI detection and print options from xcam data."""
 
 
@@ -245,6 +263,9 @@ class PrinterStatus(BaseModel):
     ams_mapping: list[int] = []
     ams_mapping: list[int] = []
     # 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: dict[str, int] = {}
     ams_extruder_map: dict[str, int] = {}
+    # Filament Track Switch (FTS) accessory — when installed, AMS reports
+    # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
+    fila_switch: FilaSwitchResponse | None = None
     # 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
     # AMS status for filament change tracking
     # AMS status for filament change tracking

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

@@ -90,6 +90,26 @@ class NozzleInfo:
     nozzle_diameter: str = ""  # e.g., "0.4"
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
 
 
+@dataclass
+class FilaSwitchState:
+    """Filament Track Switch (FTS) accessory state.
+
+    The FTS is an external accessory that mediates filament routing between an
+    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
+    track switch. Detected from print.device.fila_switch in MQTT.
+    """
+
+    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).
+    in_slots: list[int] = field(default_factory=list)
+    # out[track] = extruder this track terminates at (0 = right/main, 1 = left)
+    out_extruders: list[int] = field(default_factory=list)
+    stat: int = 0  # status flags (0 = idle)
+    info: int = 0  # info flags
+
+
 @dataclass
 @dataclass
 class PrintOptions:
 class PrintOptions:
     """AI detection and print options from xcam data."""
     """AI detection and print options from xcam data."""
@@ -170,6 +190,9 @@ class PrinterState:
     ams_mapping: list = field(default_factory=list)
     ams_mapping: list = field(default_factory=list)
     # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
     # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
     ams_extruder_map: dict = field(default_factory=dict)
     ams_extruder_map: dict = field(default_factory=dict)
+    # Filament Track Switch (FTS) accessory — when installed, AMS info reports
+    # bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
+    fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     h2d_extruder_snow: dict = field(default_factory=dict)
     h2d_extruder_snow: dict = field(default_factory=dict)
@@ -1922,6 +1945,22 @@ class BambuMQTTClient:
                 # Log 'cur' field if present (might indicate current/active extruder)
                 # Log 'cur' field if present (might indicate current/active extruder)
                 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),
+                )
+
         if "bed_temper" in data:
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
         if "bed_target_temper" in data:

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

@@ -257,6 +257,78 @@ class TestPrintersAPI:
 
 
         assert response.status_code == 404
         assert response.status_code == 404
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_status_includes_fila_switch_when_installed(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """When the FTS accessory is installed, the status response must include
+        the fila_switch object with the routing arrays. See #1162.
+
+        The accessory is detected from print.device.fila_switch in MQTT;
+        we feed a PrinterState with FilaSwitchState(installed=True, ...) and
+        confirm it survives the schema serialization round-trip.
+        """
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
+
+        printer = await printer_factory()
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "IDLE"
+        state.fila_switch = FilaSwitchState(
+            installed=True,
+            in_slots=[-1, 2],
+            out_extruders=[0, 1],
+            stat=0,
+            info=2,
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["fila_switch"] is not None
+        assert result["fila_switch"]["installed"] is True
+        assert result["fila_switch"]["in_slots"] == [-1, 2]
+        assert result["fila_switch"]["out_extruders"] == [0, 1]
+        assert result["fila_switch"]["stat"] == 0
+        assert result["fila_switch"]["info"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_status_omits_fila_switch_when_not_installed(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Without the FTS accessory, fila_switch must be null so the frontend
+        keeps applying the per-extruder filter on regular dual-nozzle printers."""
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "IDLE"
+        # default fila_switch — installed = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["fila_switch"] is None
+
     # ========================================================================
     # ========================================================================
     # Test connection endpoint
     # Test connection endpoint
     # ========================================================================
     # ========================================================================

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

@@ -4391,3 +4391,79 @@ class TestHardResetClientDirect:
         # loop_stop is still attempted after the disconnect failure.
         # loop_stop is still attempted after the disconnect failure.
         original.loop_stop.assert_called()
         original.loop_stop.assert_called()
         assert mqtt_client._client is None
         assert mqtt_client._client is None
+
+
+class TestFilamentTrackSwitchDetection:
+    """Tests for Filament Track Switch (FTS) accessory detection (#1162).
+
+    The FTS is an accessory that sits between an AMS and the printer's
+    extruders, dynamically routing any slot to either nozzle. When installed,
+    each AMS unit reports info bits 8-11 = 0xE (uninitialized) since slots are
+    no longer tied to a specific extruder. Detection comes from the presence of
+    the print.device.fila_switch object in MQTT push_status.
+    """
+
+    @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",
+        )
+
+    def test_fts_default_not_installed(self, mqtt_client):
+        """Without any MQTT data, fila_switch.installed must be False so the
+        frontend keeps applying the per-extruder filter on regular dual-nozzle
+        printers."""
+        assert mqtt_client.state.fila_switch.installed is False
+        assert mqtt_client.state.fila_switch.in_slots == []
+        assert mqtt_client.state.fila_switch.out_extruders == []
+
+    def test_fts_detected_from_device_fila_switch(self, mqtt_client):
+        """A push_status with print.device.fila_switch present must mark FTS
+        installed and capture its routing arrays. Mirrors the user's MQTT
+        bundle in #1162."""
+        data = {
+            "gcode_state": "RUNNING",
+            "device": {
+                "fila_switch": {
+                    "in": [-1, 2],
+                    "info": 2,
+                    "out": [0, 1],
+                    "stat": 0,
+                }
+            },
+        }
+        mqtt_client._update_state(data)
+        fs = mqtt_client.state.fila_switch
+        assert fs.installed is True
+        assert fs.in_slots == [-1, 2]
+        assert fs.out_extruders == [0, 1]
+        assert fs.stat == 0
+        assert fs.info == 2
+
+    def test_fts_absent_when_no_fila_switch_field(self, mqtt_client):
+        """A push_status that has device.* but no fila_switch must leave
+        fila_switch.installed = False — only that specific field flips it on."""
+        data = {
+            "gcode_state": "IDLE",
+            "device": {"extruder": {"state": 0}},
+        }
+        mqtt_client._update_state(data)
+        assert mqtt_client.state.fila_switch.installed is False
+
+    def test_fts_handles_missing_in_out_arrays(self, mqtt_client):
+        """If the firmware sends fila_switch with missing or non-list in/out,
+        we must still mark it installed (presence is the signal) and default
+        the arrays to empty lists rather than crashing."""
+        data = {
+            "gcode_state": "IDLE",
+            "device": {"fila_switch": {"stat": 0, "info": 0}},
+        }
+        mqtt_client._update_state(data)
+        fs = mqtt_client.state.fila_switch
+        assert fs.installed is True
+        assert fs.in_slots == []
+        assert fs.out_extruders == []

+ 148 - 0
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -0,0 +1,148 @@
+/**
+ * Tests for the FilamentMapping component's Filament Track Switch (FTS)
+ * handling (#1162).
+ *
+ * The FTS accessory routes any AMS slot to either extruder dynamically. When
+ * present (printer status `fila_switch.installed === true`), the per-extruder
+ * dropdown filter must be suppressed — otherwise the print modal's filament
+ * dropdown is empty since the printer reports info bits 8-11 = 0xE
+ * (uninitialized) for every AMS unit.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, cleanup } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+const mockFilamentReqs = {
+  filaments: [
+    // Required filament asks for the LEFT extruder (nozzle_id=1).
+    // Without FTS the dropdown filter would only allow slots with extruderId=1.
+    { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 25, used_meters: 8.5, nozzle_id: 1 },
+  ],
+};
+
+function createStatus(overrides: Partial<PrinterStatus>): PrinterStatus {
+  return {
+    id: 1,
+    name: 'X2D',
+    connected: true,
+    state: 'IDLE',
+    ams: [
+      {
+        id: 0,
+        // Realistic FTS-installed bundle: AMS reports extruder bits 8-11 = 0xE,
+        // so ams_extruder_map ends up empty.
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Bambu PLA' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Bambu PETG' },
+        ],
+      },
+    ],
+    vt_tray: [],
+    ams_extruder_map: {},
+    ...overrides,
+  } as PrinterStatus;
+}
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentMapping — FTS routing', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+    );
+  });
+
+  it('shows all loaded slots in the dropdown when FTS is installed', async () => {
+    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,
+              },
+            }),
+          ),
+      ),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    // Both PLA and PETG slots must appear in the dropdown despite ams_extruder_map
+    // being empty and the requirement asking for nozzle 1. Without the FTS guard
+    // the dropdown would render only the "-- Select slot --" placeholder.
+    await waitFor(() => {
+      expect(screen.getByText(/Bambu PLA/)).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\]/);
+
+    // 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]\]/);
+  });
+
+  it('still applies the per-nozzle filter when FTS is null', async () => {
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 0 },  // AMS 0 → right nozzle (extruder 0)
+            }),
+          ),
+      ),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    // Required nozzle is 1 (LEFT) but AMS 0 is on extruder 0 (RIGHT) — neither
+    // slot should appear in the dropdown.
+    await waitFor(() => {
+      // Wait for component to render — the slot label should NOT be present
+      expect(screen.queryByText(/Bambu PLA/)).not.toBeInTheDocument();
+      expect(screen.queryByText(/Bambu PETG/)).not.toBeInTheDocument();
+    });
+  });
+});

+ 56 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -569,6 +569,62 @@ describe('computeAmsMapping - nozzle filtering', () => {
 
 
     expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
     expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
   });
   });
+
+  // FTS (Filament Track Switch) — when present, AMS slots aren't tied to a
+  // specific extruder. The track switch routes any slot to either extruder, so
+  // the per-nozzle hard filter must NOT apply. See #1162.
+  it('ignores nozzle_id when FTS is installed', () => {
+    // Required filament asks for nozzle 1 (left). Without FTS this would force
+    // AMS 0 (which is on the left nozzle). With FTS we accept any AMS slot
+    // matching by type/color since the FTS routes it to whichever extruder.
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Without FTS, this AMS would be left/extruder 1; ams_extruder_map
+                // is empty because the printer reports info bits 8-11 = 0xE.
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00' },
+        ],
+      },
+    ]);
+    (status as any).ams_extruder_map = {};
+    (status as any).fila_switch = {
+      installed: true,
+      in_slots: [-1, 1],
+      out_extruders: [0, 1],
+      stat: 0,
+      info: 2,
+    };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);  // Picks AMS 0 tray 1 (PETG green) regardless of nozzle
+  });
+
+  it('still applies nozzle filter when FTS object is null', () => {
+    // Sanity check: explicit null fila_switch behaves like no FTS — nozzle
+    // filter still applies on real dual-nozzle printers.
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
+      { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+    (status as any).fila_switch = null;
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // AMS 0 (left/extruder 1)
+  });
 });
 });
 
 
 // ============================================================================
 // ============================================================================

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

@@ -238,6 +238,16 @@ export interface PrintOptions {
   filament_tangle_detect: boolean;
   filament_tangle_detect: boolean;
 }
 }
 
 
+export interface FilaSwitchState {
+  installed: boolean;
+  // in[track] = currently loaded slot for that track (-1 = empty)
+  in_slots: number[];
+  // out[track] = extruder this track terminates at (0 = right, 1 = left)
+  out_extruders: number[];
+  stat: number;
+  info: number;
+}
+
 export interface PrinterStatus {
 export interface PrinterStatus {
   id: number;
   id: number;
   name: string;
   name: string;
@@ -299,6 +309,10 @@ export interface PrinterStatus {
   // Format: {ams_id: extruder_id} where extruder 0=right, 1=left
   // Format: {ams_id: extruder_id} where extruder 0=right, 1=left
   // Note: JSON keys are always strings
   // Note: JSON keys are always strings
   ams_extruder_map: Record<string, number>;
   ams_extruder_map: Record<string, number>;
+  // Filament Track Switch accessory — null when not installed. When present,
+  // 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.
+  fila_switch: FilaSwitchState | null;
   // 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;
   // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
   // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)

+ 30 - 2
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -88,6 +88,19 @@ export function FilamentMapping({
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
   const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
   const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
 
 
+  // Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
+  // (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.
+  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;
+  };
+
   // Don't render if no filament requirements
   // Don't render if no filament requirements
   if (!hasFilamentReqs) {
   if (!hasFilamentReqs) {
     return null;
     return null;
@@ -212,7 +225,12 @@ export function FilamentMapping({
                   -- Select slot --
                   -- Select slot --
                 </option>
                 </option>
                 {loadedFilaments
                 {loadedFilaments
-                  .filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)
+                  .filter(
+                    (f) =>
+                      item.nozzle_id == null ||
+                      ftsInstalled ||
+                      f.extruderId === item.nozzle_id,
+                  )
                   .map((f) => {
                   .map((f) => {
                     const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
                     const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
                     const remainingLabel = remainingWeight != null
                     const remainingLabel = remainingWeight != null
@@ -221,9 +239,19 @@ 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')}]`;
                     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}
+                        {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
                       </option>
                       </option>
                     );
                     );
                 })}
                 })}

+ 13 - 3
frontend/src/hooks/useFilamentMapping.ts

@@ -96,6 +96,10 @@ export function computeAmsMapping(
   const loadedFilaments = buildLoadedFilaments(printerStatus);
   const loadedFilaments = buildLoadedFilaments(printerStatus);
   if (loadedFilaments.length === 0) return undefined;
   if (loadedFilaments.length === 0) return undefined;
 
 
+  // FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
+  // doesn't apply when it's installed (#1162).
+  const ftsActive = printerStatus?.fila_switch?.installed === true;
+
   // Track which trays have been assigned to avoid duplicates
   // Track which trays have been assigned to avoid duplicates
   const usedTrayIds = new Set<number>();
   const usedTrayIds = new Set<number>();
 
 
@@ -108,7 +112,8 @@ export function computeAmsMapping(
     // Nozzle-aware filtering: restrict to trays on the correct nozzle.
     // Nozzle-aware filtering: restrict to trays on the correct nozzle.
     // This is a hard filter — cross-nozzle assignment causes print failures
     // This is a hard filter — cross-nozzle assignment causes print failures
     // ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
     // ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
-    if (req.nozzle_id != null) {
+    // Skip when an FTS is installed: it can route any slot to either extruder.
+    if (req.nozzle_id != null && !ftsActive) {
       available = available.filter((f) => f.extruderId === req.nozzle_id);
       available = available.filter((f) => f.extruderId === req.nozzle_id);
     }
     }
 
 
@@ -311,6 +316,10 @@ export function useFilamentMapping(
 ): UseFilamentMappingResult {
 ): UseFilamentMappingResult {
   const loadedFilaments = useLoadedFilaments(printerStatus);
   const loadedFilaments = useLoadedFilaments(printerStatus);
 
 
+  // FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
+  // doesn't apply when it's installed (#1162).
+  const ftsActive = printerStatus?.fila_switch?.installed === true;
+
   const filamentComparison = useMemo(() => {
   const filamentComparison = useMemo(() => {
     if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
     if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
 
 
@@ -363,7 +372,8 @@ export function useFilamentMapping(
 
 
       // Nozzle-aware filtering: restrict to trays on the correct nozzle.
       // Nozzle-aware filtering: restrict to trays on the correct nozzle.
       // This is a hard filter — cross-nozzle assignment causes print failures.
       // This is a hard filter — cross-nozzle assignment causes print failures.
-      if (req.nozzle_id != null) {
+      // Skip when an FTS is installed: it can route any slot to either extruder.
+      if (req.nozzle_id != null && !ftsActive) {
         available = available.filter((f) => f.extruderId === req.nozzle_id);
         available = available.filter((f) => f.extruderId === req.nozzle_id);
       }
       }
 
 
@@ -469,7 +479,7 @@ export function useFilamentMapping(
         isManual: false,
         isManual: false,
       };
       };
     });
     });
-  }, [filamentReqs, loadedFilaments, manualMappings, preferLowest]);
+  }, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive]);
 
 
   // Build AMS mapping from matched filaments
   // Build AMS mapping from matched filaments
   // Format: array matching 3MF filament slot structure
   // Format: array matching 3MF filament slot structure

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CTxVw43p.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-CXX0Eh7g.js"></script>
+    <script type="module" crossorigin src="/assets/index-CTxVw43p.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
     <link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
   </head>
   </head>
   <body>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů