Note
This is a daily beta build (2026-06-05). 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.
Fixed
- 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 (atlayer_num >= total_layer_numwhile 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.1andcase_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 thecapture_finish_photosetting before eachstart_printcall (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overridestimelapse=Trueon the MQTT command + marks the newPrintArchive.bambuddy_forced_timelapsecolumn True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched onis_sqlite()for the boolean default (DEFAULT 0on SQLite,DEFAULT FALSEon Postgres — PG rejectsDEFAULT 0for BOOLEAN). New module-levelextract_video_last_frame(video_path, output_path)inservices/camera.pyruns a singleffmpeg -sseof -1.0 -i <video> -frames:v 1 -q:v 2 -update 1 <out>subprocess — no full transcode, ~150ms wall time on the dev box. Bounded 15s subprocess timeout that kills the child on hang. Returns False (never raises) on missing ffmpeg / missing video / non-zero exit / timeout._capture_finish_photo_from_timelapse(archive_id, archive_dir)pollsarchive.timelapse_pathevery 3s for up to 60s —_scan_for_timelapse_with_retriesruns in parallel and writes that field when the FTP download finishes. When it lands and the file exists on disk, extract the last frame asfinish_<timestamp>_<hex>.jpg._background_finish_photonow tries the timelapse path first whentimelapse_was_active=Trueand no external camera is configured; the existing external-camera / buffered-live-frame / fresh-RTSP-capture chain stays in place as the fallback. Post-extraction cleanup: whenarchive.bambuddy_forced_timelapseis True,_cleanup_forced_timelapse(archive_id, printer_id)runs after the extractor (regardless of success — the user never asked for a timelapse file and we shouldn't leave debris even if ffmpeg failed): deletes the locally-attached file, clearsarchive.timelapse_path, then walks the four scanner directories (/timelapse,/timelapse/video,/record,/recording) trying FTP DELE against the original filename. Best-effort, never raises — a printer that's offline at cleanup time means one orphaned file on the SD card, not a broken Bambuddy flow. Notification timing: the photo-task wait_for budget bumps from 45s to 75s when a timelapse was active so the notification carries the correct bed-up photo instead of falling through to the live-cam grab on slow Wi-Fi links; ~30s of added notification latency at worst is the honest tradeoff. Scope limitation, documented in the camera wiki: only covers prints dispatched THROUGH Bambuddy (queue, reprint, print-now from File Manager). Prints started directly on the printer's touchscreen, via the Bambu Handy app, or via Bambu Studio's "Send" function bypassbackground_dispatch.py, so the force-on doesn't fire and the live-cam fallback (bed-down) still applies for those. Can add the mid-printM981 S1 P20000MQTT toggle inon_print_startlater if anyone reports it for non-dispatched prints. External-camera users are unaffected throughout: their flow ignores the printer timelapse since external cams have their own framing and don't see one anyway. Verified on H2C only (core-XY); needs field verification on bed-slingers (A1, P1S) where the bed-drop kinematic geometry differs. Bed-slinger users invited to the test build to confirm. Setting description rewritten across all 11 locales to drop the "best quality when timelapse enabled" caveat (since Bambuddy now forces it) and call out the "kept-if-you-wanted-it, deleted-otherwise" behaviour. Tests: 6 intest_extract_video_last_frame.pycover the real ffmpeg happy path against a runtime-synthesised tiny MP4 (testsrc generator, ~3-5 KB, no committed binary fixture), missing source, empty source, ffmpeg-not-present (monkeypatched), nonzero-exit on garbage input, hung subprocess via patched-sleep-binary + tightened timeout. 4 intest_finish_photo_from_timelapse.pycover the polling helper with a patched-session fixture (no DB engine): timeout-without-landing, lands-and-extracts, lands-but-extraction-fails, file-materialises-mid-poll. Override + cleanup tests intest_dispatch_force_timelapse.pyandtest_cleanup_forced_timelapse.pypin: override fires only whencapture_finish_photoenabled AND user-timelapse off; user-opted-in timelapse passes through unchanged; cleanup deletes local + clearstimelapse_path+ DELEs remote when forced; cleanup skips when not forced. Round-2 fixes from field testing (Martin's H2D + X1C queue test): (a) the extractor's-sseof -1.0seek broke on small-print timelapses — Bambu records one frame per layer change, so a 16-layer cube produces a 0.625 s / 16-frame video and the 1-second seek-from-end went before the start of the file, ffmpeg silently returned frame 0 (empty bed at print start). Switched the extractor toffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg— writes every decoded frame to the same output file (overwriting), so the file left on disk is the last frame regardless of duration. Verified on Martin's actual X1C-2 timelapse: produces the finished red cube with toolhead parked upper-left. Newtest_extracts_correctly_from_sub_second_videoregression test pins it against a 0.5 s synthetic MP4. (b) The dispatch-time override only wired intobackground_dispatch.py(Print Now / Reprint flows), but the print queue runs through a separate scheduler atprint_scheduler.py:_start_printwhich callsprinter_manager.start_printdirectly. Refactored the resolver to a module-levelresolve_effective_timelapse(db, archive, user_wanted_timelapse)function and wired it into the scheduler's call site too. Newtest_scheduler_force_timelapse_wiring.pywalks the scheduler's AST and asserts thestart_print(timelapse=...)kwarg referenceseffective_timelapse(notitem.timelapse) — guards against future refactors silently dropping the override on the queue path.
Added
- 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 boundINADDR_ANYso this was never an interface-bind issue — only a routing-boundary one. The fix surfaces an always-visible subnet picker inAddPrinterModal: 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 throughPOST /discovery/scanwith the typed CIDR instead ofPOST /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 tolocalStorageunderbambuddy.discovery.customSubnetand restored on next modal open, so a user who maintains a VLAN setup doesn't retype10.1.1.0/24every 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/scanalready 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 inPrintersPageDiscoveryCustomSubnet.test.tsx— picker renders on native installs (was Docker-gated before), picking Custom + entering a CIDR routes throughdiscoveryApi.startSubnetScannotstartDiscoveryand persists the choice vialocalStorage.setItem, picker default (the detected interface subnet) still triggers SSDP viastartDiscovery.AddPrinterModalexported fromPrintersPage.tsxso the tests can mount it directly without round-tripping through the full page (same shape asProjectModalfor the #1642 tests).
Fixed
- Project edit modal couldn't be scrolled, so Save / Cancel were unreachable on short screens (#1642, reported by klevin92) — Reporter on a Pi-class display (1508 × 831) couldn't mark a project as Completed because the edit modal's height exceeded the viewport and there was no way to scroll: outer wrapper was
fixed inset-0 flex items-center justify-center p-4(vertical-center) and the inner card had nomax-hand nooverflow. The top of the form went above the viewport and the bottom — including the Status dropdown the reporter was trying to use plus both action buttons — went below it. Workaround was a full page reload to drop the modal. Standard flex-modal-scroll fix:max-h-[calc(100vh-2rem)]+flex flex-colon the card (the2remaccounts for the outerp-4), aflex-1 overflow-y-auto min-h-0wrapper around the form fields, and the Cancel / Save buttons moved into aflex-shrink-0sibling with aborder-tseparator so they become a sticky footer that's always visible regardless of scroll position. The buttons stay inside the<form>sotype="submit"still works. 2 new vitest cases inProjectsPage.test.tsxpin the structural fix: the Save button is NOT a descendant of theoverflow-y-autoregion (otherwise it would scroll off again) and the modal card carries themax-h-[calc(100vh-2rem)]cap. Other modals in the codebase with the samefixed inset-0 flex items-center justify-center+max-w-mdshape almost certainly have the same latent bug — not refactored here, will tackle when reported.
Changed
- 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/filesgains two mutually-exclusive query flags,internal_onlyandexternal_only, filtering directly onLibraryFile.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: newtopLevelView: 'internal' | 'external'state onFileManagerPage, defaultinternal; the query passes the corresponding scope only whenselectedFolderId === null. Sidebar shows the "External" row gated onfolders.some(f => f.is_external); mobile selector dropdown carries a__top:internal/__top:externalsentinel so the same state can round-trip through<option value>. Empty-state copy distinguishes "no internal files yet" from "no external files" so a user staring at an empty External view doesn't think their NAS is broken. i18n: 3 new keys (allExternal,externalIsEmpty,externalEmptyDescription) translated in all 10 non-English locales. Tests: 3 new backend integration tests intest_library_api.py(internal-only with mixed root + folder + external file mix, external-only across two NAS mounts, mutually-exclusive 400) and 3 new frontend tests inFileManagerPage.test.tsx(External entry conditional onis_external, default internal-only query, External-click switches scope). Existing 48FileManagerPagetests + 11FileManagerExternalFoldertests stay green. Behaviour change for the small set of users who relied on the combined view as default: clicking "External" once gets the previous union behaviour (across-all-externals); clicking a specific external folder still shows just that mount, same as before.
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.
Changed
- Inventory:
/reset-usagerenamed to/reset-consumed-counter; UI label is now "Reset counter" — The old endpoint name implied that calling it would dropweight_usedto 0; in practice it only stampsweight_used_baseline = weight_usedso the Inventory page's "Total Consumed" widget (which rendersweight_used - baseline) reads 0 going forward, while remaining (label_weight - weight_used) is preserved. Calling the endpoint via curl and seeingweight_usedunchanged in the JSON response is confusing — the name didn't describe what the endpoint actually does. New paths: internal/api/v1/inventory/spools/{id}/reset-consumed-counter+/spools/reset-consumed-counter-bulk, Spoolman-mode/api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter+/spools/reset-consumed-counter-bulk. Behaviour is unchanged in both modes: internal stamps the baseline directly; Spoolman-mode PATCHes upstreamused_weight=0and the_map_spoolman_spoolread mapping at_spoolman_helpers.py:252-268reconstructs the samedisplayed consumed = 0, remaining unchangedBambuddy-visible shape — Bambuddy-side parity between modes (per [[feedback_inventory_modes_parity]]) was already in place before this rename and is preserved. The Spoolman-client methodreset_spool_usagekeeps its name because it describes what's sent upstream to Spoolman, which has not been renamed. Frontend:api.resetSpoolUsage/bulkResetSpoolUsage(and Spoolman variants) renamed toresetSpoolConsumedCounter/bulkResetSpoolConsumedCounter. Button labels switch from "Reset usage to 0" to "Reset counter" / "Reset all counters" — short and unambiguous; tooltips and confirm-modal bodies still spell out the full semantics ("zero the consumed-grams counter; remaining weight is not changed"). i18n: 9 keys renamed (resetUsage*,resetAllUsage*,usageReset,allUsageReset,resetUsageFailed→resetConsumedCounter*,resetAllConsumedCounters*,consumedCounterReset,allConsumedCountersReset,resetConsumedCounterFailed); real translations shipped in all 10 non-English locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) per the hard-rule against English fallbacks. Tests:test_spool_reset_usage.py(9 tests, internal mode) and 3 reset-related tests intest_spoolman_inventory_api.pyupdated to hit the new paths; behavioural assertions unchanged. Breaking change for external API consumers that already wired/reset-usage— no compat shim shipped because the old name actively misled callers; the migration is a one-line URL swap.
Changed
docker-compose.yml: bridge-mode warning about the 1001-port FTP passive range + docker-proxy RAM footprint (#1646, reported by TheFou) — Reporter on bridge mode (Docker defaultuserland-proxy: true) saw ~2000docker-proxyhost processes spawn from the commented"50000-51000:50000-51000"line, pinning ~3.5 GB of host RAM before they had even logged in for the first time. Linux's host-mode default in the same compose file sidesteps this entirely (zero docker-proxy cost) — the issue only fires when a user forces bridge mode (typically macOS/Windows / Docker Desktop). The 1001-port range itself is load-bearing on the VP server side (virtual_printer/ftp_server.py:567-574documents the widening from 100 ports as multi-VP collision-avoidance headroom; reverting would regress that), so the fix is documentation, not code. Added a warning block above the commented FTP-passive line pointing bridge-mode users at{ "userland-proxy": false }in/etc/docker/daemon.json— the reporter confirmed this clears the issue on their setup. Kernel does NAT directly via iptables/nftables in that mode, no per-port host process needed; only side-effect is that connections originating from 127.0.0.1 on the host itself can't reach the container, which doesn't matter for nearly every Bambuddy install.
Fixed
- VP MQTT bridge
net.info[].iprewrite never armed when the printer was added by hostname/FQDN (#1429, root-caused by Mape6, also hit TrickShotMLG02) — Reporter on a flat 192.168.3.0/24 LAN had added a P1S to Bambuddy by its router-provided DNS namep1s.fritz.boxinstead of its IPv4. On 0.2.4+ that one detail kept Bambu Studio Send going to the real printer instead of the Bambuddy archive whenever the printer was powered on — exact same surface symptom #1429 was originally about, but a separate root cause from the bind-IP encoding work shipped on 2026-06-02. The defensiveNOT armedlogging ([[issue1429_vp_ip_leak]]) added in this release pinpointed it on the reporter's bundle:MQTT bridge IP encoding NOT armed: invalid IPv4 (target='p1s.fritz.box', vp='192.168.3.27'): invalid literal for int() with base 10: 'p1s'. The encoder_ip_to_uint32_le(and the host-interface pickerfind_interface_for_ip) both assume dotted-quad IPv4 and bail on anything else, soBambuMQTTClient.ip_addressbeing the configured FQDN string short-circuited the rewrite path andnet.info[*].ipkept leaking the real printer's IPv4. Switching the printer record to an IPv4 cleared the issue immediately for the reporter — that workaround confirms the diagnosis exactly. Why this didn't bite pre-0.2.4: the bridge didn't donet.info[].iprewriting at all before #1429 shipped, so FQDN-configured printers worked by accident — nothing was trying to parse the host as IPv4. Fix adds_resolve_target_to_ipv4(target)inmqtt_bridge.py: pass-through whentargetalready parses asipaddress.IPv4Address, otherwisesocket.getaddrinfo(target, None, family=socket.AF_INET)to filter to IPv4-only (thenet.info[*].ipfield is uint32 LE — there's no IPv6 representation that fits, so an AF_INET6 result must not slip through). ReturnsNoneon empty input and onOSErrorfrom getaddrinfo so transient DNS hiccups don't break the encoding permanently;_refresh_ip_encodingfalls back to the existingNOT armedthrottle which re-resolves on every 30s refresh tick (DHCP / DNS churn picks itself up). Both the_ip_to_uint32_le(target_ip)call AND the_resolve_host_interface_for_target(target_ip)call now receive the resolved IPv4, so the same fix covers the bind-address auto-resolve path used on default-config (0.0.0.0 bind) installs that don't have a dedicated VP bind IP. The configured FQDN is preserved into the armed log line asconfigured→resolved(target=p1s.fritz.box→192.168.3.153) so a bad-DNS regression stays legible indocker logswithout grepping back to the not-armed lines. The unresolvable-input not-armed reason is nowcould not resolve printer host '<input>' to IPv4 (invalid address and DNS lookup failed)— names the actual configured value, not justinvalid IPv4 (target=...), so future bundles distinguish "DNS gave us a v6 address" from "user typed garbage" without a guess. Tests: 5 new inTestHostnameResolution(pass-through for IPv4, empty/None → None, FQDN → resolved IPv4 with AF_INET filter asserted,OSError→ None, end-to-end FQDN-targeted bridge arms with the resolved IPv4 in_target_ip_uint32_leand theconfigured→resolvedshape in the armed log). The existingtest_invalid_ipv4_logs_value_errorrenamed totest_unresolvable_target_logs_reasonand now patchesgetaddrinfotoOSErrorso the test is hermetic; asserts the newcould not resolve printer host 'not.an.ip'message. 49 bridge tests pass; ruff clean. - MakerWorld URL imports into a writable external folder wrote bytes to internal storage, not the NAS (#1645, reported and root-caused by needo37) — Reporter linked a writable external SMB folder, selected it as the destination in the MakerWorld import dialog, the import succeeded, the file card appeared in the File Manager under the external folder's view — but
lson the NAS turned up nothing, and afindacross the whole NAS and the container for the original filename matched nothing either. The bytes had landed in Bambuddy's internal<DATA_DIR>/archive/library/files/<uuid>.3mfinstead of<external_path>/<filename>on the mount. Root cause was the byte-import save helpersave_3mf_bytes_to_libraryatbackend/app/api/routes/library.py:422: it acceptsfolder_idbut never loaded the folder or inspectedis_external/external_path, hardcoded the destination toget_library_files_dir() / <uuid><ext>, and left theLibraryFilerow withis_external=False. So the row'sfolder_idpointed at the external folder while its bytes +is_externalflag both said "managed/internal" — exact same class of bug as #1112 (which got fixed for the multipart-upload and move paths but never applied to the byte-import path). Compounded by the UUID-renamed on-disk copy: searching for the human-readable basename anywhere — NAS or container — never matches. Fix is a direct mirror of the multipart-upload path that's done this correctly since #1112: load the targetLibraryFolder(whenfolder_idis non-None), feed it to the existing_resolve_upload_destination(target_folder, filename)helper which already produces(dest, is_external)and enforces the 403-read-only / 400-unwritable-or-missing / 409-collision rejections, write the bytes to that destination (real filename for external, UUID for managed), and persist the row via_stored_file_path(dest, is_external)+is_external=is_external. The route-layer read-only guard atmakerworld.py:256-260is preserved — it returns the friendlier error before the upstream download burns bandwidth — and_resolve_upload_destination's identical check stays as defence-in-depth for any future caller that skips the route gate. Thumbnails continue to live under the managedget_library_thumbnails_dir()regardless of the 3MF's location, matching the upload path. Tests: 4 new inTestImport(writable external → bytes on mount +is_external=True+ absolute file_path persisted; read-only external → 403 at route, no download; missing external_path → 400; filename collision → 409 with the pre-existing file's bytes untouched). 21 existing makerworld tests + 72 library-route tests stay green. Ruff clean. - X2D archives lose 3MF metadata because FTPS handshake fails on firmware 01.01.00.00 (#1638, reported by vasmarfas) — Reporter's first archive entries from a brand-new X2D landed almost empty (only print time visible, no filament weight / layers / MakerWorld link / thumbnail), and Spoolman filament-usage tracking also went silent. The support bundle traces the symptom end-to-end: at print start
backend/app/main.py::on_print_starttries the usual FTP-download dance for the 3MF, every connect attempt to the printer fails with[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032), and ~2 minutes laterCould not find 3MF file for print: /data/Metadata/plate_1.gcode→Created fallback archive N for <name> (no 3MF available). The fallback path writes the row withfile_path="",file_size=0,content_hash=NULL, and no layers / filament / model-link fields — exactly the "almost empty card" in the reporter's screenshot. Spoolman tracking and reprint-grouping also degrade from the same root cause: both depend on metadata pulled out of the 3MF byThreeMFParser. The proximate cause is the FTPS handshake: Python 3.13's defaultssl.create_default_context()negotiates TLS 1.3, and the X2D's implicit-FTPS server on port 990 rejects the ClientHello withWRONG_VERSION_NUMBER. This is the same shape of symptom as the P2S 01.02.00.00 FTPS bug from #1401 — handshake / data-channel breakage triggered by the move to Python 3.13's TLS-1.3 default — but the wire-level failure mode is different (P2S completes the handshake and truncates mid-stream with 426; X2D fails the handshake outright). Both are addressed via the per-model registry that #1401 established:backend/app/services/ftp_profiles.pygains anX2Dentry withcap_tls_v1_2=Trueplus aN6 → X2DSSDP alias, so the X2D'sImplicitFTP_TLSconnection caps the SSL context'smaximum_versionto TLS 1.2 and the ClientHello looks like the one the firmware accepted before the Python upgrade. Deliberately conservative — every other model stays on negotiated TLS 1.3, only X2D-tagged sessions flip. Honest caveat: this ships as a hypothesis-driven trial rather than a confirmed root-cause fix. The TLS-1.2 cap is the most likely cure given the symptom's family resemblance to #1401, butWRONG_VERSION_NUMBERcould equally describe the X2D switching to explicit FTPS (AUTH TLS on a plaintext greeting) or moving the FTPS service to a different port — both would need a different code path. The reporter has been asked to test this build; if the cap doesn't clear the error, the registry slot stays useful as a tuning anchor and the next round of diagnostics (openssl s_client -connect <ip>:990 -tls1_2from a network-adjacent host) will tell us which of (2)/(3) applies. Tests: 3 new intest_ftp_profiles.pymirroring the existing P2S coverage —X2Dresolves tocap_tls_v1_2=True,N6SSDP code aliases to the X2D profile, lowercasex2dstill hits the cap. Existing P2S + default + unknown-model + frozen-dataclass + non-capped-spot-check (X1C / H2D / P1S / A1) tests stay green. Verified: ruff clean; the integration test attest_cap_tls_v1_2_actually_applied_to_ssl_contextalready pins the profile→ImplicitFTP_TLS→ssl_context.maximum_versionwiring so this entry can't silently fail to apply. - 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.