# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.4.9] - 2026-07-07 ### Added - **Spool labels: low-resolution / black-and-white thermal-printer support (#1870, reporter @fireboyff, monochrome requested by @Geoff-S)** — The QR code on the small 40 × 30 mm box label rendered too densely to print reliably on 203 dpi thermal printers — its lines bled together and it wouldn't scan. Two causes: the QR was sized at 20% of the label's inner width, which on the narrowest template came out at just ~7.5 mm (half of every other template), and it used `ERROR_CORRECT_M`. Fixes, applied adaptively so every template benefits with no configuration: the roomy layout now gives the QR a 12 mm minimum size (box_40x30 goes 7.5 → 12 mm, ~1.8 → ~3.5 dots per module at 203 dpi) while still capping it by height/width, and label QRs now use `ERROR_CORRECT_L` (same payload, fewer/chunkier modules — a label doesn't need M-level damage recovery). Also added a **Monochrome (black & white printer)** option in the label print dialog: it drops the colour swatch (a useless grey block on a B&W printer) and widens the text column, with the colour still conveyed by the hex-code line. - **Lower sponsor-prompt thresholds so the toast fires for typical new installs** (`257b9e2c`) - **SSO autologin + disable local username/password login (#1589, requested by @einstux)** (`549d3216`) - **"Auto-add unknown RFID spools" toggle + global confirmation modal (#1764)** (`9f9c1775`) - **Backup-aware filament deficit check, colour-strict (#1762)** (`29a5abd9`) - **Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows** (`7b06ebd7`) - **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** (`7e5eff14`) - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** (`ee270922`) - **In-app sponsor-toast at earned milestones** (`e761e092`) - **Prominent sponsor banner on Settings → General** (`5c16ef3e`) - **Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal** (`d1d16659`) - **AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman)** (`99c6949b`) - **Updated printer card UI for structure and readability (#1661)** (`6fa74be4`) - **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. - **Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK)** — Bambu's end-gcode lowers the build plate as soon as the print completes. Bambuddy's existing finish-photo path captured a fresh camera frame at `gcode_state=FINISH`, by which time the bed was already at the bottom of the chamber — the photo showed the top of the print well below the camera's natural framing, badly framed and sometimes invisible. Earlier capture attempts (at `layer_num >= total_layer_num` while still RUNNING) hit motion-blur because the toolhead was still parking; capturing through the window kept the wrong frame because the latest was always ~2s before FINISH, mid-bed-drop. **The fix sources the photo from a brief Bambu timelapse Bambuddy records on every dispatched print instead.** Firmware stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly — verified on N=2 H2C prints by extracting the last frame of two real timelapses (`spoolbuddy_v2.1` and `case_SpoolBuddy`); both showed the print clearly with the toolhead parked off-frame upper-left and the bed at print height, no motion blur. The post-park-pre-drop window is at least ~2 seconds wide on both, so `-sseof -1.0` (seek to last second, skip the literal last frame) is safe against any encoder tail artifact. **Implementation: force-on at dispatch + cleanup after extraction.** `BackgroundDispatchService._resolve_effective_timelapse(db, archive, job)` reads the `capture_finish_photo` setting before each `start_print` call (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overrides `timelapse=True` on the MQTT command + marks the new `PrintArchive.bambuddy_forced_timelapse` column True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched on `is_sqlite()` for the boolean default (`DEFAULT 0` on SQLite, `DEFAULT FALSE` on Postgres — PG rejects `DEFAULT 0` for BOOLEAN). New module-level `extract_video_last_frame(video_path, output_path)` in `services/camera.py` runs a single `ffmpeg -sseof -1.0 -i