mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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:
@@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
|
||||
|
||||
### Fixed
|
||||
- **X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded** ([#1257](https://github.com/maziggy/bambuddy/issues/1257)) — Repro: X2D with 0 AMS units, two external spools (Ext-L feeding left extruder, Ext-R feeding right), print job specifies `nozzle_id` per filament. The Schedule Print modal showed the orange "Filament Mapping (Type not found)" header and a forced manual slot picker, even though the matching PETG was sitting right there in the external spool holder. **Cause:** `frontend/src/hooks/useFilamentMapping.ts:18-19` derived dual-nozzle status solely from `printerStatus.ams_extruder_map` being non-empty. That map is populated from AMS units' info bits, so a dual-nozzle printer with zero AMS units gets an empty map → `hasDualNozzle = false` → external spools' `extruderId` falls through to `undefined` (line 64 ternary fallback). The downstream nozzle-aware filter at lines 117 / 377 (`available.filter((f) => f.extruderId === req.nozzle_id)`) then rejected every loaded filament because `undefined !== 0/1` for any non-null `nozzle_id`. The PETG was loaded, just incorrectly stripped from the candidate set during matching. **Fix:** widen the dual-nozzle inference to three independent signals OR'd together: (1) `nozzles[1].nozzle_diameter` populated — the most direct signal, set by `bambu_mqtt.py:2619-2621` only when the printer reports a `right_nozzle_diameter` MQTT field, so a populated value always implies real second-nozzle hardware; (2) `ams_extruder_map` non-empty — preserved as fallback for the dual-nozzle-with-AMS case the original code already handled; (3) `vt_tray.length > 1` — single-nozzle printers (P1S / A1 / X1C) only have one external feed, so multiple external trays only exist on dual-nozzle hardware. The first signal alone is *not* sufficient because the backend `state.nozzles` defaults to a 2-entry list with empty `NozzleInfo()` stubs (`bambu_mqtt.py:160`) on every printer, single-nozzle included — `nozzles.length` would always be 2 on the wire and would have regressed every single-nozzle install. Affects all dual-nozzle printers running without AMS: X2D, H2D, X2 Pro. **Tests:** two new regressions in `src/__tests__/hooks/useFilamentMapping.test.ts`. `matches external spools per-extruder on dual-nozzle without AMS` pins the bug fix — asserts each external spool gets the correct `extruderId` (1 for Ext-L id=254, 0 for Ext-R id=255) and `computeAmsMapping` picks Ext-L for a left-nozzle requirement. `does not fabricate extruderId for single-nozzle with stub nozzles[1]` is the matching guard — asserts that a P1S / A1 / X1C-shape PrinterStatus (with the default-stub second nozzle entry the backend always emits) does NOT trip the dual-nozzle inference, so single-nozzle external spools keep `extruderId=undefined` exactly as they did pre-fix. Together they pin both directions: a future change that re-breaks the X2D path fails CI, and one that mistakenly turns single-nozzle printers into dual-nozzle also fails CI. Full frontend suite (1891 tests across 138 files) green.
|
||||
|
||||
- **GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button** — Opening the GCode Viewer from a File Manager card or an Archive card calls `navigate('/gcode-viewer?archive=…' | '?library_file=…')`, which mounts `GCodeViewerPage` as a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. **Fix:** added a thin back bar above the iframe in `frontend/src/pages/GCodeViewerPage.tsx` with an `ArrowLeft` icon button. The button label adapts to the entry point — `Back to Print Archives` when the URL carries `?archive=`, `Back to File Manager` when it carries `?library_file=`, generic `Back` otherwise (covers the rare deep-link / shared-URL case). Click prefers `navigate(-1)` so the user lands back in their original list with scroll position and filters preserved; falls back to `/archives` or `/files` when the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is now `flex: 1` inside a flex column under the bar instead of a hard-coded `calc(100vh - 3.5rem)` — the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. **i18n:** new `gcodeViewer.{back,backToArchives,backToFiles}` namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary — `Druckarchiv`/`Dateimanager`, `Archives d'impression`/`Gestionnaire de fichiers`, `Archivi di stampa`/`Gestore file`, `印刷アーカイブ`/`ファイル管理`, `Arquivos de impressão`/`Gerenciador de arquivos`, `打印归档`/`文件管理器`, `列印歸檔`/`檔案管理器`).
|
||||
- **Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows** ([#1249](https://github.com/maziggy/bambuddy/issues/1249)) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used `flex-1` to share whatever space remained after the four fixed-width icon buttons, with the label rendered as `<span className="hidden sm:inline truncate">...</span>` — i.e. visible at any viewport ≥ 640px, with `truncate` ellipsizing when there isn't room. **The Tailwind viewport breakpoint can't see the card width.** The page's grid grows column count alongside viewport (`md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. **Fix:** breakpoint bumped from `hidden sm:inline` → `hidden xl:inline` on all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 of `frontend/src/pages/ArchivesPage.tsx`). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existing `title=` tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row.
|
||||
- **Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry** ([#1248](https://github.com/maziggy/bambuddy/issues/1248), reported by @andretietz) — Two distinct defects in the same code path. **Defect 1 (the reported bug):** `buildFilamentOptions` in `frontend/src/components/spool-form/utils.ts` was precedence-based — `if (cloudPresets.length > 0)` returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a green `Local` badge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. **Defect 2 (surfaced during fix verification):** the spool form was collapsing all `@Bambu Lab P1S 0.4 nozzle` / `@Bambu Lab X1C 0.4 nozzle` / `@Bambu Lab A1 0.4 nozzle` variants of "Bambu PLA Basic" into a single dropdown entry by stripping the `@printer` suffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. **Fix:** rewrote `buildFilamentOptions` to (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloud `setting_id` and each `LocalPreset` row as its own `FilamentOption` instead of collapsing by `name.replace(/@.*$/, '')`. `displayName` now keeps the full `@printer 0.4 nozzle` suffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrors `ConfigureAmsSlotModal.tsx:498` exactly). Wired `api.getBuiltinFilaments()` into both callers — `SpoolFormModal` and `SpoolBuddyWriteTagPage`. **Persistence safety:** the saved `slicer_filament` shape is unchanged — cloud picks still persist their `setting_id`, local picks still persist `preset.filament_type || String(preset.id)` (consumed by `backend/app/utils/filament_ids.py::normalize_slicer_filament` which expects `GFL05`/`GFSL05` shapes; persisting the bare LocalPreset row id would break slicing). Local-preset `allCodes` now carries both the `filament_type` form and the `String(preset.id)` form so `findPresetOption` resolves both old (pre-fix) and new picks. **React-key collision:** with collapse removed, two LocalPreset rows can share the same `code` if they share `filament_type`; the dropdown key in `FilamentSection.tsx` is now composed `${option.code}::${option.name}` to stay unique. **Tests:** new `frontend/src/__tests__/components/spool-form/buildFilamentOptions.test.ts` with 9 cases — the #1248 regression case, "one entry per cloud setting_id, no @printer collapse", "list each local preset individually", "@printer suffix preserved in displayName", local `allCodes` carrying both shapes, the `GFA00`↔`GFSA00` built-in dedup, the all-empty fallback, and the alphabetical sort. The two existing `vi.mock('../../api/client')` blocks in `SpoolFormModal.test.tsx` and `SpoolFormBulk.test.tsx` were updated with the new `getBuiltinFilaments` stub.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 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-CwfrW2a1.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CBFhMIk6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user