# Changelog All notable changes to Bambuddy will be documented in this file. ## [1.2.5.2] - 2026-08-02 ### Added - **The print queue now shows when each job would finish (#2736, contributor @mpl1337)** — A queue row carried a job's print duration but not the clock time that maps to, so "if I start this one now, when is it done?" meant doing the arithmetic yourself, once per row — while the card for the running print has shown an ETA all along. Pending and staged rows now show one too, beside the duration, in your configured 12/24-hour format and styled to match the live one. It is deliberately a per-job answer and not a forecast of the whole queue: "start this now and it finishes at 14:30", not a projection of everything ahead of it. That only means anything for a job that could actually start now, so the ETA appears on exactly those. A job sits behind whatever is printing on its printer, and behind anything the scheduler would dispatch ahead of it — resolved in the same order the scheduler itself uses, including **Shortest Job First** when that is on — and stays quiet until it is genuinely next up, so three jobs stacked on one printer no longer quote the same finishing time three times over. Staged jobs are the exception in both directions: the scheduler skips them without claiming the printer, so they neither hold up the job behind them nor wait for it, and they show an ETA whenever their printer is free — which is the honest answer to "what happens if I press Start". Jobs scheduled for later, jobs blocked with a stated reason, jobs conditional on an earlier print succeeding, and jobs with no duration in their metadata show nothing at all. The time also keeps up with the clock instead of freezing at the moment the page was opened, and every row on screen is measured from the same instant, so two rows are always comparable. Frontend-only. Translated in all locales; wiki updated. Covered by frontend tests. - **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it. - **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests. - **Telegram notifications can target a forum topic (#1518, reporter @vmhomelab)** — Telegram groups with Topics enabled always received Bambuddy's notifications in the **General** topic, because only Bot Token and Chat ID were configurable. Getting a per-printer split therefore meant creating a separate chat per printer. The Telegram provider now takes an optional **Forum Topic ID** — the last number in a topic's link (`t.me/c/1234567890/25`) — and routes its messages into that topic, so a single group can carry one topic per printer. Left empty, the behaviour is unchanged. The ID is sent on both the plain-text and the thumbnail code paths, and is validated as a number in the form and again server-side, so a typo is reported instead of silently breaking only text notifications. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Support bundles now record Bambuddy's own memory, threads and child processes (#2734)** — A bundle described everything except the thing it runs in. That made reports of memory climbing over days impossible to act on: the numbers that identify what is actually growing only exist while it is happening, and by the time anyone asked, the container had been restarted. Bundles now carry resident and virtual memory, thread count, child processes by name, open files and sockets, process uptime, and a census of live objects by type. Those figures separate causes that look identical from outside — a large virtual size against a modest resident one is address space rather than data, a rising thread count points somewhere quite different from a rising child-process count, and the object census names what a growing heap is filling up with. The object census is skipped on processes already above 2 GB, because walking the heap costs most on exactly the process that can least afford it; everything else is still collected. Child processes are recorded by executable name only — an ffmpeg command line carries the camera URL and its password. Collection happens off the main loop and every metric is best-effort, so a hardened kernel or restricted container that refuses one of them still produces a complete bundle. - **Folder rows in the File Manager now show when anything inside them last changed (#2680 follow-up, reporter @cadtoolbox)** — The calendar toggle only put dates on the file pane, so the folder tree had no way to show the timestamp it was already sorting on. Folders now render that value under their name whenever the toggle is on, nested folders included. It is labelled **last activity** rather than "last modified" deliberately: the value is the newest timestamp among the folder, its files and everything below it, so a folder can legitimately read as newer than its own directory mtime — calling that "modified" would look like a fresh instance of the `ls -lt` mismatch the issue was originally about. Folders with no activity render nothing rather than an `Invalid Date` placeholder. Frontend-only — the field was already on the wire from the sort fix. Covered by frontend tests. ### Changed - **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest. ### Fixed - **Bambu Cloud sign-in with a TOTP (authenticator app) account always failed with "Invalid code" (#2696, reporter @cmerkle)** — Every TOTP verification was rejected regardless of the code. Bambu Lab added double-submit CSRF protection to the `bambulab.com` web origin, which is where — and only where — Bambuddy posts the two-factor code; the endpoint refused the request with `403 CSRF error: missing_cookie` **before evaluating the code at all**, and Bambuddy surfaced that as "Invalid code". Reproduced against the live endpoint with a deliberately invalid key: a bare POST returns `missing_cookie`, `GET /api/csrf` mints a `bbl_csrf_token` cookie, a POST carrying only that cookie returns `missing_header`, and a POST carrying the cookie plus an `x-bbl-csrf-token` header reaches application logic. Bambuddy now performs that handshake before submitting the code. Note that landing on the sign-in page first — the intuitive fix — does **not** work: that page sets only Cloudflare's `__cf_bm`. **Also fixed:** a CSRF refusal no longer masquerades as a wrong code; it now says the code was never checked, so nobody else loses an evening to clock drift and leading-zero theories. Only TOTP sign-ins were affected — every other cloud call, including the email-code two-factor path, goes to `api.bambulab.com`, which is not gated, and existing stored tokens kept working throughout. Covered by tests that pin the exact header name and the origin used per region. - **A2L AMS filament showed as "?" in Bambu Studio through the Virtual Printer, and manual filament picks reverted (#2697, reporter @qoatzelcoat)** — Every slot of the A2L's AMS Lite rendered as an empty question mark in the slicer's Device tab while Bambuddy's own AMS card showed type, colour and spool correctly; setting a filament by hand in Studio held for a second and then snapped back to "?". **Root cause.** The A2L reports its AMS Lite as physical unit id 16, but packs the slots' presence bits at bit base 24 — so Bambuddy normalises the id to 6 at the MQTT ingest boundary and every internal reader gets the right bits. The Virtual Printer's bridge, however, parses the printer's raw payload itself (by design — the slicer-facing cache has to keep the physical ids, since Bambu Studio addresses the Lite as 16) and so still held id 16 when it ran the shared empty-slot cleanup. That cleanup read bits 64-67, where nothing is ever set, concluded all four slots were empty and wiped `tray_type`, `tray_color`, `tray_info_idx` and the RFID fields from the copy sent to the slicer — once per second, which is also why a manual pick could not survive. **Fix.** The presence-bit helper now folds the physical id 16 onto the same bit base as the normalised 6, so it computes bits 24-27 whichever id reaches it; the cached ids the slicer sees are left untouched. Only the A2L was affected — every other AMS type already reached the helper with an id whose bit base was correct, and Bambuddy's own printer card was correct throughout. Confirmed against the reporter's debug log, which shows the cleanup clearing slots at bits 64-67. Covered by tests pinning the bit base for both ids and a bridge-level regression test built from the reporter's capture. - **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests. - **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests. - **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back. - **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests. - **A printer with no K-profiles can now be given its first one (#2719, reporter @jmoore-skild)** — **Add K-Profile** built its Filament dropdown out of the profiles already on the printer, so on a printer with none the field was empty, required, and impossible to satisfy — the modal even said so, telling you to go and create the profile in Bambu Studio instead. The filament picker is now populated the way every other one in Bambuddy is, in the same order: **Imported** presets first, then **Orca Cloud**, then **Bambu Cloud**, then Bambuddy's built-in Bambu filament table. That last tier is compiled in, so the list is never empty — a brand-new printer with no cloud account and nothing imported still gets you a profile. The per-printer-model copies a cloud account carries ("Bambu PLA Basic" once for the X1C, once for the P1S, once for the A1) are collapsed into a single row, and the built-in table — a static copy of the same Bambu catalogue — no longer echoes back filaments the groups above already list. Your imported and Orca Cloud libraries are both shown in full even where they overlap by name, because they are usually the same profiles reached two ways and each group is worth seeing under its own heading. The picker is a searchable list with the source heading shown as a real, legible group header — a native dropdown can't do that, since browsers render the group label of a `` (populated from `api.getPrinters()`); pipelines without a target render an amber "Set a target printer to run this" hint in the row + a "Set a target printer before running this pipeline" warning at the bottom. Last-run summary appears inline per row — small `Last run: completed · 27/06/2026, 14:23` line driven by `GET /slicer-pipelines/{id}/runs?limit=1` with a 15 s `refetchInterval` so the chip ticks while a run is in flight. `RunStatusBadge` colour-codes the seven states. **New component `RunWithPipelineModal`** at `components/RunWithPipelineModal.tsx` — two-step dialog: step 1 lists the user's pipelines (each row shows the pinned target printer; pipelines without a target are disabled with a `No target printer set` hint), step 2 is the eligibility confirmation. Fast path: ok=true skips step 2 entirely and fires the run straight from the pipeline pick. Slow path: shows per-issue text via the `IssueText` mapper — eg. `Filament slot 1: expected PLA, AMS has PETG` for `filament_type_mismatch`, `AMS slot 2 not available on this printer` for `ams_slot_missing` — then `Run anyway` posts with `force=true`. **FileManagerPage integration**: FileCard's action menu picks up a `Run with pipeline` entry (gated on the new `pipelines:run` permission); list-view rows get a matching inline Play-icon button so list users have the same entry point as card users. Both flow into the same `setRunPipelineFile(file)` state which renders the modal. The action is only offered on slice-eligible files (3MF / STL / STEP) and only when `use_slicer_api` is on — matches the existing Slice button gating, since a non-slice-eligible file can't reach the slice step in any case. **Frontend types**: client.ts grows `PipelineEligibilityReport`, `PipelineRun`, `PipelineJob`, `PipelineRunListResponse`, plus six new `api.*` methods (`checkPipelineEligibility`, `runPipeline`, `listPipelineRuns`, `getPipelineRun`, `cancelPipelineRun`, and the updated `updateSlicerPipeline` which now accepts `target_kind` + `target_printer_id`). The `Permission` union also gets `pipelines:read | pipelines:write | pipelines:run` — these were on the backend Permission enum from PR A but had been missed in the frontend union (caught when TS rejected `hasPermission('pipelines:run')`). **i18n.** ~36 new keys across `library.runWithPipeline.*` (modal title / confirm / source-hint / pipeline-hint / target-hint / Run-anyway / 8 issue-kind strings / 2 toast / empty-state / no-target hint) and `settings.pipelines.field.targetPrinter` / `field.noTarget` / `noTargetHint` / `noTargetWarning` / `runs.lastRun` + seven `runs.status.*` strings — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5473 leaves per locale, no English fallback. The string `slicing` was added to `IT_COGNATES` (genuine cognate — same word in Italian). **Tests.** 13 new backend integration cases in `test_pipeline_runs_api.py` covering PUT target write + clear-via-0 + check-eligibility (printer_not_set / printer_disabled cascade with offline / fully-clear AMS-match) + run flow (409 on issues+!force / 400 on force+!target / 202 on clean path with creation of run+job) + list/get 404s + cancel (404 / marks queued / idempotent on terminal). Slicing itself is stubbed via `patch(..._run_pipeline_orchestration)` so CI runs without a live sidecar. 4 new vitest cases in `RunWithPipelineModal.test.tsx` pin the modal's two-step flow: empty state, disabled pipeline-without-target, fast-path (issues empty → modal closes immediately after `runPipeline(..., false)`), slow-path (issues shown → `Run anyway` posts with `force=true`). **Suites.** `pytest -n 30 backend/tests/` 6530/6530 green; `npx vitest run` 2278/2278 green (172 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **What's out of scope for PR B.** Multi-copy (`copies > 1`), class targeting (`target_kind='printer_class'`), fanout strategies, the Pipeline Runs dashboard — all PR C. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice step inside the pipeline run fails the same way the standalone slice route does, the run rolls up to `status='failed'` with the slicer's error string in `error_message`. The print queue's existing AMS / filament check + the printer-side error path remain authoritative for what actually happens at the machine — pipeline eligibility is a *pre-flight*, not a hard guard. - **Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A, requested by @TheUltimateC0der)** — Top feature in the first sponsor vote. The SliceModal forces the user to pick four slots every time: printer / process / filament(s) / bed type. For fleet production that's tedious and error-prone — operators want a named "Production PLA" bundle they can apply with one click on every file and every printer. **PR A scope.** Definitions only. The new model `slicer_pipelines` materialises the bundle plus future-PR columns (`target_kind`, `target_printer_id`, `target_model_class`, `fanout_strategy`) so PR B (single-target dispatch) and PR C (multi-copy batch with capability-matched fanout) are code-only, not migrations. The bundle is independently useful in PR A as an ergonomic improvement: pipelines are picked from the SliceModal, applied to the four slots, then sliced through the existing flow. No new dispatch behaviour yet. **Backend.** Model `SlicerPipeline` (`models/slicer_pipeline.py`), Pydantic schemas `SlicerPipelineCreate` / `Update` / `Response` reusing the existing `PresetRef` shape from `schemas/slicer.py`, CRUD routes at `/api/v1/slicer-pipelines/` (`GET list`, `POST create`, `GET/PUT/DELETE by id`). Soft-delete via `is_deleted` so PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first by `id DESC` (more reliable than `created_at` under back-to-back inserts whose DateTime precision can tie). Routes use explicit `await db.commit()` after the mutation (matches the `routes/library.py` pattern) so the response shape returns the committed row. **Permissions.** Three new `Permission` values: `PIPELINES_READ`, `PIPELINES_WRITE`, `PIPELINES_RUN`. PR A only consumes the first two; `RUN` is defined now so PR C doesn't need to backfill. `Administrators` and `Operators` get all three; `Viewers` get `PIPELINES_READ`. A backfill block in `seed_default_groups()` adds them to existing groups on upgrade (mirrors the `library:purge` / `archives:purge` pattern from earlier). All three are added to `_APIKEY_DENIED_PERMISSIONS` so they fail closed for any API-key surface — PR B / PR C may move `PIPELINES_RUN` onto `can_queue` once the dispatch lands. **Frontend.** Settings → Workflow tab is split into two sub-tabs mirroring the Authentication tab's pattern: **Queue & Dispatch** (the existing Workflow content) and **Pipelines** (the new manager). The Workflow sidebar entry stays single — no expandable submenu — and the sub-tab choice is reflected in the URL (`?tab=queue&sub=pipelines`) for deep-linking. **SlicerPipelinesPanel** lists saved pipelines with inline rename, soft-delete, and a stale-preset warning when a referenced preset no longer resolves against the unified-presets listing (e.g. an `orca_cloud` preset deleted in OrcaSlicer; the pipeline still saves, the warning prompts a re-save from the SliceModal). Full pipeline creation lives in the **SliceModal** rather than Settings — the user has already done the four-slot work there. The modal grows an `Apply pipeline ▾` dropdown plus a `Save as pipeline` button above the existing preset dropdowns. Apply fills all four slot states (`printerPreset`, `processPreset`, `bedType`, `filamentPresets[]`); the filament list right-pads from current state so a pipeline with fewer entries than the current source's slot count keeps the existing tail (lets the same pipeline apply across single-color and multi-color files). Save captures the four-slot picks under an inline-named pipeline. Stale-preset warning shows on the Settings list, not blocking apply, so an old pipeline with a one-deleted-preset can still be re-applied and re-saved with the new pick. **i18n.** ~30 new keys across `settings.pipelines.*` and `slice.pipelines.*` plus `settings.tabs.queueDispatch` / `queuePipelines`, translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5437 leaves per locale, no English fallback. `Pipeline` / `Pipelines` / `Filament {{n}}` added to `IDENTICAL_TO_EN_ALLOWED` for the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). **Tests.** Backend: 11 integration cases in `test_slicer_pipelines_api.py` covering empty list, create + round-trip, get-by-id, partial PUT preserves untouched fields, filament list replaces wholesale, soft-delete hides from list + GET-by-id, 404s on missing, schema rejection of empty filament list + invalid PresetRef source, newest-first ordering. Frontend: 3 new SliceModal cases (apply-pipeline dropdown disabled-empty / apply-sets-state / save-as-pipeline-round-trip) plus 2 SettingsPage cases (sub-tab nav renders + Pipelines deep-link). Existing SliceModal tests adjusted via a `presetSelects()` helper that filters out the new Apply-pipeline combobox so historical `selects[0]` indexing into printer/process/filament remains stable. **Suites.** `pytest -n 30 backend/tests/` 6517/6517 green; `npx vitest run` 2274/2274 green (171 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (the `target_kind='specific_printer'` path), PR C adds multi-copy batch with capability matching + the three fanout strategies (`max_parallel` / `fill_one_first` / `round_robin`). The `Run pipeline` action mentioned in the original issue is PR B/C and intentionally not exposed in this drop. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (`OrcaSlicer/OrcaSlicer#13774`); the slice fails, the pipeline doesn't pre-validate. - **Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up)** — `#1625` (`Unify print dispatch through the scheduler`) moved every print's FTP push to the printer into the server-side scheduler tick, which means the user's click no longer carries an XHR with `progress` events — the old browser-side upload modal had nothing to show because there was no browser-side upload anymore. Users only saw the queue item flip to "active" with no visibility into the multi-second to multi-minute FTP push + the H2D/H2D Pro 80–210 s `project_file` digestion window before the printer actually started extruding. **Fix.** The legacy bg-dispatch toast rendering from `0b43ac0d:frontend/src/contexts/ToastContext.tsx` lines 510–650 is **ported back in place verbatim** — same DOM tree, same Tailwind classes, same `formatFileSize` bytes line, same uppercase status chip, same collapse chevron, same `awaitingPrinter` derivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacy `background-dispatch` aggregate event. **Materialization only on actual upload start.** The toast appears when the FTP push to the printer starts (`queue_item_uploading`), NOT on `POST /queue` — a draft that emitted at queue-add time made the toast jump to "Dispatched" before any upload had happened. Four backend lifecycle WS events drive the rendering: `queue_item_uploading` (start of FTP, carries `printer_name` + `total_bytes` from `file_path.stat().st_size`), `queue_item_upload_progress` (throttled byte-level updates — first call always emits + emit when ≥200 ms elapsed OR ≥256 KB transferred since last emit, plus always emit at `bytes_transferred >= total_bytes`; this matches the legacy `background_dispatch.py:614-615` gates 1:1 so the bar feels identical on small AND large files; a single shared `_UploadProgressBridge` instance bridges from the FTP executor thread back to the asyncio loop via `run_coroutine_threadsafe`), `queue_item_acked` (watchdog confirmed printer transitioned out of `pre_state`), `queue_item_failed` (any error, with a `reason` key the toast looks up as `dispatchToast.failed.{reason}` for upload-vs-start-command differentiation, generic fallback). **No `queue_item_dispatched` event** — the legacy bg-dispatch path kept `status='processing'` from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely from `upload_progress_pct >= 99.9` (the legacy `uploadDoneAwaitingPrinter` trick at line 568-572). An explicit `dispatched` event would push the status chip out of `PROCESSING` prematurely — which is exactly what the first screenshot-iteration showed. **Per-user routing.** New `ws_manager.broadcast_to_user(user_id, msg)` filters connections by `websocket.state.bambuddy_principal_user_id` — resolved once at WS connect time via a `select(User.id).where(User.username == principal)` lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs route `user_id=None` to all connections, matching the legacy single-user toast behaviour. The watchdog success path receives `created_by_id` via a new kwarg so the static `_watchdog_print_start` method can still emit the `acked` event without re-fetching the queue item. **Backend.** ~110 LOC across 3 files: `core/websocket.py` (`broadcast_to_user` + four event helpers, `bambuddy_principal_user_id` filter on each connection), `api/routes/websocket.py` (principal username → User.id resolve at connect, stashed on `websocket.state.bambuddy_principal_user_id`), `services/print_scheduler.py` (`_UploadProgressBridge` thread-safe throttle class, `queue_item_uploading` emitted before FTP with `printer.name`, `progress_callback=` plumbed into both the `with_ftp_retry` and direct `upload_file_async` branches via `**kwargs`, `queue_item_failed` at the FTP-fail spot, watchdog success path emits `acked` on both Phase A and Phase B exits). **Frontend.** Rendering ported in place to `contexts/ToastContext.tsx` (`dispatchData` field on `Toast`, ingest `useEffect` mapping the four `bambuddy:dispatch-toast` event types to legacy `DispatchToastJob` shape, terminal-state auto-dismiss `useEffect`; legacy rendering block reused 1:1 minus the cancel button — BG dispatch's `/background-dispatch/{id}` DELETE doesn't exist in the scheduler model and adding it is out of scope). `hooks/useWebSocket.ts` forwards the four `queue_item_*` cases via `window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail }))`, matching the existing `plate-not-empty` / `unknown-tag` patterns. **i18n.** 11 keys × 11 locales under `dispatchToast` (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW): `untitled` / `startingPrints` / `progressSummary` (header `{{complete}}/{{total}} complete • Processing: {{processing}}` — `Dispatched: X` from the legacy summary was dropped because the scheduler has no pre-upload "dispatched" state) / `expandDetails` / `collapseDetails` / `awaitingPrinter` / `status.{processing|completed|failed}` / `failed.{generic|upload_failed|start_command_failed}` / `dismiss`. Locale parity check 5401 leaves per locale, no English fallback. **Tests.** Backend `test_ws_broadcast_to_user.py` pins the routing contract (filter by user_id, fan-out on None, payload shape with `printer_name` for `uploading`, server-side pct compute including divide-by-zero); `test_upload_progress_bridge.py` pins the throttle (first call always emits, 256 KB byte gate honoured even when time gate would skip, completion always emits, no-op on zero bytes, no-op when no loop). Frontend `__tests__/contexts/DispatchToastContext.test.tsx` pins the **materialization-on-uploading invariant** (stray progress / acked event before any `uploading` does NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip staying `PROCESSING` through the whole upload (regression guard for the screenshot-reported "Dispatched: 1 immediately" bug), 3.5 s auto-dismiss when terminal, concurrent jobs sharing one wrapper, collapse + dismiss buttons. **Suites.** `pytest -n 30 backend/tests/unit/test_ws_broadcast_to_user.py backend/tests/unit/test_upload_progress_bridge.py backend/tests/integration/test_print_queue_api.py` green; `vitest run src/__tests__/contexts/` 49/49 green; `ruff check backend/` clean; `npm run build` clean. **Scope.** No DB migration. No new permission. The `bambuddy:dispatch-toast` window event is internal to the frontend bundle, not a public hook — third-party plugins should not subscribe to it. The 0–30 s scheduler-tick pickup wait is unchanged; this fix only addresses *visibility* of what happens once the upload starts. Tiny test files that upload in a single FTP chunk will still jump straight to "Awaiting printer…" because the first-and-last progress callback is one and the same event — same edge as the legacy bg-dispatch behaviour on sub-256 KB files. - **Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE** — Two small but load-bearing fixes against the new cam-wall view. **(1) Offline tile chip.** A disconnected printer (`status.connected === false`) was still assigned `live` mode by `CameraWall.modeByPrinter` — it consumed a `Max live streams` budget slot AND rendered the red `LIVE` chip on top of the `WifiOff` placeholder. The allocator now treats `!connected` like off-screen — assigns `paused`, leaves the live budget intact. The existing `CameraTile` rendering (`WifiOff` icon, dark `Off` chip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. **(2) Shared-broadcaster teardown.** `/api/v1/printers/{id}/camera/stop` is the unmount cleanup for every camera consumer (`CameraTile`, `EmbeddedCameraViewer`, popup `CameraPage`). It used to unconditionally `shutdown_broadcaster(f"printer-{id}")` + kill every ffmpeg in `_active_streams` whose key starts with `{printer_id}-`. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's `` errored out and showed `No signal` until the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown calls `unsubscribe(queue)`, and when the count reaches 0 the broadcaster's own `_grace_then_stop` waits `_GRACE_SECONDS` (5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown. `/camera/stop` was just a fast-cleanup shortcut for the single-viewer case. **Fix.** New `get_subscriber_count(key)` accessor in `camera_fanout.py` exposes the broadcaster's `subscriber_count` (the private list-len already used internally). The `/camera/stop` route now reads `get_subscriber_count(f"printer-{printer_id}")` BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns `{"stopped": 0, "skipped": true}` early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the natural `iter_subscriber.finally → unsubscribe` path, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its `/camera/stop` POST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. **Tests.** New `test_stop_camera_stream_skips_shutdown_when_subscribers_remain` in `test_camera_api.py` patches `get_subscriber_count` to return 2 and asserts `/camera/stop` returns `{stopped: 0, skipped: true}`, does NOT call `shutdown_broadcaster`, and does NOT terminate any `_active_streams` ffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers — `get_subscriber_count` returns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Full `test_camera_api.py` 43/43 green. `ruff check backend/` clean. Frontend `npm run build` clean. **Scope.** No API contract change — the existing `{"stopped": int}` shape is preserved, the new `"skipped"` field is additive. No new permission. No DB migration. No i18n change. - **Cam Wall: per-tile print/printer status overlay** — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle `Status overlay` switches between `Off`, `Compact`, and `Full` (default `Full`). **Compact** paints a colour-coded state chip in the top-left corner — `Printing` / `Paused` / `Finished` / `Error` — bucketed using the same `classifyPrinterStatus` rules that drive the printer-card badges, with `Idle` deliberately suppressed so a wall of cold printers stays visually quiet. **Full** adds a bottom info strip on tiles whose state is `Printing` or `Paused`: the active file's `subtask_name ?? gcode_file`, the rounded progress percent, `Layer N/M` when both are known, and the remaining time formatted by the existing `formatDuration(remaining_time * 60)` helper from `utils/date.ts` — so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existing `filterKnownHMSErrors` from `HMSErrorModal`), the chip flips to the red `Error` colour with a `lucide-react` `AlertTriangle` icon inline. The whole overlay layer is gated by `connected` — disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. **Zero new network cost.** `CameraWall.tsx` already ran `useQueries({ queryKey: ['printerStatus', id], ... })` against every printer for the connected flag; the patch widens the `useMemo` to expose the full `PrinterStatus` payload and threads `state`, `progress`, `remaining_time`, `layer_num`, `total_layers`, `subtask_name`, `gcode_file`, and the filtered HMS error count into each `CameraTile` — same shared React Query cache the `PrinterCard` flow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. **Settings.** Per-user, persisted in `localStorage` under `camWallStatusMode` alongside the existing `camWallMaxLive` and `camWallSnapshotSec` keys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelled `Off` / `Compact` / `Full`. Default `Full` because the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. **CameraTile contract.** All new props (`statusMode`, `printerState`, `progress`, `remainingMin`, `layerNum`, `totalLayers`, `printName`, `hmsErrorCount`) are optional with safe defaults, so the 5 existing vitest cases in `CameraTile.test.tsx` continue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located in `CameraTile.tsx` (mirrors `PrintersPage.classifyPrinterStatus` for `RUNNING/PAUSE/FINISH/FAILED`) so the tile renders correctly even if called outside the cam-wall scheduler. **Temperatures intentionally not surfaced.** Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. **i18n.** 7 new keys under `printers.camWall` (`layer`, `timeLeft`, `statusMode.{off,compact,full}`, `settings.statusOverlay`, `settings.statusOverlayHint`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existing `printers.status.{printing,paused,finished,error,idle}` keys so no new translation work was needed for the bucket vocabulary. Parity script `check-i18n-parity.mjs` adds two legitimate-cognate exceptions: `Compact` for French (same word) and `Off` for Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. **Scope.** No backend change. No new request. No new permission. No DB migration. The toggle defaults to `Full`, so installs see the overlay the first time they open Cam Wall — flipping to `Off` reverts to the original camera-only behaviour. - **Cam Wall view on the Printers page** — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (`Cards` ↔ `Cam wall`). Reuses the existing per-printer FTP / RTSPS proxy on `/api/v1/printers/{id}/camera/stream`, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to `Max live streams` (default 4) at any moment — everything else falls back to per-tile snapshot polling against `/api/v1/printers/{id}/camera/snapshot` at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. **Architecture.** `frontend/src/components/CameraTile.tsx` is the leaf — three modes (`live` / `snapshot` / `paused`), a single `` element with `loading="lazy"`, an `onError` no-signal fallback, and a `useEffect` cleanup that POSTs `/camera/stop` (with `keepalive: true`) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same `/camera/stop` discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. `frontend/src/components/CameraWall.tsx` is the scheduler — an `IntersectionObserver` (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a `useMemo` walks the printer list in sort order and assigns the first N visible tiles to `live`, the rest of the visible set to `snapshot`, and off-screen tiles to `paused`. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same `['printerStatus', id]` React Query cache each `PrinterCard` already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing `Settings → camera_view_mode` preference — opens the floating `EmbeddedCameraViewer` when set to `embedded`, otherwise pops the `/camera/:id` window with the saved size/position from `cameraWindowState`. **Settings.** Both knobs are per-user, persisted in `localStorage` (`camWallMaxLive`, `camWallSnapshotSec`) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded `[1, 16]` for max live and `[2, 60]` seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on `camera:view`; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). **i18n.** 13 new keys (`printers.pageView.cards`, `printers.pageView.camWall`, `printers.camWall.{noPrinters,noSignal,live,snap,off,summary}`, `printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. **Tests.** 5 new vitest cases in `frontend/src/__tests__/components/CameraTile.test.tsx` cover live URL emission with `fps=8`, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the `/camera/stop` POST firing when the tile transitions out of live. **Scope.** No backend change. No DB migration. No new permission. The existing `EmbeddedCameraViewer` is untouched — Cam Wall is purely additive. The `printerPageView` toggle defaults to `cards`, so installs see no behaviour change until a user picks Cam Wall. - **AMS drying badge now shows the active cycle's filament + target temperature** — During an active drying cycle the AMS card on the printers page renders `Drying · PETG @ 65°C · 11h 35m left` (the loaded-filament line under the slots) instead of the bare `Drying · 11h 35m left`. Bambu's per-tick AMS push only carries the `dry_time` countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. `BambuMQTTClient.send_drying_command(mode=1, ...)` now caches `{ams_id: {filament, temp}}` on the client; the cache is cleared on `mode=0` and on the per-AMS `dry_time` falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). `PrinterManager.get_drying_targets(printer_id)` exposes it, `printer_state_to_dict` and `routes/printers.py::get_printer_status` thread it onto each AMS dict as `dry_target_temp` + `dry_filament`, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's `tray_type` + RFID-recommended `drying_temp` when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key `printers.drying.targetSummary` = `{{filament}} @ {{temp}}°C`, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in `TestSupportsDryingCommand` (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in `TestDryingTargetExposure` (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). **Note about Bambu's printer display.** A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG @ 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (`filament: "PETG"` sent, `result: success, filament: PETG, temp: 65` ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the `filament` field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice. - **Continue auto-drying while a print is running on capable hardware** — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New **Settings → Print Queue → "Continue drying while printing"** toggle (default OFF) lets the same scheduler evaluator also run on the *busy* printer set. Backend: `supports_drying_while_printing(model, firmware)` in `printer_manager.py` is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; **P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded** because the wiki is silent for them, and on those models the firmware would reject the command anyway via `dry_sf_reason=[0]` (TaskOccupied)). The capability is gated on both display names (`"H2D"`, `"X1C"`, ...) and internal SSDP / MQTT model codes (`"O1D"`, `"O1E"`, `"O2D"`, `"O1C"`, `"O1C2"`, `"O1S"`, `"N6"`, `"BL-P001"`, `"N7"`, `"N9"`) — the printer's `model` field can carry either, the existing `supports_drying` precedent uses both. `_check_auto_drying` in `print_scheduler.py` now resolves model + firmware up front for every printer and computes `mid_print = busy AND toggle_on AND supports_drying_while_printing`; when `mid_print` is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / `dry_sf_reason` / drying-presets / mode-1 send path takes over. **Safety: drying temp is capped at `max(40, preset_temp - 5)` for mid-print drying** — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when `print_drying_enabled` is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: `routes/printers.py::start_drying` has no Bambuddy-side `is_idle` gate; the "printer busy" rejection comes from firmware `dry_sf_reason=[0]`, which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on `PrinterStatus.supports_drying_while_printing` so the frontend can light up the AMS card affordances correctly. **Settings.** New `print_drying_enabled: bool = False` in `schemas/settings.py`, added to the boolean allowlist in `routes/settings.py` (`_BOOL_KEYS`), and threaded through the existing dirty-detection / save call in `SettingsPage.tsx`. **i18n.** 2 new keys (`settings.printDryingEnabled`, `settings.printDryingEnabledDescription`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. **Tests.** 7 new cases in `TestSupportsDryingWhilePrinting` cover every supported display name + internal code, below-min firmware, excluded models (`P1*`, `A1`, `A1 MINI`, `X1`, `X1E`), missing firmware, `None` model, case-insensitivity, and the strict unknown-model default (False — unlike `supports_drying` which leniently allows unknowns). 4 new scheduler integration cases in `TestMidPrintDrying` cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full `pytest -n 30` green (4251/4251 in 49 s). Backend `ruff` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via `dry_sf_reason` so being too permissive here costs nothing. - **Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web)** — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. **Frontend.** A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with **Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection**. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new `BulkEditSpoolsModal` is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per `(printer, extruder, nozzle_diameter)` and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. **Clearing fields in bulk is intentionally NOT supported** (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. **Same dropdown controls the per-spool editor uses.** Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new `SearchableSelect` component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native `` filters on the runs dashboard with themed dropdowns and fixes a `react-hooks/exhaustive-deps` warning. ## [0.2.4.8] - 2026-06-28 ### Added - **Lower sponsor-prompt thresholds so the toast fires for typical new installs** (`257b9e2c`) - **SSO autologin + disable local username/password login (#1589, requested by @einstux)** (`549d3216`) - **"Auto-add unknown RFID spools" toggle + global confirmation modal (#1764)** (`9f9c1775`) - **Backup-aware filament deficit check, colour-strict (#1762)** (`29a5abd9`) - **Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows** (`7b06ebd7`) - **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** (`7e5eff14`) - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** (`ee270922`) - **In-app sponsor-toast at earned milestones** (`e761e092`) - **Prominent sponsor banner on Settings → General** (`5c16ef3e`) - **Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal** (`d1d16659`) - **AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman)** (`99c6949b`) - **Updated printer card UI for structure and readability (#1661)** (`6fa74be4`) ### Changed - **Printer card AMS row: external tray height matches regular AMS slots** (`00e4aed7`) ### Fixed - **Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL)** (`b2b04fc4`) - **API keys with Manage Library permission can rename / delete / move library files (#1832, reporter @MorganMLGman)** (`4c795636`) - **Forecasting groups spools by colour + Forecast UI rework (#1814, by @Keybored02)** (`3cbdba0c`) - **False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba)** (`5e008744`) - **Unknown-tag modal no longer pops for slots with no RFID** (`8b72b305`) - **SpoolBuddy "Assign to AMS" preserves the user's slicer preset instead of pushing Generic (#1815, reported by @Bgabor997)** (`cd5a02c0`) - **H2S active-tray highlight no longer stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** (`2bd2bce3`) - **`require_previous_success` no longer permanently blocks a printer's queue after a failure (#1818, reported by @jmassardo)** (`ba7af59b`) - **Archives "Step 4" docs link no longer 404s (#1812, reported by @Spanholz)** (`c52aba66`) - **H2C nozzle pick from Bambu Studio preserved on dual-nozzle rack variant + VP slicer-field intake (#1780, reported by @mkoreen)** — Race window bumped to 5s with retroactive stamp; VP intake key mismatch corrected; rack-swap nozzle pick forwarded to dispatch. (`71b0575f`, `30c2e263`, `b6916055`) - **Connection diagnostic no longer reports false camera-port warning on A1 / A1 Mini / P1 (#1799 closing #1798, by @lesbass / Stefano Maffeis)** (`8e99b0c8`) - **Print-complete notification no longer drops the finish photo when the FINISH-state fallback fires (#1790, reported by @needo37)** (`a9bf6f1f`) - **Chamber-fan badge hidden on open-frame Bambu printers** (`568f220a`) - **In-app "Install Update" on Windows installer switched to release-asset update flow** (`b7ff72d8`) - **Mid-print AMS Backup spool-switch correctly splits weight instead of crediting all to the second spool (#1771, reported by @biduleman)** (`a70c2a2d`) - **AMS history modal respects theme background variant in stats modal** (`55510871`) - **Completion notification scoped to printed plate on multi-plate 3MFs (#1785)** (`964015de`) - **Docker installer escalates on EACCES instead of failing on `/opt/bambuddy` (#1774, reported by @jmoore-skild)** (`03d09238`) - **Archive thumbnails rendered server-side when the sidecar slice skips them (#1759, reported by @VID-PRO)** (`d2232e02`) - **Post-#1661 printer-card cleanup — test fixtures + hover-card fly-in removal** (`0128869d`) - **Local Presets page: deleted row optimistically removed instead of staying visible until refetch returned** (`0eed9865`) - **SpoolBuddy inventory search matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink)** (`355d08a8`) - **Sidebar entries for Files / Archives / Queue no longer hidden from non-admin users with granular `*_read` access (#1755, reported by @knifesk)** (`7f150886`) - **Push notification for "Printer offline" actually fires (#1752, reported by @saint-hh)** (`2cbbd1ee`) - **Auth preserves the original URL across login + OIDC round-trip (#1750)** (`ed1683fe`) - **Archives backfill NULL `created_at` + tolerate NULL in response (#1732)** (`8faaeb96`) ### Security - **Floor pins for pydantic-settings ≥2.14.2 + msgpack ≥1.2.1** (`458bfa15`) - **Backend dependency security floor bumps + 422 constant rename** (`580f42c1`) - **dompurify 3.4.10 → 3.4.11 (GHSA-cmwh-pvxp-8882, moderate)** (`11227f65`) - **Vite 7 → 8 + plugin-react 5.2 (major bump)** (`f7620406`) - **Frontend dependency bumps** (`000af683`) - **Printer secrets restricted to update-authority callers** (`8283b175`) ## [0.2.4.7] - 2026-06-14 ### Added - **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`. - **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. - **Re-print / Schedule modal: cross-extruder AMS slot picks on dual-nozzle (#1722, reported by @privatsturm)** — On a dual-nozzle setup (e.g. H2D with AMS A+C wired to the left extruder and AMS B wired to the right), the per-filament slot dropdown in the Re-print and Schedule modals used to hide every slot whose extruder didn't match the filament's slicer-assigned nozzle. A filament the slicer had assigned to the left extruder would only let the user pick from A or C; a right-assigned filament could only pick from B. Users who'd intentionally loaded the required filament into the "other" AMS — for example, AMS B (right side) carrying a colour the slicer had planned to print on the left — couldn't select it, even though the printer can physically run that AMS through its wired extruder. Three slice-output diffs (BambuStudio Desktop, OrcaSlicer Desktop, Bambuddy sidecar) all produced identical filament_map values for the same source 3MF, so the slicer wasn't the source of the asymmetry — Bambuddy's UI filter was. **Behaviour:** every loaded slot is now offered for every filament row in the Re-print and Schedule modals' specific-printer flow, regardless of which extruder it's wired to. The L/R badge on the filament row stays as a visual hint to what the slicer planned; the dropdown now trusts the user to pick based on their physical setup. Single-nozzle printers and FTS-equipped setups are unchanged — both short-circuited the filter already and continue to. Printer firmware accepts or rejects the resulting `ams_mapping` at start-print, so a physically-impossible pick fails loudly rather than silently. **Implementation:** `FilamentMapping.tsx:248-254` carried a guard `f.extruderId === item.nozzle_id` on the slot dropdown's `loadedFilaments` filter; the guard is now removed. The single-nozzle and FTS short-circuits stay. **Tests:** `'still applies the per-nozzle filter when FTS is null'` flipped to `'offers cross-extruder slots in the dropdown without FTS (#1722)'` — same scenario (no FTS, AMS 0 on right, filament asking for left), but now asserts both slots ARE listed. The FTS-installed case (#1162) and the rest of the FilamentMapping suite stay green. Backend untouched; no schema, no i18n. 5/5 FilamentMapping vitests green; 1043/1043 full component sweep green; frontend build clean. - **Support bundle now includes redacted cached push_status per connected printer** — The existing support bundle (`GET /support/bundle`) shipped `support-info.json` + `bambuddy.log` — useful for triage, but missing the one thing that consistently blocks per-model work: the raw shape of the printer's MQTT push_status payload. Bambu firmware ships per-model config in a different shape for every family — AMS Backup detection was deferred in `85fbd7fc` because the H2D's bit-26 of `print.cfg` doesn't translate to the X1C / P1S / P2S layout and we had no ground-truth samples to map them; the same gap surfaces every time a `vt_tray` / `vir_slot` / `mapping` shape varies across firmware (the P2S `tray_now` fix, the H2D `vir_slot` parsing, the round-5 `vt_tray` overlay fix from #1622 last week all needed wire samples to land). **What's new:** the bundle now contains a `push-status/printer-{i}.json` file per connected printer, indexed against `support-info.json["printers"]`. Each file carries `{model, firmware_version, captured_at, raw_data}` where `raw_data` is the live cached push_status from `BambuMQTTClient.state.raw_data`. Disconnected printers (no MQTT state, or `raw_data` empty) are skipped — there's nothing to capture and an empty file just adds noise. **Redaction (two-pass):** a structural pass via the new `_redact_raw_push_status` helper drops user-private top-level keys anywhere in the tree (`subtask_name`, `gcode_file`, `gcode_file_prepare_percent`, `subtask_id`, `task_id`, `project_id`, `design_id`, `profile_id`, `model_id`, `gcode_state`) — Bambu's per-print filename/cloud-ID surface — and rewrites every `net.info[*].ip` entry to `"0.0.0.0"`, mirroring the LAN-topology leak fixed for the virtual-printer bridge in #1429. **What's deliberately preserved:** `print.cfg`, `print.option`, `ams.*`, `vt_tray`, `vir_slot`, `mapping`, `ams_extruder_map`, hardware fields (`nozzle_diameter`, temperatures, layer counters). These are the fields per-model work depends on. The structural pass then runs through `sanitize_log_content` with the same DB-derived `sensitive_strings` map the log path uses (printer names, serials, IPs, access codes, usernames, Bambu Cloud email) — belt-and-suspenders against any user-named string that leaked into a tray UUID or a sub-brand field. The redactor returns a NEW dict and never mutates the live `state.raw_data` (the dispatcher reads it on every tick; mutation would race the next push). **Why always-on instead of opt-in:** the bundle endpoint is already gated on "debug logging must be enabled" — generating the bundle is an explicit user act, the file downloads to the user's machine before they choose to send it, and forcing a second toggle adds friction without changing the threat model. Once a handful of bundles arrive from new-model users we'll have what we need to unblock AMS Backup awareness in the print-queue deficit check, plus future per-model shape variance. **Tests:** 5 new unit cases in `test_support_helpers.py::TestRedactRawPushStatus` pin the contract — drops the 9 user-private keys, rewrites `net.info[*].ip` while preserving `mask` siblings + sibling `net` keys, preserves `print.cfg` / `ams` / `vt_tray` / `vir_slot` / `mapping` / `ams_extruder_map`, does not mutate input, handles non-dict input gracefully (returns `{}` for None / list / str). Full support test surface 79/79 green (`test_support_helpers.py` + `test_support_api.py`); full backend suite 5937/5937 green with `-n 30`; ruff clean across the backend; frontend untouched but rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`. No migration, no new i18n keys, no schema changes, no frontend changes. - **Windows installer build pipeline scaffolded** — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer `.exe` that doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile's `python:3.13-slim-trixie`), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. **Architecture:** install target `C:\Program Files\Bambuddy\`, data target `C:\ProgramData\Bambuddy\data\` (preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running as `LocalSystem` with autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut opens `http://localhost:8000`, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. **Why this shape over a PowerShell `install.ps1`:** the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected `%APPDATA%`, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. **Files:** `installers/windows/build.py` stages everything under `installers/windows/build/staging/`, `installers/windows/bambuddy.iss` is the Inno Setup 6 script, `installers/windows/service/install-service.bat` + `uninstall-service.bat` wrap NSSM. `build.py` hard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind `--allow-non-windows`. **CI:** `.github/workflows/windows-installer.yml` runs on tag push (`v*`) and manual dispatch, uses `windows-latest`, downloads Inno Setup via Chocolatey, runs `build.py` + ISCC, uploads the `.exe` as both a workflow artifact and a release asset. **Scope clarification:** this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installing `opencv-python-headless` / `curl_cffi` / `asyncpg` / `cryptography` / `bcrypt` against embedded Python (the `_pth` file edits in `build.py` cover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSM `AppEnvironmentExtra` line-continuation in cmd.exe. **Signing:** v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). **What's explicitly NOT in v1:** Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log under `C:\ProgramData\Bambuddy\logs\service-stderr.log`). See `installers/windows/README.md` for the full build pipeline. - **VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation)** — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set `BAMBUDDY_VP_DUMP_WIRE=1` and Bambuddy writes the bridge's cached push_status (`/vp_wire/_in.json`) and the periodic 1Hz copy that gets sent to the slicer (`/vp_wire/_out.json`) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's `_out.json` against a known-good VP's `_out.json` (e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives in `backend/app/services/virtual_printer/_debug.py` with call sites in `mqtt_server.py::_send_status_report` (cached branch only — synthetic fallback is uninteresting for this triage) and `mqtt_bridge.py::_on_printer_raw` (immediately after the merge that produces `_latest_print_state`). 21 unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back to `vp`, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. - **VP slicer↔printer command-flow trace (#1622 round 2)** — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every slicer-originated publish on `device//request` AND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to `/vp_wire/_cmd.jsonl`, one JSON line per event with UTC iso timestamp, direction (`slicer_to_bridge` / `printer_to_slicer`), MQTT topic, a `.` grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) and `pushall`/`get_version` (handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. New `append_event` helper in `_debug.py` mirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture as `dump_wire`; bytes payloads are utf-8 decoded then json-parsed with the same `\x00`-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to `{"raw": "..."}` so every line stays valid JSON. Eight additional unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. - **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `/vp_wire/_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. - **Batch grouping for queued items** — multi-plate prints from one source 3MF now auto-group into a single collapsible row with aggregate stats, and a new "Group as batch…" action turns any 2+ selected items into a manual batch. Per-batch collapse state persists across reloads. Manual batches can be disbanded via the Ungroup action on the batch parent. - **History batch grouping** — siblings of the same batch collapse into one history row with status-rollup chips (e.g. 3 ✓ / 1 ✗) and the latest activity timestamp. - **History thumbnail hover preview** — hover any small history thumbnail and a 192×192 preview pops out next to it. ### Changed - **Queue page restructured around three tabs** — Queue, History, and Timeline now live as separate tabs at the top. History no longer competes with the active queue for screen space. - **Active queue layout toggle** — pick between a flat list (current default) and a per-printer view where each printer becomes a section card with aggregate item count, total time, and total filament weight in its header. - **Multi-drag reorder** — selecting N items and dragging any one of them moves the whole selection as a contiguous block; the drag ghost shows a "+N" badge. - **History rows redesigned** — each row now carries a filament color swatch + weight + type, the user who started the print, and the failure reason inline on failed/skipped rows. Rows lay out in a responsive 1/2/3 column grid so a long history uses available horizontal space. - **Timeline tab rebuilt as a Gantt swimlane** — one horizontal row per printer (plus per target_model and unassigned), jobs rendered as bars positioned by start time and sized by duration. Live NOW marker, 24-hour rolling window with 12-hour step controls. Only committed schedules are shown — staged items, waiting items, and ASAP jobs on idle printers are hidden so the timeline reads as a real forecast. ### Fixed - **Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate** — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR — `slice_info.config` inside the file carries N `` blocks (one per plate), each with its own `` and its own `Metadata/plate_N.gcode` payload. Previously the VP queue path only ever extracted the FIRST plate's index via `_extract_plate_id` and created exactly ONE PrintQueueItem with that single `plate_id`; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. **Confirmed against the wire** on the live H2D-1 Proxy VP: `Cube.gcode.3mf` carrying three `` blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of `` blocks inside the file. **Fix:** replaced `_extract_plate_id` (returning `int | None`) with `_extract_plate_ids` (returning `list[int]`). The list contains every `` block's `index` metadata, in order; falls back to `[1]` for files missing `slice_info.config` or with no parseable plates so the single-plate path is preserved. `_add_to_print_queue` now loops over the list — each iteration calls `extract_filament_requirements(file_path, plate_id)` per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position = `MAX(position) + iteration`. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). **What stays the same:** the single archive row per upload (the archive backs the queue items via `archive_id`); the `auto_dispatch=False` / `manual_start=true` posture inherited from the VP config (so multi-plate items still require manual start); the `queue_force_color_match` per-VP toggle (now applies per-plate). **What this also fixed downstream:** the `required_filament_types` / `filament_overrides` JSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. **Tests:** 1 new regression case in `test_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate` — builds a 3-plate 3MF (writes the per-plate `` blocks into `slice_info.config` and the per-plate gcode payloads), runs `_add_to_print_queue`, asserts 3 PrintQueueItems with `plate_id == [1, 2, 3]`, `position == [1, 2, 3]`, shared `archive_id`, all `manual_start=True`. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. **Live-verified** on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive. - **Archive delete now removes related queue items instead of leaving "cancelled" rows behind** — Previously the soft-delete path (the default — what the trash-can button does) called `_cancel_pending_queue_items`, which only flipped queue rows with `status='pending'` to `status='cancelled'` while leaving every other status alone AND leaving every row in the DB. The Send All multi-plate work above made this much more visible: deleting an archive backed by N queue items now had to clean up N rows, and what users saw instead was N "cancelled" rows lingering in the queue history. **Fix (backend):** replaced `_cancel_pending_queue_items` with `_delete_related_queue_items(db, archive_id) -> int` that DELETEs every queue row where `archive_id = X` regardless of status. Behavior now matches what the hard-delete path already did via the `ON DELETE CASCADE` FK on `print_queue.archive_id` — both paths produce the same end state. Print history lives in `PrintLogEntry` (FK `ON DELETE SET NULL`) and is untouched, so stats / Quick Stats / accuracy bands are preserved across both delete paths. **New guard:** the route at `archives.py::delete_archive` now 409s when any related queue item is currently in `status='printing'` — both soft and hard delete are gated by the same precondition, because deleting the archive while a print is live would strip the dispatcher's metadata trail (filament / plate / ams_mapping) out from under the running print. The 409 surfaces a clear "Stop the print first, then retry" message. **Pre-flight count for the UI:** new endpoint `GET /archives/{id}/delete-impact` returns `{related_queue_items: N, currently_printing: M}` — cheap, single endpoint, not folded into the archive list response so the much larger list endpoint isn't forced to run the same query per row. Frontend ArchivesPage delete-confirm modal queries this when the modal opens (`useQuery({queryKey: ['archive', id, 'delete-impact'], enabled: showDeleteConfirm})`) and renders: an amber warning "**N queue item(s) linked to this archive will also be removed.**" when total > 0 AND printing = 0, OR a red warning "**Cannot delete — M queue item(s) are currently printing. Stop the print first, then retry.**" when printing > 0 (with the confirm button disabled in that case so the user can't bonk the 409 on submit). **ConfirmModal extension:** added optional `confirmDisabled?: boolean` prop. Existing `isLoading` was the only disable knob; this adds an external-precondition path that disables the confirm without the loading spinner. **Tests:** rewrote `test_print_queue_api.py::test_soft_delete_archive_cancels_pending_queue_items` → `test_soft_delete_archive_deletes_all_related_queue_items` to pin the new contract (both pending AND completed rows are gone post-soft-delete). 2 new integration cases in `test_archives_api.py`: `test_delete_archive_blocked_when_related_queue_item_printing` (both soft and hard paths return 409 with "printing" in detail message) + `test_archive_delete_impact_reports_counts` (3 mixed-status related rows + 1 unrelated row → endpoint reports `related_queue_items=3, currently_printing=1`, unrelated row doesn't bleed in). **i18n:** 2 new keys (`archives.modal.deleteQueueItemsWarning`, `archives.modal.deleteBlockedByPrinting`) translated across all 11 locales per `feedback_translate_dont_fallback` — no English fallbacks. **Verification:** full backend suite 5964/5964 green with `-n 30`; ruff clean; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5109 × 11 locales green. No DB migration — the CASCADE FK was already in place; only the helper's semantics changed. - **Print Log table: multi-color filament rows render one swatch per color instead of a single barely-visible gray dot (#1731 part 1, reported by @IndividualGhost1905)** — The per-archive Print Log table cell at `frontend/src/pages/ArchivesPage.tsx:3882` rendered the `filament_color` column as ONE swatch with `style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}`. For multi-color prints, the backend writes `filament_color` as a comma-joined string (e.g. `"#FFFFFF,#000000,#FF0000"` — three filaments used in the print), which trivially passes the `.startsWith('#')` check but is not a valid CSS color. The browser silently dropped the `backgroundColor` declaration, leaving the swatch as only its black/20% border on the app's dark theme — visually a tiny grey dot, near-invisible against the row background, which the reporter's screenshots showed as "PLA" text in the cell with no apparent swatch at all. The DB column was correct (the reporter confirmed both colors were recorded for the old example); the render dropped them. The Archive Card view at `:1072-1083` and `:2114-2125` already split on comma and rendered one swatch per color — only the Print Log table cell had been missed when multi-color support was added across the rest of the page. **Fix:** the Print Log table cell now mirrors the card-view pattern — wraps the swatches in a `flex` container, splits `entry.filament_color` on `,`, trims each value, and renders one `w-3 h-3 rounded-full` per color with `backgroundColor: trimmed.startsWith('#') ? trimmed : undefined` and a `title={trimmed}` for hover-tooltip parity. Single-color prints render exactly one swatch (the trivial case — no behaviour change). Empty / non-hex slot values gracefully fall through to no `backgroundColor` rather than poisoning the CSS for adjacent slots. The filament-type text (`{entry.filament_type || '—'}`) keeps its existing position to the right of the swatches. **What this does NOT fix:** the reporter also flagged that new multi-color prints don't appear in the filament usage history. That's a separate code path (`backend/app/services/usage_tracker.py::_track_from_3mf` and the slot-to-tray mapping chain at `usage_tracker.py:899-901`), where the diagnostic needs the archive's captured `ams_mapping`, the `mapping` field from MQTT push_status at print start, and the `[UsageTracker] PRINT START` / `PRINT COMPLETE` log lines — none of which are in the reporter's first bundle. Tracking under #1731 part 2, blocked on a support bundle from the affected install. **Tests:** existing `ArchivesPage.test.tsx` (23 cases) green; ESLint clean; `npm run build` clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend-only change. - **Finish-photo force-on removed; user's explicit timelapse=off in the slicer send dialog is now respected (#1721, reported by @agrisci)** — On H2D 01.x firmware, `capture_finish_photo` (default-on global setting) was forcing every print's `timelapse` MQTT field to `enable` regardless of whether the user had unchecked the Timelapse box in OrcaSlicer's send dialog. That bit flips the printer's runtime `timelapse_record_flag`, which un-gates the slicer-baked `M1002 judge_flag timelapse_record_flag` / `M622 J1` / `G1 X-48.2 F3000` / `M971 S11 C11 O0` wipe blocks emitted by **Smooth**-mode timelapse profiles — so the toolhead parked off the part and snapped a frame every single layer, on prints the user explicitly opted out of recording. The reporter's gcode export confirmed the macro block was baked in (28 occurrences across the file) and the printer's MQTT log showed `Sending print command: {"print": { … "timelapse": true, … }}` even though the slicer-side checkbox was unchecked. Live-stop confirmed: turning the global `capture_finish_photo` setting off in Bambuddy made the per-layer parking stop immediately. **Root cause:** the #1397 "finish photo from timelapse" feature used "force the printer into timelapse-recording mode at dispatch" as the side-channel to get a well-framed end-of-print shot (toolhead parked, before bed drop, extracted from the recorded video's last frame). That mechanism conflated two semantically different things — recording a timelapse video vs. snapping a finish photo — and the per-layer side effects of the recording mode were decided at slice time by the user's `timelapse_type` profile setting, which Bambuddy has no visibility into post-slice. Traditional-mode gcode has no per-layer wipe block (no parking, no defects) — so the bug was invisible to anyone whose slicer profile defaults to Traditional. Smooth-mode gcode (the reporter's case) bakes the wipe block and gates it on `timelapse_record_flag`, so flipping the runtime flag fired the macro every layer. **Fix:** replaced the force-on mechanism entirely with a clean MQTT-state-driven trigger. `bambu_mqtt.py::_handle_push_status` now fires a new `on_finish_photo_moment` callback when `stg_cur` transitions INTO **22** ("Filament unloading") while `_was_running == True` AND the end-of-print gate matches (`progress >= 99` OR `layer_num >= total_layers` OR `remaining_time <= 0`) — that's the same framing window #1397 was after (toolhead parked, bed not yet dropped, AMS pulling filament back) but reached via a clean state signal instead of by exploiting the per-layer macros. The end-of-print gate is what disambiguates from mid-print filament swaps in multi-color prints, which ALSO transit through stage 22 (M620 unload → 22, M621 load → 24) but always at progress < 99 / layer < total / remaining > 0. A FINISH-state fallback in the same handler fires the same callback at the existing FINISH-state transition if stage 22 never arrived — covers cancel-mid-print (state goes RUNNING → IDLE / FAILED without 22), external-spool-only prints where some firmwares skip the unload phase, HMS halts before unload, and any firmware variant we don't see stage 22 on. Net behavior: every print that gets a finish photo today still gets one; the lucky majority get the better-framed pre-bed-drop shot too. `main.py::on_finish_photo_moment` is a new top-level handler that pre-captures one camera frame at the trigger edge — external camera (snapshot URL → MJPEG fallback), buffered live RTSP frame from `_active_streams` / `_active_chamber_streams`, or a fresh RTSP grab via `capture_camera_frame_bytes` — and caches the JPEG bytes in a module-level `_stage22_finish_frames: dict[int, bytes]` keyed by printer_id. `_background_finish_photo` (inside `on_print_complete`) consumes the cached bytes via `_stage22_finish_frames.pop(printer_id, None)` before falling through to its existing live-grab chain, so the saved photo has the better framing without the existing complex archive-resolution / fallback / notification wiring needing to move. When a timelapse IS actively recording (user explicitly opted in this time), the pre-capture is skipped — `_capture_finish_photo_from_timelapse` still extracts the last frame from the recorded video, which is still the highest-quality option and now has no force-on side effects because the user actually wanted the video. **What was removed:** `resolve_effective_timelapse` in `background_dispatch.py` (the shared force-on resolver), `BackgroundDispatchService._resolve_effective_timelapse` wrapper, both call sites in `background_dispatch.py` (`_run_reprint_archive` + library-file print path), the `resolve_effective_timelapse` call in `print_scheduler.py::_dispatch_item`, the `archive.bambuddy_forced_timelapse` write in the resolver, the `if archive.bambuddy_forced_timelapse: await _cleanup_forced_timelapse(...)` branch in `_background_finish_photo`, and the entire `_cleanup_forced_timelapse` function (~75 lines including the FTP-DELE walk across `/timelapse` / `/timelapse/video` / `/record` / `/recording`). All call sites now read `bool(item.timelapse)` / `bool(job.options.get("timelapse", False))` directly — the literal user choice flows straight through to `start_print(timelapse=…)`. The `archive.bambuddy_forced_timelapse` DB column stays defined (default `False`) for back-compat with existing rows that may have it set to `True` from before — no consumer reads it anymore, and dropping a column on the user-data table risks breaking restore-from-backup flows we don't need to break. **New callback wiring:** added `on_finish_photo_moment` parameter to `BambuMQTT.__init__`, new `_finish_photo_captured` one-shot flag (reset on each new print at the same site as `_completion_triggered`), new `PrinterManager._on_finish_photo_moment` field + `set_finish_photo_moment_callback` setter, new `on_finish_photo_moment` inner wrapper in `_setup_callbacks`, threaded through to the `BambuMQTTClient` constructor call. `main.py::on_print_start` clears any leftover `_stage22_finish_frames` entry from a prior print so a never-consumed cache (e.g. capture succeeded but on_print_complete bailed before reaching it) can't bleed into the new print's photo. **Tests removed:** `test_cleanup_forced_timelapse.py` (~290 lines, 7 test cases pinning the FTP-DELE walk and `bambuddy_forced_timelapse` flag handling), `test_scheduler_force_timelapse_wiring.py` (the source-pattern check that pinned `print_scheduler.py` imports `resolve_effective_timelapse`), `test_dispatch_force_timelapse.py` (5 test cases pinning the `_resolve_effective_timelapse` wrapper's interaction with `capture_finish_photo` + archive flag). The behaviour these tests verified is intentionally gone. **Tests updated:** `test_background_dispatch_watchdog.py` dropped two `patch.object(BackgroundDispatchService, "_resolve_effective_timelapse", ...)` blocks that stubbed the now-removed method; `test_background_dispatch.py::test_dispatch_options_pass_through_pattern` comment updated to explain why `timelapse` stays excluded from the bare-pattern needle check (the wrap in `bool(...)` is intentional to coerce non-bool option payloads, not a force-on remnant). **Verification:** ruff clean; full backend suite 5961/5961 green with `-n 30`; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5107 leaves × 11 locales green (no new keys). No migration. **What this does NOT change:** users who explicitly enable the Timelapse checkbox in the slicer send dialog still get the timelapse video AND the timelapse-extracted finish photo (highest-quality framing, no per-layer parking because that was never the issue — it's the user's intentional choice). Users who explicitly disable the Timelapse checkbox now get no per-layer parking AND still get a finish photo (pre-captured at the stage-22 edge for the same pre-bed-drop framing). - **Configure AMS Slot: filament profiles for other printer models now filtered out (#1623, reported by @shaddowlink)** — Three independent gaps in the same picker, each surfaced by a different round of reporter screenshots. **(1) Local "Custom" imported profiles** were unconditionally listed regardless of the slot's printer; a user with PETG / PLA profiles imported from OrcaSlicer / BambuStudio for A1 mini, H2D, and P1S saw all three lined up when configuring an AMS slot on any one of those printers. **(2) Cloud presets using the `@Bambu Lab ` suffix form** (user-renamed Bambu Cloud presets and most Orca Cloud profiles) slipped through the existing filter, which only matched the `@BBL ` form Bambu's system presets use. **(3) Cloud presets with the printer model in the BODY of the name** (the literal failure shape the reporter screenshotted on H2D: `"X1C eSUN PETG-Basic Filament"` with no `@` suffix at all) — the existing extractor returned null for these and the filter no-op'd. **Fix:** `ConfigureAmsSlotModal.tsx` now (a) queries the backend's Bambu printer-model registry (`/slicer/printer-models`, same fetch SliceModal uses), (b) for local presets — reverse-looks-up the slot's short model code to a long printer-preset fragment, pairs it with the slot's nozzle diameter to synthesise the full slicer preset name ("Bambu Lab P1S 0.4 nozzle"), and passes that into `presetCompatibility(...)` from `utils/slicerPrinterMatch.ts` against each local preset's parsed `compatible_printers` JSON; (c) for cloud / Orca Cloud presets — `extractPresetModel(name, registry)` is now multi-strategy: first the `@BBL ` form (existing), then the `@Bambu Lab ` form with case-insensitive reverse-lookup against the registry (so "A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini profiles, preserving the #1649 alias-aware match), then a body-text scan against every known model token (long-name fragments and short codes from the registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro" aren't eaten by their shorter siblings, word-boundary regex so "PA1" doesn't match "A1" and "X1Box" doesn't match "X1"). Presets where no strategy resolves still pass through — free-form names with no recognisable model token stay visible (can't filter what we can't classify). **Fail-open posture preserved:** `match` and `unknown` verdicts keep showing for local presets (back-compat for hand-edited imports without `compatible_printers`); the currently-configured preset (`slotInfo.savedPresetId`) bypasses the filter so the active selection always remains visible; built-in filaments stay unfiltered (generic fallback); when the registry hasn't loaded yet OR `printerModel` is empty, every filter no-ops. **No backend / schema / i18n changes.** Frontend ESLint clean; `npm run build` clean; vitest `ConfigureAmsSlotModal` 24/24 green. - **Virtual Printer: empty AMS slots forwarded as phantom loaded filaments to BambuStudio Sync (#1726, reported with full code-level analysis by @needo37)** — On any VP bound to a target printer (Proxy mode, or Queue mode with a specific target), the slicer-facing AMS state was the printer's raw push_status — the empty-slot cleanup that `bambu_mqtt.py::_handle_ams_data` applies to Bambuddy's own internal state was NEVER run on the bridge cache. Concrete case: real printer has 3 filaments loaded (AMS-A slots 2/3/4), AMS-A slot 1 and all of AMS-B empty; Bambuddy's AMS card renders the empty slots correctly as Empty (control — internal state path is fine), but BambuStudio after Sync paints 7 populated/green-checked filament slots — the 3 real ones plus 4 phantoms whose color/material is stale RFID/calibration data from before those slots went empty. The diagnostic signature is the mismatch between the AMS card (correct) and the slicer view (wrong) for the same payload. Archive mode and Queue-by-model are NOT affected — no target printer → no bridge → the slicer gets the synthetic stub at `mqtt_server.py:927` with no real AMS data. **Root cause:** two code paths consume the same printer AMS payload. Internal (`bambu_mqtt.py::_handle_ams_data` lines 1802-1858) parses `tray_exist_bits`, promotes empty slots to `state=9`, and wipes the stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` fields. VP bridge (`mqtt_bridge.py::_on_printer_raw` lines 551-656) deep-merges AMS structurally via `_merge_ams_dict` and copies `tray_exist_bits` through as an opaque top-level scalar — but never applies the bit→clear-empty-slot logic. The cached state ships to the slicer untouched. **Fix:** factored the bit-clear logic out of `_handle_ams_data` into a shared module-level helper `bambu_mqtt.py::apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label)` and call it from both paths. The internal call site is replaced with a single helper invocation; the bridge calls it on the merged AMS dict after `_merge_ams_dict` runs, before the 1 Hz cached-as-base push picks the cache up. Shared shutdown guard preserved on both sides: all-zero bits + `power_on_flag=False` is the printer-off pattern (#765) and skips cleanup — a non-zero bits + power-off combo is valid idle-printer state (#1365 — X1C between prints) and still applies. AMS-HT units (`id >= 128`) skipped on both sides (separate addressing scheme). **Tests:** new `TestApplyTrayExistBitsHelper` class in `test_bambu_mqtt.py` (10 cases pinning the helper contract directly — missing/unparseable bits → no-op, shutdown guard, nonzero+power-off X1C case, int-9 state, AMS-HT skip, string id handling, multi-AMS global bit math, state-promote-even-without-stale-data). 3 new bridge regression tests in `test_vp_mqtt_bridge.py::TestPushStatusCache`: `test_tray_exist_bits_clears_empty_slots_in_slicer_cache` reproduces the #1726 wire shape (slot 0 carries stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` + `tray_exist_bits="e"` → slot 0 must clear, slots 1-3 preserved), `test_tray_exist_bits_shutdown_guard_preserves_cache` pins the printer-off path won't propagate phantom empties on every reconnect, `test_tray_exist_bits_skips_ams_ht_units` pins the HT addressing skip. Existing internal-state tests for the bit-clear logic (`test_tray_exist_bits_clears_empty_slots`, `test_tray_exist_bits_promotes_empty_slot_to_state_9`, `test_tray_exist_bits_does_not_change_state_on_loaded_slots`, …) continue to pass against the refactored internal path — same contract, same behavior, different implementation seam. One pre-existing bridge fixture (`test_partial_ams_unit_update_preserves_other_units`) had an inconsistent `tray_exist_bits="3"` for two AMS units (bit 0 set, bit 4 unset, but both unit 0 and unit 1 had slot 0 populated as loaded). The fix exposed the inconsistency — corrected to `"11"` (bits 0 + 4) to match what the real printer would send. Full backend suite 5955/5955 green; ruff clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend untouched. **Verification on a live system (per @needo37's analysis):** set `BAMBUDDY_VP_DUMP_WIRE=1`, restart, Sync the slicer, inspect `/vp_wire/_out.json`. For any tray whose bit in `tray_exist_bits` is 0, `tray_type`/`tray_color` should now be empty. - **Windows: `/api/local-backup/status` 500 on `ZoneInfoNotFoundError: 'No time zone found with key UTC'` (from a user's log on the Windows installer)** — Reported via a Windows traceback against the new local-backup status endpoint. The stdlib `zoneinfo` module reads the system IANA tz database on Linux/macOS, but Windows has none — and the embedded Python in our Windows installer doesn't carry the `tzdata` PyPI package either, so even `ZoneInfo("UTC")` raises `ZoneInfoNotFoundError`. `_local_zone()` in `services/local_backup.py` only caught that exception for the `TZ`-env branch; the empty-`TZ` fallback and the unrecognised-`TZ` fallback both unconditionally called `ZoneInfo("UTC")` and re-raised, bubbling out of the FastAPI handler as a 500. **Fix (two parts):** (1) `_local_zone()` is now resilient — return type widened from `ZoneInfo` to `tzinfo`, the `UTC` fallback is wrapped in its own try, and the last-resort fallback returns the stdlib `datetime.timezone.utc` (which needs no IANA DB and satisfies every `astimezone` / `str()` call site downstream — `str(timezone.utc) == "UTC"` matches the previous response shape). Restores function on existing Windows installs without re-bundling. (2) `requirements.txt` now pins `tzdata>=2024.1; sys_platform == "win32"` so the next Windows installer build ships the IANA DB and any non-UTC `TZ` value (e.g. `Europe/Berlin`) resolves correctly — the stdlib fallback can only ever give UTC. Linux/macOS unaffected: the platform marker keeps them on the system tz DB they already have. **Tests:** new `test_zoneinfo_completely_unavailable_falls_back_to_stdlib_utc` in `test_local_backup.py` monkeypatches `ZoneInfo` to always raise `ZoneInfoNotFoundError` and pins that `_local_zone()` returns `datetime.timezone.utc` rather than propagating. 31/31 local_backup tests green; ruff clean. - **Print-modal "off" toggles for `flow_cali` and `nozzle_offset_cali` now actually suppress the calibration stage (live-tested on H2D 01.x)** — The Re-print / Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted the user's "off" choice and flowed it correctly through to the `project_file` MQTT publish — Bambuddy sent `extrude_cali_flag: 2` and `nozzle_offset_cali: 2` per our reading of "1 = run, 2 = skip" inherited from the #1478 / #1682 work. Live test on an H2D running firmware 01.x: with both toggles off in Bambuddy's modal, the printer's `stg` queue (the pre-print stage list firmware publishes via push_status) still included stage **8** ("Calibrating dynamic flow") and stage **39** ("Nozzle offset calibration") — and physically ran them at print start. The `2` value did NOT suppress the stage despite our earlier "skip and reuse stored PA" reading. **Root cause:** the encoding for the "off" wire value is `0`, not `2`. The `2` value appears to mean "skip the explicit calibration pass but still apply / verify the stored PA value via the calibration stage" — close to a no-op in terms of K-factor but the printer still queues the stage and runs the per-print physical sequence. `0` is what actually drops the stage from the `stg` queue. A real BambuStudio Send-dialog capture on the same firmware (proxy-mode VP echo) also showed `0` for both fields when calibrations are unchecked, contradicting the #1478 commit message which read `0` as "never sent by BambuStudio." **Fix:** `bambu_mqtt.py::start_print` — `extrude_cali_flag` is now `1 if flow_cali else 0` (was `2`), and `nozzle_offset_cali` is `1 if (nozzle_offset_cali and is_dual_nozzle) else 0` (was `2`). The dual-nozzle gate stays — single-nozzle prints continue to force-skip the nozzle-offset calibration their head doesn't support (#1682). `1` (run) is unchanged on both fields. **Verification:** live re-test on the same H2D with both toggles still off — `stg: [29, 13, 4, 14, 3]` (cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 dropped out cleanly. **What's NOT fixed:** `vibration_cali` is a JSON `false` bool in both Bambuddy's and BambuStudio's wire format, and the H2D firmware queues stage **3** ("Vibration compensation") regardless of the bool value — this is firmware-side and not solvable at our dispatch layer with the current field. Captured as a follow-up to investigate whether a parallel `vibration_cali_flag` integer field exists. **Tests:** `test_bambu_mqtt.py` — `test_p2s_uses_boolean_format` flipped `extrude_cali_flag == 2` → `== 0`; `test_nozzle_offset_cali_default_is_skip`, `test_nozzle_offset_cali_ignored_on_single_nozzle`, `test_nozzle_offset_cali_false_on_dual_nozzle` flipped `== 2` → `== 0`; docstrings updated to reflect the #1721 finding. The `1 if user_wants` branch in both tests for the "on" case is unchanged. 281/281 bambu_mqtt tests green; full backend suite 5941/5941 green with `-n 30`; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). - **Support-bundle log noise: VP bridge nudge + SD-card cleanup (#1721 adjacent, observed on reporter's A1)** — Two warnings polluting every A1 support bundle on a healthy print. Neither was the cause of #1721's timelapse complaint — both are adjacent noise. **(1) `request_status_update: not connected`** — `mqtt_bridge.py::_resolve_client` calls `_request_version` + `request_status_update` immediately after attaching a raw-message handler so the bridge cache populates without waiting for the next periodic pushall. The bind frequently races the real printer's MQTT TLS handshake — a slicer-side reconnect re-resolves the client before the underlying session has reconnected, especially on A1 firmware which reconnects more aggressively than X1/H2/P. `request_status_update` logs `[serial] request_status_update: not connected` at WARNING on the not-connected return path. The nudge is a best-effort optimisation; the fall-through (next periodic pushall) populates the cache anyway, so the WARNING fires on routine, expected, recoverable state. **Fix:** gate both nudges on `current.state.connected` at the bind site. When the client comes up, the next `_resolve_client` tick re-enters this branch on identity change OR the periodic pushall in `bambu_mqtt.py` fills the cache — same end state, no benign WARNING. The WARNING in `bambu_mqtt.py:3224` is unchanged: it's still a real signal for the other callers (`/printers/{id}/refresh-status` user API, bug-reporter helper) where "you asked for a refresh on a dead client" is genuinely worth logging. New `test_post_bind_nudge_skipped_when_target_not_connected` in `test_vp_mqtt_bridge.py::TestBridgeLifecycle` pins the contract. **(2) `SD card cleanup failed after 3 attempts ... (file may linger on SD card)`** — The post-finish helper in `main.py` deletes the uploaded file from the printer's SD card to prevent the ghost-print-on-power-cycle behaviour (#374, #1542). It tries up to three candidate paths (`derive_remote_filename(archive.filename)`, then `{subtask_name}.3mf`, then `{subtask_name}.gcode`), each up to 3 times with 2 s backoff, then logs WARNING if all fail. `delete_file_async` returned `bool` — `True` for success, `False` for ANYTHING else (FTP 550 file-not-found, network error, auth fail, transient FTP error). The A1 firmware (and most other Bambu firmwares post-print) cleans the SD-card upload itself before our cleanup runs, every candidate FTP-DELE returns 550, all three retries × three candidates × 2 s sleeps fire, then WARNING. That WARNING shouldn't exist on a healthy print where the printer self-cleaned. The same shape exists in `_cleanup_forced_timelapse` (#1397) walking the four timelapse dirs. **Fix:** `bambu_ftp.py` now exports a `DeleteResult` enum (`DELETED` / `NOT_FOUND` / `FAILED`). `BambuFTPClient.delete_file` detects the 550 case via `isinstance(e, ftplib.error_perm) and str(e).startswith("550")` (same pattern already used in the download path for the symmetric `FileNotOnPrinterError` sentinel from #972). `delete_file_async` now returns `DeleteResult`. Both post-finish cleanup helpers (`main.py::on_print_finished` SD branch + `_cleanup_forced_timelapse`) only WARN when at least one candidate returned `FAILED`; an all-`NOT_FOUND` outcome logs DEBUG ("nothing to delete — printer likely self-cleaned"). The cleanup helper also no longer burns the 2 s × 3 retry budget on a `NOT_FOUND` result (550 will never recover by waiting); only `FAILED` triggers backoff. `DELETE /printers/{id}/files/...` returns 404 (not 500) on `NOT_FOUND`, more accurate for the user-facing UI. Three other production callers (`print_scheduler` pre-upload delete, two `background_dispatch` fire-and-forget cleanups) are unchanged at the call site — they discard the return value. **Tests:** `test_delete_file` and `test_delete_file_async` in `test_bambu_ftp.py` switched to the enum (3 cases each). 2 new regression tests in `test_cleanup_forced_timelapse.py`: `test_forced_no_warning_when_every_dir_returns_not_found` pins the #1721 path (every candidate dir → 550 → no WARNING, one DEBUG summary), `test_forced_warns_when_any_dir_returns_failed` pins the counterpart (any FAILED keeps the WARNING — that's the signal the maintainer wants). `caplog` asserts the log record's level + content directly. Full backend suite 5941/5941 green; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). No migration, no new i18n keys, no schema changes. - **Virtual printer external spool (`vt_tray`) went "invalid" right after a slicer filament pick (#1622 round 5, reported by @shaddowlink)** — On a P1S in non-proxy VP mode, the reporter picked a filament for the external spool slot in BambuStudio's Device tab and the slot immediately rendered as invalid (color only, no profile, no K-profile, no nozzle temps), but recovered after a virtual-printer reload. AMS slot picks worked correctly. Wire dumps (BAMBUDDY_VP_DUMP_WIRE=1) captured the asymmetry: the bridge's outgoing 1 Hz cached-as-base push delivered `vt_tray = {tray_info_idx, tray_color}` — 2 fields — where a real P1S sends ~20 (`tray_type`, `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`, `tray_uuid`, `xcam_info`, ...). The same `_out.json` showed AMS slots with the full 24-field dict because `_merge_ams_dict` deep-merged them. **Root cause:** Bambu firmware sends a partial `vt_tray` incremental right after acknowledging an `ams_filament_setting` for `ams_id=255` (external spool) — carrying just the fields the slicer's pick changed. The round-4 per-field accumulate (#1622 / da799447) carried over prev keys NOT present in new, but `vt_tray` IS present in new, so the cached dict was REPLACED wholesale with the 2-field partial. The next 1 Hz cached-as-base push handed the slicer the stripped vt_tray; BambuStudio rendered the slot as invalid. Reloading the VP forced a reconnect → pushall → full vt_tray restored, and the cycle repeated on the next pick. **Fix:** `mqtt_bridge.py::_on_printer_raw` now applies the same per-field accumulate one level deeper: for every top-level key whose prev AND new are both dicts, overlay new onto prev rather than replace. `ams` is explicitly excluded (already deep-merged by `_merge_ams_dict`). The same overlay protects `device`, `online`, `upgrade_state`, `ipcam`, `upload`, `net` against future firmware partials with the same shape; the `net.info` IP rewrite path is unaffected because `_rewrite_net_info_ips` runs against `new_state["net"]` before caching and the rewritten list overrides the cached one on overlay (only `net.conf` and friends, when sent without `info`, draw from prev now). **Tests:** new `test_partial_vt_tray_update_overlays_onto_cached_full_dict` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs the exact P1S wire shape — pushall with the full ~20-field vt_tray, followed by the `{tray_info_idx, tray_color}` partial that shaddowlink's dump captured — and asserts `tray_type`, `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`, `tray_uuid`, `id` all survive while the two incoming fields take their new values. All 53 bridge tests stay green; 287/287 across the broader VP test surface (mqtt_bridge / mqtt_server / vp_wire / virtual_printer); ruff clean. Bridge code path only; no migration, no new i18n keys, no frontend touch. - **Library G-code preview returned raw ZIP bytes as `text/plain` for sidecar-sliced rows (#1709, root cause + fix from @yanglei1980)** — `slice_and_persist` writes its output as a `.gcode.3mf` (a ZIP container with embedded G-code) but persisted the LibraryFile row with `file_type="gcode"`. The G-code preview endpoint at `library.py::get_gcode` short-circuits on `file_type == "gcode"` and streams the on-disk bytes with `media_type="text/plain"`, so every preview of a sidecar-sliced row handed the embedded viewer the raw ZIP body (`PK\x03\x04…`) instead of toolpath text — the viewer rendered nothing. External-folder scans (#1600) already typed `.gcode.3mf` rows correctly and hit the unzip branch, so the bug was specific to the sidecar slice path. Plain `.gcode` uploads were unaffected (their on-disk bytes really are text). **Fix:** (1) forward — `slice_and_persist` now persists `file_type="gcode.3mf"`, matching what `_classify_file_type` returns for the `.gcode.3mf` extension and what external-scan rows already use; (2) back-compat — `get_gcode` also routes to the unzip branch when the filename ends with `.gcode.3mf`, so rows already written under the bug self-heal on first preview without a DB migration. **UI gates:** three frontend call sites that gated badge colour or the preview-eye icon on `file_type == "gcode"` were extended to also accept `"gcode.3mf"` — `FileManagerPage.tsx` badge + viewer-affordance gate, `ProjectDetailPage.tsx` badge — so the new typing doesn't regress visuals. The print / queue / slice action buttons use filename-based helpers (`isSlicedFilename`, `isSliceableFilename`) that already accept `.gcode.3mf`, so they need no change. **Tests:** new `test_library_get_gcode_recovers_legacy_gcode_type_for_3mf` regression case in `test_library_api.py` constructs a row with `file_type="gcode"` + `.gcode.3mf` filename pointing at a real ZIP, asserts the response is `text/plain`, contains `G28`, and does NOT start with `PK` — pins the legacy-row recovery path. Existing `test_library_get_gcode_endpoint_accepts_compound_file_type` continues to cover the forward path. Full backend suite 5920/5920 green; ruff clean; frontend ESLint + `npm run build` clean; FileManagerPage / ProjectDetailPage / FileManagerExternalFolder vitests 69/69 green; i18n parity unchanged (no new keys). PR #1709 closed for CONTRIBUTING.md non-compliance (branched from main, no issue, template incomplete); root cause + fix shape preserved here on `dev`. - **Cloud + Orca Cloud preset resolver: pin `type` and `from` to CLI-accepted values (#1712 follow-up, reported by maziggy on the Mecha Mewtwo slice)** — Removing bundle mode (entry above) routed every slot through the cross-tier preset resolver. Cloud-tier presets surfaced two latent shape mismatches that bundle dispatch had been masking by materialising preset JSONs from `.bbscfg`-on-disk. (1) **`type` field**: Bambu Cloud labels presets with `type: "printer"` / `"print"` / `"filament"`, but the BambuStudio CLI's `--load-settings` parser only accepts `"machine"` / `"process"` / `"filament"`. The user's first failing slice produced `operator(): unknown config type print of file preset.json in load-settings` with exit code -5; the sidecar surfaces this as a generic "The input preset file is invalid and can not be parsed." (2) **`from` field**: Bambu Cloud's filament detail endpoint routinely ships presets with empty `from` (or no `from` at all). The CLI's compatibility check rejects either with `operator(): file ... 's from unsupported` (the double space in stderr = empty value). Same -5 exit, same generic "input preset invalid" surface. The sidecar's `normalizeFromField` already maps `"User"` / `"System"` → `"system"`, but it doesn't touch empty / missing values. **Fix:** `_resolve_cloud` and `_resolve_orca_cloud` now force `type = _SLOT_TO_PROFILE_TYPE[slot]` and `from = "system"` on the payload before `json.dumps`, mirroring what `_resolve_standard` already does for the standard-tier stub. Both fields are unconditionally rewritten — idempotent on already-correct payloads, and pinning to "system" is consistent with how Bambuddy presents these post-flatten presets to the CLI (no parent walk needed, the cloud detail comes back fully expanded). **Tests:** new `test_cloud_rewrites_type_field_for_cli` (7 parametric cases covering all six type-name variants Bambu Cloud emits plus the missing-type case), `test_cloud_pins_from_field_to_system` (4 cases: empty, already-system, GUI User, GUI System), and `test_cloud_synthesises_from_field_when_missing` (the actual Mecha Mewtwo failure shape) pin the resolver-level contract. Existing happy-path assertions for `_resolve_cloud` / `_resolve_orca_cloud` updated to include the new fields. 28/28 preset-resolver tests green, full backend suite 5919/5919 green, ruff clean. **What this can NOT recover:** if Bambu Cloud later starts emitting a `from` value other than empty / "User" / "System" that genuinely means something (e.g. "project"), Bambuddy will silently flatten it to "system" too. We accept that trade-off because the alternative is leaving "input preset invalid" failures on every cloud slice, and "system" matches how the sidecar's own resolver normalises the post-flatten state. - **Virtual printer cache drained capability/lifecycle fields between pushalls, greying out Device-tab UIs (#1622 round 4, reported by @shaddowlink)** — Reporter on a P1S in archive mode saw the AMS-slot filament dropdown empty and the "Manage calibration data" UI disabled in BambuStudio's Device tab, while the same panels worked correctly on his H2D. After three rounds of triage on the printer-side payload (which traced clean — bridge passes `vt_tray` byte-identical, `tray_info_idx` resolves, AMS slots populate), the actual asymmetry surfaced in the bridge cache dumps: P1S cached `print` state contained 17 top-level keys; H2D contained 99. The missing fields were exactly the capability/lifecycle gates BambuStudio reads to decide which Device-tab UIs to enable (`cali_version`, `print_type`, `gcode_state`, `mc_print_stage`, `mc_stage`, `device`, `cfg`, `home_flag`, the `mc_*` family, fan speeds — ~80 fields). **Root cause:** Bambu firmware sends a full top-level field set in pushall responses (on `pushall` request / printer reconnect) and ~1 Hz incrementals carrying just what changed (typically temps, fan, wifi, status). `_on_printer_raw` in `mqtt_bridge.py` cached the latest push as `new_state = copy.deepcopy(print_data)` — replacing the prior cache wholesale — then re-merged only a hand-picked allowlist (`_SLICER_VISIBLE_STICKY_KEYS`) of 14 keys back from prev. The allowlist covered the #1371 / #1387 / #1228 / #1558 failure modes but missed capability/lifecycle fields entirely, so every 1 Hz incremental drained ~80 fields out of the cache and the slicer's gated UIs flipped off as soon as the cache thinned. The code comment claimed the cache "mirrors the same preservation pattern Bambuddy uses for its own internal state in bambu_mqtt.py" but it didn't: internal state is updated per-field (`if "X" in data: self.state.X = ...`), never drops what it's seen, and accumulates monotonically. **Fix:** replace the allowlist-preserve with per-field accumulate. For every key in the prior cache, carry over verbatim when the incoming push omits it; let new values overwrite when present. The `_merge_ams_dict` deep-merge for partial `ams` blobs stays (#1387 / #1371 regression guards still pass). `_SLICER_VISIBLE_STICKY_KEYS` is removed entirely — the new logic is a strict superset of every case the allowlist handled. **Why most P1S users don't hit it:** timing. The typical workflow is connect → BS issues pushall → cache fills → click Device tab within seconds → UI works. shaddowlink's sequence kept BS idle long enough between pushalls that the cache thinned to incremental-only state before he clicked. X1C users hit the same drain but don't notice — older BS capability spec doesn't gate the same UIs on `cali_version` / `mc_print_stage`. H2D escaped detection because his captures happened to land close to a pushall reply (cache still fat). **Tests:** new `test_incremental_push_preserves_non_allowlisted_capability_fields` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs a full push with `cali_version` / `print_type` / `gcode_state` / `mc_print_stage` / `mc_stage` / `device` / `cfg` / `home_flag`, follows it with a temps-only incremental, and asserts every capability field survives. All 51 existing bridge cache tests stay green — same behaviour for the allowlist subset, plus the formerly-dropped fields. Bridge code path; no migration, no new i18n keys. - **Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by @SamNuttall)** — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders `FilamentOverride` which carries the checkbox, but picking a single printer renders `FilamentMapping` instead — a separate component that had no force-match UI. The dispatcher in `print_scheduler.py:535` already honours `force_color_match` regardless of how the queue item was created (the flag survives end-to-end on the `filament_overrides` payload `buildFilamentOverridesArray` constructs in `PrintModal/index.tsx:613`), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. **Fix:** `FilamentMapping` accepts new optional `forceColorMatch` + `onForceColorMatchChange` props mirroring `FilamentOverride`'s shape; it renders the same ``-iconed checkbox under each filament row when a handler is provided. `PrintModal/index.tsx:1100` passes the existing `forceColorMatch` state and a `setForceColorMatch` setter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existing `printModal.forceColorMatch` key already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). **Tests:** new `renders the per-slot force-color-match checkbox in printer mode (#1717)` case clicks the checkbox and asserts `onForceColorMatchChange(slotId, true)` fires; companion `omits the force-color-match checkbox when no handler is provided` case pins the absent-handler branch. Existing FTS dropdown-filter tests stay green. `FilamentMapping.test.tsx` 4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **In-app updater fails when DATA_DIR is on a separate mount from the install (#1715, reported by @francescocozzi)** — Native installs that follow the systemd template `WorkingDirectory=/opt/bambuddy` with `Environment="DATA_DIR=/srv/bambuddy/data"` (or any layout where `DATA_DIR` is not a subdirectory of the install path) couldn't apply in-app updates. Every git step in `_perform_update` (`remote get-url`, `remote set-url`, `fetch`, `reset --hard`) used `cwd=settings.base_dir`, and `safe.directory` was pointed at `base_dir` too. On the standard install (DATA_DIR=INSTALL_PATH/data) this happened to work by accident — git walks up from a subdirectory of the repo to find `.git` — but on a separate-mount layout the data dir is not under the install, the walk-up has nowhere to go, and every operation returns "fatal: not a git repository." Even on the standard install `safe.directory={base_dir}` was wrong (it must equal the repo root git discovers, not the data dir), surfacing on hardened systemd units as "fatal: detected dubious ownership." **Fix:** route every git subprocess in `_perform_update` and `_origin_points_at_repo` through `cwd=settings.app_dir` (the working tree), and set `safe.directory={app_dir}` to match. `app_dir` is now resolved once at the top of `_perform_update` instead of lazily re-resolved before the pip step. The `base_dir` parameter on `_origin_points_at_repo` is renamed to `app_dir` so the signature documents the contract. The pip-install step keeps `cwd=app_dir` (unchanged — that step was already correct). **Tests:** new `test_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mount` integration case constructs a sibling-paths layout (`tmp/opt/bambuddy` + `tmp/srv/bambuddy/data` — the exact #1715 shape), mocks `asyncio.create_subprocess_exec` to capture every call's cwd, and pins (a) every git subprocess runs with `cwd=app_dir`, (b) the embedded `safe.directory=` config equals `app_dir` on every git call. The existing pip-cwd test stays green (pip's cwd was already `app_dir`). Existing SSH-origin-preserve + origin-rewrite + reset-target tests stay green (they don't assert on git cwd). Full `test_updates_api.py` 21/21 green; ruff clean. **Credit:** root cause + fix shape from francescocozzi via PR #1716 (couldn't be merged as-is — that branch had drifted off an older `dev` and pulled in unrelated upstream commits including a version regression). - **SliceModal preset-lookup precedence + cross-tier dedup + signed-out banner (#1712, reported by @IndividualGhost1905)** — After the Orca Cloud integration shipped (2026-06-04), every user — including Bambu-Cloud-only / Bambu-Studio-preferred users — got Orca Cloud as the top tier across the SliceModal preset picker, the per-preset auto-pick scoring, the dropdown's optgroup rendering, the AMS slot picker's filament sort, and the backend dedup precedence. A Bambu-Cloud / X1C user reported seeing his Bambu Cloud profiles disappear from auto-pick because Orca Cloud's empty tier shadowed them. The cross-tier dedup (introduced with #1150 and inherited as-is by the Orca change) compounded the problem: a name that existed in multiple tiers showed in only ONE group, so a user with a local-imported and Orca-synced "Bambu PLA Basic" never saw the Orca copy as a picker option — even though they curate both sources. And the cloud-status banner (`CloudStatusBanner`) nagged signed-out users with a permanent *"Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets"* at the top of every SliceModal open — even after a user had explicitly logged out of Orca Cloud. The Bambu Cloud banner had the symmetric problem. **Fix — order:** precedence is `local > orca_cloud > cloud > standard` across `SliceModal.tsx` (`SLICE_MODAL_TIER_ORDER` + `TIER_BONUS` + dropdown tier list), `ConfigureAmsSlotModal.tsx` (`sourceOrder`), and docstrings in `slicer_presets.py` / `schemas/slicer_presets.py` / `client.ts`. Local imports win (the user did them on purpose), Orca Cloud comes next, Bambu Cloud, bundled fallback last. The order drives auto-pick + visual group order; it does NOT hide profiles. **Fix — no dedup, full lists:** `_dedupe_by_name` is replaced by `_enrich_cloud_metadata`, which returns every tier's full preset list across all three slots (printer / process / filament) — a name in local AND orca_cloud AND cloud renders in EACH of their groups so the user can pick any source. The only work the function still does is filament-metadata backfill: a Bambu Cloud filament without its own `filament_type` / `filament_colour` inherits values from a same-named local / orca_cloud / standard entry so it can still score in `pickFilamentForSlot`. Printer + process presets carry their metadata inline and need no enrich. Frontend code already iterates tiers in priority order and surfaces every entry — no change needed there once the backend stops filtering. **Fix — banner:** `CloudStatusBanner` now silently returns null on `not_authenticated` in addition to `ok` — applies symmetrically to both Bambu and Orca cloud banners. `expired` (token broke) and `unreachable` (network / service down) still surface — those are real breakage states a previously-signed-in user needs to see. Sign-in lives on the Profiles page; the modal doesn't need to advertise it. The `slice.cloud.notAuthenticated` / `slice.orcaCloud.notAuthenticated` i18n keys stay in the locale files (dormant) so re-enabling later doesn't need a re-translation pass. **Fix — ConfigureAmsSlotModal source badges:** before this change, the per-row source badge fired three branches independently — `local` got a green "Local" badge, `builtin` got an amber "Built-in" badge, and a blue "Custom" badge appeared on top of those when `isUser` was true. Since ALL Orca Cloud entries are marked `isUser: true` and Bambu Cloud user presets also get the same flag, the result was visually inconsistent: Orca Cloud rows showed *only* "Custom" (no source identification, no way to tell them from Bambu Cloud user presets), Bambu Cloud built-in rows had NO badge at all, and the "Custom" badge collided with the actual source. Replaced with a single source badge per row: green "Local", purple "Orca Cloud" (new), bambu-blue "Bambu Cloud" (new), amber "Built-in". One badge per row; one colour per source; the `isUser` distinction within the Bambu Cloud tier is dropped (the preset name itself carries the "is this user-authored" signal). Same change in both render blocks (the filament-list code is duplicated in the modal — kept the duplication local rather than refactoring out a helper component in this PR to keep the diff tight). i18n: 2 new keys (`configureAmsSlot.orcaCloud`, `configureAmsSlot.bambuCloud`) translated to all 11 locales — both are brand names, already on the per-locale `IDENTICAL_TO_EN_ALLOWED` lists so the parity check is satisfied without per-locale variants. The dormant `configureAmsSlot.custom` key stays in the locale files. **Tests:** `TestEnrichCloudMetadata` replaces `TestDedupeByName` (5 cases): regression guard pinning that a name in all four tiers appears in EACH (not just local), tier order preserved within a tier, Bambu Cloud filament metadata backfilled from local, backfill falls through to orca / standard when local doesn't carry the name, backfill does NOT overwrite Bambu Cloud's own metadata when present. The "renders a sign-in banner when cloud_status is not_authenticated" case flipped to assert no banner appears, with the test name updated to call out the #1712 reason. Backend `test_slicer_presets.py` 47/47 green; `SliceModal.test.tsx` 34/34 green; `ConfigureAmsSlotModal.test.tsx` 24/24 green; ruff clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **Telegram (and other image-bearing) finish notification on a reprint-from-archive showed the original print's finish photo instead of the new run's (#1707, reported by @kycrna)** — P2S user reprinted an archived job and observed the Telegram notification arriving with the photo of the *original* print (white box) attached to the completion message for the *new* run (black box). **Root cause:** reprints reuse the source archive row — `register_expected_print` stores the source `archive_id` in `_expected_prints`, and the on-print-start expected-archive promotion branch at `main.py:2207-2245` updates the row's status / started_at / printer_id / subtask_id but never reset `archive.timelapse_path`. Two failure modes cascaded from the stale path: (a) `_scan_for_timelapse_with_retries` early-returns at `main.py:3062` with `if archive.timelapse_path: return` — the reprint's new timelapse MP4 sitting on the printer's SD card was never downloaded, the archive's `timelapse_path` kept pointing at the original run's local file; (b) `_capture_finish_photo_from_timelapse` polls `archive.timelapse_path` and immediately found the *original* video, extracted ITS last frame as `finish__.jpg`, and handed those bytes to `_background_notifications` as `image_data` — which then went out to Telegram via the `sendPhoto` path. The filename was new (so log lines and the archive's `photos` list looked correct), but the pixels were the original run's finish frame. Surface was specific to the timelapse-prefer path: with `data.timelapse_was_active` true and no external camera, `prefer_timelapse_source` was True, which is the exact configuration on P2S with timelapse-on for both runs. External-camera, buffered-frame, and fresh-RTSP fallback paths grab the *current* camera frame, so users on those paths saw correct photos and the bug stayed hidden. **Fix:** at expected-archive promotion, capture and clear `archive.timelapse_path` to None before the commit, and `os.unlink` the stale on-disk video so reprints don't accumulate orphaned MP4s in the archive directory. Photos list is left alone — accumulating one finish photo per run across the archive's lifetime is the right behaviour. The unlink is wrapped in `OSError`-catching best-effort logging so a missing file (manual delete, archive purge, container rebuild with bind-mount drift) doesn't break promotion. The clear-and-unlink runs unconditionally when `timelapse_path` is set, so even if a user has been reprinting under the buggy build for months, the next reprint self-heals. **Tests:** 3 new cases in `test_reprint_clears_stale_timelapse.py` exercise the full `on_print_start` callback through the expected-archive branch — happy path (path cleared + file unlinked), no-prior-timelapse (no-op, promotion still succeeds), missing-stale-file (best-effort unlink doesn't raise). Full `test_print_start_expected_promotion.py` + `test_print_start_assigns_printer_id_to_vp_archive.py` suite (28/28) stays green; ruff clean. - **Connection diagnostic no longer flags `external_storage: fail` on A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by @MartinNYHC)** — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reported `external_storage: fail` in the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. **Root cause:** the `external_storage` check at `services/printer_diagnostic.py:179-189` reads `state.store_to_sdcard`, which is parsed from MQTT `home_flag` bit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes as `False` and the check fell through to `fail` instead of `skip`. **Fix:** new `NO_EXTERNAL_STORAGE_MODELS` frozenset in `utils/printer_models.py` enumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus a `has_external_storage(model)` helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships *with* a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits to `skip` before reading `store_to_sdcard` when `printer.model` is in the set. **What this does NOT change:** X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluate `store_to_sdcard` exactly as before — the home-flag-bit-off → `fail` path is still the right signal for them. **The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change.** **Tests:** 8 new cases — `TestHasExternalStorage` (5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true; `TestExternalStorageCheck` gains `test_skips_on_a1_no_external_storage_slot`, `test_skips_on_a1_mini_no_external_storage_slot`, and `test_still_fails_on_x1c_when_toggle_off` (regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Full `test_printer_models.py` + `test_printer_diagnostic.py` + archives integration suite green (172/172); ruff clean. - **AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+")** — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data: `slot_preset_mappings` row for `(printer_id=1, ams_id=1, tray_id=2)` was `preset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15` — three months stale. **Root cause:** `slot_preset_mappings.preset_name` is first in the PrintersPage display chain (`PrintersPage.tsx:3624`) and overrides the spool's own `slicer_filament_name` plus the cloud catalog `cloudInfo.name`. The internal-mode manual-assign path (`inventory.apply_spool_to_slot_via_mqtt`) kept this row in sync, but the internal-mode RFID auto-assign path (`spool_tag_matcher.auto_assign_spool`) skipped it entirely. The Spoolman-mode sync path (`main.auto_sync_spoolman_ams_trays`) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. **Fix — three writers in lockstep via one shared helper.** New `backend/app/services/slot_preset_writer.py` exposes a primitive `upsert_slot_preset` plus two convenience wrappers: `upsert_slot_preset_for_spool` for internal `Spool` ORM objects (local-preset numeric ids → `local_{n}`, cloud ids run through `filament_id_to_setting_id`) and `upsert_slot_preset_for_spoolman_spool` for Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block in `inventory.py:396-438`, the RFID auto-assign tail in `spool_tag_matcher.py:auto_assign_spool`, and the per-tray-sync branch in `main.py:auto_sync_spoolman_ams_trays` — now go through the helper. **Self-heal:** existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. **What this also covers per `feedback_inventory_modes_parity`:** the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. **Tests:** new `test_slot_preset_writer.py` (6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. New `test_spool_tag_matcher.py` cases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists, `local_{n}` formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean. - **Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905)** — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (`filamentRunout`) which is what the new backend PATCH validates against, but the older `EditArchiveModal` was still saving the localised label (`"Filament runout"`) as the value — two formats landing in the same `PrintLogEntry.failure_reason` column from two different UI surfaces. The Failure Analysis widget at `frontend/src/pages/StatsPage.tsx:817` and the per-archive run history sub-table at `frontend/src/components/PrintLogTable.tsx:81` both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. **Fix — three sites in one drop:** (1) `StatsPage.tsx` and (2) `PrintLogTable.tsx` now wrap the value in `t('editArchive.failureReasons.${reason}', { defaultValue: reason })` — same pattern already used at `ArchivesPage.tsx:3874` for the Print Log table. The `defaultValue` fallback keeps legacy translated-text rows rendering as-is, no regression. (3) `EditArchiveModal.tsx` now saves the camelCase key (`