Note
This is a daily beta build (2026-06-04). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Added
- 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/pullprofile-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 parallelOrcaCloudProfilesViewcomponent. We chose paste-flow rather than a clean OAuth callback because Orca's Supabase project only honors localhost in itsredirect_toallowlist. Slicer integration: the unified-presets endpoint surfaces Orca Cloud as a 4th tier above Bambu Cloud > local > standard;_dedupe_by_nameand the SliceModal dropdowns both updated to walk all 4 tiers. The dedicated_fetch_orca_cloud_presetsextractsfilament_typeanddefault_filament_colourinline from each profile's content (cheap because/sync/pullreturns 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 separateCloudStatusBannerinstance shows Orca Cloud's auth status independently of Bambu's. AMS slot integration:ConfigureAmsSlotModalacceptsorca_cloudas a new preset source (prefixedorca_<UUID>to match the existinglocal_*/builtin_*convention), gracefully tolerating raw UUIDs from historical saves; Orca presets are treated like local imports fortray_info_idxderivation (no Bambu setting_id, generic filament-ID map by parsed material). Slot mapping persisted withpreset_source='orca_cloud'. SpoolBuddy integration:SpoolFormModalandSpoolBuddyWriteTagPagefetch Bambu + Orca filaments in parallel viaPromise.allSettledand concat;ConfigureAmsSlotModalopens fromSpoolBuddyAmsPage's Configure flow with Orca presets surfaced first. Storage: 8 new columns onusers(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.comis behind a UA-only gate;Bambuddy/<version>clears it (no TLS-fingerprint games). Per the [[bambu-compliance-outreach]] posture we identify honestly. Preset resolver:PresetRef.sourceextended to'orca_cloud' | 'cloud' | 'local' | 'standard';_resolve_orca_cloudlists, filters, and forwards profile content. Permissions: new explicitorca_cloud:authflag (per [[feedback_specific_scopes_over_folding]]); folded into the existingcan_access_cloudAPI-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/profilesbecause the plainrequire_permission_if_auth_enableddep returnsNonefor 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 theorca-cloud-integrationproject-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 ownstateto/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=0returns410 cursor_too_old; bare/sync/pullwith no cursor parameter is the first-sync bootstrap, same as Orca's own client. (c) The/api/v1/sync/profilesconstant is declared in source but isn't deployed — returns 404. (d) Orca'scontent.typevocabulary isprinter/print/filament, not the BambuStudiomachine/process/filamenttriplet 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 PostgresTIMESTAMP WITHOUT TIME ZONEcolumns 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_utcnormalises 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; existingtier.cloudrelabelled 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.
Changed
- Empty AMS units no longer trigger hourly humidity/temperature notifications (#1619) — The hourly AMS sensor recorder in
backend/app/main.py::record_ams_historyfanned out humidity and temperature alarms for every AMS unit above threshold without checking whether the unit was actually loaded with filament. Empty AMS units still report ambient sensor readings, so users with one loaded AMS and one empty one got useful alarms for the loaded unit and steady noise for the empty one every hour. The reporter's workaround (disable all AMS humidity notifications) also killed the useful alarms — not a real choice. New_ams_has_filament(ams_data)helper inspects the firmware-reportedtray_exist_bitshex bitmap (one bit per tray slot,"0"/"00"= empty unit) with a fallback to thetrayarray'stray_typestrings for early-pushall shapes where the bitmap is missing. The recorder gates the alarm dispatch on this check per-AMS-unit, so a multi-AMS printer with one loaded + one empty still alarms on the loaded one. Sensor history still records regardless of the gate so the System page humidity/temperature charts stay continuous — the only thing the gate suppresses is the outbound notification. 9 unit tests intest_ams_alarm_gating.pycover the bitmap-zero-is-empty case, single/multi/all-loaded variants, thetray_exist_bitsmissing → tray-array fallback, garbage bitmap → fallback, blank bitmap → fallback, non-string bitmap → fallback (Bambu sometimes sendsint), whitespace-onlytray_typenot counting as loaded, and defensive non-dict tray entries.
Added
- VP MQTT bridge surfaces why
net.info[].iprewrite didn't arm (#1429 defensive) —MQTTBridge._refresh_ip_encodinghad 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 theMQTT bridge IP encoding armedINFO line — diagnosing which path was firing meant grepping the source. Each path now emits oneMQTT bridge IP encoding NOT armed: <specific reason>INFO line; the message names the actual failure (target IP, the missing-interface case, etc.). Throttled via a_not_armed_reasondedup 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 inTestNotArmedDiagnosticLoggingpin 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.
Fixed
- Label printing produced two identical PDFs per click (#1628) —
LabelTemplatePickerModal.tsx::openBlobInNewTabcalledwindow.open(url, '_blank', 'noopener,noreferrer')and treated anullreturn as "popup blocked → fall back to<a download>click." Per the WindowFeatures spec,noopenerdeliberately forceswindow.opento returnnulleven on success, so theif (!win)fallback fired on EVERY click. Path 1 (window.open) opened the blob tab — on Linux Chromium without an inline PDF viewer the OS saved a random-named copy (thezo70GhSL.pdf/f7w0OcDi.pdffiles in the reporter's screenshot). Path 2 (fallback) downloaded a second copy namedbambuddy-labels.pdf. Two identical PDFs per click. Fix: dropnoopener,noreferrer. The blob is same-origin (created viaURL.createObjectURLfrom our own fetch response), the destination is a passive PDF preview tab with no script context to abusewindow.opener, andnoreferreris a no-op for blob URLs. After removal,window.openreturns a real window reference on success →if (!win)only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases inLabelTemplatePickerModal.test.tsxstill pass; the change is comment + one parameter.
Changed
- AMS drying now enabled for H2C starting at firmware 01.02.00.00 — H2C was previously in
_DRYING_UNSUPPORTED_MODELSalongside the A1 family. Moved to_DRYING_MIN_FIRMWAREwith the same01.02.00.00floor as H2S / P2S. Both SSDP model codes the H2C advertises (O1C,O1C2— single- and dual-nozzle variants) get the same firmware gate so thesupports_drying()check fires correctly regardless of which form is in the printer record. Test coverage extended inTestSupportsDrying: H2C / O1C / O1C2 cases added to the with-firmware pass set, the old-firmware fail set, and removed from the unsupported-models loop.
Added
- 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_authbecause 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 loggedConnected and subscribed, but the printer has sent zero status reports. The most common cause is a wrong or mis-cased serial number…atbambu_mqtt.py:498when this happened, but the only way to see it was to grep container logs. Newprinter_publishingcheck 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 exposesreport_messages_since_connectas a public property onBambuMQTTClientso 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 returnsmax_wait_secondsin itsparamsso 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 thewaitingForReportHintline (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 = 10is 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 callsrun_connection_diagnosticwithoutwait_for_publish_seconds, getting an instant pass/fail with nomax_wait_secondsexposed. 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.
Fixed
- Scheduled local backup time is now interpreted as local time, not UTC (#1602 follow-up) — Pre-fix: the time-of-day picker in Settings → Scheduled Local Backups stored the value as
HH:MMand_calculate_next_runinbackend/app/services/local_backup.pyinterpreted it as UTC (datetime.now(timezone.utc).replace(hour=..., minute=...)), so a UTC+3 user who wanted a 21:00 local backup had to enter 18:00. The UI hinted at this with a literal "UTC" label, but it was still surprising. Post-fix: the picker is interpreted in the container's local timezone, resolved from theTZenv var viazoneinfo.ZoneInfo(same source the Support page'senvironment.timezonealready shows). UTC fallback whenTZis unset or unrecognised — preserves the legacy behaviour rather than crashing. The UI now shows the resolved zone name next to the time field (Local time (Europe/Berlin)/Yerel saat (Europe/Istanbul)/ etc.) via a new i18n keybackup.localTimeHintwith real translations across all 10 non-English locales, replacing the oldbackup.utcliteral. Newtimezonefield on/api/local-backup/statusexposes the resolved zone to the UI. One-time behaviour change for existing users: anyone who entered a UTC time as a workaround (per #1602's UTC+3 reporter — "I have to write 18:00 to get 21:00 local") will see the first scheduled cycle after upgrade run at their local TZ offset earlier than expected. Re-enter the time as local once and it's correct from then on. No migration is shipped; the population is small and migrating around a DST boundary would be ambiguous. Tests (backend/tests/unit/test_local_backup.py): existing 5 cases pinned withmonkeypatch.setenv("TZ", "UTC")so they don't depend on the test runner's TZ; 5 new — Europe/Berlin local→UTC, Europe/Istanbul (the #1602 reporter's zone) local→UTC, no-TZ-env UTC fallback, unrecognised-TZ UTC fallback, DST spring-forward gap (Europe/Berlin 2026-03-29 02:30 wall-clock doesn't exist) asserting no crash. All 30 tests pass. Frontend i18n parity green at 5007 keys across 10 non-English locales.