# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added - **Inventory page now supports native CSV import / export (#1576, PR #1659 by @samedyuksel)** — Bulk-add spools without manually clicking through the form, and back up / migrate the local inventory in a single round-trip. Export downloads `bambuddy-spools-YYYY-MM-DD.csv` (header + one row per active spool); Import shows a preview table that classifies each row as valid / error / skipped before anything hits the database, then a confirm click persists only the valid rows in one transaction (invalid rows are skipped, the user fixes them and re-uploads). Local inventory only — in Spoolman mode the buttons render disabled with a tooltip pointing at Spoolman's own CSV import/export, since the Spoolman backend has its own data store. **Schema**: fixed 18 columns, case- and whitespace-tolerant headers, includes `weight_used`, `last_used`, and the SpoolCreate fields `storage_location` / `category` / `low_stock_threshold_pct` so the round-trip preserves the per-spool location data from #1291. `remaining` is a derived, export-only column (`label_weight - weight_used`, clamped at 0) — it's written for human readability and ignored on import (weight_used is the source of truth, accepting both would let them contradict). **Colour resolution**: explicit `rgba` wins, otherwise `brand + color_name` resolves against the Color Catalog (case-insensitive, single in-memory pass — no N+1); a catalog entry with `material = NULL` is treated as the project's "matches any material" convention so a generic match counts as exact rather than firing the cross-material warning. Validation reuses `SpoolCreate` so every constraint that already protects manual adds (`weight_used >= 0`, `weight_used <= label_weight`, `low_stock_threshold_pct` range, etc.) protects bulk imports too. **Hardening**: 5 MB upload cap with a structured `csv_import_too_large` 413 response — Bambuddy doesn't have a global HTTP-level cap so the check lives on the route, and the implementation is a bounded 64 KB chunked read that bails the moment the accumulated body crosses the cap (file.size is `None` for chunked uploads so the loop is what actually prevents the OOM, not the pre-check). Spreadsheet formula-injection guard: every exported cell starting with `=` / `+` / `-` / `@` / tab / CR is prefixed with a single quote on export, and the inverse strip on import keeps the round-trip lossless instead of accumulating quotes on every cycle. Soft-warn surface in the preview: a `duplicate_of_existing` flag fires when an active spool with the same material + brand + color_name exists (single SELECT, no N+1) so a double-click or re-upload of the same CSV doesn't silently duplicate the inventory — the row still imports (Spool has no unique constraint, by design), but the preview renders a Copy icon + tooltip so the user knows. **Frontend**: new `SpoolCsvImportModal` (file pick → preview table with per-row status / colour swatch / warnings → confirm imports valid rows) wired to Import + Export buttons on the inventory header; swatch rendering uses the existing `getSwatchStyle` helper so alpha=00 shows the checkerboard underlay instead of rendering as solid black, matching the rest of the inventory surface. **i18n**: new `inventory.csv` namespace with full translations in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW). **Tests**: 25 backend integration cases pin every behaviour — export shape, import dry-run vs real, color resolution (catalog hit, explicit rgba wins, cross-material flagged, exact-material match, generic-material match not flagged), 5 MB rejection, weight_used bounds, formula-injection round-trip without quote accumulation, dated filename, extra-column round-trip, duplicate-warn flag. Plus 3 frontend modal tests. Full backend suite + ruff + ESLint + frontend build + i18n parity (5092 leaves × 11 locales) green. **Companion docs**: wiki PR maziggy/bambuddy-wiki#41 documents the schema, behaviour, and the Spoolman-mode disabled-with-tooltip semantics. ### Fixed - **Print modal now exposes a "Nozzle Offset Calibration" toggle for dual-nozzle printers (#1682, reported by @louiskleiman)** — Reporter on H2D running diamond nozzles: BambuStudio exposes a per-print "Nozzle Offset Calibration" option that is incompatible with diamond hot ends, but Bambuddy had no way to control the same flag, so every dispatch silently set it to the firmware default. **Root cause: the field was hardcoded.** `bambu_mqtt.py:3445` always wrote `"nozzle_offset_cali": 2` (skip) into the MQTT `project_file` payload, regardless of model, regardless of any user choice. The wire format is tri-state — `1`=run, `2`=skip — and matches BambuStudio's encoding; the manual-calibration route (`/printers/{id}/calibration`) already wired the corresponding `cali_idx=2` MQTT command, but the **dispatch-time** toggle was simply absent. For most users this was invisible (BambuStudio's default is "run" on H2D / H2D Pro / H2C / X2D, Bambuddy's default was effectively "skip"), but a diamond-nozzle setup that needs the calibration explicitly off had no way to confirm Bambuddy's behaviour or override it the other way once we add a toggle that follows the slicer's default. **Fix: end-to-end plumbing of `nozzle_offset_cali` with a hard MQTT-layer gate on dual-nozzle.** `start_print()` (`bambu_mqtt.py:3300`) gains a `nozzle_offset_cali: bool = False` kwarg and the project_file payload line becomes `"nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2`. The dual-nozzle check reuses `is_dual_nozzle_model()` and the runtime `_is_dual_nozzle` flag (set when `device.extruder.info` has ≥ 2 entries) — same canonical signal the rest of bambu_mqtt.py uses for routing decisions. **Even if a stale queue item from when the printer was misidentified carries the flag, the MQTT layer downgrades it to `2`** so firmware never tries to calibrate a head it doesn't have. The kwarg threads through `printer_manager.start_print()`, both `background_dispatch` call sites, and `print_scheduler._start_print` so every dispatch path — direct reprint, library file, queue-dispatched, watchdog-recover — respects the per-item setting. **Persistence:** `print_queue.nozzle_offset_cali` column (BOOLEAN DEFAULT TRUE, branched on `is_sqlite()` because Postgres rejects `DEFAULT 1` for BOOLEAN, caught by my Postgres test environment before this shipped) — default TRUE matches BambuStudio's behaviour on dual-nozzle, the MQTT gate makes the value a no-op on single-nozzle. New `default_nozzle_offset_cali` setting (default TRUE) plumbed through `schemas/settings.py`, the settings PUT allowlist, and the SettingsPage card — the row in **Settings → Default Print Options** only renders when `printers.some(p => p.nozzle_count === 2)`, so single-nozzle-only users never see a control they can't act on. ReprintRequest + FilePrintRequest schemas (`schemas/archive.py`, `schemas/library.py`) carry the field too so the API surface is consistent across the three "send 3MF to printer" routes. **Frontend:** `PrintOptionsPanel` (`components/PrintModal/PrintOptions.tsx`) accepts a `showDualNozzleOptions` prop and filters the option list; `PrintModal/index.tsx` computes it from `selectedPrinters.some(p => p.nozzle_count === 2)` in printer-mode or from a small inline `DUAL_NOZZLE_MODELS` set in model-mode (mirrors the backend `DUAL_NOZZLE_MODELS` frozenset: `H2D`, `H2DPRO`, `H2C`, `X2D`). The same gate flows through `QueuePage` bulk-edit — the new tri-state toggle only renders if any registered printer has `nozzle_count === 2`. Labels reuse the existing `settings.defaultBedLevelling` / `settings.defaultFlowCali` / etc. translation keys (identical strings, already translated) to keep i18n churn proportional to the actual new copy. **i18n:** 3 new keys per locale × 11 locales = 33 entries — `settings.defaultNozzleOffsetCali`, `settings.defaultNozzleOffsetCaliDesc`, `queue.bulkEdit.nozzleOffsetCali` — real translations in every locale (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallbacks. i18n parity check confirms 5069 leaves × 11 locales. **Tests:** 4 new in `test_bambu_mqtt.py` pin the four-quadrant gate: default value (P1S, no kwarg → `2`), single-nozzle ignore (P1S, kwarg `True` → still `2` — the safety net), dual-nozzle honour (H2D, `True` → `1`), dual-nozzle false (H2D Pro, `False` → `2` — the diamond-nozzle case). `test_printer_manager.py` updated for the new kwarg in `assert_called_once_with`. Frontend tests: existing PrintModal / QueuePage / SettingsPage suites pass with the new field threaded through (117 / 117). Full backend suite: 3840 / 3840 pass. ruff clean; frontend build clean; ESLint clean; i18n parity green. - **"Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by @kleinwareio)** — Reporter clicked Assign Spool from the printer card for AMS-B slot 4 while that slot was empty. The toast said "Spool assigned and AMS slot configured" but the AMS card kept showing slot 4 as Empty. **Root cause: misleading toast on the empty-slot deferred-config path.** The backend (`inventory.py:1385-1405`) deliberately skips the MQTT `ams_filament_setting` publish when the AMS reports an empty tray state (state ∈ {9, 10}) because Bambu firmware silently drops the push for empty slots — there's no point sending a command the printer will discard. The assignment row is persisted with `pending_config=true`, and `on_ams_change` (`main.py:1031-1054`) re-fires the full configuration the moment the AMS reports a non-empty fingerprint in that slot. The flow is correct; the success log line `Pre-configured assignment: spool 16 → printer 1 AMS1-T3 (slot empty, will configure on insert)` confirms the backend did exactly that. **But the frontend ignored the response flag.** `AssignSpoolModal.tsx:153` always called `showToast(t('inventory.assignSuccess'), 'success')` — the wording "Spool assigned and AMS slot configured" — regardless of whether the backend actually configured the slot or deferred. The sibling SpoolBuddy modal (`spoolbuddy/AssignToAmsModal.tsx:212-226`) already branched on `pending_config` and showed a distinct "Slot will configure when you insert the spool" message; the printer-card modal was just never updated to match. **Fix:** `AssignSpoolModal.tsx` now reads `newAssignment.pending_config` and picks between `'inventory.assignSuccess'` (slot configured immediately) and the new `'inventory.assignPendingInsert'` ("Assigned. Slot will configure when you insert the spool.") key. Spoolman-mode branch unchanged — the Spoolman backend route always sends the MQTT push (no pending_config flag is exposed) and the SpoolBuddy modal's existing comment documents that. **i18n:** new `inventory.assignPendingInsert` key in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), translations copied verbatim from the existing parallel `spoolbuddy.modal.assignPendingInsert` entries so the message reads identically across the app. No English fallbacks per the project's hard rule; i18n parity check confirms 5066 leaves × 11 locales. **Tests:** 2 new in `AssignSpoolModal.test.tsx` — `shows the pending-insert toast when backend returns pending_config=true (#1680)` pins the new branch (slot-was-empty case the reporter hit), and `shows the configured toast when backend returns pending_config=false (#1680)` is the counterpart regression guard so a future refactor can't silently mark every assign as pending. Both also assert the WRONG toast is NOT also called (defense against accidental double-toast). 16/16 AssignSpoolModal tests pass; frontend build clean; ESLint clean. - **Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by @IndividualGhost1905)** — Reporter on X1C, daily build `v0.2.5b1-daily.20260607`: a print was running, the host was restarted (planned reboot / power outage / watchtower image update), and Bambuddy's printer card showed the print as **cancelled** while the printer continued printing happily. Print log showed `aborted` for that row, filament usage was deducted at the cancellation moment (48.6 g / 5 % in the supplied screenshots), and when the print actually finished a *second* archive was created and filament was deducted *again*. Net effect: filament inventory off by the entire print weight, statistics showing one "user-cancelled" entry alongside one "completed" entry for the same physical print. Second confirmed hit from the same reporter, plus a corroborating comment from @Arn0uDz on watchtower-driven restarts. **Root cause: connected-edge reconciliation fired on a bare MQTT-connected state that had no real data yet.** On Bambuddy startup, a fresh `BambuMQTTClient` is constructed with `PrinterState` defaults — most importantly `state.state = "unknown"` and `state.subtask_name = ""`. The MQTT `_on_connect` callback (`bambu_mqtt.py:668-669`) broadcasts `on_state_change(self.state)` *immediately* after the broker accepts the connection — BEFORE the `_request_push_all` round-trips with the printer's real status. `on_printer_status_change` (`main.py:825`) sees `state.connected=True` flip on the connected-edge, spawns `reconcile_stale_active_prints` for that printer. The reconcile walks every archive in `status="printing"`, calls `_is_active_archive_stale` (`main.py:3352`) — which sees `state.state="UNKNOWN"` (skips the IDLE/FINISH/FAILED branch), then `state.subtask_name=""` (matches trigger 3, "printer subtask_name empty") and **returns stale**. A synthesised `aborted` PRINT COMPLETE fires for every in-flight archive on every printer, clears `_active_prints`, and when the real PRINT COMPLETE finally arrives at print end, `_active_prints` doesn't have the entry, so a brand-new archive row is created instead of overwriting the synthesised one. The pre-existing comment at `_is_active_archive_stale` ("the next real PRINT COMPLETE would have overwritten the status anyway") was wrong: the reactive completion handler uses `_active_prints` for lookup, not a join on filename/subtask_id, so the original row stays cancelled and a duplicate is born. Timing-dependent in practice — on hosts where the printer's first `push_status` response wins the race against the reconcile background task, state is real and reconcile doesn't false-positive; on slower hosts or busy MQTT brokers, the bare-connect-edge fires first and the bug hits. The reporter is on a slower-race host and saw it twice. **Fix: two-layer guard.** (1) Primary: `on_printer_status_change` now gates the reconcile spawn on `state.state` being a real value — `state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")` — so reconcile doesn't fire until the first `push_status` updates `state.state` to a real Bambu firmware value (RUNNING / IDLE / FINISH / PREPARE / SLICING / PAUSE / FAILED). When that real push arrives, `on_printer_status_change` fires again, the connected-edge flag is still `False` (we never set it), and reconcile runs against actual evidence. The existing #1542 mechanism — synthesising a missed PRINT COMPLETE for prints that finished during a disconnect window — keeps working: if the printer reports `IDLE` on its first real push after reconnect, reconcile catches it the way it always did. (2) Belt-and-braces: `_is_active_archive_stale` now returns `(False, "")` when `state.state` is empty / `"unknown"` / `None`, regardless of the subtask fields. Strictly more conservative than the previous behaviour; only suppresses the degenerate-input false positive. Any future caller that bypasses the primary gate still can't synthesise an aborted completion from defaults. **Tests:** `test_reconcile_stale_active_prints.py` 26 cases (up from 21) — new parametrize `test_pre_push_state_returns_not_stale_even_with_empty_subtask` pins all five degenerate forms (`"unknown"`, `"UNKNOWN"`, `"Unknown"`, `""`, `None`) and asserts none triggers stale even with empty `subtask_id` + empty `subtask_name`. The existing #1542 regression coverage stays green — terminal-state, subtask-id-mismatch, and empty-subtask-name-under-RUNNING all still report stale on real state pushes. Full backend suite: 3836 / 3836 pass. ruff clean. - **Print queue no longer wedges in "Currently Printing" when a printer accepts `project_file` but never starts (#1678, reported by @kleinwareio)** — Reporter on two P1S, one was power-cycled mid-print and came back online; from then on Bambuddy showed the next queue item as "Currently Printing" at 0% while the printer card showed "Idle / Ready to print". The same file also re-appeared in the Queued list as Pending after the user resubmitted. Only restarting the Bambuddy container ever recovered it. Support log + screenshots confirm: at dispatch time MQTT `project_file` was ACK'd, printer pushed `gcode_state=IDLE, gcode_file=, subtask_id=` — i.e. the file landed on the printer but the printer never transitioned IDLE → PREPARE → RUNNING. **Root cause: `_watchdog_print_start` returned SUCCESS as soon as `subtask_id` advanced.** The subtask_id-as-pickup-signal was added for H2D, which can sit at `FINISH` for ~50 s after accepting `project_file` before flipping to PREPARE (#1078) — but it's strictly a "command landed" signal, not "actually printing". When the printer accepts the file but then wedges (cloud+LAN re-auth dance after a power cycle, old firmware, partial network outage), the watchdog returned success, the queue row stayed at `status='printing'`, the in-memory `_expected_prints` entry stayed registered (TTL is 2 hours and only clears the dict, not the DB row), and every subsequent queue item was blocked because the printer was still "in flight". This reporter's firmware (01.07.00.00, current is 01.08.x+) and `bambu_cloud_token`-enabled cloud+LAN mode make the post-power-cycle wedge measurably more likely on their box, but the queue-wedge bug applies to any printer that accepts a file but stalls before starting. **Fix: split the watchdog into two phases.** Phase A (up to `timeout`, default 90 s, unchanged behaviour) waits for either an active-state transition OR a `subtask_id` advance — if neither happens the publish was lost on a half-broken MQTT session (#887/#936) and we revert + force-reconnect (the original #967 recovery path). Phase B (new, up to `phase_b_timeout`, default 180 s) only runs when Phase A exited via subtask_id-alone: keep watching for the active-state transition. 180 s is ~3.5× the worst observed H2D FINISH → PREPARE delay (#1078), so the H2D path stays green. If Phase B times out the queue item is reverted to `pending` so the user can retry without restarting Bambuddy — and Phase B explicitly does NOT force a MQTT reconnect because subtask_id-advance proves the project_file landed and a forced reconnect mid-parse triggers 0500_4003 (#1150). Phase A's existing `gcode_file`-changed discriminator (#1150) stays put for the no-subtask-id-advance case. **Tests:** `test_scheduler_watchdog.py` 14 cases (up from 13) — the #1078 H2D regression test rewritten to step the status through Phase A (subtask_id advance with state=FINISH) then Phase B (state flips to RUNNING) and pin success; new `test_reverts_when_subtask_advanced_but_state_never_active` pins the #1678 wedge case (subtask_id advances, state stays IDLE for the full Phase B window → revert + NO force_reconnect call); new `test_default_phase_b_timeout_is_180_seconds` pins the new default so a future refactor doesn't silently shrink the H2D headroom. Existing #967 / #1150 / #1370 / disconnect / fallback / discriminator regression coverage all stays green. Wider scheduler + queue + dispatch test surface (305 tests) stays green; ruff clean. - **Service-worker activate handler no longer hangs first-install browsers (demo site stuck spinner + Firefox Corrupted-Content)** — Reproduced live on the demo platform: a visitor lands on `{session}.demo.bambuddy.cool/`, the Printers page renders, but clicking any sidebar entry sticks the next page on a spinner; only a manual reload recovers. In Firefox the same race surfaces as a "Corrupted Content Error" with `sw.js` stuck in `activating` for the entire session. **Root cause:** the `client.navigate(client.url)` call added to the `activate` handler in `sw.js` (commit `18d534c9`, shipped 2026-06-04 alongside the Orca Cloud landing) was intended to force kiosks running an old SW to reload after a deploy, but its only guard was `client.url && typeof client.navigate === 'function'` — neither distinguishes a first install from an upgrade. On every fresh origin (every demo session is a new subdomain, but also any browser visiting Bambuddy for the first time, or after clearing site data) the activate handler still fired the forced navigation: Chromium raced it against React Router's in-flight SPA mount and wedged the page; Firefox's `event.waitUntil` deadlocked on `await client.navigate(...)` because the SW intercepts its own document fetch while still `activating`, the document load aborts, and the SW never reaches `activated`. The "first install on a never-controlled client" guard the commit's comment claimed simply didn't exist in code. **Fix: split the lifecycle correctly.** `sw.js` activate handler is reduced to cache cleanup + `clients.claim()` (matches the standard PWA lifecycle and lets activation complete in low single-digit ms regardless of in-flight document state). The deploy-pickup reload moves to `sw-register.js`: capture `hadController = !!navigator.serviceWorker.controller` at script load (true ⇔ a previous SW was controlling the document), listen for `controllerchange`, and only `location.reload()` when `hadController` was true. A returning kiosk hits a new deploy → had a controller → reloads as before. A first-install visitor (no prior SW, or hard-refresh, or first demo session) → no controller → no forced navigation → React mount completes cleanly. `CACHE_NAME` bumped `bambuddy-v29 → bambuddy-v30` and `STATIC_CACHE` `bambuddy-static-v28 → bambuddy-static-v29` so existing browsers fetching the new `sw.js` drop the old CacheStorage in the same pass — without the bump the SW file byte content might equal the cached one and the upgrade installs nothing. The SpoolBuddy-kiosk unregister branch at the top of `sw-register.js` is unchanged (still wipes registrations on `/spoolbuddy` paths). The `notificationclick` handler in `sw.js` (open-tab-on-push) still uses `client.navigate(url)` — different code path, unrelated, unchanged. - **VP archive/queue names with `&` no longer render as `&amp;` + tooltip corrected for BambuStudio 2.7.x reality (#1658 follow-up, reported by @IndividualGhost1905)** — Two bugs surfaced on the same screenshot set: (A) Metadata-mode archive and queue names showed `PCB Vise &amp; Solder Station` where the 3MF's Title metadata is `PCB Vise & Solder Station`. **Root cause:** `ThreeMFParser._parse_3dmodel` (`backend/app/services/archive.py:495-538`) parsed the XML `` payload via regex and stripped whitespace but never called `html.unescape()`. The raw `&` landed in the DB; React then auto-escaped the `&` again on render, producing `&amp;`. The sibling parser `ProjectPageParser` (line 754) already had a loop-until-stable unescape and a comment explaining why ("content is often triple-encoded" — observed BambuStudio behavior), the makerworld-fields path just didn't share it. **Fix:** module-level `import html` and the same loop-until-stable unescape pattern in `_parse_3dmodel`, applied uniformly to all `` values so `Title`, `Designer`, and any future fields all get peeled the same way. The loop terminates as soon as `html.unescape()` stops changing the string, so single-, double-, and triple-encoded payloads all converge to the correct value; plain ASCII passes through untouched. (B) Filename-mode showed the slugified project title (`PCB_Vise_&_Solder_Station`) instead of the user-typed Send-dialog text ("Main Parts"). **This is NOT a Bambuddy bug** — BambuStudio source confirms it. `PrintJob.cpp:314-325` (`src/slic3r/GUI/Jobs/PrintJob.cpp`) reads `BBL_DESIGNER_MODEL_TITLE_TAG` (defined as `"Title"` in `bbs_3mf.hpp`) from the 3MF, slugifies it (space → `_`, unusable chars `<>[]:/\|?*"` → `_`, collapse runs of `_`, truncate to 100 chars), and **unconditionally overwrites** the user-typed `m_project_name` with it before sending. `params.project_name` becomes both the FTP filename and the MQTT `subtask_name`. The user-typed string never leaves BambuStudio when a Title metadata exists — there is no MQTT field carrying it, so Bambuddy has no recovery path. The previous tooltip ("handy if you renamed the job in the 'send to printer' dialog") promised something BambuStudio strips, and the previous reply to the reporter dismissed this as "OrcaSlicer-style upload, working as designed" which was wrong on BambuStudio 2.7.1.57. **Fix:** tooltip rewritten in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to spell out the BambuStudio behavior — both modes often produce the same string because BS overwrites the Send-dialog name with the 3MF Title field when present. **Tests**: 3 new in `test_archive_service.py::TestThreeMFMetadataHTMLUnescape` — `Title` with `&` unescapes to `&` (the reporter's exact case), `Title` with triple-encoded `&amp;amp;` peels all three layers (the BambuStudio worst-case ProjectPageParser already documents), plain `Title=Benchy` passes through unchanged (regression guard against accidentally munging non-encoded payloads). Full 104-test archive suite green; ruff clean; i18n parity holds (5065 leaves × 11 locales); frontend build clean. - **FTP passive-port pool now sliced per-VP (10 ports each) so bridge-mode Docker drops from ~3.5 GB to ~210 MB host RAM (#1646, reported by @TheFou — followed up with corrections we acted on)** — Reporter on a Linux Docker VM (`network_mode: host` not viable because other containers already bind the same ports) measured 2002 `docker-proxy` host processes spawned from the previously-exposed `50000-51000:50000-51000` range — one process per port per address family, ~3.5 MB RSS each, ~3.5 GB total that doesn't show up in `docker stats` because it's host-level not container-level. **Root cause: shared port pool, treated as symptom not cause.** `VirtualPrinterFTPServer` exposed `PASSIVE_PORT_MIN/MAX` as **class constants** (`backend/app/services/virtual_printer/ftp_server.py:573-574`), so every VP's FTP session passed the same `(50000, 51000)` range into `_bind_passive_port` and competed on the same 0.0.0.0 binds. The widening from 100 → 1001 ports in an earlier round had been collision-avoidance headroom for multi-VP-on-shared-bind, but the cost was paid by every install — including the reporter's single-VP install that only ever needed ~10 ports of headroom. **Fix: per-VP non-overlapping slices, allocated by VP id.** New module-level `compute_passive_port_slice(vp_id) → (port_min, port_max)` returns a 10-port window: VP id 1 → 50000-50009, VP id 2 → 50010-50019, …, VP id 100 → 50990-50999. Class constants are gone; `VirtualPrinterFTPServer.__init__` now takes `passive_port_min` / `passive_port_max` instance args. `manager.py` computes the slice at server-construction time from `self.id` and passes it in. Result for the reporter (single VP): 10 exposed ports → 20 docker-proxy processes → ~70 MB instead of ~3.5 GB. Three VPs → 30 ports → ~210 MB. **Wrap-around behaviour pinned**: VP ids beyond `PASSIVE_MAX_SLOTS = 100` wrap modulo 100 (an install that's churned through many VPs over time still produces a valid in-range slice). A same-slot collision (vp_id 101 lands on the same slice as vp_id 1) falls back to the per-session 10-attempt random retry that pre-#1646 code already had — same recovery, no regression. **Compose default narrowed**: `docker-compose.yml` now exposes `50000-50029:50000-50029` by default (covers 3 VPs out of the box) instead of the 1001-port range. The comment explains how to widen for more VPs (`50000-500N9` for `N = vp_count - 1`) and that proxy-mode VPs still need `50000-50100:50000-50100` because proxy mode forwards the real printer's full range — that codepath uses a separate `TCPProxy.FTP_DATA_PORT_MIN/MAX` and isn't sliced (the real printer owns that range, not Bambuddy). **Doc corrections in the same drop**: the previous warning over-stated `userland-proxy: false` as "confirmed by the reporter" — TheFou had flagged it as theoretical, not tested; the new comment doesn't push it as a recommendation at all (it's a global daemon flag, too blunt for a per-container problem). The new comment also explicitly names Linux multi-service hosts (NAS, dedicated Docker VMs, Unraid, Synology DSM) as a primary bridge-mode audience instead of leaving the warning under a "macOS/Windows" framing that TheFou pointed out missed his use case. Acknowledges that host-mode default is a deliberate trade-off for SSDP discovery, not a security-blind default. **Tests**: 10 new in `test_vp_ftp_port_slicing.py` — `compute_passive_port_slice` pins: vp_id=1 starts at base, consecutive vp_ids get adjacent non-overlapping slices, no two distinct vp_ids within MAX_SLOTS share a port (exhaustive across all 100 slots), wraps modulo MAX_SLOTS, top slot stays within the documented pool, non-positive vp_ids clamp to slot 0 (defensive — never produce a negative port that would crash `asyncio.start_server`). Two `VirtualPrinterFTPServer` instance tests pin: two instances constructed with different slices stay independent (regression guard against re-introducing class-level state), default-arg construction yields a valid one-slice window. Existing proxy-mode test at `test_virtual_printer.py:2269` (101 ports for `_ftp_data_proxies`) stays green — that path is unchanged. Full 130-test VP suite green. - **Print Log "User" column now shows the user for prints started from the Queue (#1670, reported by @JmanB52D)** — Reporter on a P2S with auth enabled, Virtual Printer in Queue mode and Auto-dispatch off: a user uploads a `.3mf` to the VP (FTP, anonymous), then logs into Bambuddy and clicks ▶ on the staged queue item to start it; the print finishes and the PrintLogEntry's User column is blank. Same setup with the VP in Archive (slicer-initiated) mode correctly attributes the user. **Root cause: two-link gap on the Queue→manual-start dispatch path.** (a) `POST /queue/{id}/start` (`print_queue.py:1039`) auth-protected, but the route's user dep was bound to `_` and discarded — the clicker was never recorded. (b) `PrintScheduler._start_print` (`print_scheduler.py:1886`) dispatches the queue item directly and never calls `printer_manager.set_current_print_user(...)`. The print-complete callback (`main.py:3513`) reads `_print_user_info = printer_manager.get_current_print_user(printer_id)` — which is only ever populated by `background_dispatch.py:747/943` (the Archive→Print and Library→Print flows). Queue dispatch had no equivalent hop, so `_print_user_info` was always `None` and the PrintLogEntry's `created_by_username` landed `NULL`. **Fix (two-sided):** (1) `print_queue.py /start` now binds the auth dep to `user: User | None` and writes `item.created_by_id = user.id` when `user is not None AND item.created_by_id is None` — credits the clicker on VP-uploaded (unattributed) items without overwriting existing attribution from UI-added queue items (matches the standard "first claim wins" ownership rule in `auth.py::require_ownership_permission`). (2) `print_scheduler.py` gains a small `_propagate_owner_to_printer_manager` helper, called from `_start_print` immediately after `register_expected_print`: when `item.created_by_id` resolves to a real User row, it forwards `(printer_id, owner.id, owner.username)` into `printer_manager.set_current_print_user`. No-ops cleanly when the item has no owner (auto-dispatched VP items intrinsically) or when the user row is missing (e.g. user deleted between queue-add and dispatch — the print log row falls back to un-credited rather than crashing the dispatch). **Tests:** 6 new in `test_queue_start_user_attribution.py` — three route tests pin (a) authenticated `/start` writes `created_by_id` on an unattributed item, (b) an existing owner is preserved when a different user clicks `/start`, (c) auth-disabled leaves `created_by_id=NULL` (no synthetic placeholder user invented); three helper tests pin (d) the propagation forwards the resolved username into `set_current_print_user`, (e) a `None` owner is silently skipped, (f) a missing User row is silently skipped instead of raising. Full 63-test `test_print_queue_api.py` suite stays green. Backend ruff clean. - **AMS drying popover's "Start Drying" button is no longer hidden behind iOS Safari's bottom URL bar on iPhone (#1669, reported via in-app bug report, iPhone 17 Safari)** — Reporter could see the temperature / duration sliders and the "Rotate spool during drying" checkbox but couldn't reach the orange Start Drying button at the bottom of the popover — only a thin sliver of it was visible just above Safari's URL bar. **Root cause:** the popover sizes its `maxHeight` against CSS `100vh` (`PrintersPage.tsx:5443`) and positions itself using `window.innerHeight` (via `computePopoverPosition`, `popoverPosition.ts:53`). On iOS Safari both of those report the **layout** viewport — the full screen ignoring the bottom URL/toolbar overlay — not the visual viewport. The popover therefore extends *behind* Safari's bottom toolbar and the footer button gets clipped. Earlier iterations of the same surface (#1447 popover-off-bottom, #1458 footer-scroll-reachability) fixed desktop / normal-viewport cases but assumed `100vh` matched the visible viewport. **Fix:** two-line change. (a) `frontend/src/pages/PrintersPage.tsx:5443` switches `maxHeight: calc(100vh - …)` → `calc(100dvh - …)` so the dynamic viewport units shrink with iOS toolbars. (b) `frontend/src/utils/popoverPosition.ts:53` defaults `viewportHeight` from `window.visualViewport?.height ?? window.innerHeight` so the flip-above decision also uses the actually-visible area; the existing optional override still wins (tests keep their explicit viewport values). Result: when the iOS toolbar is up, either the popover flips above the trigger earlier (visualViewport too short for below-placement), or the body scrolls within a capped maxHeight and the `shrink-0` footer stays pinned to the visible bottom — the Start Drying button is reachable in both cases. **Tests:** 3 new in `popoverPosition.test.ts::computePopoverPosition (#1669)` — flip-above triggers when visualViewport.height (700) is shorter than innerHeight (800) and the trigger position would only overflow under the visual viewport; falls back to innerHeight when visualViewport is unavailable (older WebViews / jsdom); an explicit `viewportHeight` override still wins over a configured visualViewport.height (test-injection contract). 8 pre-existing tests stay green. dvh / svh browser support — Safari 15.4+, Chrome 108+, Firefox 101+ — comfortably covers iPhone 17 Safari and every supported desktop browser; no behavioural change on non-iOS. - **Print queue `require_previous_success` no longer cascades indefinitely after a user-cancelled print (#1667, fully root-caused by @599w6c26tv-droid)** — Reporter on an A1 saw a single user-cancelled print block 18 downstream queue items over 3 days, all marked `skipped` with `Previous print failed or was aborted`. They captured the override log line proving Bambuddy correctly detects the cancellation (`Overriding status 'failed' -> 'cancelled' for printer 1 (print was stopped from queue by user)`) but the scheduler's gate ignored the override; they dumped the affected DB rows confirming the cascade pattern; and they reproduced from clean state in one cycle. Two distinct bugs in one function (`PrintScheduler._check_previous_success` in `services/print_scheduler.py`): **(a)** The lookback query `.in_(["completed", "failed", "skipped", "aborted"])` excluded `cancelled`, so a user cancellation was never found as the most-recent predecessor — the query walked past it to whatever real outcome existed before. **(b)** The same lookback INCLUDED `skipped`, so once one item got skipped (under any reason — bug-cascaded or genuinely failure-gated) it became the next item's "failed predecessor" and the cascade compounded. **Fix:** swap the lookback list to `["completed", "failed", "cancelled", "aborted"]` and broaden the success check to `prev_item.status in ("completed", "cancelled")`. A user cancellation is a deliberate action — treating it as neutral matches the user's intent ("I'm done with that one, move on"); `skipped` is excluded so the query always walks back to the most recent REAL print attempt and `failed` / `aborted` still gate as before. **Conservative recovery migration**: a one-shot pass in `core/database.py::run_migrations` resets only the skipped items whose immediate real predecessor (by `completed_at` desc, excluding the skipped-cascade itself) was `cancelled` — same fingerprint as the bug, narrow enough not to disturb skipped items whose true predecessor was a real `failed` / `aborted` print. Items match on `status='skipped' AND error_message='Previous print failed or was aborted'` and the predecessor check via correlated subquery; logged per-row at INFO so operators can audit the count after upgrade. Portable across SQLite and Postgres. Idempotent (post-reset rows no longer match). **Tests**: 10 new behaviour tests in `test_check_previous_success.py` pin every status/cascade combination — bug A (cancelled → True), bug B (skipped walked past), the reporter's exact failed→cancelled→skipped→skipped→pending cascade, regression guards on real failed / aborted still gating, edge cases (no-predecessor, only-skipped history, completed-then-failed). 7 new tests in `test_cancellation_cascade_recovery_migration.py` pin the migration — skipped-after-cancelled resets, skipped-after-failed stays, skipped-after-aborted stays, different-error-message untouched, reporter's multi-item cascade resets all, idempotent on re-run, per-printer isolation. All green; full scheduler + migration test suite stays green. - **Firmware-update check no longer 403s against Bambu Lab's Cloudflare-gated download page (#1666, reported by @arekm, with the working bypass demonstrated)** — Reporter on a fresh install hit `Could not reach Bambu Lab's firmware download page...` when checking firmware for an A1 Mini, and surfaced the diagnostic: `curl -H 'User-Agent: Bambuddy/1.0' https://bambulab.com/en/support/firmware-download/all` returns `HTTP 403 cf-mitigated=challenge` — Cloudflare upped the bot-protection on `bambulab.com` to a JA3 / TLS-fingerprint challenge. Plain Python TLS handshakes (httpx, requests, urllib) don't match Chrome's ClientHello bytes, so CF rejects before the request reaches the app layer. The `Accept` / `Accept-Language` header workaround we shipped for #1350 was below-HTTP and no longer enough. Existing users with a `build_id.json` on disk from a previous successful fetch kept working until Bambu rebuilt the page (every few weeks); fresh installs and wiped data dirs hit the wall immediately — exactly the reporter's path. **Fix: use `curl_cffi` for the two `bambulab.com` fetches only.** New dependency added to `requirements.txt`; `firmware_check.py` lazy-initialises a `curl_cffi.requests.AsyncSession(impersonate="chrome", ...)` for the `bambulab.com` calls (the index page that carries the Next.js `buildId`, and the per-model `_next/data/{buildId}/.../{api_key}.json` endpoint). Smoke-tested end-to-end against the live page: returns 200 OK + valid `buildId`, vs the reporter's 403. **Compliance framing matters here**: per the Bambu-compliance email from 2026-05-12, Bambuddy committed to "no falsified client identity." `curl_cffi`'s Chrome impersonation only governs TLS handshake bytes — the **HTTP-layer User-Agent is overridden back to `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)`** via the session's `headers=` parameter. Defensible read: TLS fingerprint matches Chrome (necessary because Python's TLS is the signal CF gates on), but every application-layer identity remains honestly Bambuddy. A new test (`test_bambulab_curl_cffi_session_keeps_honest_user_agent`) pins this — a future refactor that drops the `headers=` override would silently revert to curl_cffi's Chrome-default UA and break the compliance commitment; the test fails on any non-Bambuddy UA in the session. **Soft dependency**: if `curl_cffi` fails to import (rare platforms, alpine without wheels, etc.), the service logs a one-time warning at startup and falls back to httpx; wiki-based version detection continues to work for the badge, only the in-app firmware download URL stops resolving. New test `test_bambulab_get_falls_back_to_httpx_when_curl_cffi_missing` pins the fallback path. The wiki path (`wiki.bambulab.com`) and the CDN download path (`public-cdn.bblmw.com`) stay on httpx — neither sits behind the same JA3 gate. Three existing tests (`test_build_id_is_persisted_to_disk`, `test_build_id_falls_back_to_disk_on_403`, `test_download_page_unreachable_flag_set_on_403_json`, `test_download_page_retries_once_when_buildid_stale`) updated to mock `_bambulab_get` instead of the raw httpx client — a tighter mock target that's stable across the curl_cffi / httpx switch. ### Changed - **Slicer sidecar now ships as pre-built images on GHCR + Docker Hub — install works on QNAP / Synology / Container Station (#1657, reported by @d3nn3s08)** — Reporter on QNAP QTS 5.2.9 hit three install failures in sequence: the official `slicer-api/docker-compose.yml` used `build: { context: https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver }`, which requires `git` in the Docker BuildKit worker — Container Station and Synology DSM don't ship git there, so the build fails immediately with `exec: "git": executable file not found`. Manual ZIP-as-local-context workaround tripped a QNAP filesystem quirk in the systemd post-install (`Failed to copy permissions from /etc/group`). Fallback to `ghcr.io/afkfelix/orca-slicer-api:latest-orca2.3.0` ran but couldn't slice — that image lacks the `bambuddy/profile-resolver` patches (the `inherits:` chain resolver, the `from: "User"` → `"system"` rewrite, the `# ` clone-prefix strip, and the sentinel-value strip), so `/profiles/bundled` returned 400 and `/slice` returned `Invalid parameter value(s) included in the 3mf file`. **The fix removes the build-from-source requirement entirely.** Both sidecar images are now built locally on Martin's box and pushed to two registries (`ghcr.io/maziggy/orca-slicer-api`, `docker.io/maziggy/orca-slicer-api`, and the same two for `bambu-studio-api`) via a new `docker-publish-sidecars.sh` helper in the `orca-slicer-api` repo; the stable Bambuddy publish script auto-invokes it after each release, and the beta script too. Daily-beta opts in only via `--include-sidecars` (slicer rebuilds are expensive). The helper has hard safety guards: aborts unless the orca-slicer-api repo is on `bambuddy/profile-resolver` AND the working tree is clean, and never executes `git checkout` / `pull` / `fetch` / `reset` itself. `slicer-api/docker-compose.yml` switches from `build:` to `image: ghcr.io/maziggy/orca-slicer-api:${SIDECAR_TAG:-latest}`. New `SIDECAR_TAG` env var in `.env.example` defaults to `latest`; set `SIDECAR_TAG=bambuddy-X.Y.Z` to pin to the sidecar image that shipped with a specific Bambuddy release. **Scope limitation**: both images are `linux/amd64` only. The OrcaSlicer multi-arch path stays on hold pending an upstream extraction fix — the kldzj/orca-slicer-arm64 AppImage's `--appimage-extract` silently fails under QEMU build emulation; the Dockerfile's `;`-chained RUN block masked the failure until the final `COPY squashfs-root` tripped. ARM64 hosts (Pi 4/5, Apple Silicon Linux) should run the sidecar on a separate x86_64 box and point Bambuddy at it via the **Sidecar URL** field — the sidecar doesn't need to live next to Bambuddy. **Docs aligned**: `slicer-api/README.md` and `wiki/features/slicer-api.md` rewrote the Quick start, Updating, and Sidecar source sections — `docker compose up -d` now pulls instead of building, and `docker compose pull && docker compose up -d` is the new update path (no `--no-cache --pull` dance because Compose only ever sees `image:` references). The build-from-source path stays documented as an advanced option under "Building from source (advanced)" for forks / dev work. ### Changed - **VP access code is now auto-derived from the target printer in non-proxy modes (Discord report)** — A user on Discord set up a Queue-mode VP with a different access code than the real target printer and couldn't get the slicer to connect, even after the cert-trust path was sorted. Root cause: the live target-printer mirror that landed earlier in the 0.2.5 cycle forwards the slicer's MQTT/RTSPS auth bytes through to the real printer — the slicer holds **one** code in its profile (the one it bound the VP with), and that code has to pass two checks (VP listener, then real printer). If the codes diverge the bridge silently fails at the second hop and the slicer abandons the connection (e.g. opens 8883, FINs before sending a ClientHello). The wiki *did* document a code-match requirement but framed it as a camera-only concern (`MQTT and FTP work either way; only the camera path needs the match`) — wrong, all bridged protocols inherit. **The fix removes the foot-gun rather than re-document it.** When a target printer is selected on a non-proxy VP (Archive / Review / Queue), the access-code field in the VP card switches to a read-only display showing the target's code with an Eye-toggle reveal, and the backend auto-inherits the value on every `create` / `update` (any explicit `access_code` submitted alongside a target is silently overridden — belt-and-braces for non-UI clients). When no target is set, the field stays editable as before. The same `inheritsAccessCodeFromTarget` predicate gates a small "Inherited from target" badge in place of the existing `isSet` / `notSet` status pill. Changing the target after the slicer has already bound triggers an info toast ("Access code now matches the new target — re-add this device in your slicer") because the slicer's stored code is now stale. **One-shot startup migration** in `core/database.py` corrects any pre-existing mismatched VPs on first boot after the upgrade: SELECTs the diverged rows for an INFO log per VP (`VP 'Workshop Queue' (id=3) access code synced from target printer 'X1C #2'` — audit trail for anyone digging through logs), then UPDATEs via correlated subquery (idempotent — the WHERE clause excludes already-synced rows, so re-running is a no-op; portable across SQLite and Postgres). No user-facing banner because there's no action for the user to take — the fix is done, and a previously-stuck bridge now works. **Wiki**: `features/virtual-printer.md` line 1189 flipped from the wrong MQTT/FTP-work-either-way claim to "the bridge forwards slicer auth bytes through; Bambuddy auto-derives so the codes can't diverge", the line-84 tip's "for camera" framing replaced with the broader rule, and the port-table row for RTSP `:322` annotated with "transparent passthrough to the real printer's `:322`, same end-to-end TLS as proxy mode" so the dedicated-bind-IP-vs-passthrough-to-printer apparent contradiction reads as one consistent model. **i18n**: 5 new keys (`accessCode.inheritedFromTarget`, `accessCode.derivedFromTargetHint`, `accessCode.reveal`, `accessCode.hide`, `toast.targetCodeChangedRebind`) translated in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), no English fallbacks per the project's hard rule. ### Fixed - **Background asyncio tasks no longer get garbage-collected mid-flight (#1648 follow-up)** — Support-bundle review under #1648 surfaced 94 `Task was destroyed but it is pending!` warnings in 8 days of v0.2.4.5. **Root cause:** asyncio holds only a weak reference to the result of `create_task` — any "fire and forget" call site that doesn't store the returned task lets the event loop GC the task before it finishes. The warning gives no traceback, so the originating exception (if any) vanishes silently into a support bundle that looks scary but isn't actionable. **Fix:** new `backend/app/core/tasks.py::spawn_background_task(coro, *, name=None)` helper that stores a strong reference in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts a `name=` argument so a leak source is traceable in `/tracebacks` and the log line. **Migration:** the 16 truly-orphan `asyncio.create_task(...)` call sites — across `main.py` (8), `printers.py`, `print_queue.py`, `firmware_update.py`, `archive.py`, `print_scheduler.py`, `library.py`, `smart_plugs.py`, `discovery.py`, `smart_plug_manager.py` (3), and `background_dispatch.py` (2 lambda-wrapped) — switched to `spawn_background_task`. Other `create_task` sites already kept strong refs via `self._tasks.append(...)`, `self._x_task = ...`, or local `await`/`gather` and stay unchanged. **Tests:** 5 unit cases in `test_tasks.py` pin the contract — strong-ref retention through completion, set-shrinkage after done, uncaught exception logged at WARNING with `exc_info`, cancellation does not log (a shutting-down service is not an error), and named tasks propagate `name=`. Net result: support bundles stop showing the opaque GC warnings, and any silent fire-and-forget exception now reaches the logger with a traceback attached. Severity reclassification of unrelated noise (the 791 "Failed to get cloud preset 400" spam, the `bambu_cloud.Login failed` mis-ERRORs, etc.) is a separate follow-up. - **Home-page filament assign no longer leaves the slicer unaware of PFCN cloud presets (#1648, reported by @ferch-G)** — Reporter on an H2D with a Polymaker spool noticed that assigning the spool from the Dashboard left the slicer's filament dropdown showing "unknown" / generic, but clicking Configure right after made the slicer recognize it correctly — "Configure" felt like a mandatory follow-up step rather than a refinement. **Root cause: PFCN-prefix cloud preset IDs were never handled.** Bambu's cloud uses three preset-ID shapes: `GFS…` (official Bambu), `PFUS…` (cloud user-created), and `PFCN…` (cloud shared / partner-uploaded — e.g. Polymaker's "(Custom)" Bambu Lab H2D variants like the reporter's `PFCN80e80c1f79db85`). `apply_spool_to_slot_via_mqtt` only routed `GFS` and `PFUS` through the cloud-detail lookup that extracts the real `filament_id`. PFCN slipped past the cloud-lookup branch, fell into the local-preset `int()` parse path, raised ValueError, dropped into `normalize_slicer_filament` which returns any `P`-prefix unchanged, and the raw PFCN landed in `tray_info_idx` — which the printer's calibration table can't index, so the slicer rendered "unknown". The Configure modal rescued each assign because it does its own `getCloudSettingDetail` lookup and writes the resolved `filament_id`. **Fix:** extend the cloud-detail-lookup branch (`inventory.py:129`) and the discard safety net (`inventory.py:223`) to include `PFCN` alongside `GFS`/`PFUS`. After the fix, the same three paths work: cloud-authenticated → real `filament_id` from `detail["filament_id"]` ships as `tray_info_idx` (Polymaker PLA Matte resolves to `GFL05`); cloud unavailable → raw PFCN discarded, slot reuses an existing valid P-prefix preset if material matches; nothing else available → falls through to the spool's generic material id (`PLA → GFL99`). Source comment now lists all three cloud-ID shapes so the next time Bambu invents a new prefix (PFXX, PFYY, …) the maintainer doesn't have to re-derive the structure from a bug report. **Tests:** 3 new integration cases in `test_inventory_assign.py::TestAssignSpoolPfcnCloudPreset` — falls back to generic when cloud unavailable (and pins the no-PFCN-leak invariant), reuses an existing slot's valid P-prefix preset when material matches, and the happy-path cloud lookup that produces a resolved `filament_id` while preserving the original PFCN as `setting_id`. Existing 28 assign-flow tests stay green. - **Bambu cloud A1 Mini filament / process profiles no longer hidden in AMS slot picker (#1649, root-caused by @technopaw)** — Reporter on an A1 Mini observed that the AMS slot Configure dropdown showed no Bambu / Generic filament profiles; only user-authored profiles surfaced. Mirror in the Profiles tab: filtering by "A1 Mini" left only A1 (non-mini) results. **Root cause: Bambu rolled out a profile rename mid-2026.** The `@BBL ` suffix on cloud profiles shifted from the long display form to a terse model code — `Bambu PLA Basic @BBL A1 Mini ...` is now `Bambu PLA Basic @BBL A1M ...` across 106 cloud profiles. User-authored profiles still use the long form (which is why the reporter's custom A1 Mini profile worked, and Bambu PLA Basic happened to render via the `localPreset` always-shown path). Bambuddy's filter compared the extracted token verbatim against the display name (`"A1M".toUpperCase() === "A1 MINI"` is false), so the rename silently stripped every newly-renamed profile from the picker. **Fix: centralized alias-aware match in `frontend/src/utils/slicerPrinterMatch.ts`.** New `PRINTER_MODEL_SUFFIX_ALIASES` table holds the bidirectional `A1 Mini` ⇄ `A1M` mapping (uppercase-normalised, narrow on purpose — wide-net aliasing like `X1` ⇄ `X1C` would silently group truly distinct printers); exported `matchesPrinterModelSuffix(presetSuffix, printerModel)` helper does the case-insensitive compare with alias fallback. Both consumer sites switched to the helper: `ConfigureAmsSlotModal.tsx:586,607` (the AMS slot picker, hit directly + reached from SpoolBuddy's AMS page via `mapModelCode(printer?.model)`), and `slicerPrinterMatch.ts:classifyByBambuName` (the SliceModal Process / Filament compatibility check). Backend `PRINTER_MODEL_MAP` also gains a `Bambu Lab A1M` → `A1 Mini` entry so server-side 3MF printer-model normalization stays consistent if a future 3MF embeds the short form. The structure stays open: when Bambu introduces the next rename, it's a single new row in the alias table — `/api/v1/cloud/settings` is the place to grep, called out in the source comment. **Tests**: 7 new unit cases in `slicerPrinterMatch.test.ts` pin the alias helper (canonical, case-insensitive both directions, A1M ↔ A1 Mini in both orientations, A1M does NOT collapse to A1, A1 does NOT collapse to A1 Mini, unrelated models reject) plus 3 integration cases in `presetCompatibility` (cloud filament `@BBL A1M` matches A1 Mini, cloud process `@BBL A1M` matches A1 Mini, `@BBL A1M` does NOT match A1). 2 new component-level cases in `ConfigureAmsSlotModal.test.tsx`: `@BBL A1M` cloud preset surfaces when picker is for A1 Mini (with `@BBL A1` correctly filtered out), and `@BBL X1C` stays filtered out when picker is for A1 Mini (sanity check against accidental widening). All existing 2062 vitest cases stay green. - **VP Queue / Archive / Review: Bambu Studio 2.7.x stayed stuck at "Downloading" after Send (#1658, reported by @IndividualGhost1905)** — Reporter on Bambu Studio 2.7.1.57 + X1C reported that sending a model to a Queue-mode VP (with Auto-Dispatch off) left the slicer's send modal stuck at "Downloading" forever; clicking Delete on the queued item didn't release it, and even Auto-Dispatch ON + a successful real print didn't release it. Only toggling the VP off/on cleared the slicer. The deleted-from-queue framing is a red herring — the slicer was stuck *before* deletion, the user just noticed it most when they deleted. **Root cause: the #1280 fix assumed the wrong event order.** The original assumption was MQTT `project_file` → FTP upload → set `gcode_state=FINISH`, and the slicer's "Downloading" UI releases on FINISH. Bambu Studio 2.7.x flipped the Send sequence to FTP `verify_job` → FTP `.3mf` → MQTT `project_file`, so on_file_received's `set_gcode_state("FINISH", …)` fires *first*, then the synthetic `_send_print_response` ack runs and overwrites `_gcode_state` back to `"PREPARE"`. From that point the 1 Hz cached-as-base push stream carries PREPARE forever, the slicer waits for the FINISH transition it'll never see, and the modal sits stuck. **Auto-Dispatch ON is the same bug**: the real printer's gcode_state goes PREPARE → RUNNING → FINISH on its bridge, but `_send_status_report` overrides the cached push's `gcode_state` with the local `_gcode_state` (still PREPARE), so the real state changes never reach the slicer. The fix re-fires `set_gcode_state("FINISH", filename, prepare_percent="100")` from `on_print_command` 1.5 s after the synthetic ack, for every non-proxy mode (queue / archive / review). The 1.5 s window is long enough for the slicer's modal to see at least one PREPARE push on the 1 Hz cycle (so the transition reads as PREPARE → FINISH, matching what the slicer expects) and short enough that the modal feels responsive. Proxy mode is exempt — there the real printer drives the bridge state and a synthetic FINISH would clobber a real PREPARE/RUNNING transition. The scheduler cancels any in-flight timer when a new project_file lands so a slicer that retries doesn't end with two competing FINISH timers. **Tests**: 6 new cases in `test_virtual_printer.py` — schedules on archive (and by extension queue/review), proxy mode does NOT schedule, no-MQTT skip is silent, second `project_file` cancels the first timer, delayed run sets the expected `(state, filename, prepare_percent)` triple, empty filename does not schedule. - **Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK)** — Bambu's end-gcode lowers the build plate as soon as the print completes. Bambuddy's existing finish-photo path captured a fresh camera frame at `gcode_state=FINISH`, by which time the bed was already at the bottom of the chamber — the photo showed the top of the print well below the camera's natural framing, badly framed and sometimes invisible. Earlier capture attempts (at `layer_num >= total_layer_num` while still RUNNING) hit motion-blur because the toolhead was still parking; capturing through the window kept the wrong frame because the latest was always ~2s before FINISH, mid-bed-drop. **The fix sources the photo from a brief Bambu timelapse Bambuddy records on every dispatched print instead.** Firmware stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly — verified on N=2 H2C prints by extracting the last frame of two real timelapses (`spoolbuddy_v2.1` and `case_SpoolBuddy`); both showed the print clearly with the toolhead parked off-frame upper-left and the bed at print height, no motion blur. The post-park-pre-drop window is at least ~2 seconds wide on both, so `-sseof -1.0` (seek to last second, skip the literal last frame) is safe against any encoder tail artifact. **Implementation: force-on at dispatch + cleanup after extraction.** `BackgroundDispatchService._resolve_effective_timelapse(db, archive, job)` reads the `capture_finish_photo` setting before each `start_print` call (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overrides `timelapse=True` on the MQTT command + marks the new `PrintArchive.bambuddy_forced_timelapse` column True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched on `is_sqlite()` for the boolean default (`DEFAULT 0` on SQLite, `DEFAULT FALSE` on Postgres — PG rejects `DEFAULT 0` for BOOLEAN). New module-level `extract_video_last_frame(video_path, output_path)` in `services/camera.py` runs a single `ffmpeg -sseof -1.0 -i