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