github maziggy/bambuddy v0.2.5b1-daily.20260605
Daily Beta Build v0.2.5b1-daily.20260605

pre-release6 hours ago

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 (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 <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) polls archive.timelapse_path every 3s for up to 60s — _scan_for_timelapse_with_retries runs in parallel and writes that field when the FTP download finishes. When it lands and the file exists on disk, extract the last frame as finish_<timestamp>_<hex>.jpg. _background_finish_photo now tries the timelapse path first when timelapse_was_active=True and 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: when archive.bambuddy_forced_timelapse is 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, clears archive.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 bypass background_dispatch.py, so the force-on doesn't fire and the live-cam fallback (bed-down) still applies for those. Can add the mid-print M981 S1 P20000 MQTT toggle in on_print_start later 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 in test_extract_video_last_frame.py cover 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 in test_finish_photo_from_timelapse.py cover 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 in test_dispatch_force_timelapse.py and test_cleanup_forced_timelapse.py pin: override fires only when capture_finish_photo enabled AND user-timelapse off; user-opted-in timelapse passes through unchanged; cleanup deletes local + clears timelapse_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.0 seek 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 to ffmpeg -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. New test_extracts_correctly_from_sub_second_video regression test pins it against a 0.5 s synthetic MP4. (b) The dispatch-time override only wired into background_dispatch.py (Print Now / Reprint flows), but the print queue runs through a separate scheduler at print_scheduler.py:_start_print which calls printer_manager.start_print directly. Refactored the resolver to a module-level resolve_effective_timelapse(db, archive, user_wanted_timelapse) function and wired it into the scheduler's call site too. New test_scheduler_force_timelapse_wiring.py walks the scheduler's AST and asserts the start_print(timelapse=...) kwarg references effective_timelapse (not item.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/24printer 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).

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 no max-h and no overflow. 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-col on the card (the 2rem accounts for the outer p-4), a flex-1 overflow-y-auto min-h-0 wrapper around the form fields, and the Cancel / Save buttons moved into a flex-shrink-0 sibling with a border-t separator so they become a sticky footer that's always visible regardless of scroll position. The buttons stay inside the <form> so type="submit" still works. 2 new vitest cases in ProjectsPage.test.tsx pin the structural fix: the Save button is NOT a descendant of the overflow-y-auto region (otherwise it would scroll off again) and the modal card carries the max-h-[calc(100vh-2rem)] cap. Other modals in the codebase with the same fixed inset-0 flex items-center justify-center + max-w-md shape 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/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 <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 in test_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 in FileManagerPage.test.tsx (External entry conditional on is_external, default internal-only query, External-click switches scope). Existing 48 FileManagerPage tests + 11 FileManagerExternalFolder tests 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/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_<UUID> 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/<version> 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.

Changed

  • Empty AMS units no longer trigger hourly humidity/temperature notifications (#1619) — The hourly AMS sensor recorder in backend/app/main.py::record_ams_history fanned 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-reported tray_exist_bits hex bitmap (one bit per tray slot, "0" / "00" = empty unit) with a fallback to the tray array's tray_type strings 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 in test_ams_alarm_gating.py cover the bitmap-zero-is-empty case, single/multi/all-loaded variants, the tray_exist_bits missing → tray-array fallback, garbage bitmap → fallback, blank bitmap → fallback, non-string bitmap → fallback (Bambu sometimes sends int), whitespace-only tray_type not counting as loaded, and defensive non-dict tray entries.

Added

  • 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: <specific reason> 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.

Changed

  • Inventory: /reset-usage renamed to /reset-consumed-counter; UI label is now "Reset counter" — The old endpoint name implied that calling it would drop weight_used to 0; in practice it only stamps weight_used_baseline = weight_used so the Inventory page's "Total Consumed" widget (which renders weight_used - baseline) reads 0 going forward, while remaining (label_weight - weight_used) is preserved. Calling the endpoint via curl and seeing weight_used unchanged 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 upstream used_weight=0 and the _map_spoolman_spool read mapping at _spoolman_helpers.py:252-268 reconstructs the same displayed consumed = 0, remaining unchanged Bambuddy-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 method reset_spool_usage keeps its name because it describes what's sent upstream to Spoolman, which has not been renamed. Frontend: api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants) renamed to resetSpoolConsumedCounter / 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, resetUsageFailedresetConsumedCounter*, 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 in test_spoolman_inventory_api.py updated 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 default userland-proxy: true) saw ~2000 docker-proxy host 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-574 documents 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[].ip rewrite 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 name p1s.fritz.box instead 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 defensive NOT armed logging ([[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 picker find_interface_for_ip) both assume dotted-quad IPv4 and bail on anything else, so BambuMQTTClient.ip_address being the configured FQDN string short-circuited the rewrite path and net.info[*].ip kept 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 do net.info[].ip rewriting 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) in mqtt_bridge.py: pass-through when target already parses as ipaddress.IPv4Address, otherwise socket.getaddrinfo(target, None, family=socket.AF_INET) to filter to IPv4-only (the net.info[*].ip field is uint32 LE — there's no IPv6 representation that fits, so an AF_INET6 result must not slip through). Returns None on empty input and on OSError from getaddrinfo so transient DNS hiccups don't break the encoding permanently; _refresh_ip_encoding falls back to the existing NOT armed throttle 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 as configured→resolved (target=p1s.fritz.box→192.168.3.153) so a bad-DNS regression stays legible in docker logs without grepping back to the not-armed lines. The unresolvable-input not-armed reason is now could not resolve printer host '<input>' to IPv4 (invalid address and DNS lookup failed) — names the actual configured value, not just invalid IPv4 (target=...), so future bundles distinguish "DNS gave us a v6 address" from "user typed garbage" without a guess. Tests: 5 new in TestHostnameResolution (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_le and the configured→resolved shape in the armed log). The existing test_invalid_ipv4_logs_value_error renamed to test_unresolvable_target_logs_reason and now patches getaddrinfo to OSError so the test is hermetic; asserts the new could 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 ls on the NAS turned up nothing, and a find across 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>.3mf instead of <external_path>/<filename> on the mount. Root cause was the byte-import save helper save_3mf_bytes_to_library at backend/app/api/routes/library.py:422: it accepts folder_id but never loaded the folder or inspected is_external / external_path, hardcoded the destination to get_library_files_dir() / <uuid><ext>, and left the LibraryFile row with is_external=False. So the row's folder_id pointed at the external folder while its bytes + is_external flag 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 target LibraryFolder (when folder_id is 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 at makerworld.py:256-260 is 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 managed get_library_thumbnails_dir() regardless of the 3MF's location, matching the upload path. Tests: 4 new in TestImport (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_start tries 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 later Could not find 3MF file for print: /data/Metadata/plate_1.gcodeCreated fallback archive N for <name> (no 3MF available). The fallback path writes the row with file_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 by ThreeMFParser. The proximate cause is the FTPS handshake: Python 3.13's default ssl.create_default_context() negotiates TLS 1.3, and the X2D's implicit-FTPS server on port 990 rejects the ClientHello with WRONG_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.py gains an X2D entry with cap_tls_v1_2=True plus a N6 → X2D SSDP alias, so the X2D's ImplicitFTP_TLS connection caps the SSL context's maximum_version to 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, but WRONG_VERSION_NUMBER could 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_2 from a network-adjacent host) will tell us which of (2)/(3) applies. Tests: 3 new in test_ftp_profiles.py mirroring the existing P2S coverage — X2D resolves to cap_tls_v1_2=True, N6 SSDP code aliases to the X2D profile, lowercase x2d still 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 at test_cap_tls_v1_2_actually_applied_to_ssl_context already pins the profile→ImplicitFTP_TLSssl_context.maximum_version wiring so this entry can't silently fail to apply.
  • Label printing produced two identical PDFs per click (#1628)LabelTemplatePickerModal.tsx::openBlobInNewTab called window.open(url, '_blank', 'noopener,noreferrer') and treated a null return as "popup blocked → fall back to <a download> click." Per the WindowFeatures spec, noopener deliberately forces window.open to return null even on success, so the if (!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 (the zo70GhSL.pdf / f7w0OcDi.pdf files in the reporter's screenshot). Path 2 (fallback) downloaded a second copy named bambuddy-labels.pdf. Two identical PDFs per click. Fix: drop noopener,noreferrer. The blob is same-origin (created via URL.createObjectURL from our own fetch response), the destination is a passive PDF preview tab with no script context to abuse window.opener, and noreferrer is a no-op for blob URLs. After removal, window.open returns a real window reference on success → if (!win) only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases in LabelTemplatePickerModal.test.tsx still 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_MODELS alongside the A1 family. Moved to _DRYING_MIN_FIRMWARE with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes the H2C advertises (O1C, O1C2 — single- and dual-nozzle variants) get the same firmware gate so the supports_drying() check fires correctly regardless of which form is in the printer record. Test coverage extended in TestSupportsDrying: 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_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.

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:MM and _calculate_next_run in backend/app/services/local_backup.py interpreted 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 the TZ env var via zoneinfo.ZoneInfo (same source the Support page's environment.timezone already shows). UTC fallback when TZ is 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 key backup.localTimeHint with real translations across all 10 non-English locales, replacing the old backup.utc literal. New timezone field on /api/local-backup/status exposes 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 with monkeypatch.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.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.