Browse Source

fix(filament-mapping): X2D/H2D dual-nozzle without AMS lost
external-spool extruder routing (#1257)

X2D with 0 AMS units and two external spools (Ext-L feeding left
extruder, Ext-R feeding right) showed "Required filament type not
found in printer" even when the matching filament was physically
loaded. Cause: useFilamentMapping derived dual-nozzle status from
ams_extruder_map being non-empty -- that map is populated from AMS
info bits, so dual-nozzle printers without AMS got an empty map
and hasDualNozzle=false. External spools then fell through to
extruderId=undefined, and the nozzle-aware filter rejected every
candidate because undefined !== 0/1.

Prefer the hardware-reported printerStatus.nozzles array length as
the dual-nozzle signal -- populated regardless of AMS configuration
-- and keep the ams_extruder_map branch as fallback for older
firmware that might not surface nozzles. Affects all dual-nozzle
printers running without AMS: X2D, H2D, X2 Pro.

Regression test pins both layers the bug straddled --
buildLoadedFilaments extruderId assignment per external spool, and
computeAmsMapping picking the correct external for a per-nozzle
requirement -- so a future change that re-breaks either fails CI.

maziggy 2 weeks ago
parent
commit
080176c6e5

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


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

@@ -606,6 +606,69 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([1]);  // Picks AMS 0 tray 1 (PETG green) regardless of nozzle
   });
 
+  // X2D / H2D / X2 Pro with no AMS but two external spools (one feeding each
+  // extruder). Pre-fix, dual-nozzle was inferred from `ams_extruder_map` being
+  // non-empty, which fails when there are no AMS units — both vt_tray entries
+  // got `extruderId=undefined`, the per-nozzle filter rejected everything, and
+  // the UI surfaced "Required filament type not found in printer" even though
+  // the matching filament was sitting in the external spool. (#1257)
+  it('matches external spools per-extruder on dual-nozzle without AMS', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, nozzle_id: 1 },  // Left
+      ],
+    };
+    const status = createPrinterStatus([], [
+      // Two external spools, both PETG. Ext-L (id=254) feeds left extruder (1),
+      // Ext-R (id=255) feeds right (0). 255 - id formula in buildLoadedFilaments
+      // routes them when hasDualNozzle is true.
+      { id: 254, tray_type: 'PETG', tray_color: 'FFFFFF' } as PrinterStatus['vt_tray'][number],
+      { id: 255, tray_type: 'PETG', tray_color: '000000' } as PrinterStatus['vt_tray'][number],
+    ]);
+    // Real X2D hardware: both nozzles report a populated diameter via the
+    // MQTT right_nozzle_diameter / left_nozzle_diameter fields. ams_extruder_map
+    // is empty because there are zero AMS units.
+    (status as any).nozzles = [
+      { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
+      { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
+    ];
+    (status as any).ams_extruder_map = {};
+
+    // Loaded filaments must surface extruderId on each external entry,
+    // otherwise computeAmsMapping's per-nozzle filter strips them out.
+    const loaded = buildLoadedFilaments(status);
+    expect(loaded).toHaveLength(2);
+    expect(loaded.find((f) => f.globalTrayId === 254)?.extruderId).toBe(1);  // Ext-L → left
+    expect(loaded.find((f) => f.globalTrayId === 255)?.extruderId).toBe(0);  // Ext-R → right
+
+    // Mapping must succeed and pick Ext-L (left extruder, white PETG).
+    const result = computeAmsMapping(reqs, status);
+    expect(result).toEqual([254]);
+  });
+
+  // Sibling regression: the bambu_mqtt state defaults `nozzles` to a 2-entry
+  // list with empty NozzleInfo() stubs even on single-nozzle printers, and the
+  // route emits both entries on the wire. The dual-nozzle inference must NOT
+  // be tripped by a stub second entry — only by populated hardware info,
+  // populated ams_extruder_map, or >1 external trays. Pin: single-nozzle
+  // printer (P1S/A1/X1C) with one external spool gets extruderId=undefined,
+  // matching pre-fix behaviour. (#1257)
+  it('does not fabricate extruderId for single-nozzle with stub nozzles[1]', () => {
+    const status = createPrinterStatus([], [
+      { id: 254, tray_type: 'PLA', tray_color: 'FF0000' } as PrinterStatus['vt_tray'][number],
+    ]);
+    // Single-nozzle: nozzles[1] is the default stub (empty fields).
+    (status as any).nozzles = [
+      { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
+      { nozzle_type: '', nozzle_diameter: '' },
+    ];
+    (status as any).ams_extruder_map = {};
+
+    const loaded = buildLoadedFilaments(status);
+    expect(loaded).toHaveLength(1);
+    expect(loaded[0].extruderId).toBeUndefined();
+  });
+
   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.

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

@@ -16,7 +16,19 @@ import type { PrinterStatus } from '../api/client';
 export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
   const filaments: LoadedFilament[] = [];
   const amsExtruderMap = printerStatus?.ams_extruder_map;
-  const hasDualNozzle = amsExtruderMap && Object.keys(amsExtruderMap).length > 0;
+  // Dual-nozzle detection. The backend always emits a 2-entry nozzles array
+  // (default-stub second entry for single-nozzle printers), so length is not
+  // a reliable signal. Real second-nozzle hardware sets `nozzle_diameter` from
+  // the MQTT `right_nozzle_diameter` field (bambu_mqtt.py:2619-2621); without
+  // that field, nozzles[1] stays at its empty default. Belt-and-braces: a
+  // populated ams_extruder_map (dual-nozzle with AMS) and >1 vt_tray (only
+  // dual-nozzle hardware exposes multiple external feeds) each independently
+  // imply dual-nozzle — keep them as fallbacks for any firmware rev that
+  // surfaces one signal but not the other. (#1257)
+  const hasDualNozzle =
+    Boolean(printerStatus?.nozzles?.[1]?.nozzle_diameter)
+    || (amsExtruderMap && Object.keys(amsExtruderMap).length > 0)
+    || (printerStatus?.vt_tray?.length ?? 0) > 1;
 
   // Add filaments from all AMS units (regular and HT)
   printerStatus?.ams?.forEach((amsUnit) => {

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CBFhMIk6.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff