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.
This commit is contained in:
maziggy
2026-04-29 16:18:45 +02:00
parent f45f6a131d
commit b45ca2a662
14 changed files with 596 additions and 117 deletions
+3
View File
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.4b2] - Unreleased
### Added
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
### Fixed
- **AMS slot configuration intermittently fails to reach the printer after several configs in a row** ([#1164](https://github.com/maziggy/bambuddy/issues/1164), reported by @RosdasHH) — Configuring AMS slots a handful of times (the reporter saw it almost every 6th change) would silently stop reaching the printer; ~1 minute later the filament colours on the printer would briefly jump between slots, then settle. Root cause was the zombie-session watchdog at `bambu_mqtt.py:861` introduced for [#887](https://github.com/maziggy/bambuddy/issues/887). When an `ams_filament_setting` response took >10 s (normal under load — concurrent K-profile fetches, busy printer, network jitter) the watchdog incremented an `_ams_cmd_unanswered` counter and zeroed `_last_ams_cmd_time` so it wouldn't re-trigger on the next status push. The bug: the response handler that reset the counter was guarded by `and self._last_ams_cmd_time > 0` — so when the late response *did* arrive (after the watchdog had already zeroed the timer), the counter stayed armed at 1. The next slow response on any `ams_filament_setting` command — possibly minutes or hours later, on an entirely unrelated config attempt — would take the counter to 2 and trigger `force_reconnect_stale_session()`. The user-visible symptoms match exactly: configs stop landing (because MQTT reconnects mid-publish, dropping the in-flight command and surfacing as `Cannot set AMS filament setting: not connected` if the user retries during the ~1 min reconnect window), then the queued state finally lands when the reconnect completes (the "filament colours jumping around" the reporter described). Fix is to drop the `_last_ams_cmd_time > 0` guard: any `ams_filament_setting` response — late or not — proves the channel is alive, so the counter must reset. Watchdog still trips on a real zombie session (no responses at all for two consecutive >10 s windows). Regression test in `test_bambu_mqtt.py::TestZombieSessionDetection::test_late_response_after_watchdog_clears_counter_issue_1164` simulates the exact sequence (watchdog fires → late response arrives → second slow response on a fresh command) and asserts the counter resets to 0 on the late response and the second command doesn't tip the threshold to 2. Other 10 zombie-detection tests still pass unchanged.
+1 -1
View File
@@ -92,7 +92,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Duplicate detection & full-text search
- Photo attachments & failure analysis
- 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)
- Archive comparison (side-by-side diff)
- Tag management (rename/delete across all archives)
+12
View File
@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
AmsLabelBody,
AMSTray,
AMSUnit,
FilaSwitchResponse,
HMSErrorResponse,
NozzleInfoResponse,
NozzleRackSlot,
@@ -635,6 +636,17 @@ async def get_printer_status(
supports_drying=supports_drying(printer.model, state.firmware_version),
current_archive_id=current_archive_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
View File
@@ -179,6 +179,24 @@ class AmsLabelBody(BaseModel):
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):
"""AI detection and print options from xcam data."""
@@ -245,6 +263,9 @@ class PrinterStatus(BaseModel):
ams_mapping: list[int] = []
# Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
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
tray_now: int = 255
# AMS status for filament change tracking
+39
View File
@@ -90,6 +90,26 @@ class NozzleInfo:
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
class PrintOptions:
"""AI detection and print options from xcam data."""
@@ -170,6 +190,9 @@ class PrinterState:
ams_mapping: list = field(default_factory=list)
# Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
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}
# snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
h2d_extruder_snow: dict = field(default_factory=dict)
@@ -1922,6 +1945,22 @@ class BambuMQTTClient:
# Log 'cur' field if present (might indicate current/active extruder)
if "cur" in ext_data:
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:
temps["bed"] = float(data["bed_temper"])
if "bed_target_temper" in data:
@@ -257,6 +257,78 @@ class TestPrintersAPI:
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
# ========================================================================
@@ -4391,3 +4391,79 @@ class TestHardResetClientDirect:
# loop_stop is still attempted after the disconnect failure.
original.loop_stop.assert_called()
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 == []
@@ -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();
});
});
});
@@ -569,6 +569,62 @@ describe('computeAmsMapping - nozzle filtering', () => {
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
View File
@@ -238,6 +238,16 @@ export interface PrintOptions {
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 {
id: number;
name: string;
@@ -299,6 +309,10 @@ export interface PrinterStatus {
// Format: {ams_id: extruder_id} where extruder 0=right, 1=left
// Note: JSON keys are always strings
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)
tray_now: number;
// AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
@@ -88,6 +88,19 @@ export function FilamentMapping({
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
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
if (!hasFilamentReqs) {
return null;
@@ -212,7 +225,12 @@ export function FilamentMapping({
-- Select slot --
</option>
{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) => {
const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
const remainingLabel = remainingWeight != null
@@ -221,9 +239,19 @@ export function FilamentMapping({
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 (
<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>
);
})}
+13 -3
View File
@@ -96,6 +96,10 @@ export function computeAmsMapping(
const loadedFilaments = buildLoadedFilaments(printerStatus);
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
const usedTrayIds = new Set<number>();
@@ -108,7 +112,8 @@ export function computeAmsMapping(
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
// 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.
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);
}
@@ -311,6 +316,10 @@ export function useFilamentMapping(
): UseFilamentMappingResult {
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(() => {
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.
// 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);
}
@@ -469,7 +479,7 @@ export function useFilamentMapping(
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest]);
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive]);
// Build AMS mapping from matched filaments
// Format: array matching 3MF filament slot structure
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-CXX0Eh7g.js"></script>
<script type="module" crossorigin src="/assets/index-CTxVw43p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
</head>
<body>