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

pre-release2 hours ago

Note

This is a daily beta build (2026-05-30). 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

  • System theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by TempleClause via PR #1501)ThemeMode gains a third value 'system' alongside the existing 'dark' / 'light'. The provider listens to window.matchMedia('(prefers-color-scheme: dark)'), tracks the OS preference in real time, and exposes a new resolvedMode: 'light' | 'dark' to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cycles dark → light → system → dark with the icon hinting at the next stop (SunMonitorMoon); the existing logo selection and the dark/light "active" panel highlight in Settings switched from mode to resolvedMode so they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-mode so System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persisted theme-mode is untouched — anyone on dark or light stays there and simply gains an extra stop in the cycle; new installs default to dark. Review-caught fixes shipped in the same PR: (a) the project's __tests__/setup.ts mocked window.matchMedia with vi.fn().mockImplementation(...), which vi.restoreAllMocks() in three test files reset to "return undefined" — pre-PR nothing called matchMedia at render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })) so restoreAllMocks can't touch it. (b) themeToggleHint had previously only been updated in en.ts; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (dark → light → system now intercepts where users previously got dark → light → dark), with the persisted-preference-unchanged caveat made explicit. (d) New i18n key nav.switchToSystem with real translations across all 9 locales ('Switch to system mode' / 'Zum Systemmodus wechseln' / 'システムモードに切替' etc.). Tests: 11 new in ThemeContext.test.tsx (systemPreference inits from matchMedia.matches, change event updates state, resolvedMode follows explicit mode vs systemPreference per mode value, dark class applied based on resolved mode, toggleMode cycles dark→light→system→dark); 1 new in Layout.test.tsx (toggle button title attribute walks the cycle); 4 new in SettingsPage.test.tsx (all three buttons render, active green border keys off mode, click switches mode, click fires toast). 26 previously-broken tests in AddNotificationModal.test.tsx + NotificationProviderCardStockAlerts.test.tsx + CameraTokensPage.test.tsx pass again post-setup.ts fix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 from switchToSystem). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity, .map() refactor for the button group) in a single revision push, no follow-ups deferred.
  • MQTT auth rate-limit on the virtual printer — Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT server on port 8883. Without a rate limit the code is brute-forceable by anyone who can reach the VP's bind IP (LAN, Tailscale, or any other tunnel the user chose to expose). The new per-IP limiter records each failed CONNECT auth attempt and rejects further CONNECTs from that IP once 5 failures occur within a 60 s window. The window is sliding (not cumulative), recovers automatically after expiry — no manual unblock — and successful auth clears the IP's prior failure history so a user who fat-fingered their code 3 times then got it right isn't penalised on their next reconnect. Per-IP tracker uses time.monotonic() so wall-clock jumps can't extend or shorten the window unexpectedly. Constants _AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5 and _AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0 are module-level for ops tunability. 5 unit tests in test_vp_mqtt_server.py::TestAuthRateLimit pin the under-limit/at-limit/window-recovery/multi-IP/success-clears semantics.
  • Per-slicer MQTT response routing for multi-slicer VP setups — Pre-fix: when slicer A sent extrusion_cali_get (or any other bridge-forwarded command) to a non-proxy VP bound to a target printer, the printer's response was fanned out to every connected slicer — leaking slicer A's response into slicer B's command stream. Slicers ignore responses to sequence_ids they didn't send, but the leak is still wrong and could confuse multi-slicer-host setups (workstation + laptop both connected to the same VP). The fix records sequence_id → originating client_id in SimpleMQTTServer._pending_requests on the way out and looks it back up in push_raw_to_clients on the way in, routing the response only to that one client. Falls back to broadcast for printer-initiated unsolicited pushes (push_status etc. — every slicer expects these) and for sequence_ids the map never saw recorded (covers slicers subscribing mid-flight). Bounded at 256 entries with FIFO eviction so a slicer that sends commands without ever consuming responses can't leak memory. 6 unit tests in test_vp_mqtt_server.py::TestPendingRequestRouting cover seq-id capture across nested blocks, lookup-pops-entry semantics, FIFO eviction at cap, malformed-payload fallback, and broadcast on unrecorded seq.
  • H2D Pro virtual-printer support (experimental — needs field confirmation) — Added SSDP model codes O1E and O2D to VIRTUAL_PRINTER_MODELS and matching 09400A serial prefixes to MODEL_SERIAL_PREFIXES so the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D's O1D. The codes were transcribed from the project's model-codes reference but have not been validated against a live H2D Pro's SSDP response. Anyone with an H2D Pro who picks this from the dropdown should confirm BambuStudio recognises the VP correctly; if not, the code values need a one-line correction and a follow-up release.
  • VP child-service readiness barrier — Pre-fix: VirtualPrinterInstance.start_server spawned each child sub-service (FTP, MQTT, Bind, SSDP) as a asyncio.create_task and returned immediately. is_running then reported True even though the child sub-services' sockets were still in the gap between asyncio.create_task(...) and the inner asyncio.start_server returning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could see running=pass while port_ftps=fail. Each child now exposes a ready: asyncio.Event that's set after the actual socket bind, and start_server awaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs a Sub-service didn't bind within 5s: ... warning and the VP continues — the existing task-tracking still catches the failure on the next iteration. The 5 s ceiling is well above any legitimate bind on healthy hardware; on a Pi 3 with a congested SD card it's tight but bounded.

Changed

  • Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load — 170 issues have been closed with the invalid label (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. Form changes (bug_report.yml): (a) the "I ran the Connection Diagnostic" checkbox flipped from required: false to required: true, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is now required: true instead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the why before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. Bug categorization (the gap that motivated the rewrite): the old single Component dropdown only carried Bambuddy / SpoolBuddy / Both — useless for area triage. Replaced with TWO required dropdowns: Product (Bambuddy / SpoolBuddy) and Area (15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). Auto-labeling (.github/workflows/auto-label-area.yml): on every issue open/edit, an actions/github-scriptv7 step parses the Area dropdown out of the rendered issue body (matching the ### Area\n\nValue block GitHub forms produce) and applies the matching area:* label. Tolerant of CRLF, the _No response_ placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit a core.warning so missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15 area:* labels need to be created once via gh label create (see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause the addLabels call to throw, so this prerequisite is load-bearing. Printer Model dropdown verified against PRINTER_MODEL_MAP in backend/app/utils/printer_models.py — all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Python yaml.safe_load for both the template and the workflow.
  • VP virtual-printer FTP server: cmd_STOR streams chunks straight to disk instead of buffering the whole upload in memory — Pre-fix: cmd_STOR accumulated every chunk in a list[bytes] and called write_bytes at the end. Peak RSS for a multi-GB .gcode.3mf (multi-plate dense prints) was ~2× the file size — chunks held + the b''.join of them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk to file_path.open("wb") inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same 150 → 226 sequence, same destination path, no new verbs, no concurrency guard. The visible difference is that the destination file grows progressively rather than appearing all-at-once on completion; slicers don't LIST during STOR so this isn't observable. Same change adds a MAX_UPLOAD_BYTES = 4 GiB hard cap — a runaway or malicious client can no longer drive RSS or disk to exhaustion. On the cap path the partial file is unlinked so a slicer retry starts clean. 4 unit tests in test_vp_ftp_stor.py (happy-path bytes on disk + 226, cap-violation 426 + partial cleanup, mid-stream read error cleanup, MAX_UPLOAD_BYTES sanity floor).
  • VP virtual-printer FTP passive port range widened from 50000-50100 (101 ports) to 50000-51000 (1001 ports) — The original range was sized for a single VP. With multiple VPs each running their own FTP server, concurrent passive data connections compete for the 101-port pool and the bind-retry loop's 10 random picks can collide; 1001 ports gives headroom. Only affects the non-proxy path (VirtualPrinterFTPServer.PASSIVE_PORT_MIN/MAX). The proxy path's SlicerProxyManager.FTP_DATA_PORT_MIN/MAX stays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to 50000-51000:50000-51000docker-compose.yml, install/docker-install.ps1 warning, and the wiki (docs/getting-started/docker.md, docs/features/virtual-printer.md — port table, two UFW rules, two firewalld rules, Cloudflare-tunnel list, firewall troubleshooting line) all updated with "widened in 0.2.5" notes. Docker host-mode and bare-metal users are unaffected (no port mapping involved). The proxy-mode FTP-data row in the wiki stays at 50000-50100 because that path is unchanged.
  • VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes — Pre-fix: when the bridge cached a real printer's push_status, the very next 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lost upgrade_state, xcam, hw_switch_state, nozzle_diameter, nozzle_type, online and ams_status after a single tick — BambuStudio's Send pre-flight reads several of these (upgrade_state.dis_state / force_upgrade in particular) and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields) — the cached-branch field-shape parity, not a new mechanism. Sticky-keys carry-forward is now also a copy.deepcopy (was reference) so a future merge that mutates a carried-forward dict in place can't corrupt both copies.
  • VP target-printer DHCP IP / serial refresh now restarts proxy VPs — Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever because sync_from_db's "changed" predicate didn't compare proxy_ips against the running instance's target_printer_ip / target_printer_serial. The user had to manually toggle the VP to refresh. Now sync_from_db re-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes — same code path as a config change. If the target printer's DHCP lease cycles frequently this means more proxy restarts, but the alternative was silent breakage; documented in the release-notes for users on flaky-DHCP networks.
  • VP queue_force_color_match setting takes effect immediately — Pre-fix: toggling the per-VP Force exact color match setting via the UI silently no-op'd because sync_from_db's "changed" predicate didn't include the field. The user had to restart the process for the new value to land. The predicate now also checks queue_force_color_match so the running instance gets restarted on toggle.
  • VP MQTT client session errors elevated from DEBUG to WARNING — The outer except Exception in SimpleMQTTServer._handle_client was logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility.
  • VP MQTT periodic status push now logs a one-line per-minute counter per active slicer connection (#1548 follow-up)_periodic_status_push emits 1Hz status push: N pushes/min to <client> at INFO level once per minute per connected slicer (silent when no slicer is attached). The 1 Hz status push was previously silent at INFO; when a reporter sent a support bundle showing an idle disconnect, there was no way to tell whether the push task was actually pushing to that connection or being eaten silently. The counter both confirms the task is healthy for a given client and gives us a concrete data point (N < 60 means pushes were dropped) when triaging future "slicer disconnects on idle" reports. No behaviour change to the push itself.

Security

  • Trivy DS-0026 (Dockerfile.test missing HEALTHCHECK): silenced via HEALTHCHECK NONE — The test image runs pytest and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. HEALTHCHECK NONE is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813.
  • VP access codes now compared with hmac.compare_digest (constant-time) — Pre-fix: both FTPSession.cmd_PASS and SimpleMQTTServer._handle_connect used Python's == operator on the 8-char access code. Constant-time comparison closes the timing-side-channel without changing the protocol surface. Same auth, no UX change.
  • VP MQTT brute-force rate-limit per source IP — 5 failed CONNECT attempts within a 60 s sliding window block further auth attempts from that IP for the rest of the window. Auto-recovers — no manual unblock. Constants _AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5 / _AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0 are module-level for ops tunability. See Added section for full description.
  • VP access_code no longer leaked in DEBUG logs — Pre-fix: PUT /virtual-printers/{id} logged body.model_dump(exclude_unset=True) at DEBUG, which dumped the plaintext access code whenever the user saved a new one. Now the field is redacted (***) before the log emission. Violation surfaced by no-secrets-in-logs audit; not exploitable in the field (DEBUG is off by default) but is exactly the kind of leak the rule exists to prevent.
  • VP FTP upload capped at 4 GiB (DoS guard)cmd_STOR now rejects an upload that crosses MAX_UPLOAD_BYTES = 4 GiB, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate .gcode.3mf. Same code path adds the streaming rewrite (see Changed section for details).

Fixed

  • Sliced .gcode.3mf files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by Vlado-Tarakan) — Reporter exported a multi-plate .gcode.3mf from Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classify file_type differently. backend/app/api/routes/library.py:1343-1348 (the shared-folder scan path) does a compound-extension check and tags the file gcode.3mf; the upload path at the same file's 1588 does a single ext[1:] and tags it 3mf. Then frontend/src/components/ModelViewerModal.tsx:71-73 had hasModel = normalizedType === '3mf' || 'stl' and hasGcode = normalizedType === 'gcode' || '3mf' — neither matched gcode.3mf, so the capabilities object landed with both flags false and the modal rendered an empty bed. FileManagerPage.tsx:858 also gated the Preview-3D context action on file_type === '3mf' || 'gcode' || 'stl', so for shared-folder files the entry didn't even appear, and the type pill at 765-770 had no colour case for gcode.3mf so it fell through to the generic gray. Fix (frontend-only, no backend churn): ModelViewerModal.tsx introduces an isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf' predicate used in two places — the capabilities branch (hasModel = isThreeMfFamily || 'stl', hasGcode = isThreeMfFamily || 'gcode') and the plates-loading branch that previously hard-gated on !== '3mf' and would have returned setPlatesData(null) for the shared-folder file. FileManagerPage.tsx adds gcode.3mf to the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compound gcode.3mf classification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. The canOpenInSlicer and sliceableType checks at ModelViewerModal.tsx:269, 277-280 are deliberately left alone — a sliced output isn't openable in the slicer, and sliceableType already explicitly excludes .gcode and .gcode.3mf per the comment "the file type can't be sliced". Out of scope (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's .gcode.3mf export contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates via gcode_*.gcode entries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. Tests: existing full 2043-test frontend suite green; no test asserted on the unsupported gcode.3mf capabilities branch (the change is additive — 3mf and stl and gcode behaviours are unchanged). Frontend build clean.
  • Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by vixussrl-ui) — Reporter ran a fresh trace after the doubled-extension fix landed and found a distinct second cause behind his ghost prints, hitting 4-of-4 of his A1s. Timeline: 22:50 PRINT START → print runs all night → MQTT disconnects multiple times (A1's keepalives are unstable on his network) → print finishes during one of those disconnect windows so PRINT COMPLETE is never observed → smart plug cuts power on idle → power resumes for the next scheduled print → firmware auto-replays the leftover .3mf from the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check at backend/app/services/bambu_mqtt.py:3022 was meant to catch the simple disconnect-then-finish case via _previous_gcode_state preserved across reconnects, but with multiple disconnect/reconnect cycles + a smart-plug power-off that Bambuddy can't distinguish from any other transient drop, the IDLE window that branch needs simply never reaches it. The SD .3mf lingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. Fix: a new connected-edge reconciliation pass — new reconcile_stale_active_prints(printer_id) in backend/app/main.py queries archives in status="printing" for the printer at MQTT (re)connect time and synthesises on_print_complete(status="aborted") for any whose print can't actually be running anymore. The decision is made by a pure _is_active_archive_stale(archive, state) function with three triggers: (1) current printer state is terminal (IDLE / FINISH / FAILED) — covers the clean disconnect-then-finish case the existing #3022 branch was already trying to handle; (2) printer is running but with a different subtask_id than the archive — Bambu firmware mints a fresh subtask_id for each print including the ghost-replay it runs after a power cycle, so a mismatch is unambiguous evidence the in-DB archive is no longer the print on the printer; (3) printer is running but subtask_name is empty — the printer doesn't know what it's running, archive reference is broken. PAUSE / PREPARE / SLICING / RUNNING with matching subtask are intentionally left alone — false positives there cost a single misreported "aborted" status that the real PRINT COMPLETE would have overwritten anyway, while a false negative is the ghost-print loop being reported. The synthesised on_print_complete reuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call sees status != "printing" and falls through). Status "aborted" is the conservative label; we have no progress evidence to promote to "completed". Wiring: new _printer_reconciled_since_connect: dict[int, bool] edge tracker at module scope, checked at the start of on_printer_status_change — when state.connected flips False → True (which covers both Bambuddy startup with no prior connection AND a mid-session MQTT reconnect), reconciliation fires exactly once for that connection. Setting the edge to True BEFORE the spawned task starts prevents concurrent status updates within the same connection from re-triggering it. Concurrency: reconciliation runs as asyncio.create_task so it doesn't block the WebSocket dedup / broadcast logic that on_printer_status_change is the hot path for. Ghost-print collateral worth being explicit about: if the ghost is already running when reconciliation fires, the synthesised SD-cleanup will hit 550-file-locked (firmware locks the file during print, same cause as the #1542 first case). The cleanup retries 3× then logs "lingering" — same as any other in-print cleanup attempt. The ghost runs to completion, its own end-of-print cleanup deletes the file, and the next power cycle has nothing to replay. The loop breaks even when reconciliation can't physically delete the file mid-ghost. A perfect cancel would require sending a print_stop MQTT command to the printer, which is invasive and explicitly out of scope. Tests: 21 in test_reconcile_stale_active_prints.pyTestIsActiveArchiveStale covers all three stale triggers with case-insensitive state matching, the four healthy-no-op cases (RUNNING / PAUSE / PREPARE / SLICING with matching subtask), the IDLE-overrides-subtask-match precedence, and the missing-subtask_id edge cases that fall through to the subtask_name check. TestReconcileStaleActivePrints covers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesised on_print_complete(status="aborted", _reconciled=True) payload with the archive filename; a healthy in-flight archive doesn't fire any completion; an exception inside one archive's synthesis doesn't block the rest or propagate to the caller. Full 5399-test backend suite green (5378 + 21 new). Backend ruff clean.
  • Fallback-archive MQTT filament extraction now actually fires for real prints (#1533 follow-up, reported by JmanB52D) — Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (AMS 0: T0(type=PETG, color=FFFFFFFF, …)). Cause: the #1533 helper _extract_filament_data_from_mqtt(data) in backend/app/main.py only looked at data["ams"], but the dict that on_print_start actually receives at runtime is the wrapper shape {"filename", "subtask_name", "remaining_time", "raw_data": <mqtt_payload>, "ams_mapping"} that backend/app/services/bambu_mqtt.py:2971-2980 constructs — so data["ams"] was undefined on every real call and the helper silently returned {}, leaving the fallback archive's filament_type / filament_color NULL. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through the green build. Fix: the helper now resolves data["raw_data"]["ams"] first (the callback shape) and only falls back to data["ams"] when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dict raw_data (e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. Tests: 5 new in TestOnPrintStartCallbackShape (backend/tests/unit/test_fallback_archive_mqtt_filament.py) — wrapper payload with ams_mapping resolves to the inner data; wrapper with no ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missing raw_data returns {} instead of raising; junk raw_data (string) doesn't shadow a present inner ams. Full 5378-test backend suite green. Backend ruff clean. What this does NOT fix: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point that drives the AMS-expansion planning the reporter explicitly called out, so this is the fix that moves the needle for him.
  • Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs, and the warning now states the AMS slot will be reconfigured (#1552, reported by anthonyma94) — Reporter assigned a spool to a slot whose stored slicer profile (e.g. "Bambu PLA Matte") differed from the new spool's profile (e.g. "Bambu PLA Basic"), got a warning popup with only Cancel / Assign Anyway, and was under the impression that confirming the popup just linked the spool in Bambuddy's DB without touching the AMS — i.e. that he then had to manually open Configure AMS Slot to push the new profile to the printer. The auto-push has actually been in place since the assign route existed: backend/app/api/routes/inventory.py::assign_spool calls apply_spool_to_slot_via_mqtt after upserting the SpoolAssignment row, which publishes both ams_filament_setting (tray_info_idx, tray_sub_brands, color, temps) and extrusion_cali_sel (K profile) over MQTT, and backend/app/api/routes/spoolman_inventory.py::assign_spoolman_slot does the same on the Spoolman side. The only short-circuit is when the firmware explicitly reports the slot empty (tray_state ∈ {9, 10}), in which case main.py::on_ams_change deferred-replays the configure as soon as a spool appears. So the popup was creating friction without revealing what it actually did. Two changes: (1) AssignSpoolModal.tsx + spoolbuddy/AssignToAmsModal.tsx no longer fire the mismatch popup for profile-only mismatches — if (materialMatchResult !== 'exact') replaces the old materialMatchResult !== 'exact' || !profileMatches, and the 'profile' member is dropped from the mismatchType union (the standalone profile branch in both popup render bodies is removed as dead code). Material mismatch — where Bambu firmware can refuse the print because the type is wrong — still warns. (2) Every firing warning (material, partial, material+profile, partial+profile) now appends a new line via the new inventory.assignReconfigureNote i18n key: "The AMS slot will be reconfigured to use the spool's profile." This makes the Assign Anyway button's effect explicit instead of leaving users to guess. i18n: real translations across all 9 locales per [[feedback_translate_dont_fallback]]; parity script clean at 4999 leaves per locale. Tests: existing 14 AssignSpoolModal + 7 AssignToAmsModal tests pass unchanged — no test asserted on the profile-only popup firing. Frontend build clean, full 2043-test suite green. Open follow-up: if anthonyma94 confirms after this change that his slot still shows the old profile after Assign Anyway, the real bug is in apply_spool_to_slot_via_mqtt's tray_info_idx / setting_id resolution for his specific spool shape — would need his spool's slicer_filament value plus the live tray state to diagnose.
  • Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by Synec5, confirmed by CMW-ISS) — Reporter wanted to select a transparent filament colour in the spool editor; CMW-ISS independently confirmed on v0.2.5b1 that AMS-detected transparent spools were silently labelled "Black" in the filament-mapping dropdown because the colour name resolver dropped the alpha byte and the underlying RGB 000000 HSL-bucketed to "Black". Spoolman already supported 8-digit RRGGBBAA hex; the built-in inventory didn't. Five distinct sites collapsed alpha → 6-char RGB and had to be fixed together: (a) frontend/src/utils/colors.tshexToColorName, getColorName, resolveSpoolColorName, and isLightColor now short-circuit to "Clear" when the input is 8 chars with alpha 00, before either the catalog lookup or the HSL fallback can mislabel transparent as black; isLightColor returns true for clear so text contrast matches the light/mid-gray checkerboard underlay the swatch paints. (b) frontend/src/utils/amsHelpers.ts::normalizeColor no longer unconditionally strips the alpha byte — it preserves #RRGGBBAA when alpha < FF so the AMS-side colour reaches CSS fill= / backgroundColor as a translucent value instead of a solid one; opaque colours still emit #RRGGBB and normalizeColorForCompare (which DOES strip alpha) is unchanged so type/colour matching for auto-mapping is unaffected. (c) backend/app/api/routes/printers.py::get_available_filaments no longer truncates tray_color to 6 chars before emitting it on /printers/available-filaments — both the AMS and vt_tray branches now pass the full #RRGGBBAA through; the dedup key still uses the 6-char RGB so two slots that share an RGB but differ only in alpha still merge into one filament requirement. (d) frontend/src/components/spool-form/constants.ts gained a { name: 'Clear', hex: '00000000' } entry to QUICK_COLORS — the only 8-char preset, because the native <input type="color"> can't pick alpha and a dedicated swatch is the only UX that lets the user actually choose transparency. (e) frontend/src/components/spool-form/ColorSection.tsx reworked the hex draft contract: previously the hex input was hardcoded to 6 chars and every commit path unconditionally appended 'FF', so even pasting 00000000 got truncated to 000000FF (solid black). Now: the draft accepts up to 8 hex chars; a 6-char commit appends FF, an 8-char commit passes through verbatim; on blur a 7-char draft (RGB + one alpha nibble) right-pads the nibble to 0 instead of jumping back to 6-char-pad-RGB; the selectColor() helper that the preset swatches call only appends FF when the preset is 6 chars, so the new Clear swatch lands as 00000000 in formData.rgba instead of 00000000FF. currentRgba is canonicalised to 8 chars uppercase and isSelected() matches on the full rgba so Clear (00000000) doesn't collide with Black (000000FF) in the swatch highlight. (f) Two new shared helpers in frontend/src/utils/colors.tsgetSwatchStyle(rgba) returns a { backgroundColor } for opaque colours and a { backgroundImage, backgroundSize } 8px checkerboard for alpha=00 (use for div / button backgrounds); spoolColorString(rgba) returns a hex string that preserves the alpha byte when alpha < FF (use for SVG fill= props and other single-string colour contexts where the consumer can interpret 8-char hex natively). Applied to every simple-swatch site that previously did style={{ backgroundColor: '#' + rgba.slice(0, 6) }} or passed a 6-char fill to an SVG icon — those sites would have rendered Clear spools as solid black after the cream rewrite was removed: the three preset rows in ColorSection.tsx (recent / catalog / fallback), the spool checkbox swatch in LabelTemplatePickerModal.tsx, the per-card colour dot + the SVG SpoolCircle in SpoolBuddyInventoryPage.tsx, the assigned-spool indicators in SpoolBuddyAmsPage.tsx (both internal and Spoolman branches), the four selected-spool summary swatches + the simple-view's spool dot in SpoolBuddyWriteTagPage.tsx, the lead-spool indicator in ForecastPanel.tsx, the header swatch in AssignToAmsModal.tsx, the two spool-list dots in AssignSpoolModal.tsx (internal + Spoolman columns), and the SpoolIcon fed by InventorySpoolInfoCard.tsx / TagDetectedModal.tsx / SpoolInfoCard.tsx / LinkSpoolModal.tsx (which now pass the full 8-char rgba — SVG fill= interprets translucent values correctly). FilamentSwatch.tsx's tooltip title fallback also widened so the on-hover hex code shows #00000000 for a Clear spool instead of misreporting it as #000000. What is intentionally NOT changed: the native <input type="color"> value in SpoolBuddyWriteTagPage.tsx's simple-view picker keeps its 6-char hex — that input element doesn't support alpha, and its onChange handler still sets rgba back to opaque FF (which is correct behaviour: the user explicitly picked a colour via the picker, not transparency). The colour-sort comparator in LabelTemplatePickerModal.tsx::colorSortKey keeps its 6-char alpha-strip — transparent spools sort into the same bucket as black/neutrals which is the right behaviour for ordering. The label-renderer in backend/app/services/label_renderer.py keeps its 6-char alpha-strip in _hex_code_label because the printed text on a physical label can't show transparency — _color_from_hex does honour the alpha byte for the printed swatch fill (alpha=00 → invisible swatch on the label, which is the honest physical answer). The Spoolman auto-sync's _find_or_create_filament in backend/app/services/spoolman.py still strips alpha when looking up the Spoolman catalog because Spoolman's filament catalog schema only supports 6-char color_hex — a transparent AMS spool synced into Spoolman will now match against a 000000 (Black) Bambu Lab filament entry instead of the pre-fix synthetic "PLA Basic" cream entry (RGB F5E6D3); both are inaccurate, the post-fix behaviour is at least honest about which colour the catalog has chosen rather than silently inventing a cream spool — users on the Spoolman backend can manually correct the filament assignment if desired. (g) Removed the cream rewrite at backend/app/services/spoolman.py::parse_ams_tray that silently replaced AMS-reported 00000000 with F5E6D3FF ("Light cream/natural color"). That rewrite was a workaround from when the swatch renderer couldn't show alpha — filamentSwatchHelpers.ts::buildFilamentBackground already paints a checkerboard underlay for alpha < FF (added in #1154), so the rewrite has been hidden technical debt that made every AMS-detected transparent spool land in inventory as cream instead of clear, with no signal to the user that a colour was substituted. AMS-synced spools now keep their true 00000000 value; the swatch renders the checkerboard; getColorName resolves to "Clear". (h) backend/app/services/spool_tag_matcher.py::create_spool_from_tray short-circuits the colour-catalog lookup when rgba is alpha=00 and stores color_name="Clear" directly — without this, a Bambu-RFID transparent spool would resolve against the #000000 row in the catalog (or Black via the HSL fallback) before the frontend's name resolver ever sees it, defeating the alpha-aware fix in colors.ts. Testssrc/__tests__/utils/colors.test.ts: 5 new assertions covering alpha=00 → "Clear" for hexToColorName, getColorName (including precedence over a catalog entry on the same RGB), and resolveSpoolColorName; one existing assertion changed from 12345600 (which now correctly resolves to "Clear") to 123456FF to keep its intent of "unknown opaque colour returns null". src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx: header docblock rewritten to reflect the new 0–8 char draft contract; the "truncates 7–8 char pastes to RGB" test replaced with two new tests — '0011223344' paste now truncates to the leading 8 chars (00112233) and commits verbatim with no FF append, and a 7-char draft on blur pads to 8 with a trailing 0 instead of jumping back to RGB. 17 colours tests, 9 hex-input tests, 53 useFilamentMapping tests, 14 FilamentOverride tests, 10 FilamentSlotCircle tests, 6 /printers/available-filaments integration tests, 50 Spoolman API integration tests all green. Backend ruff clean; frontend build clean; i18n parity clean at 4998 leaves per locale. What this does NOT change: Spoolman-mode parity is preserved — Spoolman's own picker already supported 8-digit hex and inventory.py:119 / spoolman.py:887-889 already passed 00000000 through verbatim (the 6→8 char FF pad only fires when len == 6), so no parallel mutation is needed on the Spoolman-mode write path. Existing inventory rows that were already rewritten to F5E6D3FF stay as cream until the next AMS sync overwrites them — a one-time edit is the only path to recover them, and dropping the rewrite means future AMS syncs land the true value.
  • Virtual-printer MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by hollajandro) — Reporter pointed OrcaSlicer at a Bambuddy virtual printer and got a clean MQTT/TLS connect, successful auth, and a normal pushall/get_version exchange — then the slicer dropped exactly ~60 s later, every time, even after a fresh trust of the VP CA, a logged-out Bambu account, and toggling VP mode. Trace from his support bundle: 5 consecutive connect→disconnect cycles all exactly 60 s apart, with no intervening client packets after the initial exchange. Root cause: backend/app/services/virtual_printer/mqtt_server.py::_handle_client used a hardcoded timeout=60 on every per-packet read, and _handle_connect two functions below explicitly skipped the keepalive field from the CONNECT payload (# Skip keepalive / idx += 2). So no matter what the client negotiated, the VP server would close the socket after 60 s of silence — and OrcaSlicer's normal pattern after the initial exchange is to sit quietly waiting for the printer to push status updates, which a virtual printer with no real state changes doesn't do. The real Bambu firmware honours the client's keepalive (MQTT spec §3.1.2.10 / §4.4: server must allow 1.5× the negotiated value before disconnecting), which is why Orca works against a real P1S but failed at exactly 60 s against the VP. Fix: _handle_connect now parses the 2-byte big-endian keepalive value from the CONNECT payload and returns it alongside the auth bool (tuple[bool, int]). _handle_client uses that to set its per-packet read timeout to 1.5 × keep_alive after a successful CONNECT, or None (no timeout) when the client opted out with keep_alive == 0 per spec. The 60 s default is retained for the initial read before CONNECT arrives, so a TCP-connect-but-never-send still gets reaped. Tests: 7 in test_vp_mqtt_server.pyTestHandleConnectKeepalive (4: returns negotiated value on success, returns 0 for opt-out, returns (False, 0) on auth fail / parse error so the caller's tuple-unpack never crashes), TestHandleClientHonoursKeepalive (3: idle client with keep_alive=180 is still alive past the old 60 s boundary; keep_alive=2 closes idle in ~3 s; a PINGREQ inside the window resets the timeout and the connection exits via DISCONNECT instead of timeout). The integration-style tests feed a synthetic CONNECT into a real asyncio.StreamReader and drive the handler on an event loop, so the timeout math is exercised end-to-end, not just unit-mocked. Backend ruff clean.
  • A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled .gcode.3mf (#1542, reported by vixussrl-ui) — Reporter has seven A1s powered through Tuya smart plugs + Home Assistant. After every plug-driven auto-off, turning the printer back on would sometimes start the previous print on its own. Trace from his support bundle: the library row in his DB had archive.filename = "Cube (1).gcode.3mf.gcode.3mf" — the .gcode.3mf suffix had been appended twice somewhere during the file's import. The dispatcher's archive.filename → SD-card-name derivation only stripped ONE trailing .gcode.3mf, so the upload landed at /Cube_(1).gcode.3mf.3mf. The print ran fine, but the post-print SD cleanup in main.py derived its delete target from subtask_name + ext (/Cube_(1).3mf, /Cube_(1).gcode) — neither matched the actually-uploaded path, both 550'd three times, and the real file lingered on the SD card. On next power-up the A1 firmware picked up the leftover .3mf at the SD root and started printing it, exactly like the P1S behaviour the original Issue #374 cleanup was meant to prevent. Two structural fixes, both shipped together (no follow-ups per [[feedback_no_followups]]): (1) shared name derivation. New derive_remote_filename(filename) helper in backend/app/utils/filename.py iteratively strips trailing .gcode.3mf / .3mf suffixes until the bare stem remains, then appends a single .3mf and underscore-replaces spaces (the firmware parses ftp://{filename} as a URL, spaces break it). Iterative strip handles the doubled-suffix data; the previous single-iteration strip silently fell through to "append .3mf to whatever's left", which is how doubled extensions ended up on the SD card in the first place. The helper is the single source of truth for the SD-card target name — three previously-duplicated upload sites now route through it: _run_reprint_archive and _run_print_library_file in backend/app/services/background_dispatch.py, and the queue dispatch in backend/app/services/print_scheduler.py. (2) cleanup uses the same algorithm as upload. The post-print SD cleanup in main.py now fetches archive.filename when archive_id is resolved and tries derive_remote_filename(archive.filename) FIRST, with the legacy /{subtask_name}.3mf and /{subtask_name}.gcode paths kept as fallbacks for archive-less prints (subtask never matched any archive) and for older naming variants. De-duped when the primary target equals one of the fallbacks, so the happy-path delete count is unchanged. On the reporter's case the new primary candidate is /Cube_(1).gcode.3mf.3mf, matching the on-card file and deleting it cleanly — no more ghost print. Out of scope (separate concern): the upstream import path that produced the doubled .gcode.3mf.gcode.3mf filename is not addressed here — the iterative strip in derive_remote_filename defends against it everywhere it matters (upload target, cleanup target), so any future user with the same legacy data still gets clean dispatch and cleanup. Defensive hardening caught in the first integration run: the initial helper had no input type check, just a while True strip loop with endswith / slice. When a unit test mock (unittest.mock.MagicMock) was passed in by accident via the new cleanup path, mock.endswith(".gcode.3mf") returned a truthy MagicMock on every iteration and the slice stem[:-10] returned another MagicMock — the loop never reached the else: break branch. Each iteration allocated a fresh MagicMock until the LXC cgroup OOM-killer reaped the pytest worker at 61 GB anon-rss (visible in journalctl -k as oom_memcg=/lxc/109 with CONSTRAINT_MEMCG). Fixed by adding an isinstance(filename, str) guard that raises TypeError instead of entering the loop — turns the silent infinite allocation into a loud, debuggable error. The same guard protects production: if a corrupt DB row or ORM edge case ever surfaces a non-str archive.filename, the cleanup logs a warning via its outer try/except instead of OOMing the backend. Tests: 10 in TestDeriveRemoteFilename in test_filename_validation.py (single .gcode.3mf strip, single .3mf strip, bare stem appends .3mf, space→underscore, the literal Cube (1).gcode.3mf.gcode.3mf reproducer from #1542Cube_(1).3mf, doubled .3mf.3mf, mixed .gcode.3mf.3mf, raw .gcode preserved as .gcode.3mf since .gcode alone is a valid sliced file, idempotence — running the helper on its own output is a no-op, Unicode stem preserved, type guardMagicMock / None / int inputs all raise TypeError with a clear message instead of entering the loop). 315 dispatch + print-complete-path tests green (test_phantom_print_hardening.py, test_print_start_assigns_printer_id_to_vp_archive.py, test_print_start_expected_promotion.py, test_cost_tracking.py, test_print_queue_api.py's TestAbortedStatusNormalisation — which was the suite that originally OOM'd, now passes in 2 s serial / 12 s under -n 30). Backend ruff clean.
  • Print filenames with FAT32-illegal characters now rejected at rename/upload/queue time instead of failing at FTP (#1540, reported by anthonyma94) — Reporter could rename a library file to L|R.3mf, and the PUT /library/files/{id} endpoint accepted it because library.py:4011 only blocked / and \. The pipe (and the rest of the FAT32/exFAT-illegal set < > : " / \ | ? *, control chars, trailing dots/spaces) flowed through to FTP upload time, where the printer's SD card rejected the create with 553 Could not create file — far from the rename action that caused it. Bambu Studio refuses these names client-side in its save dialog; Bambuddy now does the same. Fix: new backend/app/utils/filename.py exporting validate_print_filename(name) and InvalidFilenameError — single source of truth for the rejected set (Bambu-Studio-parity: the nine chars above, control codes 0x00-0x1F, empty/whitespace-only, bare ./.., trailing space or dot, and 255 UTF-8 bytes max). Wired into three boundaries: (a) update_file at library.py replaces the path-separator-only check; (b) upload_file at library.py rejects bad multipart-upload filenames before they're persisted; (c) print_library_file adds a pre-flight check so older library rows that pre-date the rename validation fail with an actionable 400 instead of an obscure FTP 553; (d) add_to_queue at print_queue.py same pre-flight so queued files don't sit waiting just to fail at dispatch. The print/queue checks deliberately refuse rather than auto-rename — silently rewriting user filenames was the wrong UX (Studio doesn't, and the user explicitly chose that name). Existing rows with illegal names are left alone; users see a clear error pointing at rename. Frontend: the rename modal in FileManagerPage.tsx now mirrors the same character set client-side, shows the offending char inline as a red error below the input, and disables the Rename button while invalid — matches Bambu Studio's instant feedback rather than a round-trip-to-400. i18n: new fileManager.invalidFilenameChar key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]]; parity script clean at 4998 leaves per locale. Tests: 26 in test_filename_validation.py (parameterised over every char in INVALID_FILENAME_CHARS, the exact L|R.3mf reproducer from the bug, empty/whitespace/./.., control chars, trailing space/dot, byte-length cap with multi-byte UTF-8 to verify it's bytes not codepoints). Backend ruff clean; frontend build clean.
  • Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by JmanB52D) — Reporter (lead of a maker-space 3D Fab area) was evaluating Bambuddy partly to count filaments per print for AMS expansion planning; print log was showing "—" in the filament column for every job. Trace: a P2S in VP proxy mode where the slicer's .3mf upload lands on the real printer's SD card, then the printer locks the file mid-print and refuses every FTP read (the existing fallback-archive code path in main.py:2596, originally added for P1S/A1 printers, anticipates this: "FTP has file size limitations" — same effective behaviour on P2S). The user log shows ~12 FTP candidate paths attempted on every print start, every one returning 550, then directory listings on /cache /model /data /data/Metadata also returning 550, then the fallback archive being created with file_path="" and every filament column NULL — even though the MQTT print-start payload already had the AMS state and the slicer's slot-per-print-filament mapping sitting in data["ams"]["ams"] / data["ams_mapping"]. Fix: new _extract_filament_data_from_mqtt(data, ams_mapping) helper in backend/app/main.py (placed next to the existing _get_start_ams_mapping) walks data["ams"]["ams"][*].tray[*] to build a global-tray-id → (tray_type, tray_color) map, then narrows to slots referenced by ams_mapping if present (slicer order preserved; -1 entries for VT-tray skipped), or falls back to every loaded slot otherwise. Output is a comma-separated filament_type + filament_color in the same shape the 3MF extractor produces — so the inventory page, Quick Stats filament rollup, and len(filament_type.split(',')) per-print count all light up identically for fallback rows. Truncated to the model's column limits (50 / 200). Defensive against malformed MQTT shapes (non-dict entries, non-int ids, missing fields) since this runs in the print-start hot path and a raise would break print logging entirely. The fallback PrintArchive(...) constructor now passes filament_type= / filament_color= from the helper. What this is NOT: not per-filament gram usage (that needs the 3MF's slice_info.config or a deep AMS layer-delta integration via usage_tracker) — only types and colours. The user explicitly asked for "the number of filaments used to know if or when we need to expand AMS units", which is exactly what this gives them (SELECT COUNT(DISTINCT split(filament_type, ',')) ... or the existing inventory count surfaces). A separate, larger piece of work to capture the .3mf in VP proxy mode at upload time (by sniffing FTP STOR in tcp_proxy.py) is the real long-term fix for any user who wants full 3MF-derived archive metadata in proxy mode; it's not bundled here. Tests: 15 in test_fallback_archive_mqtt_filament.py (backend/tests/unit/) covering: empty / malformed / no-loaded-slot payloads return {}; the no-mapping path lists every loaded slot in ascending global-id order with colours uppercased; an ams_mapping filters to and reorders by the slicer's order; VT-tray sentinels (-1) are filtered; dual-AMS layouts resolve unit*4 + tray correctly across units; a mapping pointing at unknown slots falls through to the known subset, but an entirely-unknown mapping returns {} rather than misreporting from the all-slots fallback; both column-limit truncations enforced; missing-colour-but-present-type emits filament_type only; defensive against non-dict/non-int garbage in the AMS list without raising. Existing 22 print-start unit tests untouched and green. Backend ruff clean.
  • SpoolBuddy: Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by flom89) — On the SpoolBuddy kiosk's Settings → Scale (Waage) tab, pressing TARE wrote the "Tare command sent. Waiting for device..." banner but had no mechanism to resolve it. The daemon writes back through POST /spoolbuddy/devices/{id}/calibration/set-tare (which stamps tare_offset + last_calibrated_at on the device row), the device list query already polls every 10 s, but handleTare in frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx was set-and-forget — the banner persisted indefinitely. The "Calibration complete!" banner on the full calibration flow had the same shape and stayed forever too. Fix: a completion watcher that snapshots device.last_calibrated_at when TARE is pressed, sets an awaitingTareSince state, invalidates the device-list query every 1 s while that state is active (so detection responds within ~1 s instead of waiting on the 10 s background poll), and when last_calibrated_at advances past the snapshot flips the banner to "Tare complete!" with a 3 s auto-dismiss timer. A 15 s timeout on the watcher fails open to "Tare timed out — is the SpoolBuddy daemon running?" so a dead daemon doesn't leave the user staring at the spinner. The Calibration-complete success banner and the calibration-failed error banner now share the same auto-dismiss helper (3 s success, 5 s error). All timers are owned by a useRef that cleans up on unmount; pressing TARE while a previous dismiss is queued cancels the old timer. i18n: two new keys (spoolbuddy.settings.tareComplete, spoolbuddy.settings.tareTimedOut) translated into all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]] — no English fallbacks. Parity script passes at 4997 keys × 9 locales. Frontend build clean.
  • ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by apizz) — Reporter pointed an ntfy server behind a Cloudflare Tunnel at Bambuddy and got HTTP 403: <!DOCTYPE html>...Just a moment... on every Test click. They reproduced the same response with a plain curl -H "Authorization: Bearer <token>" -d "test" https://ntfy.example/<topic> — confirming the 403 originates from Cloudflare's JS challenge intercept (Bot Fight Mode / "Under Attack" mode), not from Bambuddy or ntfy. Cloudflare returns its interstitial HTML to any non-browser client at the edge, so the request never reaches the user's ntfy backend at all. Bambuddy can't solve a JS challenge from a backend — the only real fix is on the user's Cloudflare side (a security-skip rule for the hostname/path, disabling Bot Fight Mode for that hostname, or fronting the server with Cloudflare Access using a service token). Two improvements shipped to make this footgun self-diagnosable for the next user who hits it. (1) Honest User-Agent on the notification HTTP client. backend/app/services/notification_service.py was the one outbound httpx client in the codebase that didn't set the project-standard Bambuddy/1.0 (+https://github.com/maziggy/bambuddy) UA — it leaked python-httpx/<version> instead. Brings it in line with bambu_cloud / makerworld / firmware_check / inventory (all unified during the May 2026 compliance pass) and makes Bambuddy a more obvious citizen to upstream WAFs and proxy operators. Won't defeat Cloudflare's JS challenge (the user's curl test proves CF blocks regardless of UA) but it's a consistency / hygiene fix with no regression risk. (2) Cloudflare-challenge detection on the ntfy error path. New _looks_like_cloudflare_challenge(response) helper checks the response shape (Server: cloudflare or cf-mitigated header, or <!DOCTYPE html>...Just a moment... body). When a 403/non-success response matches, the error returned to the UI now reads: "HTTP 403 — ntfy server is behind a Cloudflare challenge. Bambuddy was served the JS challenge page instead of reaching ntfy. Cloudflare cannot be solved from a backend; add a Cloudflare security-skip rule for this hostname, disable Bot Fight Mode, or front the server with Cloudflare Access using a service token. (#1534)" — actionable, points at the real fix, removes the raw HTML dump. A regular 403 (e.g. ntfy auth failure with a plain forbidden: invalid auth token body) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. Tests: 3 new in TestNtfyOutbound in test_notification_service.py — (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 with Server: cloudflare + Just a moment... body produces the actionable error and does not echo <!DOCTYPE to the user; (c) a 403 with a plain text auth-failure body keeps the original HTTP 403: forbidden: invalid auth token so we don't hide real errors. 110/110 in the notification suites green under pytest -n 30. Backend ruff clean.
  • Source-3MF upload on "fallback" archives no longer crashes with HTTP 500 (and stops orphaning files outside the data volume) (#1531, reported by d3nn3s08) — When MQTT reports a print start but Bambuddy never saw the source 3MF (cloud-initiated prints, Bambu Handy, prints already on the printer's SD card when Bambuddy connected), main.py:2596 creates a "fallback" PrintArchive row with file_path="". The two Archives → Source 3MF Upload routes computed the destination directory as (settings.base_dir / archive.file_path).parent / "source" — which on a fallback row collapsed to Path('/app/data') / '' = Path('/app/data'), whose .parent is Path('/app'), sending the upload to /app/source/<filename>.3mf. The file was physically written there (a path outside the user's mounted data volume — orphaned on container restart) and only the final source_path.relative_to(settings.base_dir) raised, so every retry left another orphan. Affected reporter is on a QNAP Docker host with the standard /app/data mount; both maintainer and triage initially diagnosed it as a Docker volume misconfiguration, but the traceback shows the bug is purely on Bambuddy's side — the user's setup was correct. Fix: new private helper _resolve_source_3mf_path(archive, source_filename) in backend/app/api/routes/archives.py centralises the destination computation. Normal archives still nest the source under <archive_file_dir>/source/<filename>. Fallback archives (empty file_path) now land under <base_dir>/archive/no_source/<archive_id>/<filename> instead — a deterministic, addressable location that stays inside the data volume, and the existing read sites (download_source_3mf, download_source_3mf_by_filename, the slicer-token routes, delete_source_3mf) all continue to work because they read back via settings.base_dir / archive.source_3mf_path. The helper also defensively asserts the resolved directory is inside base_dir.resolve() regardless of where it came from, so a row corrupted by an old import or a manual SQL edit fails with a clear 500 message ("Archive N resolves to a path outside the data directory; cannot attach source.") instead of silently writing outside the volume. Both upload sites (upload_source_3mf and upload_source_3mf_by_name, the slicer-post-processing endpoint) now route through the helper, so neither can independently drift back into the bug. Tests: 2 new in TestUploadSourceThreeMF in backend/tests/integration/test_archives_api.py — (a) test_fallback_archive_source_upload_lands_under_base_dir creates an archive with file_path="", uploads a minimal valid 3MF, asserts 200 status, that the returned source_3mf_path is relative (not /app/source/...), that the file physically exists under the patched base_dir, and that the path is the deterministic fallback location keyed off archive.id; (b) test_normal_archive_source_upload_unchanged is the same flow against an archive with a populated file_path, asserting the existing archives/test/source/<filename>.3mf layout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 in test_archives_api.py green under pytest -n 30. Backend ruff clean. Note: existing orphan files at /app/source/<filename>.3mf from prior failed retries inside an affected user's container can be safely deleted; they were never indexed in the DB, never reachable from the UI, and would have vanished on the next container restart anyway.
  • SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by chesterakl) — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause: POST /spoolbuddy/scale/update-spool-weight (backend/app/api/routes/spoolbuddy.py) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstream nfc/tag-scanned route is exclusive (always-Spoolman when spoolman_enabled=true, after the #1119 / nfc-routing fix). When the user's local DB still held a stale Spool row that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the local weight_used, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line logged SpoolBuddy updated spool 2 weight: …g on scale, …g used (the local-branch log format) and the SpoolBuddy updated Spoolman spool … line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. Fix: update_spool_weight now routes exactly like nfc_tag_scanned_get_spoolman_client_or_none(db) first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches [[feedback_inventory_modes_parity]] — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. Tests: 1 new regression test in TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman — creates a local Spool with the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman's update_spool was called with the correct remaining weight, and (b) the local row's weight_used and last_scale_weight are unchanged after a refresh() against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green under pytest -n 30. Cleanup hint for affected users: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean.
  • Paused prints no longer inflate maintenance hours (#1521, reported by TempleClause) — The track_printer_runtime background task in backend/app/main.py counted both RUNNING and PAUSE states equally toward runtime_seconds, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure mechanical wear, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line in main.py and three ranked solution options. Fix: option 1 (exclude PAUSE entirely) — state.state in ("RUNNING", "PAUSE")state.state == "RUNNING". PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, and last_runtime_update is cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated across main.py, models/printer.py:23, and the two api/routes/maintenance.py route docstrings that all previously described the field as covering "RUNNING and PAUSE states". Out of scope: retroactive backfill of existing runtime_seconds values — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. Tests: 3 new in test_runtime_tracking_pause.py pinning the new contract — PAUSE does NOT accumulate and clears last_runtime_update; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clears last_runtime_update to prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actual track_printer_runtime() coroutine through a single iteration via patched asyncio.sleep against an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green.
  • Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by IndividualGhost1905) — Reporter saw Total prints: 20 / Success: 18 / Failed: 1 and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that locked in the bug — it('uses total_prints as denominator so cancelled/stopped events count') asserted the gauge should divide by total_prints, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. Cause: PrintLogEntry.status has six values in production (completed, failed, aborted, stopped, cancelled, skipped) but the Quick Stats endpoint in api/routes/archives.py only counted two — completed → Successful, status == "failed" → Failed — and used a raw count(*) for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row. aborted was particularly silent: classified as a failure elsewhere in the codebase (failure_analysis.py, main.py:430,1729) but not counted toward failed_prints in stats. Fix: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returns successful_prints (completed), failed_prints (failed + aborted — printer-detected quality failures), and a new cancelled_prints (stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides by successful + failed only, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (failure_rate = failed / (successful + failed)) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. Schema change is additive-safe: ArchiveStats.cancelled_prints defaults to 0 so any historical fixture validating against the model still parses; the frontend type also defaults the display to 0 when the field is missing. i18n: new stats.cancelled key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. Tests: existing it('uses total_prints as denominator …') test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35), cancelled_prints: 0 added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since 140 / (140 + 10) = 93.33% rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. Follow-up (cosmetic): the new Cancelled row's Ban icon rendered in text-bambu-gray while the Successful and Failed icons used semantic text-status-ok / text-status-error tokens — reporter (IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row to text-status-warning (amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI.
  • VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by phieb) — Reporter set up a non-proxy queue-mode VP with a target printer bound, started a print on the real printer, then tried Send to the VP from BambuStudio — slicer refused with the "busy" pre-flight error even though Bambuddy's whole job is to look idle so jobs queue any time. Cause traced by reporter: SimpleMQTTServer._send_status_report forces gcode_state=IDLE and storage indicators on top of the cached-as-base mirror — good — but the cached branch overrode only a handful of fields, and the live print-progress fields from the mirrored real push_status (mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num, total_layer_num, print_error) passed through unchanged. The VP emitted a contradictory report (gcode_state=IDLE but mc_percent>0, stg_cur>0, ...) and BambuStudio's Send pre-flight read it as busy. Without a bound target the synthetic-stub branch reported all of these idle and Send worked — isolating the leak to the cached branch. Fix: in the cached branch, also override those 8 activity fields to the idle values the synthetic-stub branch uses (mc_print_stage="", mc_percent=0, mc_remaining_time=0, stg=[], stg_cur=0, layer_num=0, total_layer_num=0, print_error=0). Same shape as the #1228 storage-indicator overlay — internally consistent with the forced IDLE state while AMS / version / temperatures keep mirroring. Behavioural caveat for users: a slicer connected to the VP just for monitoring no longer sees the real printer's mid-print progress through the VP (since the cached push now reports idle). The real printer's IP / UI remains the source of truth for progress. Per the issue intent, this trade-off is explicit. Tests: new test_live_progress_fields_zeroed_in_cached_branch in test_vp_mqtt_bridge.py::TestStatusReportCachedAsBase.
  • VP _pending_files / temp-file leak on every error path across the three file handlers — Pre-fix: _archive_file, _queue_file, and _add_to_print_queue only popped _pending_files and unlinked the temp file on the success branch. When archival failed (DB outage, ArchiveService raise, queue insert error), the entry stayed in the dict — and since the FTP layer keys its "same-name STOR already in flight" guard on filename, the slicer's next retry was spuriously rejected; the upload_dir also accumulated orphan temp files indefinitely. Each handler now uses a try / finally that pops the marker and unlinks the temp file regardless of whether the body succeeded. 3 unit tests in test_virtual_printer.py::TestVirtualPrinterInstance (one per handler) inject a failure mid-flight and assert both invariants.
  • VP queue position now picks MAX(position)+1 instead of hardcoded 1 — Pre-fix: VP-uploaded queue items always landed at position=1. With non-empty queues this created duplicate position=1 rows; the scheduler orders by (printer_id, position) so ties resolved in undefined DB-internal order, and repeat VP uploads accumulated multiple position=1 rows — making the queue's visible ordering non-deterministic and dispatching out of the user's intended sequence. Now the VP path runs the same SELECT MAX(position) FROM print_queue_items WHERE printer_id=<target or NULL> AND status='pending' query the canonical POST /print-queue/ route uses and inserts at max_pos + 1. Defensive try/except around the .scalar() call so a mocked DB in tests can't cause a TypeError from MagicMock arithmetic. 1 unit test pins the MAX+1 behaviour (with MAX=7 the inserted item lands at position=8).
  • VP DELETE route cleans orphan PendingUpload rows + on-disk upload_dir — Pre-fix: DELETE /virtual-printers/{vp_id} stopped the running instance and removed the row, but the base_dir/uploads/<vp_id>/ directory and any PendingUpload rows that referenced it lingered. The user only learned the rows were orphaned by trying to archive one and getting a "file missing" → flip-to-discarded auto-handler — not exactly a clear signal. Now the DELETE handler queries PendingUpload rows whose file_path starts with the VP's upload_dir prefix, marks them status='discarded', then shutil.rmtrees the directory after the DB commit succeeds (so a crash between commit and rmtree leaves orphan files at worst, not orphan rows pointing at a missing tree). 2 unit tests in test_vp_delete_cleanup.py cover the cleanup-with-orphans + clean-no-op paths.
  • VP MQTTBridge._refresh_loop crash no longer leaks the raw_message_handler — Pre-fix: if any exception escaped _resolve_client (the IP-encoding branch was the most likely culprit), _refresh_loop caught it with logger.exception and returned. The task completed status=done — not cancelled, not raising — so stop() never ran and _unbind_client never fired. self._on_printer_raw stayed registered on the live BambuMQTTClient and kept reading / writing self._latest_print_state on every real-printer message even though the VP bridge was functionally dead, creating a behaviour leak that persisted across VP restart. Now the crash exit explicitly calls _unbind_client() so the orphaned handler is detached even when the loop dies abnormally.
  • VP sync_from_db serialised by asyncio.Lock (concurrent-PUT race) — Two simultaneous PUT /virtual-printers/{id} calls (e.g. browser racing the auto-save trigger) could race the inner start/stop sequence and leave duplicate sub-services bound to the same port — split-brain state that only resolved on the next Bambuddy restart. VirtualPrinterManager.__init__ now holds a _sync_lock; sync_from_db wraps the body in async with self._sync_lock. Single VP updates still complete in well under a second, so the serialisation isn't visibly slower.
  • VP _slicer_print_options cache bounded at 128 entries with FIFO eviction — Pre-fix: the dict that stashes the slicer's project_file options (so _add_to_print_queue can inherit timelapse / bed_leveling / flow_cali / etc.) had no bound. If the slicer sent project_file for a filename whose FTP upload was rejected / cancelled / non-3MF, the stash was orphaned and the dict grew one entry per such event for the VP's entire uptime. The new bound triggers eviction of the oldest entry once 128 entries accumulate.
  • VP MQTTBridge sticky-key carry-forward now uses copy.deepcopy — Pre-fix: a sticky key carried over from the previous cache was assigned by reference, sharing nested dicts/lists between the old and new state. No current code path mutates a carried-forward sticky key in place, so this was latent — but a future merge that did would corrupt both copies. Defensive copy.deepcopy on the carry-forward removes the foot-gun without changing observable behaviour.
  • VP MQTTBridge._refresh_loop and SimpleMQTTServer._send_status_report cached-path use deepcopy_send_status_report cached branch was using dict(cached) — a shallow copy. Today's mutations are top-level only, but a future override that wrote into a nested dict (e.g. online, upgrade_state, ipcam) would corrupt the bridge cache and be read by every subsequent subscriber until the next real-printer push landed. Switching to copy.deepcopy removes the foot-gun.
  • VP SlicerProxyManager lifecycle hardening — Multiple proxy-mode fixes shipped together: (a) _ftp_data_proxies and _actual_ftp_port are pre-initialised in __init__ instead of start(), so stop() called before start() finishes (rapid mode-switch race) no longer raises AttributeError and leaves sockets stranded; (b) _actual_ftp_port now tracks the iptables-redirect target when the deployment uses REDIRECT --to-port to let non-root containers serve on 990, and get_status() returns it — diagnostic was previously probing the class constant 990 and false-failing on every working redirect deployment; (c) the FTP-data-proxy auto_close tasks (101 of them in FTPTLSProxy) are now tracked on _auto_close_tasks and cancelled in stop() — previously they lingered ~60 s holding server references and could fail the next start with "address already in use"; (d) probe servers await server.wait_closed() on stop instead of just srv.close() — same rapid-restart race.
  • VP diagnostic now probes both bind ports 3000 and 3002 — Pre-fix: non-proxy bind diagnostic only probed 3002. The bind server in server mode actually listens on both (plain on 3000, TLS on 3002 per bind_server.py:BIND_PORTS); a VP whose plain listener failed to start but TLS listener succeeded would pass the diagnostic while being half-broken. Now port_bind reports pass only when both probes succeed. New PORT_BIND_PLAIN = 3000 constant.
  • VP FTP stop() awaits cancelled sessions instead of sleep(0.1) — A session mid-write, mid-TLS-handshake, or holding a 60 s data-read could easily outlive the 100 ms sleep, and the server's close() would run while underlying sockets were still in use. Now stop() cancels each session task and asyncio.gathers them with return_exceptions=True. Stop is a few ms slower in the typical case; worst-case bounded by whatever asyncio takes to propagate cancellation.
  • VP child sub-services (FTP / MQTT / Bind / SSDP) expose ready event for accurate is_running — See Added section for full description.
  • VP per-VP TLS certificate auto-regenerates when the shared CA is rotated — Pre-fix: ensure_certificates only checked that the per-VP cert file existed. When the shared CA was regenerated (its expiry within 30 days), per-VP certs on disk were still signed by the OLD CA — slicers that imported the NEW CA failed handshake. The check is now a real signature verification: ensure_certificates loads the on-disk per-VP cert and the on-disk CA, and verifies the cert's signature against the CA's public key via cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15. On InvalidSignature (rotation detected), the per-VP cert is regenerated under the current CA. The unit-test driven a real bug in an earlier version of this fix: comparing Subject DN was insufficient because Bambuddy's auto-generated CAs share the same Subject Name ("Virtual Printer CA"), so DN-match returned True even after rotation. 3 tests in test_vp_certificate_rotation.py (reuse-when-issuer-matches, regen-when-rotated, no-CA-returns-False).
  • VP tailscale.py::get_status now catches asyncio.TimeoutError — Pre-fix: _run_tailscale could re-raise TimeoutError after killing a stuck subprocess. The except OSError clause in get_status didn't catch it, so the exception propagated all the way to the FastAPI route handler and crashed the VP management UI for any user whose host tailscaled was lagging. Now the except clause covers both OSError and asyncio.TimeoutError, returning a TailscaleStatus(available=False, error=...) either way.
  • VP certificate.py CA save uses correct parent directory — Pre-fix: _get_or_create_ca created self.cert_dir (the per-VP subdirectory) before writing the CA, but the CA writes target self.ca_key_path.parent (the shared CA dir — potentially a different path). Latent because the manager pre-creates both directories; surfaced by the path-correctness audit.
  • VP _extract_plate_id logs failures at debug instead of silent — Pre-fix: except Exception: return None swallowed any failure to parse Metadata/slice_info.config without a log. A malformed 3MF then produced a wrong-plate dispatch with no diagnostic trail. The except now logs at debug so support bundles capture the parse error.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.