diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e7cd942..2daa5ee9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to Bambuddy will be documented in this file. - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable. ### Fixed +- **Asking for 25 copies across two printer models queued exactly one (#3101, reported by @Leander-Vh)** — the reporter sliced one project for two printer types, selected both files in the File Manager, set the count and pressed Print. One item printed, the queue was empty behind it, and the Batches tab held nothing. Two features had met badly. Cross-model alternatives (#671) put several sliced files behind one queue slot, and a multi-plate file (#342) moves the run count out of the modal's single Quantity field onto a stepper beside each plate. The cross-model submit reads the field, not the steppers — and on a multi-plate file that field is not rendered at all, so it stayed at its initial 1 with nothing on screen able to change it. The modal read "19 runs in total" directly above a button that queued one. Cross-model no longer borrows the per-plate steppers: such a job picks its plate per candidate, down in the alternatives list, so there is only ever one number to give and the ordinary Quantity field is back. The plate selector above it is gone in that mode for the same reason — its choice never reached the request. It only decided which plate the filament panel described, which is why picking plate 3 for a candidate while plate 1 stayed ticked above it produced overrides computed from a plate the job would not print; that panel now follows the first candidate's own plate. The backend needed nothing for the count itself — it has always created one item per copy and given each its own candidate rows, so the same request now produces 25 jobs, each free to land on whichever of the two models frees up first. It did need a name: a cross-model job carries neither an archive nor a library file, because the candidates are the files, so both branches that name a batch missed and the order the reporter went looking for would have appeared in the Batches tab as "Batch". It is now named after its first candidate, the same file the print dialog names the job after. - **A print from Bambu Studio archived under the name `plate_1` instead of its own (#3126, reported by @alex-2000-ac-ghetto)** — the reporter's X2D archived Bambu Studio prints with no thumbnail, no filament total and no layer count, while the same project sent from OrcaSlicer archived in full. Two separate things were happening. The first is the known internal-storage limitation: Bambu Studio filed the sliced file on the printer's eMMC (`"url": "brtc://emmc/..."`), which FTPS on port 990 does not serve, and the X2D does this like the H2 series and the P2S. Bambuddy's bounded probe then found a file of exactly that name on the card and downloaded it — but it was an earlier slice of the same project, plate 4, while the running print was plate 1. The guard added in #1204 caught the contradiction and refused it, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it did next was not. Having rejected the file, it asked `swap_plate_suffix` for a name with the correct plate number and blanked the print's name entirely whenever that came back `None` — but `None` covers a name that carries no `- Plate N` suffix at all, and such a name holds no stale plate number to be wrong about. So a single-plate project lost the name Bambuddy was holding and the row fell through to the gcode_file path, titled `plate_1`. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way; it is now kept as the archive's title, and only a suffix that really does point at the wrong plate is rewritten. Kept as the title and nothing else, though: a name the guard has just watched fetch another plate's 3MF still gets disowned everywhere a file is looked up by name. That includes the `_active_prints` keys, and it matters — the cover endpoint downloads the running print's 3MF under that same name for a thumbnail and offers the bytes to the fallback-recovery path, which checks that a candidate is a readable 3MF but never which plate it holds. Registering the row under the rejected name would have let it be filled in with the exact file this branch had just discarded. The archive is still thumbnail-less — nothing can read that eMMC file — but it carries the print's name again. Separately, the Archives banner and the connection diagnostic named only "H2-series and P2S" as the models that do this, in all 13 languages; an X2D owner reading either was told the explanation did not apply to them. All three now name the X2D too. - **Linking a tag another spool already carries gave an answer nothing could act on, and a duplicate broke the request outright (#3110, reported by @Niko11111)** — the built-in inventory refused with "Tag UID already linked to another active spool", naming nobody, although the route had just loaded the conflicting spool row and was holding it when it wrote that sentence. Spoolman mode did name the spool, but only inside a different English sentence. Same operation, two answers, neither machine-readable, so a client had to parse prose to learn which spool to look at — and could only do it in one of the two inventory modes. Both now raise one structured 409, `{"code": "tag_already_linked", "message": ..., "spool_id": N, "field": "tag_uid" | "tray_uuid"}`, the shape Bambuddy already answers `insufficient_filament` and `printer_connection_failed` with. The SpoolBuddy kiosk reads it: a refused link showed a flat "Failed to assign spool" and now names the spool holding the tag, so the operator can walk to that spool instead of rescanning one that cannot link. The other half was worse than untidy. Nothing stops two active spools from carrying one tag — neither column has a unique index, `PATCH /inventory/spools/{id}` writes both with no conflict check, and `POST /inventory/spools/bulk` copies a single payload, tag included, into every row it creates — and the conflict lookup read its result with `scalar_one_or_none()`, which raises when two rows come back. That exception escaped the route into the authentication middleware’s fail-closed handler, so the caller was told "Authentication service temporarily unavailable" for a request that had nothing to do with authentication. The lookup is now ordered and takes the first row, the way the by-tag lookup earlier in the same file always has, and a duplicate yields a 409 naming the lowest id. - **"Clear RFID Tag" was permanently greyed out for a spool linked by its Bambu tray UUID (#3109, reported by @Niko11111)** — the button gated on `tag_uid` alone, and a spool linked by its 32-character tray UUID carries none: Bambuddy splits a stored tag by length, so a 32-char value becomes `tray_uuid` and `tag_uid` stays empty. In Spoolman mode that is every Bambu Lab spool synced from the AMS — on the reporter's instance, 35 of the 39 tagged spools, not one of which could have its tag cleared from the dialog. Everything around the button already treated those spools as tagged: the Tag ID column renders whichever identifier is present, and the payload the button sends clears both fields. Only the one condition disagreed. Either identifier now enables it, and clearing still removes both. The documented workaround — editing `extra.tag` in Spoolman's own interface — is no longer needed. diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 001663bcb..2c606f569 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -971,6 +971,15 @@ async def add_to_queue( batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename else: batch_name_base = library_file.filename + elif variant_specs: + # A cross-model job carries neither archive_id nor library_file_id -- + # the candidates are the files (#671) -- so both branches above miss + # and every such batch was named "Batch". Unreachable until the print + # dialog could ask for more than one copy of one (#3101). Name it + # after the first candidate, which is what the dialog names the job + # after and what the resolver prefers when both printers are free. + first_file = variant_specs[0][1] + batch_name_base = (first_file.file_metadata or {}).get("print_name") or first_file.filename or "Batch" batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "") batch = PrintBatch( diff --git a/backend/tests/integration/test_queue_variants_api.py b/backend/tests/integration/test_queue_variants_api.py index 706665f02..b870521df 100644 --- a/backend/tests/integration/test_queue_variants_api.py +++ b/backend/tests/integration/test_queue_variants_api.py @@ -295,3 +295,28 @@ class TestQueueWithVariants: assert len(item_ids) == 3 total = (await db_session.execute(select(PrintQueueVariant))).scalars().all() assert len(total) == 6 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_quantity_batch_is_named_after_the_first_candidate( + self, async_client, db_session, sliced_file_factory, printer_factory + ): + """A cross-model job has no archive_id and no library_file_id -- the + candidates are the files -- so the batch name has to come from one of + them or every such order reads "Batch" in the Batches tab (#3101).""" + from backend.app.models.print_batch import PrintBatch + + await printer_factory(model="H2S") + await printer_factory(model="H2C") + h2s = await sliced_file_factory("H2S", filename="bloom.gcode.3mf") + h2c = await sliced_file_factory("H2C") + + r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=4) + assert r.status_code == 200 + + batch = (await db_session.execute(select(PrintBatch))).scalars().one() + assert batch.name == "bloom ×4" + # Both stay null: the row cannot name one source without disowning the + # others, and every consumer derives progress from the items instead. + assert batch.archive_id is None + assert batch.library_file_id is None diff --git a/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx b/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx index 0ea6d5bc7..00ae8e62d 100644 --- a/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx +++ b/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx @@ -9,7 +9,8 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { render } from '../utils'; import { server } from '../mocks/server'; @@ -141,3 +142,91 @@ describe('PrintModal cross-model mode', () => { expect(screen.queryByText('Filament Mapping')).not.toBeInTheDocument(); }); }); + +/** + * Cross-model on a multi-plate file (#3101). + * + * The two features met badly. A multi-plate file moves quantity onto the + * per-plate steppers and hides the global field (#342), but the cross-model + * submit posts the global one — which in that configuration nothing can + * change. The reporter asked for 19 runs across an X1C and a P2S, watched the + * modal say "19 runs in total", and got exactly one print. + */ +describe('PrintModal cross-model mode on a multi-plate file', () => { + const PLATES = [1, 2, 3].map((i) => ({ + index: i, + name: `Plate ${i}`, + objects: ['bracket'], + filaments: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }], + has_thumbnail: false, + thumbnail_url: null, + bed_type: null, + print_time_seconds: 3600, + })); + + let posted: Record | null; + let reqUrls: string[]; + + beforeEach(() => { + posted = null; + reqUrls = []; + mockBackend(); + server.use( + http.get('/api/v1/library/files/:id/plates', ({ params }) => + HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: PLATES, is_multi_plate: true }), + ), + http.get('/api/v1/library/files/:id/filament-requirements', ({ request }) => { + reqUrls.push(request.url); + return HttpResponse.json({ + filaments: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, used_meters: 5 }], + }); + }), + http.post('/api/v1/queue/', async ({ request }) => { + posted = (await request.json()) as Record; + return HttpResponse.json({ id: 1, status: 'pending', variants: [] }); + }), + ); + }); + + it('offers the global Quantity field, not the per-plate steppers', async () => { + renderCrossModel(); + await screen.findByText('x1c.gcode.3mf'); + + // The plate selector is gone: its choice never reached the request, and the + // candidate list below is where a cross-model job picks its plates. + expect(screen.queryByRole('button', { name: /Select All/i })).toBeNull(); + expect(await screen.findByLabelText('Quantity')).toBeInTheDocument(); + }); + + it('queues the number of copies the user asked for', async () => { + const user = userEvent.setup(); + renderCrossModel(); + await screen.findByText('x1c.gcode.3mf'); + + fireEvent.change(await screen.findByLabelText('Quantity'), { target: { value: '19' } }); + await user.click(screen.getByRole('button', { name: /^Print$/i })); + + await waitFor(() => expect(posted).not.toBeNull()); + expect(posted!.quantity).toBe(19); + // One job with both candidates, nineteen times over — not nineteen jobs + // each pinned to a model, and not one job. + expect((posted!.variants as Array<{ library_file_id: number }>).map((v) => v.library_file_id)) + .toEqual([11, 12]); + }); + + it('reads filament requirements for the plate the primary candidate will run', async () => { + const user = userEvent.setup(); + renderCrossModel(); + await screen.findByText('x1c.gcode.3mf'); + await waitFor(() => expect(reqUrls.some((u) => u.includes('plate_id=1'))).toBe(true)); + + await user.selectOptions( + screen.getByLabelText('Plate for x1c.gcode.3mf'), + '3', + ); + + // Without this the override panel would describe plate 1 while the job ran + // plate 3 — the plate selector that used to key it is no longer on screen. + await waitFor(() => expect(reqUrls.some((u) => u.includes('plate_id=3'))).toBe(true)); + }); +}); diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index ba65f551d..b1e7f7522 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -673,6 +673,18 @@ export function PrintModal({ } }, [platesData, selectedPlates.size]); + // Cross-model: the candidate list owns plate choice, and `platesData` is the + // primary file's. `selectedPlate` still keys the filament-requirements query, + // so it has to follow that file's dropdown — otherwise the override panel + // describes plate 1 while the job runs plate 3 (#3101). An untouched dropdown + // renders its first plate, which is what the auto-select above already set. + useEffect(() => { + if (!isCrossModel || !libraryFileId) return; + const chosen = candidatePlates[libraryFileId]; + if (chosen == null) return; + setSelectedPlates((prev) => (prev.size === 1 && prev.has(chosen) ? prev : new Set([chosen]))); + }, [isCrossModel, libraryFileId, candidatePlates]); + // Auto-select first printer when only one available useEffect(() => { // Skip auto-select for edit mode (already initialized from queueItem) @@ -1414,7 +1426,11 @@ export function PrintModal({ // global field is hidden (#342) — the reporter's case is "plate 1 once, // plate 2 twice", which one shared number cannot express. Single-plate // files, and edit mode, keep the single field exactly as before. - const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1; + // Cross-model is excluded: its plate choice is per candidate and lives in + // VariantCandidates, so there are no per-plate steppers to own the number + // and the global Quantity field below is the only one there is (#3101). + const usePerPlateQuantities = + mode === 'create' && !isCrossModel && isMultiPlate && plates.length > 1; /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */ const quantityForPlate = (plateIndex: number | null): number => { @@ -1572,37 +1588,43 @@ export function PrintModal({ ); })()} - {/* Plate selection - first so users know filament requirements before selecting printers */} - { - setSelectedPlates(prev => { - const next = new Set(prev); - if (!isEditing) { - // Multi-select: toggle the plate - if (next.has(plateIndex)) { - next.delete(plateIndex); + {/* Plate selection - first so users know filament requirements before + selecting printers. Cross-model has no use for it: the plate is + chosen per candidate in the list below, and this selector's own + choice never reached the request — it only decided which plate + the filament panel described (#3101). */} + {!isCrossModel && ( + { + setSelectedPlates(prev => { + const next = new Set(prev); + if (!isEditing) { + // Multi-select: toggle the plate + if (next.has(plateIndex)) { + next.delete(plateIndex); + } else { + next.add(plateIndex); + } } else { + // Single-select: replace selection + next.clear(); next.add(plateIndex); } - } else { - // Single-select: replace selection - next.clear(); - next.add(plateIndex); - } - return next; - }); - }} - onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined} - onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined} - multiSelect={!isEditing} - quantities={usePerPlateQuantities ? plateQuantities : undefined} - onQuantityChange={usePerPlateQuantities - ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value })) - : undefined} - /> + return next; + }); + }} + onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined} + onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined} + multiSelect={!isEditing} + quantities={usePerPlateQuantities ? plateQuantities : undefined} + onQuantityChange={usePerPlateQuantities + ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value })) + : undefined} + /> + )} {/* Cross-model alternatives (#671) replace the printer picker entirely: the user already answered "which printer" by choosing these files,