# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.4.9] - 2026-07-07 ### Added - **Spool labels: scannable QR on 203 dpi thermal printers + monochrome mode (#1870)** — The 40 × 30 mm box label rendered its QR too densely for low-res thermal printers, so the modules bled together and wouldn't scan. The roomy layout now gives the QR a 12 mm minimum size and label QRs use `ERROR_CORRECT_L` (chunkier modules, same payload), so every template stays scannable. Also adds a **Monochrome (black & white printer)** option that drops the colour swatch and widens the text column, with the colour still carried by the hex-code line. Threaded through the renderer, route, API client, and modal, translated in all 11 locales. - **Preheat & heat-soak before queued prints — per-filament chamber targets + airduct flap control (#1468)** — A new scheduler stage heats the bed (and the chamber, on capable printers) and holds temperature before each queued print starts, giving engineering filaments the heat-soak they need for adhesion and warp control (M191 is firmware-ignored, so this only works at the orchestration layer). Per-item Inherit/On/Off override, per-filament chamber targets (max across loaded slots), three hardware tiers (active heater / sensor-only / bed-only), and automatic airduct-flap switching (heating vs cooling). Default off — existing installs are unchanged. - **API keys: `can_manage_maintenance` scope for HA-style automations (#1832 follow-up)** — Carves the MAINTENANCE create/update/delete permissions out of the API-key admin denylist so a Home Assistant automation can log "cleaned nozzle" or reset a counter via an API key without granting broader printer control. New per-key scope + Settings toggle + badge (11-locale i18n); existing keys migrate to off, so no upgrade silently widens scope. - **Indonesian Rupiah (IDR) currency support (#1869)** — Adds IDR (Rp) to the supported currencies under Settings → Cost Tracking. ### Fixed - **Filament Track Switch (FTS) on H2C fed the wrong filament for prints on the "other" nozzle (#2186)** — The backend dispatch mapping hard-filtered candidate trays to the requested extruder, so a print targeting one nozzle couldn't use the correct spool loaded in the other nozzle's AMS (which the FTS routes across) and fell through to a same-type wrong-colour spool. The mapping now reads `fila_switch.installed` and skips the per-nozzle filter when an FTS is present, so the right spool is matched by colour and the FTS routes it to the target nozzle. Single-nozzle printers are unaffected (no `nozzle_id`, no FTS). - **Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865)** — The scheduler read the setting with a `True` default while the schema and the whole frontend default it `False`, so installs that never saved it enforced a plate-clear gate the UI showed as off — a finished printer never dispatched the next job and there was no UI control to clear `awaiting_plate_clear`. The scheduler now defaults the setting to `False`, matching the schema and the toggle. - **Light theme: low-contrast washed-out text on status banners and coloured badges, app-wide (#1909)** — The app was built dark-first, so hundreds of hardcoded light-shade Tailwind text/icon utilities had no `dark:` variant and applied in light theme too — washed-out text on pale tints and white cards. Each now has a theme-aware pair (a readable darker shade in light theme, the original pinned to `dark:`), so dark theme is unchanged. ~100 files; the self-correcting `bambu-*` palette and the dark-only SpoolBuddy kiosk were left untouched. - **Sponsor toast ignored its 14-day cooldown and re-fired on every fresh browser session (#2477)** — The cooldown anchor was only persisted when the user clicked the toast's "View supporters" CTA, so a toast that was seen but not clicked recorded no state and re-showed on every new session. The toast is now recorded as shown the moment it renders, so being displayed arms the cooldown; clicking the CTA stays optional. - **Windows: fresh install failed to start — nothing listening on :8000 (#2474)** — On a clean Windows 10 box, greenlet failed to load (`vcruntime140_1.dll` missing — the embeddable Python ships only `vcruntime140.dll`), so `init_db()` crashed and uvicorn never bound the port while the NSSM service still showed running. The installer now stages `vcruntime140_1.dll` and `msvcp140.dll` next to `python.exe` at build time. - **Virtual Printer "bind interface" dropdown was empty on macOS** — Interface enumeration only routed Windows through psutil; macOS fell into the Linux-only ioctl branch (`SIOCGIFADDR`/`SIOCGIFNETMASK`) and returned an empty list. All non-Linux platforms now use the cross-platform psutil path. - **macOS native install failed with Homebrew / venv permission errors** — The installer mixed root-only steps with steps that must not run as root (brew refuses to run as root; a root-owned venv can't be managed by the launchd agent). The macOS path is now fully rootless — refuses `sudo`, defaults to `~/bambuddy`, and drops `sudo` from the download/venv/frontend/env steps. Linux (service user + systemd) is unchanged. - **External camera "connection lost" when the snapshot URL served a non-JPEG image (#1902)** — HTTP-snapshot cameras serving PNG/WebP/BMP tore down the MJPEG stream because every part is labelled `image/jpeg`. Non-JPEG stills are now transcoded to JPEG via OpenCV; genuine JPEGs keep a byte-for-byte fast path, and undecodable responses fall back with a single warning instead of a per-frame log flood. - **Per-user Notifications page unreachable from the sidebar (#1901)** — A sidebar-ordering refactor (#1673) dropped the `notifications` nav entry and its permission mapping while keeping the visibility gate that references it, so the page was reachable only by typing the URL. Both are restored, with comments so it isn't dropped again. - **Virtual Printer FTP uploads silently truncated under uvloop (#1896)** — Native installs auto-selected uvloop, whose SSL layer can drop buffered data when the client closes without a TLS close_notify, so a corrupt `.gcode.3mf` could be acked `226`, archived, and pushed to the printer. Fixed on two layers: pin `--loop asyncio` on every native launch path, and validate that a received `.3mf` opens as a ZIP before replying `226` (truncated files answered `426`, never archived or forwarded). - **API keys could not manage Projects (#1893)** — Every project mutation returned a generic `403` for any API key. A new `can_manage_projects` per-key scope (Settings toggle + badge, 11-locale i18n) covers create/update/delete; existing keys migrate to off. Same regression class as archives (#1888) and library (#1832). - **Auto-drying stopped a manually started AMS dry after exactly 30 minutes (#1892)** — The already-drying branch applied an unreliable humidity-based early-stop (RH reads ~15–20 % within minutes of heated air even with wet filament), pinned to the 30-minute floor, which also truncated Bambuddy's own preset-duration dries. The humidity early-stop is removed — a running dry now runs to its configured duration (firmware stops it); scheduling stops are unchanged. - **WebSocket auth failure caused an endless token-mint reconnect loop** — When the ws-token mint failed (typically a logged-in user whose group lacks `WEBSOCKET_CONNECT` → `403`), the hook opened a tokenless socket, got closed `4401`, and reconnected every 3 s — hammering `/auth/ws-token`. Mint failures are now classified: `401`/`403` stop the hook (degrade to REST polling), network/`5xx` still reconnect, and an unmount-race reconnect is guarded. Adds a group-editor hint explaining the permission rather than auto-granting it. - **A transient load-time error could discard a valid stored login token (#1889)** — On mount, any failure validating the persisted "Remember Me" token cleared it, so a brief backend-not-ready hiccup during page load (e.g. right after a container restart) bounced the user to login with no way to recover. Validation now retries transient failures (up to 3 attempts) and only discards on a definitive `401`. - **Smart plug cut power when a print restarted, ignoring per-plug cooldown (#1890)** — The queue "auto off after this job" trigger used a second inline implementation that hardcoded a 50 °C / 600 s cooldown and powered off on timeout regardless of print state, cutting power mid-print on a touchscreen reprint, and the tasks were uncancellable. Consolidated into the plug's configured, cancellable strategy, guarded by `is_print_active()` so no path powers off during a loaded print. - **API keys could not delete or edit archives (#1888)** — `DELETE /archives/{id}` rejected every API key with a generic admin-denied `403`. A new `can_manage_archives` per-key scope moves the create/update/delete permissions off the denylist (PURGE stays admin-only); existing keys migrate to off. Settings toggle + badge, dialect-agnostic migration verified on SQLite and Postgres 17. - **PVA-for-support intent lost when re-slicing a source 3MF (#1881)** — Three bugs on the PLA-model + PVA-support flow: a support-only slot was treated as "unused" and overwritten with slot 1's PLA, support filaments were stripped from unsliced archive cards, and the picked process preset shipped `enable_support=0`. Support slots are now read from project settings and unioned into the unused-slot set, all configured filaments surface, and the source 3MF's support settings are overlaid onto the process preset before `--load-settings`. - **Bambu Studio couldn't see or reconnect to the VP after a Mac sleep/wake (#1872)** — A drain timeout on the report topic was caught as a non-`OSError`, so the push loop never evicted the zombie writer until the kernel's ~2 h keepalive. On drain timeout the writer is now closed and re-raised as `BrokenPipeError` (evicted on the same tick), and TCP keepalive is tightened (`KEEPIDLE`/`KEEPINTVL`/`KEEPCNT`) to ~2 min dead-peer detection. - **Non-proxy VP camera passthrough was dead for A1 / P1 targets (#1868)** — The passthrough hardcoded port 322 (RTSPS), but A1 / A1 Mini / P1P / P1S use Bambu's chamber-image protocol on port 6000, so those targets got a 322 listener with no upstream (OrcaSlicer Liveview `[2:-10061]`). The port is now chosen from the target printer's model via the same source of truth as the camera route. - **Editing a queue item assigned to "Any of model X" showed a blank printer selector** — The three model-mode props were gated behind `!isEditing`, which hid the mode toggle, model dropdown, and location filter (and the printer list was gated on printer-mode), leaving the selector empty. The gate is dropped so the target model/location can be changed without delete + re-queue. - **Finish photo captured the wrong (swapped) plate on A1 / A1 Mini (#1867)** — A1 Mini firmware skips the stage-22 pre-capture, so the fallback fired at `FINISH` — after Bambu Studio ran the user's End G-code (e.g. a SwapMod plate swap). A `layer_num >= total_layer_num` edge trigger now fires the pre-capture the moment the last layer completes, on every variant, guarded by the existing one-shot. - **Spoolman didn't split mid-print usage across an AMS backup switch (#1793)** — A same-material runout switch mid-print charged the whole slot to the origin spool and double-credited the backup via the remain-delta path. The segment math is now shared by both inventory backends so mid-print switches attribute identically, and the remain-delta fallback skips trays the split path already covered. - **P1S / P1P showed a permanent "Door Closed" badge (#1866)** — P1S has an enclosure door but no hall sensor for it, and P1P has no enclosure at all, yet both rendered the green badge from a status bit that stays 0. The door-badge whitelist now covers only models that actually ship a door sensor (X1 family, X2D, P2S, H2 family). - **Custom Bambu Cloud filament presets showed as "Generic" (#1815)** — The singular slicer-setting GET/DELETE calls omitted the `?version=` param Bambu Cloud requires and returned HTTP 400, so the resolver swallowed it and fell back to a generic `tray_info_idx` (BambuStudio's AMS panel then showed "Generic PLA"). The param is now sent on those calls, restoring custom cloud-preset lookup across the delete/update and preset-resolver surfaces. - **Cancel during queue dispatch didn't cancel — the print started anyway (#1853)** — A check-then-act race in `_start_print` (plus a WAL writer lock held through the FTP upload) let the scheduler's stale in-memory write overwrite a user's cancel, and caused `database is locked` contention. Fixed with an atomic pending→printing CAS, an early re-check-and-bail, and committing before the FTP block so cancels and the sensor recorder stop queueing behind the scheduler. - **"Inject auto-print G-code" checkbox couldn't be ticked on single prints (#1852)** — A `useEffect` reset the checkbox whenever quantity ≤ 1 in create mode, even though it renders whenever G-code snippets are configured — so a single-print user's click was immediately reverted. The quantity clause was dropped; the scheduler already reads `gcode_injection` per item regardless of batch size. - **HMS wrong-plate "Ignore" didn't ignore, and the action buttons read as inert badges (#1869)** — Three compounding bugs on a wrong-plate HMS: `IGNORE_RESUME` sent a plain `resume` (so detection re-fired 1–2 s later) instead of BambuStudio's decimal-`err` ignore command; the button hover class was a non-literal template Tailwind's JIT couldn't compile; and ack-detection false-`502`'d on the transient re-pause. Fixed — the correct ignore command shape, static button styling with disabled/spinner states, and ack-detection via the last-message timestamp. - **Slicer auto-pick could silently land an incompatible filament, and hid the real CLI error (#1851)** — The actual "not compatible with printer" diagnostic was discarded in favour of Bambu Studio's catch-all "input preset file is invalid" placeholder, and the picker used a soft mismatch penalty rather than a hard skip, so one bad pick propagated across every unused slot. The real `[error]` line is now surfaced, and incompatible presets are hard-skipped whenever a compatible one exists. - **Uncataloged HMS faults that carry firmware actions were hidden (#1840)** — Fault visibility gated on catalog membership, so an actionable H2C fault missing from the bundled 853-entry catalog never rendered — no pip, count, panel, or action buttons. The gate now keeps `cataloged OR has-actions` faults (still filtering junk echoes), with an "unknown HMS code" fallback label in all 11 locales. - **First-layer notification photo showed pre-print calibration, not the actual print (#1837)** — Bambu printers tick `layer_num` during calibration (homing, bed levelling, purge), so a bare `2 ≤ layer_num ≤ 5` gate fired the notification minutes early with a photo of an empty plate. It now waits for `gcode_state == RUNNING` (and the printing sub-stage) before firing, and widens the trigger window so a deferred edge still lands. - **Administrators didn't gain new permissions on upgrade + Pipelines runs-dashboard polish** — Upgraded Administrator groups only received permissions listed in one-off backfill blocks, so any newly-added permission (most recently `printer_sensor_history:read`, which 403'd the Sensor History charts) silently stayed missing. `seed_default_groups()` now syncs Administrators to every current permission on startup (additive only; custom permissions preserved). Also replaces the Pipeline/Status/Target native `` pair as a side benefit (lets `getByLabelText` in tests reach the control, plus a small a11y improvement). **What this also fixes invisibly:** German / Japanese / Turkish users who classified rows under one UI language and then switched languages would have seen their historical buckets fragment in the Failure Analysis widget (each translation = its own group). With keys as the storage format, language switch no longer reclassifies anything. **Tests:** 5 new vitest cases — StatsPage `translates camelCase failure-reason keys` and `renders legacy translated-text failure reasons unchanged`; EditArchiveModal `preselects the option when the stored value is already a camelCase key`, `reverse-looks-up a legacy translated value back to its key`, and `sends the camelCase key on save, not the translated label`; PrintLogModal `translates camelCase failure_reason keys`. The existing `shows failure_reason under failed runs` case (which checks legacy text path) keeps passing under the defaultValue fallback. Full vitest 58 / 58 across touched files. ESLint clean; frontend build clean (vite 9.61s); i18n parity 5118 leaves × 11 locales green (no new keys — reuses `editArchive.failureReasons.*`). - **System page boot time was rendered with a doubled timezone offset (#1690 follow-up, reported by @IndividualGhost1905)** — After the original #1690 fix landed in 0.2.4.6, the reporter on UTC+3 (Turkey) confirmed uptime was correct but boot time displayed +3 hours ahead of reality. **Root cause:** `backend/app/api/routes/system.py` built `boot_time` as a NAIVE LOCAL datetime via `datetime.fromtimestamp(psutil.Process(1).create_time())` and serialised it with `.isoformat()`, which emits no timezone marker (e.g. `"2026-06-09T11:22:05"`). The frontend's `parseUTCDate()` helper at `frontend/src/utils/date.ts:206` is documented to append `'Z'` when no tz marker is present, treating the string as UTC, then `toLocaleString` converts UTC → local — applying the local offset on top of an already-local timestamp. Uptime was unaffected because it's computed entirely backend-side as `datetime.now() - boot_time`, two naive-local values whose delta is correct regardless of the missing tz info. **Fix:** make both boot_time and the uptime anchor tz-aware UTC — `datetime.fromtimestamp(ts, tz=timezone.utc)` on the main path and the `psutil.boot_time()` fallback, and `datetime.now(timezone.utc)` in the uptime subtraction. `isoformat()` then emits `"+00:00"` and the frontend's parseUTCDate uses the marker as-is. Same naive-datetime pattern surfaced in two adjacent `generated_at` fields — the storage-usage cache snapshot in `system.py` and the support bundle root in `support.py`. Neither is rendered as a wall-clock timestamp in the frontend today, but both now emit tz-aware UTC for consistency so any future surface that does render them won't recreate this bug. **Tests:** new `test_boot_time_isoformat_carries_utc_marker` regression case asserts the boot_time string ends in `+00:00` (or `Z`) — without that marker the frontend double-converts, which is exactly the reporter's symptom. Existing `test_boot_time_uses_pid1_create_time` and `test_boot_time_falls_back_to_psutil_boot_time_on_pid1_failure` still pass under the tz-aware values because `1700345600` is `2023-11-18T20:53:20+00:00` UTC, so the date-prefix assertion is unaffected. Full system API suite 21/21 green; support API 72/72 green; ruff clean. - **A1 / A1 Mini internal-code map was swapped in `PRINTER_MODEL_ID_MAP` (surfaced while scoping A2L support, #1684)** — `backend/app/utils/printer_models.py` mapped `N1 → "A1"` and `N2S → "A1 Mini"`, but every other registry that names these codes — `firmware_check.py` (`N2S → "a1"`), `virtual_printer/manager.py` (both the model map and the serial-prefix map: `N2S → "03900A"` is the A1's `039` prefix, `N1 → "03000A"` is the A1 Mini's `030`), `printer_manager.py` `A1_MODELS` — consistently uses the opposite (correct) direction. Any path that resolved an A1-family printer by internal code rather than serial prefix would silently misclassify. **Fix:** swap `PRINTER_MODEL_ID_MAP` to `N1 → "A1 Mini"`, `N2S → "A1"`; the matching comment in `LINEAR_RAIL_MODELS` was also wrong and got the same swap (the frozenset's contents don't change — both codes were already in it — so this is cosmetic, but kept the file self-consistent). New regression test class `TestA1SeriesModelIds` pins both directions so a future re-flip fails loudly. Functional impact in practice is small (most A1 detection runs off the serial prefix), but the inconsistency was a footgun for any future caller that trusted `normalize_printer_model_id`. Backend printer-model suite 46 / 46 green; ruff clean. - **Print Queue filament-override panel showed raw 3MF base material instead of Bambu Studio's sub-brand colour name (#1718, reported by @SamNuttall)** — The Print Queue's filament-override panel rendered every "Original" row as `{type} ({colorName})` — just the raw 3MF `` attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name from `getColorName(hex)`. A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (`tray_info_idx`, e.g. `GFA01`) on each `` element — `backend/app/api/routes/archives.py:3634/3665` already returns it in the `/archives/{id}/filament-requirements` response — but `FilamentReqsData` at `frontend/src/components/PrintModal/types.ts:178` didn't carry the field, so `FilamentOverride.tsx` couldn't see it. The resolution path was also already in place: `_BUILTIN_FILAMENT_NAMES` at `backend/app/api/routes/cloud.py:568` maps Bambu factory SKUs (`GFA01` → "Bambu PLA Matte"), exposed as `/cloud/builtin-filaments`; `/cloud/filament-id-map` returns the same shape for user custom presets (`P*` prefix). `KProfilesView.tsx:791` already merges those two for its own labels. **Fix:** add `tray_info_idx?: string` to the `FilamentReqsData.filaments` type. `FilamentOverride` now loads both maps via `useQuery(['builtin-filaments'])` + `useQuery(['filament-id-map'])` (both shared caches the rest of the app already populates, `staleTime: 5 min`) and merges them into a single `idx → name` lookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the raw `req.type` stays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color from `getColorName(hex)`. "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. **Color disambiguation (round 2):** the sub-brand half above is necessary but not sufficient — `getColorName(hex)` resolved through `/api/inventory/colors/map`, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex `#000000` has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpoint `GET /api/inventory/colors/by-material?hex=X&material=Y` (`backend/app/api/routes/inventory.py:get_color_by_material`) preserves the material context: same case-insensitive hex match as `/colors/map`, then a `material` filter on top. When no entry matches the requested material it falls back to the same priority order as `/colors/map`, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage). `FilamentOverride.tsx` derives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches one `useQuery` per slot via `useQueries` keyed on `(hex, material)`, and uses `data.color_name || getColorName(hex)` so a slow query never blanks out the placeholder. Five new tests in `test_color_catalog_extras.py` pin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returns `color_name=null` (no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back to `getColorName(hex)`. **Tests overall:** 20 `FilamentOverride.test.tsx` cases green; 12 `test_color_catalog_extras.py` integration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. **Same fix applies to printer-mode FilamentMapping (round 3):** the schedule modal's "Specific Printer" branch renders `FilamentMapping` instead of `FilamentOverride` and was reading the same raw fields (`item.type` + generic `getColorName(item.color)`) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery from `FilamentOverride.tsx` into a shared hook `useFilamentLabels` in `frontend/src/components/PrintModal/useFilamentLabels.ts` so the two panels can't drift on label content; `FilamentOverride` and `FilamentMapping` now both call `useFilamentLabels(filamentReqs?.filaments)` and read positional `{ resolvedName, colorLabel }` per slot. The hook also exports the `extractMaterialHint` helper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads `{resolvedName}` instead of raw `{item.type}`, and the colour swatch tooltip reads `Required: {resolvedName} - {colorLabel}` instead of `Required: {item.type} - getColorName(item.color)`. New vitest case `renders sub-brand + material-disambiguated colour on the required side (#1718)` mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a new `useFilamentLabels.test.tsx` (11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and the `enabled: !!color` query gate). The earlier "case-insensitive on both inputs" backend test (in `test_color_catalog_extras.py`) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. **What this fix can NOT recover:** for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name from `getColorName(hex)` — still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged. ### Removed - **Slicer Bundle (.bbscfg) import (#1712, reported by @IndividualGhost1905)** — Bundle import never delivered what users expected. BambuStudio's "Export Preset Bundle" only includes user-customised presets; system processes / filaments are deliberately excluded by BS. So a fresh-install user who only used stock processes (the common case) got back a bundle containing their printer + maybe four custom filaments + zero processes. Importing that bundle into Bambuddy and then opening the SliceModal flipped into bundle mode — which constrained the dropdowns to bundle contents only — and surfaced "no presets" for process, blocking slicing on STL (3MF still worked because the embedded process JSON bypasses the dropdown). The first round of #1712 (`d459b6ea`, 2026-05-XX) addressed cross-tier visibility / dedup / banner behaviour but didn't touch the bundle-mode dropdown trap. Investigating the second round made it clear the bundle import wasn't unlocking anything the existing tiers don't already cover — custom presets reach Bambuddy through Bambu Cloud sync, Orca Cloud sync, or Single Preset Import; standard presets come from the sidecar's `/profiles/bundled` route automatically — so bundle mode was a fourth code path delivering no unique value while gating users on a slot they couldn't populate. **What was removed.** Backend: `POST/GET/DELETE /slicer/bundles*` routes, `SliceRequest.bundle` field + `SliceBundleSpec` schema, the bundle-dispatch fork in `library.py::_run_slicer_with_fallback` (cross-class slice-all loop, normal slice branch, `_resolve_target_printer_model` short-circuit), the bundle-context query params on `GET /library/files/{id}/filament-requirements` and `GET /archives/{id}/filament-requirements`, the bundle-fingerprint key in `slice_preview.py`'s LRU cache (back to `(kind, source_id, plate_id, content_hash)`), `SlicerApiService.import_bundle/list_bundles/get_bundle/delete_bundle/slice_with_bundle`, the `BundleSummary` / `BundleNotFoundError` types. Frontend: `BundlePicker` + `BundleStringDropdown` components, `isBundleMode` state and every branch on it in `SliceModal.tsx`, `selectedBundleId` / `bundleProcessName` / `bundleFilamentNames` state, the bundle-mode auto-pick effect, the bundle dispatch shape in `buildSliceBody`, the `bundlesQuery` itself, `SlicerBundle` / `SliceBundleSpec` types, `listSlicerBundles` / `importSlicerBundle` / `deleteSlicerBundle` API methods. The bundle-derived path in `buildCompatibilityIndex` is also gone — the function now only takes the printer-model registry and returns `{bambuModelByShortCode}`. `presetCompatibility` keeps its two remaining paths: the slicer's own `compatible_printers` list on local-imported presets (authoritative when set) and the `@BBL ` name-based fallback against the printer-model registry. Tests: `TestBundleRoutes` / `TestBundleClientMethods` / `TestSliceWithBundle` / `TestBundleAwarePreview` / `TestBundleDispatchShape` classes deleted across `test_slicer_presets.py` / `test_slicer_api.py` / `test_slice_preview.py` / `test_slice_request_schema.py` / `test_library_slice_api.py`; the SliceModal's "Bundle tier" describe block and the bundle-only assertions in `slicerPrinterMatch.test.ts` deleted; `SlicerBundlesPanel.test.tsx` removed; `TestNozzleClassGuard` simplified (no more bundle vs preset request distinction). **What replaces the Settings panel.** `SlicerBundlesPanel` is kept under the same name and slot in `SettingsPage` but now renders a static notice (title: "Slicer Bundles (removed)") explaining the removal and pointing users at Single Preset Import / Bambu Cloud / Orca Cloud, with the slicer sidecar covering stock presets automatically. The notice is permanent and can be removed in a future cleanup. **i18n.** `settings.slicerBundles.*` block replaced with `settings.slicerBundlesRemoved.{title,description,alternatives}` translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) per `feedback_translate_dont_fallback`. `slice.bundle` / `slice.bundleNone` / `slice.bundleAllRequired` keys removed across all locales. Parity check 5106 leaves × 11 locales green. **Migration.** Hard cutover, no automatic preset migration. Users who previously imported bundles will see them disappear from Settings → Slicer Bundles after this drops; their printer preset still lives on the sidecar bundle store but is no longer surfaced. Standard presets from the sidecar's BBL tree cover stock slicing; users who need their customs re-upload them via Single Preset Import or sync via Bambu Cloud / Orca Cloud. **Why this resolves #1712.** shaddowlink's failing path was: import bundle for H2D → bundle has 0 processes (BS-side limitation) → SliceModal flips into bundle mode → process dropdown empty → can't slice STL. Post-removal: same import isn't possible, but the cross-tier preset picker shows H2D processes from the sidecar's standard tier (which always had them — bundle mode was the thing hiding them), filtered by `@BBL H2D` compatibility. STL slicing works without any user action. **Tests:** full backend suite 5907/5907 green; ruff clean; frontend ESLint clean; `npm run build` clean; vitest 158 files / 2118 tests green; i18n parity 5106 leaves × 11 locales green. ## [0.2.4.6] - 2026-06-09 ### Added - **Archives page banner: reactive install-step-4 nudge for the slicer-side setting** — Companion to the new `external_storage` diagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" via `home_flag` bit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row with `extra_data.no_3mf_available=True` (`main.py:2770`) — that's the signal this banner watches. New backend endpoint `GET /archives/no-3mf-warning` returns `{has_fallback: bool}` — true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot via `localStorage` key `archiveNo3MFWarningDismissed` (matches the existing `Layout.tsx` update-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query is `enabled: !dismissed` so the endpoint isn't polled after dismissal. 5 backend integration tests (`TestNo3MFWarning`) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (`title`, `body`, `docsLink`, `dismissLabel`) under `archives.no3mfBanner` translated to all 11 locales — no English fallbacks. - **Connection diagnostic now verifies install step 4 ("Store sent files on external storage")** — Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a `.gcode.3mf` on the printer's SD card, every archived print falls back to no-thumbnail / no-metadata, and the cause is invisible until the user notices the archive is empty. **The trap with detecting this**: on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle moved onto the printer itself and is pushed on MQTT `home_flag` bit 11 (Bambuddy already parses this into `state.store_to_sdcard`). On older versions it's a purely slicer-side preference invisible to the printer. An FTP upload-probe approach was tried first — it always passed regardless of the slicer toggle because the `/cache` directory is always writable from Bambuddy's perspective; the slicer toggle only controls what BambuStudio chooses to do, not what the printer accepts from other clients. Confirmed empirically against an X1C + H2D with the slicer option toggled off (probe still succeeded, `home_flag` bit 11 stayed True). **Fix**: new `external_storage` check reads `state.store_to_sdcard` directly. Pass when the printer reports the bit on, fail when off, skip when no live MQTT state or the field has never been populated (older firmware that doesn't push `home_flag`). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; the `skip` text explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits between `port_ftps` and `mqtt_auth`. 5 new tests (`TestExternalStorageCheck`) cover pass-on-true, fail-on-false, skip-on-disconnect, skip-on-pre-add (no state), skip-on-missing-field. The reactive symptom-side detection — a one-time banner the first time the archiver records `extra_data.no_3mf_available=True` after a slicer-initiated print — is planned as a separate follow-up to cover the slicer-only setting case. Wiki updated on the System page (`features/system-info.md`) and the Troubleshooting page (`reference/troubleshooting.md`). i18n: 4 new keys (title, pass, fail, skip) localised to all 11 locales (de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW) — no English fallbacks. - **"Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by @hasmar04)** — Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing `preferred_slicer` setting drove both, so picking one forced the other. The slicer-URI flow on Workflow → Slicer literally swapped the BambuStudio handler for the OrcaSlicer one whenever the user switched the API choice. **Fix: new `open_in_slicer` setting** (`'bambu_studio' | 'orcaslicer' | null`) drives only the desktop "Open in Slicer" URI handoff; the in-app SliceModal + sidecar URL routing in `library.py`, `archives.py`, `slicer_presets.py` continue to use `preferred_slicer` exactly as before. Default is `null` — the frontend falls back to `preferred_slicer` so existing installs behave identically until a user changes it (no migration, no churn). **Storage** lives in the existing `app_settings` key/value table; the PUT path serialises a Python None as the literal string `"None"`, and the GET path normalises it back via a new branch in `_build_settings_response` matching the existing `default_printer_id` convention — without that normalization the frontend can't tell "explicit override absent" from "explicit override set to a bogus value". **Frontend**: Settings → Slicer card relabels the existing dropdown's description ("Slicer used for in-app slicing via the API sidecar"), adds a new "Open in Slicer" dropdown below it with three options — "Same as API slicer" (the inherit-from-preferred default), "Bambu Studio", "OrcaSlicer". `ArchivesPage` (5 `openInSlicerWithToken` call sites), `MakerworldPage` (the URI handoff branch when `useSlicerApi=false`), and `ModelViewerModal` (4 `openInSlicer(...)` call sites) all switched from reading `settings?.preferred_slicer` to `settings?.open_in_slicer ?? settings?.preferred_slicer`. MakerworldPage's "Slice in {{slicer}}" button label additionally branches on `useSlicerApi`: when on, the label reflects the API slicer; when off, the desktop slicer — so the button text always matches what the button actually does. The OrcaSlicer "known CLI bugs" warning stays attached to the API dropdown (where it belongs — it's about the sidecar's CLI). **i18n**: 3 new keys in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) — `settings.openInSlicerLabel`, `settings.openInSlicerInherit`, `settings.openInSlicerDescription` — plus an updated `settings.preferredSlicerDescription` everywhere (the old wording "Choose which slicer application to open files with" became wrong once the field stopped driving the desktop handoff). No English fallbacks per the project's hard rule. **Tests**: 3 new in `TestOpenInSlicerOverride` pin the contract — default is null, override persists across GET, explicit reset to null round-trips correctly without leaving the `"None"` string leak. Full backend suite green (5798/5798); frontend ESLint + build clean; vitest on SettingsPage + MakerworldPage 48/48 green; i18n parity 5095 leaves × 11 locales green. - **Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by @CMW-ISS)** — Reporter on a multi-printer farm with 40+-plate runs needed to walk to the printer with the right physical plate; the archive card had recently grown a bed-type badge, but the queue and the scheduling modal didn't. They were having to open the source 3MF in the slicer to look up which plate each queued / scheduled job needs. **Backend**: new `extract_bed_type_from_3mf(file_path, plate_id)` helper in `utils/threemf_tools.py`, alongside the existing `extract_filament_usage_from_3mf` shape — reads `Metadata/slice_info.config`, finds the `` with the matching `index`, returns its `curr_bed_type`. When `plate_id` is None it returns the first plate's value (matches the archive-level capture convention). `PrintQueueItemResponse` gains a `bed_type: str | None` field; `_enrich_response` populates it from `archive.bed_type` / `library_file.file_metadata["bed_type"]` as the file-level default, then overrides per-plate via the new helper when `item.plate_id` is set. This matters because `archive.bed_type` is captured at ingest as the FIRST plate's value only (see `services/archive.py:235`) — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, even though the user's plate 17 actually needs Engineering. The per-plate override re-reads the 3MF and returns the truth. **`/archives/{id}/plates`** (and the library-file equivalent) now include `bed_type` in each plate object so the PrintModal's plate selector can render the badge inline. **Frontend**: queue card meta row gains a bed badge after filament weight — uses the existing `getBedTypeInfo(bed_type)` helper from `utils/bedType.ts` (the same one the archive card uses, so all 11 canonical bed labels + icons are covered including the BambuStudio / OrcaSlicer spelling drift). PrintModal's per-plate `PlateSelector` shows the bed badge under each plate's filament line; the modal header carries a bed badge for the selected (or sole) plate, surfaced before the user hits Schedule. `PlateInfo` + `PlateMetadata` types both get an optional `bed_type` field. No new i18n keys needed — `getBedTypeInfo` returns the canonical English plate name as the human label, matching the archive card's existing convention. **Tests**: 8 new unit cases in `test_threemf_tools.py::TestExtractBedTypeFrom3mf` pin the helper (single-plate, multi-plate per-plate, no-plate-id defaults to first, unknown-plate-id → None, plate-without-bed-type → None (no fall-through to another plate's value), missing slice_info, invalid file, whitespace trim). Full backend suite green (3848/3848); frontend build clean; ESLint clean; vitest on touched pages 81/81; i18n parity 5092 leaves × 11 locales green. - **Print Log page: per-row failure-cause classification (#1687 part 4, reported by @IndividualGhost1905)** — Reporter clarified after part 1 shipped that what he actually wanted for point 2 was failure-cause grouping on the *log* (spaghetti, jam, bed-adhesion, etc.), not the archive tags I'd pointed him at. Archive `tags` describe the model (home decor, toys); the log row needs to describe what went wrong on a single print event. Different surface, different lifetime. **What was already there:** `PrintLogEntry.failure_reason: String(100)` already exists, gets *mirrored* from `archive.failure_reason` when the user edits the archive (see `archives.py:1421` for the mirror that ships with #1444), and the Failure Analysis widget already groups by it. So the storage and the aggregation were both done — the only gaps were (a) the Print Log table couldn't *render* the value because the GET serialiser silently dropped it from `PrintLogEntrySchema`, and (b) **orphan log entries** (failures with no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal can't reach them. **Fix:** four pieces. (1) `print_log.py` GET endpoint now includes `failure_reason` (and `archive_id`, `created_by_id`) in the serialised response — pre-fix it was silently None in every response even when the column was populated. Regression guard added. (2) New `PATCH /print-log/{entry_id}` endpoint accepting `{failure_reason, status}`, gated on `require_ownership_permission(ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN)` — same ownership shape as the per-row delete that already shipped. Backend validates `failure_reason` against the same canonical vocabulary the Archive Edit modal uses (11 enumerated keys + empty-string-clears + the `other` catch-all); unknown values return 400 rather than getting stored as raw garbage (the i18n layer renders the value as a key, so an unrecognised one would surface as a literal string in the UI). Status validated against the 5-value `{completed, failed, stopped, cancelled, skipped}` set. Empty-string `failure_reason` stores back as NULL so the column's `nullable=True` intent is preserved end-to-end. (3) `FAILURE_REASON_KEYS` constant moved to an export from `EditArchiveModal.tsx` so the new editor reuses the exact same vocabulary as the archive editor — backend and frontend stay in lockstep. (4) Frontend: pencil icon added beside the existing trash icon on every Print Log row, gated on `archives:update_own`/`archives:update_all`. Click opens a compact two-field modal (status + failure reason dropdowns). Save invalidates both `print-log` and `archives-stats` query keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason is also rendered as a sub-label under the status badge in the table, mirroring the per-archive `PrintLogTable.tsx` convention so the two views agree. **i18n:** 10 new keys (`editEntryTitle`, `editEntryDescription`, `entryUpdated`, `entryUpdateFailed`, `archives.permission.noEdit`, plus a 5-key `statuses` block) translated across all 11 locales — no English fallbacks per `feedback_translate_dont_fallback`. **Tests:** 8 new backend integration cases — GET surfaces `failure_reason` (regression guard for the silent-drop bug), PATCH sets / clears / rejects unknown failure_reason, PATCH updates status, PATCH rejects unknown status, PATCH returns 404 on missing ID, PATCH works on **orphan entries** (archive_id IS NULL) — the actual reason this endpoint exists. Full backend suite 5843/5843 green; ruff clean. Frontend vitest 2108/2108 green; ESLint + build clean. i18n parity check 5110 leaves × 11 locales green. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). - **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. - **Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by @MartinNYHC, root-caused by @IndividualGhost1905)** — Reporter on a flat LAN couldn't add a printer that lived in a different subnet (`Bambuddy 192.168.1.0/24` ↔ `printer 10.1.1.0/24`). SSDP multicast (`239.255.255.250:2021`) doesn't traverse routers, so the existing "Discover Printers on Network" pass found nothing; Docker mode had a CIDR text input but only as a fallback when zero interface subnets were detected, and native mode had no subnet field at all. The discovery socket has always bound `INADDR_ANY` so this was never an interface-bind issue — only a routing-boundary one. The fix surfaces an always-visible subnet picker in `AddPrinterModal`: the detected interface subnets stay as the dropdown options, plus a new "Custom subnet..." sentinel reveals a CIDR text input the user can type any reachable subnet into (`10.1.1.0/24`, a VLAN, a Tailscale subnet route, etc.). When custom is picked, the discovery routes through `POST /discovery/scan` with the typed CIDR instead of `POST /discovery/start` — SSDP would no-op against a foreign subnet anyway, so this is the only behaviour that can succeed. The Scan-button label and the scanning / no-printers-found messages all key off the `(isDocker || useCustomSubnet)` predicate so the wording stays "Scan Subnet…" / "Scanning subnet…" — the user sees one consistent verbal model whether they're on Docker or just picked Custom. Last custom CIDR is persisted to `localStorage` under `bambuddy.discovery.customSubnet` and restored on next modal open, so a user who maintains a VLAN setup doesn't retype `10.1.1.0/24` every time. **Backend changes: none.** `SubnetScanner.scan_subnet()` already accepts any CIDR, already caps the scan at /22 (1024 hosts) with batch-50 concurrency, and the route `/discovery/scan` already takes user-supplied input — the existing plumbing was complete. **i18n**: 3 new keys (`customSubnetOption`, `customSubnetLabel`, `customSubnetNote`) translated in all 10 non-English locales (de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), no English fallbacks per the project's hard rule. The note text spells out the routing-boundary requirement: "The FTP (990) and MQTT (8883) ports must be reachable across the routing boundary" — a user who can pick a subnet but whose firewall blocks 8883 will at least see why the scan came up empty. **Tests**: 3 new in `PrintersPageDiscoveryCustomSubnet.test.tsx` — picker renders on native installs (was Docker-gated before), picking Custom + entering a CIDR routes through `discoveryApi.startSubnetScan` not `startDiscovery` and persists the choice via `localStorage.setItem`, picker default (the detected interface subnet) still triggers SSDP via `startDiscovery`. `AddPrinterModal` exported from `PrintersPage.tsx` so the tests can mount it directly without round-tripping through the full page (same shape as `ProjectModal` for the #1642 tests). - **Orca Cloud profile sync — end-to-end integration with the slicer + SpoolBuddy surfaces (OrcaSlicer/OrcaSlicer#14028 filed for upstream allowlist broadening)** — Bambuddy now reads, lists, and slices with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration. OrcaSlicer 2.4.0-alpha shipped its own cloud (Supabase-backed at `auth.orcaslicer.com` / `api.orcaslicer.com`); this integrates with it using the in-source publishable client key, a standard PKCE handshake, and the `/api/v1/sync/pull` profile-sync endpoint. **Four sign-in providers**: Google, Apple, GitHub (paste-flow PKCE) and email+password (direct grant — Orca's web sign-in offers it even though their desktop SDK refuses); UI defaults to password with the three OAuth options listed below. **UX shape**: the Cloud Profiles tab is now two — "Bambu Cloud" (existing, unchanged) and "Orca Cloud" (new); the paste flow's "page will fail to load — that's expected" instruction is rendered as a prominent amber callout so the connection-refused page isn't mistaken for a Bambuddy error. The Orca Cloud tab renders the same rich profile-browser layout as Bambu Cloud (search + 5 filter dropdowns + 3-column grouped grid + click-to-detail) via a parallel `OrcaCloudProfilesView` component. We chose paste-flow rather than a clean OAuth callback because Orca's Supabase project only honors localhost in its `redirect_to` allowlist. **Slicer integration**: the unified-presets endpoint surfaces Orca Cloud as a 4th tier above Bambu Cloud > local > standard; `_dedupe_by_name` and the SliceModal dropdowns both updated to walk all 4 tiers. The dedicated `_fetch_orca_cloud_presets` extracts `filament_type` and `default_filament_colour` inline from each profile's content (cheap because `/sync/pull` returns full content per profile — no rate-limit dance like Bambu Cloud's per-setting fetch), so multi-color pre-pick scoring works against Orca presets too. A separate `CloudStatusBanner` instance shows Orca Cloud's auth status independently of Bambu's. **AMS slot integration**: `ConfigureAmsSlotModal` accepts `orca_cloud` as a new preset source (prefixed `orca_` to match the existing `local_*` / `builtin_*` convention), gracefully tolerating raw UUIDs from historical saves; Orca presets are treated like local imports for `tray_info_idx` derivation (no Bambu setting_id, generic filament-ID map by parsed material). Slot mapping persisted with `preset_source='orca_cloud'`. **SpoolBuddy integration**: `SpoolFormModal` and `SpoolBuddyWriteTagPage` fetch Bambu + Orca filaments in parallel via `Promise.allSettled` and concat; `ConfigureAmsSlotModal` opens from `SpoolBuddyAmsPage`'s Configure flow with Orca presets surfaced first. **Storage**: 8 new columns on `users` (5 persistent + 3 transient PKCE state with 10-min TTL), dialect-branched DATETIME / TIMESTAMP, verified on SQLite and Postgres. Auth-disabled mode falls back to global Settings table. **Refresh rotation**: Supabase issues single-use refresh tokens; service refreshes just-in-time (<5min leeway) and persists the new pair BEFORE the downstream call so a mid-flight crash doesn't strand the user. **Cloudflare**: `api.orcaslicer.com` is behind a UA-only gate; `Bambuddy/` clears it (no TLS-fingerprint games). Per the [[bambu-compliance-outreach]] posture we identify honestly. **Preset resolver**: `PresetRef.source` extended to `'orca_cloud' | 'cloud' | 'local' | 'standard'`; `_resolve_orca_cloud` lists, filters, and forwards profile content. **Permissions**: new explicit `orca_cloud:auth` flag (per [[feedback_specific_scopes_over_folding]]); folded into the existing `can_access_cloud` API-key scope (same trust dimension as Bambu Cloud — extending automatically rather than requiring a per-key opt-in). The orca_cloud router carries the same `_cloud_api_key_gate` + `cloud_caller()` deps as the Bambu Cloud router — a copy-paste miss caught only when the SpoolBuddy kiosk's API-keyed requests came back with empty preset lists from `/orca-cloud/profiles` because the plain `require_permission_if_auth_enabled` dep returns `None` for API-key callers, falling through to the global Settings table that doesn't carry per-user Orca tokens. **Load-bearing gotchas surfaced and fixed during the build** (captured in the `orca-cloud-integration` project-memory file so future contributors don't re-discover them): (a) Supabase silently falls back to the project Site URL when a client passes its own `state` to `/auth/v1/authorize` — overrides GoTrue's internal redirect_to tracking, browser lands at cloud.orcaslicer.com instead of localhost; we don't send state, PKCE alone gives CSRF protection. (b) `cursor=0` returns `410 cursor_too_old`; bare `/sync/pull` with no cursor parameter is the first-sync bootstrap, same as Orca's own client. (c) The `/api/v1/sync/profiles` constant is declared in source but isn't deployed — returns 404. (d) Orca's `content.type` vocabulary is `printer` / `print` / `filament`, not the BambuStudio `machine` / `process` / `filament` triplet you'd guess from the wider source; without alias mapping every printer + process profile gets silently dropped (caught against a real account showing 54 filament + 0 process + 0 printer instead of 54+18+3). (e) Naive datetimes from Postgres `TIMESTAMP WITHOUT TIME ZONE` columns get `.astimezone()` interpreted as local time on the read path, shifting freshly-stored pending PKCE state by the host's TZ offset and instant-firing the 10-min TTL — `_as_utc` normalises on load. **Tests**: 32 unit tests on the OrcaCloudService (PKCE / token exchange / single-use refresh rotation / rejected-refresh-clears-tokens / JIT refresh / profile walk + content.type mapping); 6 preset-resolver orca tier tests (permission gate, content unwrap, auth error 401, not-found 400, dispatcher routing); 6 new orca-fetch tests in test_slicer_presets.py paralleling the Bambu Cloud fetcher (status vocabulary, permission shortcut, cache hit, type vocabulary); existing SliceModal vitest updated for the 4-tier shape; 6 frontend OrcaCloudView tests (all four sign-in providers + paste flow + connected + disconnect). **i18n**: ~35 new keys translated in all 10 non-English locales (de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW); brand-name "Bambu Cloud" / "Orca Cloud" cognates allowlisted in the parity check; existing `tier.cloud` relabelled from "Cloud" to "Bambu Cloud" everywhere it was previously generic. **Service worker**: bumped to v29/v28 with a forced reload-on-activate so the SpoolBuddy kiosk (Pi + Chromium + locked into kiosk mode, no devtools, no way to navigate or refresh) picks up the new bundle on a single restart instead of needing two. **Verified**: backend ruff clean; full pytest pass at 5648 across the suite (-n 30 in 84s); frontend eslint + build + vitest 2051 clean; i18n parity green at 5054 leaves × 11 locales. - **VP MQTT bridge surfaces why `net.info[].ip` rewrite didn't arm (#1429 defensive)** — `MQTTBridge._refresh_ip_encoding` had 4 silent early-return paths (`target_client is None`, `printer client has no ip_address yet`, `no host interface shares a subnet with printer IP X and bind_address is 0.0.0.0/empty`, `invalid IPv4 …`). When the rewrite silently no-op'd on a user's setup, the only signal was the absence of the `MQTT bridge IP encoding armed` INFO line — diagnosing which path was firing meant grepping the source. Each path now emits one `MQTT bridge IP encoding NOT armed: ` INFO line; the message names the actual failure (target IP, the missing-interface case, etc.). Throttled via a `_not_armed_reason` dedup field so an idle unarmed bridge doesn't spam one line per 30s refresh tick — only state changes log. Cleared on successful arm so a regression (e.g. printer client unbinds) re-emits the diagnostic. 5 new tests in `TestNotArmedDiagnosticLogging` pin each path's specific reason text, the once-per-state-change throttle, and the arm-clears-dedup behaviour. **Not a fix for #1429 itself** — the bridge logic is unchanged; this just turns the silent failure into visible signal so the next "fix didn't work for me" report can be triaged in one round-trip instead of multiple. - **Connection diagnostic now verifies the printer is actually publishing on its report topic (#1622)** — The existing checks proved TCP + TLS + auth + SUBSCRIBE, but a printer with a wrong-cased serial — or one that simply isn't publishing for some other reason — would still pass `mqtt_auth` because the broker accepts the subscription regardless. The user-visible symptom in that case was "AMS / K-profiles / custom filaments missing on the slicer side": the VP bridge had nothing cached to mirror because no reports ever arrived. Bambuddy already logged `Connected and subscribed, but the printer has sent zero status reports. The most common cause is a wrong or mis-cased serial number…` at `bambu_mqtt.py:498` when this happened, but the only way to see it was to grep container logs. New `printer_publishing` check turns that warning into a structured diagnostic result. Pass = the bridge has seen at least one report since the latest (re)connect; fail = zero reports across the wait window with a fix-text pointing at the case-sensitive serial. The check exposes `report_messages_since_connect` as a public property on `BambuMQTTClient` so the diagnostic doesn't reach into private state. **Bounded wait with countdown UX**: the bridge resets the counter to 0 on every (re)connect, so a fresh reconnect would otherwise be reported as fail before the printer's first idle push lands. The on-demand UI check polls for up to 10s (`PUBLISH_WAIT_DEFAULT`) at 0.5s intervals and exits the moment a message arrives — typical wall-clock is 1-2s, not the full 10. The check returns `max_wait_seconds` in its `params` so the frontend can render a countdown next to the spinner instead of looking hung. The Connection Diagnostic modal (`ConnectionDiagnostic.tsx`) now displays an elapsed-seconds counter (`Running diagnostic... (3s)`) plus the `waitingForReportHint` line (`Listening for the printer to publish a status report — this can take up to 10 seconds.`) during the pending state for the existing-printer flow. `PUBLISH_WAIT_DEFAULT_SECONDS = 10` is pinned in the frontend to match the backend constant; the 2 new i18n keys ship in all 11 locales. The support-package gathering path stays fast: it calls `run_connection_diagnostic` without `wait_for_publish_seconds`, getting an instant pass/fail with no `max_wait_seconds` exposed. 6 new tests covering pass-on-reports-seen, fail-on-zero-after-wait, skip-on-disconnect, skip-on-missing-client, instant-no-wait-path, plus updated all-healthy + disconnected-state assertions to include the new check. i18n strings (`title` / `pass` / `fail` / `skip`) shipped in all 10 non-English locales with real translations — no English fallbacks per the project's hard rule. 5011 leaves × 11 locales in parity. **Why this directly closes #1622**: the reporter's bridge to printers 2 + 4 (P1S + A1 Mini real targets) repeatedly hit keep-alive timeouts and force-reconnected; on every reconnect the printer published nothing in the stale window, leaving the VP cached state empty. The slicer Device tab pulls AMS / cali_id / custom filaments from cached state — empty cache = empty dropdown. The reporter's H2D bridge stayed healthy throughout and its slicer Device tab populated correctly. The in-app Connection Diagnostic had passed (`port_mqtt: pass`, `mqtt_auth: pass`) because it didn't observe publish behaviour. The new check catches this class of failure on the user's first try. ### 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. - **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. - **File Manager sidebar: "All Files" now scopes to your own uploaded files; new "External" entry holds the combined linked-folder view (#1621, reported by @kcw96)** — Reporter linked a NAS share that auto-imported hundreds of 3MFs, and from then on their handful of Bambuddy-uploaded files was lost in the "All Files" listing — no filter, no toggle, only per-folder clicks to escape the noise. Restored the pre-external semantics so long-time users get their muscle memory back: "All Files" lists managed-storage files only (`is_external=False`), exactly what it meant before external folders existed. The combined "everything across every external mount" view moves to a new sibling sidebar entry, **External**, which only renders when at least one external folder is linked (zero-cost on installs that don't use the feature). Per-folder clicking is unchanged: clicking any folder in the tree — internal or external — still shows that folder's contents directly. **Backend**: `/api/v1/library/files` gains two mutually-exclusive query flags, `internal_only` and `external_only`, filtering directly on `LibraryFile.is_external`. Both-flags-set is a 400 (catches frontend regressions immediately instead of silently picking one). Folder- or project-scoped requests bypass both flags because they already imply a single scope. **Frontend**: new `topLevelView: 'internal' | 'external'` state on `FileManagerPage`, default `internal`; the query passes the corresponding scope only when `selectedFolderId === null`. Sidebar shows the "External" row gated on `folders.some(f => f.is_external)`; mobile selector dropdown carries a `__top:internal` / `__top:external` sentinel so the same state can round-trip through `