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.
This commit is contained in:
maziggy
2026-05-10 10:15:07 +02:00
parent 79d54a8d53
commit 080176c6e5
5 changed files with 80 additions and 3 deletions
@@ -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.