# Changelog All notable changes to Bambuddy will be documented in this file. ## [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. ### 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 (`