mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user