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

pre-release4 hours ago

Note

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

  • **WebSocket auth gate + audit-driven hardening sweep — A proactive auth-surface audit run surfaced one critical (/api/v1/ws broadcast every printer-status / archive / inventory event to anyone reachable on the HTTP port. All fixed in the same PR.
  • API-key permission enforcement is allowlist-based (reported by vfxdev) — The three documented API-key scopes ("Read Status", "Manage Queue", "Control Printer") were enforced only inside the legacy /api/v1/webhook/* router; every other route used require_permission_if_auth_enabled which fell through to a 17-entry admin denylist for API keys and ignored the per-key scope flags. The structural failure modes: (a) any valid key, including one with every scope checkbox unticked, could call print start/stop/pause/resume, queue create/delete/reorder, archive reprint, and every *_READ endpoint outside the denylist; (b) require_any_permission_if_auth_enabled (inventory.py) and require_ownership_permission (print_queue.py, archives.py, library.py, library_trash.py) returned None for any valid key with zero scope check, granting full ownership-modify access to ~10 ownership-gated routes; (c) every new Permission enum value added to core/permissions.py since the denylist was written silently joined the "API-key-allowed" bucket — fail-open-by-construction, which is exactly how the surface grew over time. Fix: core/auth.py::_check_apikey_permissions now consumes a new _APIKEY_SCOPE_BY_PERMISSION allowlist that maps every non-admin Permission to exactly one scope flag on the APIKey row; unmapped permissions return 403 ("administrative operations") regardless of which flags are set; the helper is now invoked in all three previously-skipping dependencies. The denylist is retained as a redundant explicit "these are admin" marker plus drift-detection in tests, but the allowlist is the load-bearing check. Two new scope flags (per same-PR design discussion): can_manage_library (gates LIBRARY_UPLOAD / LIBRARY_UPDATE_OWN / LIBRARY_DELETE_OWN / MAKERWORLD_IMPORT — distinct trust level from queue management; rejected the "fold library upload into can_queue" shortcut) and can_manage_inventory (gates INVENTORY_CREATE / INVENTORY_UPDATE / INVENTORY_DELETE / INVENTORY_FORECAST_WRITE — required because SpoolBuddy kiosks write NFC scans, scale readings, and /spoolbuddy/devices/{id}/system/command + /update via INVENTORY_UPDATE under the prior denylist gap; 15+ kiosk routes depend on this scope). CLOUD_AUTH is now routed through the existing can_access_cloud flag (was unmapped → would have admin-denied; the router-level _cloud_api_key_gate already does this check, but the route-level dep now fails closed too for defence in depth). Migration (core/database.py::run_migrations, dialect-branched per [[feedback_sqlite_and_postgres_upfront]]): two new boolean columns added to api_keys with DEFAULT TRUE, one-shot backfilled to mirror can_queue (gated on a new _api_keys_column_exists check so the backfill runs only on the migration that adds the column — user-edited values on subsequent restarts are never clobbered). Backfill rationale: a key the operator created as "queue-only" was implicitly relying on the upload+queue and inventory-write workflows the queue scope already let through, so mirroring can_queue preserves the operator's intent; a hardened "read-only" key (can_queue=False) does NOT silently gain new writes on upgrade. The bundled SpoolBuddy CLI key is explicitly granted can_manage_inventory=True because the kiosk itself is the legitimate writer (NFC scan, scale reading, /system/command). Structural drift backstop: new test_every_permission_has_a_classification fails CI on any future Permission added to core/permissions.py without an entry in _APIKEY_SCOPE_BY_PERMISSION or _APIKEY_DENIED_PERMISSIONS — the previous denylist shape allowed silent surface growth, this catches it. Tests (test_auth_apikey_rbac.py): 78 new — pure-logic _check_apikey_permissions matrix covers every (Permission × scope-flag combo) outcome with cross-scope leakage assertions, the structural drift-detection guard, allowlist/denylist disjointness, scope-flag-has-permissions sanity, unknown-perm-string + empty-perm-list fail-closed cases, and the require_any=True semantics; the existing denylist-integrity test is updated to reflect that INVENTORY_CREATE/UPDATE are now allowlisted (not admin-only-by-omission) and that operations admin only via omission (PRINTERS_CREATE, LIBRARY_DELETE_ALL, LIBRARY_PURGE, DISCOVERY_SCAN) still 403 with a fully-flagged key. Full 5469-test backend suite green; backend ruff clean. Frontend: API-key create dialog gains "Manage Library" + "Manage Inventory" checkboxes with descriptions, the existing list view gains Library and Inventory badges, the cosmetic apiKeyName/save-toast flow is unchanged; api/client.ts APIKey / APIKeyCreate / APIKeyUpdate types extended. i18n parity: real translations for the 6 new keys (manageLibrary / manageLibraryDescription / manageInventory / manageInventoryDescription / libraryBadge / inventoryBadge) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5005 leaves × 9 locales. Wiki (features/api-keys.md): permissions table grows from 5 to 7 toggles with the new scopes and an updated "Principle of Least Privilege" examples list; upgrade notes call out the can_queue-mirroring backfill so operators understand why an existing "queue-only" key keeps uploading after upgrade (and why a "read-only" key still won't); a new explicit "Allowlist model since 0.2.4.5 (GHSA-r2qv-8222-hqg3)" callout documents the shift from denylist to allowlist with the exact previous failure mode (so the audit-trail isn't only in this CHANGELOG). Out of scope / explicit choice: did not refactor the SpoolBuddy kiosk routes to use a more semantically-accurate permission than INVENTORY_UPDATE for /system/command and /update (large blast radius across 15+ route decorators, and can_manage_inventory matches the trust dimension correctly); did not consolidate the bespoke require_energy_cost_update into the new allowlist (its narrow-scope semantics — bypass the SETTINGS_UPDATE denylist via can_update_energy_cost — predates this work and is still the right shape for that one electricity-price endpoint).
  • 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).

Security

  • Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside — A private path-traversal report against POST /api/v1/projects/import/file traced two attacker-controlled strings being joined to library_dir with no resolve + containment check: (a) linked_folders[*].name from the request's project.json ("Vector A" — an absolute path in this field collapsed library_dir / "/anywhere" to Path("/anywhere") because pathlib discards the left side when the right is absolute, letting the next write_bytes land anywhere the backend could write), and (b) per-entry zf.namelist() paths from the ZIP itself ("Vector B" — ZIP filenames carry .. segments by spec and the join library_dir / folder_name / relative_path had no per-component check). Concrete escalation: drop a .pth file into the venv's site-packages directory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite ~/.ssh/authorized_keys or ~/.bashrc on native installs. Fix is structural, not just patch the diff (per [[feedback_dont_dismiss_preexisting]]). New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) helper joins under a trusted parent, resolves both sides, asserts is_relative_to(parent.resolve()), and rejects up-front empty / null-byte / absolute path components. Wired into import_project_file at both vectors. Adjacent fix from the routes audit: GET /api/v1/archives/{id}/photos/{filename} had NO validation on filename and FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check against archive.photos (which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route through safe_join_under for defence-in-depth on top of the membership check. Second adjacent fix from the services audit: ArchiveService.attach_timelapse(archive_id, data, filename) in backend/app/services/archive.py:1456 wrote archive_dir / filename where filename ultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the ?filename=... query param on POST /api/v1/archives/{id}/timelapse/select. A malicious printer that returns a directory listing entry with .. segments could write the timelapse bytes outside the archive directory; the f.get("name") == filename gate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk. attach_timelapse now routes through safe_join_under(..., http=False) and returns False (logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. Audit sweep methodology: AST-walked every Python file under backend/app/api/routes/ AND backend/app/services/ for Path / Name shapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself, _safe_filename(...) / Path(arg).name basename-stripped inputs, os.walk-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a # SEC-PATH-OK: <reason> marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (library.py external upload, archives.py timelapse output, projects.py attachment download/delete, settings.py backup extractall) carry the same marker shape. Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked (backend/tests/unit/test_no_unsafe_path_joins.py) AST-walks every Python file in backend/app/api/routes/ AND backend/app/services/ and fails the build on any <directory-variable> / <bare-variable> join that doesn't either route through safe_join_under or carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string, str(...) call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). Tests: 17 unit tests for safe_join_under covering every escape vector (absolute path, Windows abs path, .. segments, embedded .., null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests against POST /api/v1/projects/import/file exercising the full FastAPI stack with the verbatim shape from the report (absolute path in folder_name → 400 + filesystem assertion that the target file doesn't exist; .. in folder_name → 400; .. in relative_path → 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests against ArchiveService.attach_timelapse exercising the compromised-printer threat model (filename with .. segments → returns False + no file at the escape target; absolute filename → returns False + no file at /tmp; legitimate timelapse_YYYY-MM-DD_HH-MM-SS.mp4 → returns True + file lands inside archive_dir, guarding against the fix being over-strict). SECURITY.md gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean.

Fixed

  • Custom maintenance type "documentation URL" now persists on create (#1596, reported by BurntOutHylian — with the exact root cause pre-triaged in the issue body) — POST /api/v1/maintenance/types hard-coded every field on the MaintenanceType constructor by name (name, description, default_interval_hours, interval_type, icon, is_system) and silently dropped wiki_url, even though the Pydantic schema accepted it and the response model echoed it back as null. PATCH was fine because it used data.model_dump(exclude_unset=True) + setattr, which is why editing a freshly-created type DID save the URL — masking the bug under any "save then immediately fix it" test. Fix: add wiki_url=data.wiki_url to the constructor call at routes/maintenance.py:206. Frontend nit also addressed in the same drop (#1596 nit section): MaintenancePage.tsx:1131 updateTypeMutation's inline Partial<{...}> shape listed name | default_interval_hours | interval_type | icon only. The value reached the API correctly at runtime because api.updateMaintenanceType accepts Partial<MaintenanceTypeCreate> (which includes wiki_url), but the local type was misleading — anyone reading the mutation would wrongly conclude wiki_url wasn't part of the update payload. Extended the inline shape to include wiki_url?: string | null. Tests: one new integration test in test_maintenance_api.py::test_create_custom_type_persists_wiki_url — POSTs a custom type with a wiki_url, asserts the POST response carries it, and verifies via a separate GET round-trip that the value actually committed (defending against the "response echoes request body" failure mode the bug would have masked). Full 5565-test backend suite green; ruff clean; frontend build clean; ESLint zero output; touched MaintenancePage vitest green.
  • External-folder .gcode.3mf files now show thumbnails, and every ingest path stores the same canonical file_type for sliced outputs (#1600, reported by maziggy) — Reporter noticed external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classified LibraryFile.file_type differently for the same .gcode.3mf family. The upload, ZIP-extract, and in-process paths used os.path.splitext(filename)[1] which returns .3mf for foo.gcode.3mf, stored file_type="3mf", and matched the thumbnail-extraction gate at library.py:1467 (if file_type == "3mf":). The external-folder scan path explicitly detected the compound and set file_type="gcode.3mf" — preserving the "sliced output" identity — but then skipped both if file_type == "3mf": (mismatch) and if file_type == "gcode": (also mismatch), so the file landed with thumbnail_path = None. Same compound-extension drift that bit #1543's 3D preview gates, just in a different surface that the #1543 frontend audit didn't trace back to. Unified fix (per the user's "unify if it's safe" directive): new classify_file_type(filename) helper in library.py is now the single source of truth — returns gcode.3mf for sliced outputs and ext[1:] otherwise. Applied to every ingest path: upload (routes/library.py:1704), ZIP-extract (routes/library.py:1998), external-folder scan (the bug site, plus the manual compound check is replaced), and the in-process save_3mf_from_bytes() helper (routes/library.py:471 — used by MakerWorld import). The external-scan thumbnail gate is widened to if file_type in ("3mf", "gcode.3mf"): so a sliced output now goes through ThreeMFParser (a .gcode.3mf IS a 3MF zip with Metadata/plate_1.png thumbnail; the parser doesn't care about the trailing extension). The gcode-download endpoint at GET /api/v1/library/files/{id}/gcode (routes/library.py:4390) had the same drift in reverse — its gate was elif file.file_type == "3mf": so a row stored with file_type="gcode.3mf" (the external-scan path's pre-unification behaviour, and now the canonical going forward) was rejected with HTTP 400. Widened to elif file.file_type in ("3mf", "gcode.3mf"): so both ingest histories work. One-shot DB migration in backend/app/core/database.py::run_migrations backfills existing legacy rows: UPDATE library_files SET file_type='gcode.3mf' WHERE file_type='3mf' AND LOWER(filename) LIKE '%.gcode.3mf'. Idempotent (post-update rows no longer match the file_type='3mf' predicate, so re-runs at every boot are no-ops) and dialect-neutral (LOWER + LIKE are identical under SQLite and Postgres per the [[feedback_sqlite_and_postgres_upfront]] HARD RULE; behaviour-identical on Postgres by construction, tested explicitly on SQLite in the new regression suite). Without the backfill, users would have a permanent split state in the DB — old uploads at 3mf, new uploads at gcode.3mf — which would (a) double-bucket sliced outputs in the dashboard stats query at routes/library.py:4615 (SELECT file_type, count(*) GROUP BY file_type) and (b) show two entries in the file-manager filter dropdown for the same conceptual type. Frontend untouchedFileManagerPage.tsx and ProjectDetailPage.tsx already accept both '3mf' and 'gcode.3mf' for Preview-3D, type-pill colour, and the file action gate per the #1543 fix. After the migration the DB only contains canonical values, so the legacy '3mf' branches in the frontend become dead code for sliced files — they stay in place to handle any future ingest path I missed (defence in depth — better a redundant gate than an empty card). Tests: 13 new in test_library_classify_file_type.py covering the helper across every compound / casing / no-extension case; 3 new in test_library_file_type_backfill_migration.py (legacy .gcode.3mf/3mf row backfilled, mixed-case filenames upgraded via LOWER(), unrelated .bak-suffixed compound substring left untouched, plain .3mf / raw .gcode / .stl untouched, idempotent on re-run); 2 new integration tests in test_library_api.py (upload of .gcode.3mf now stores file_type="gcode.3mf" via the unified path; the gcode-download endpoint accepts a row with file_type="gcode.3mf" and returns the embedded gcode). Full backend pytest 5564 passed under -n 30; ruff clean; frontend build clean; eslint zero output; i18n parity green at 5007 leaves × 9 locales.
  • Virtual-printer "Send file" no longer redirects from Bambuddy to the physical printer's SD card once the printer powers on, and the mode button labels finally match the wire values stored in the DB (#1429, reported by TrickShotMLG02, confirmed by Mape6) — Two reporters on completely different network topologies (3-subnet routed via OPNsense vs. flat single-LAN) saw the same symptom: with the physical printer off and Bambuddy freshly restarted, the slicer's "Send" landed in Bambuddy's archive; once the printer powered on, every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy entirely. Mape6's packet capture on the flat-LAN case ruled out subnet / mDNS-reflector / firewall theories — the slicer just had a non-Bambuddy IP for the FTP destination once the printer was online. Bundle analysis: mape6-before (printer off) showed clean FTP receive + archive lines; mape6-after (printer on) had zero FTP connection attempts to Bambuddy, full stop. The mode-label discrepancy in every support bundle was a separate red herring that needed clearing up in the same drop. Root causebackend/app/services/virtual_printer/mqtt_bridge.py::_on_printer_raw caches the real printer's push_status and rewrites net.info[*].ip from real-printer LE-uint32 to VP-bind-IP LE-uint32 so the slicer's FTP destination resolves to the VP. The rewrite has been in tree since 2026-05-03 and the unit test that ships with it passes. But the encoding (_target_ip_uint32_le, _vp_ip_uint32_le) was only computed inside _resolve_client on client-identity change, and _resolve_client early-returned (if current is self._target_client: return) on every refresh tick when the same client object was still bound. So if the printer's MQTT client object existed but ip_address was empty/stale at first bind (e.g. the printer's DB row hadn't picked up its discovered IP yet, or the client was constructed before the SSDP refresh), the encoded LE-uint32 stayed None, the rewrite block was skipped, the cache filled with the real printer IP, the sticky-keys preservation in the same function kept that poisoned net value alive across every subsequent incremental push, and the slicer followed the leaked IP to the real printer. The only way to clear it was to restart Bambuddy with the printer off — which is exactly the workaround both reporters independently arrived at. Same shape on multi-NIC printers: the rewrite only matched entries whose ip equalled _target_ip_uint32_le, so an X1C / H2D Pro reporting two active interfaces (WiFi + Ethernet) would have one entry rewritten and the other leaking the printer's other IP — a separate FTP fallback path that bypasses the VP even when the primary rewrite worked. Fixes (mqtt_bridge.py): (1) _resolve_client now calls a new _refresh_ip_encoding() helper on every refresh tick, even when the client identity is unchanged — re-reads current.ip_address, re-encodes if either side changed, self-heals once ip_address becomes valid. (2) When the encoding becomes valid for the first time after the cache has already been populated, _refresh_ip_encoding() sweeps the cached _latest_print_state via the new _rewrite_net_info_ips() helper so the slicer's next pull sees the rewritten value — without this, sticky-key preservation keeps the poisoned cache alive across every incremental update. (3) _rewrite_net_info_ips() rewrites every non-zero net.info[].ip entry that doesn't already equal the VP bind IP, not only entries matching _target_ip_uint32_le — defensive against multi-NIC printers, against _target_ip_uint32_le being stale, and against unknown secondary interfaces leaking. Zero-IP entries (placeholders for unpopulated interfaces) are deliberately left alone so the slicer's "active interface" detection still recognises them as absent. (4) The rewrite path now logs at INFO when encoding arms or updates and at INFO when the cache sweep rewrites entries, so future support bundles directly answer "did the rewrite fire?" without re-reasoning about timing. Mode wire-value rename (#1429 follow-up, separate confusion source) — The UI button labeled "Archive" had always saved the wire value immediate, and "Queue" had always saved print_queue. Both reporters' support bundles showed mode: immediate while the UI said "Archive", and TrickShotMLG02 specifically asked "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". The mismatch was load-bearing for the debug session and had to be cleared up. Canonical wire values are now archive / review / queue / proxy matching the button labels 1:1. Backend rename: new backend/app/models/virtual_printer.py::VP_MODE_* constants + normalize_vp_mode() helper accepts legacy immediate / print_queue and translates to canonical. VirtualPrinter.mode default flipped to archive. backend/app/services/virtual_printer/manager.py::VirtualPrinterInstance.__init__ normalises on construction so a legacy DB row read before the migration window has finished still dispatches to the correct handler; on_file_received, on_print_command, and sync_from_db's change-detection all consume canonical values via normalize_vp_mode(). backend/app/api/routes/virtual_printers.py::create_virtual_printer and update_virtual_printer accept both forms on input and normalise to canonical before storage; backend/app/api/routes/settings.py::get_virtual_printer_settings normalises on read so frontend mode-button highlighting works for legacy stored values; update_virtual_printer_settings accepts and normalises on write. backend/app/schemas/settings.py::AppSettings.virtual_printer_mode default flipped to archive with updated description. One-shot DB migration: backend/app/core/database.py::run_migrations rewrites every virtual_printers.mode and settings.virtual_printer_mode row from immediatearchive and print_queuequeue. Idempotent — re-running on canonical values is a no-op, important because the full migration set runs every boot. Identical statement under SQLite and Postgres (plain UPDATE ... WHERE on a string column, no dialect-specific syntax) per the [[feedback_sqlite_and_postgres_upfront]] HARD RULE; tested explicitly on SQLite in the new regression suite, behaviour-identical on Postgres by construction. The historical single-VP migration (legacy settings rows → virtual_printers table on first multi-VP boot) gets the same immediatearchive / print_queuequeue translation; the historical queuereview alias is preserved because it predates the rename and reflected the user's intent at the time (the old wire queue meant "pending review", not "add to print queue"). Frontend rename: VirtualPrinterSettings.tsx, VirtualPrinterCard.tsx, and VirtualPrinterAddDialog.tsx all switched their button click handlers and LocalMode/Mode type aliases from 'immediate' | 'review' | 'print_queue' | 'proxy' to 'archive' | 'review' | 'queue' | 'proxy'. Each file gained its own normalizeMode() helper that translates legacy values arriving via stale-cached settings payloads to canonical, so the right mode button lights up even when the backend migration hasn't completed for that user's session yet. The two printer.mode === 'queue' ? 'review' : printer.mode legacy mappings in VirtualPrinterCard.tsx::useEffect and the error-recovery path have been replaced with normalizeMode() — they were the source of the test failure I caught mid-implementation where mode: 'queue' (the new canonical for the Queue button) was being incorrectly aliased back to 'review' and hiding the auto-dispatch + force-color-match toggles. frontend/src/api/client.ts::VirtualPrinterMode is now the union of both canonical and legacy values ('archive' | 'review' | 'queue' | 'proxy' | 'immediate' | 'print_queue') so older API clients (forks, mobile shortcuts, scripted setups) typecheck; the updateSettings body type narrows to canonical-only to steer new code. Mode handler is NOT the dispatch bug: manager.py::_archive_file is the handler for archive mode and it does archive-only (no dispatch to the physical printer). The user-visible "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache, not a mode-dispatch bug. The mode rename is purely a clarity / support-bundle-accuracy fix. Testsbackend/tests/unit/test_vp_mqtt_bridge.py: 2 new in the bridge-rewrite class — test_net_info_ip_rewritten_for_unknown_secondary_interface covers the multi-NIC X1C / H2D Pro case where the printer reports an interface IP Bambuddy never saw; both entries get rewritten, the placeholder zero entry stays untouched. test_late_arriving_printer_ip_rewrites_existing_cache is the primary #1429 regression — bridge binds to a client with ip_address="", first push lands and poisons the cache with the real-printer IP (the pre-fix state), the printer's ip_address then becomes known, the next _resolve_client tick arms the encoding AND sweeps the cached net.info[].ip so the slicer's next pull sees the VP IP. Without the sweep, sticky-key preservation would keep the poisoned value alive forever. backend/tests/unit/test_vp_mode_rename_migration.py: new file, 3 tests — legacy immediatearchive and print_queuequeue rewrites under SQLite, canonical values pass through untouched; legacy virtual_printer_mode setting also gets rewritten; running the migration twice is idempotent (every boot re-runs the full migration set). backend/tests/integration/test_virtual_printer_api.py: 3 reworked tests cover input-side normalisation — test_update_mode_to_queue asserts canonical, test_update_mode_legacy_print_queue_normalises_to_queue and test_update_mode_legacy_immediate_normalises_to_archive assert legacy → canonical translation on storage. The pre-existing test_update_mode_legacy_queue_maps_to_review (predating the rename, asserted the old queuereview alias) is removed; the new test_update_mode_to_archive covers canonical archive setting. All other VP tests were updated to canonicaltest_virtual_printer.py (43 occurrences), test_vp_diagnostic.py (1), test_virtual_printer_api.py mocks (5) renamed; the sync_from_db_restarts_on_mode_change test had to be repaired by hand because the sed pass made both sides archive (defeating the change detection); now uses archivereview to actually exercise the change branch. Frontend: VirtualPrinterCard.test.tsx, VirtualPrinterSettings.test.tsx, VirtualPrinterDiagnosticModal.test.tsx updated to canonical fixtures and assertions; the legacy queue maps to review test in VirtualPrinterSettings.test.tsx replaced with two tests — legacy immediate lights up the Archive button, legacy print_queue lights up the Queue button, both via the new client-side normalizeMode() helper. The five InventoryPage*.test.tsx files that hardcoded virtual_printer_mode: 'immediate' in their settings mocks bulk-renamed to 'archive'. CI gates green: backend pytest 5546 passed in 73.88s + 7.10s under -n 30 / -n 12 parallel; ruff clean; frontend npm run build clean (TypeScript + Vite); ESLint zero output; vitest 2045 passed in 26.12s; i18n parity script clean at 5007 leaves × 9 locales. Deferred (fix D in the diagnosis writeup): bind_ip == 0.0.0.0 path. The rewrite is still explicitly skipped when bind_ip is the unspecified address, which is correct for the routing (you can't tell a slicer to FTP to 0.0.0.0) but leaves users without a dedicated bind IP exposed to the same IP-leak pattern. Both reporters had has_bind_ip=true in their bundles so this isn't load-bearing for #1429 itself; will be addressed as a separate audit-shaped change that needs to enumerate the host's outbound IPs and pick the one that can reach the printer, with its own test surface. Out of scope for this PR: port 40024 in Mape6's packet capture (Bambu Network Plugin's LAN-Send pre-flight port) — a probe that arrives at the VP IP, finds no listener, and the slicer falls back. Adding a 40024 listener is conceptually a different surface (handshake parsing, not MQTT cache state) and the cache-leak fix alone removes the underlying redirection so the 40024 probe lands on a VP that's actually the right destination. Will reassess if either reporter still sees mis-routing after this fix.
  • Multi-plate .gcode.3mf archives + reprints no longer under-report filament, time, and cost — project stats and parser both fixed (#1593, reported by needo37) — Reporter printed 3 plates of a multi-plate file: Archive Print Log correctly recorded 3 completed runs at distinct durations and filament weights; Project page showed Print Jobs: 1 / 1 parts printed, plate-1's 1h53m / 58g / $1.09; Archive card said 3 prints but rendered plate-1's 57.6g / 1h45m / 1 object. Two distinct causes stacked. Root cause 1 — 3MF parser only read the first plate: ThreeMFParser._parse_slice_info (backend/app/services/archive.py:191) called root.find(".//plate") and pulled prediction / weight from that one element — so for any multi-plate file the archive's file-level print_time_seconds / filament_used_grams reflected plate 1 alone. The per-plate /plates endpoint already looped findall(".//plate") and was correct, which is why the plate carousel showed the right numbers while the archive card was wrong. Root cause 2 — project rollup aggregated PrintArchive, not the per-run log: compute_project_stats and the list_projects quick-stats block (backend/app/api/routes/projects.py) summed PrintArchive.print_time_seconds / filament_used_grams / cost / energy_* WHERE project_id = X. A reprint reuses the source archive row and only adds a new PrintLogEntry, so 3 sequential runs of one file collapsed to 1 archive — and that archive's numbers were already plate-1-only because of root cause 1. The Archive Print Log path was correct because it already drove off print_log_entries (archives.py:420"Reads from print_log_entries so reprints contribute each run"); project stats just hadn't been pointed at the same source. Parser fix: _parse_slice_info now loops findall(".//plate") and sums predictionprint_time_seconds and weightfilament_used_grams across all plates. Per-plate concepts (plate_number, _plate_index, printable_objects) are only set when there's exactly one plate — for multi-plate exports the archive represents all plates and a single plate index is meaningless at the file level. bed_type keeps the first plate's value as a best-effort archive default. Malformed prediction / weight values on individual plates skip cleanly rather than poison the sum. Stats fix: compute_project_stats and the list_projects quick-stats block both switch to an inner join print_log_entries → print_archives WHERE archives.project_id = X. total_archives becomes COUNT(PrintLogEntry.id) (actual runs, not files); failed_prints becomes the count of runs in failed/aborted/cancelled/stopped; completed_items becomes SUM(PrintArchive.quantity) filtered to runs with status='completed' (each run contributes its archive's quantity); total_print_time_hours / total_filament_grams / estimated_cost / total_energy_* come from PrintLogEntry columns. Orphan log rows (archive_id IS NULL after archive deletion via ON DELETE SET NULL) are excluded by the inner join — they can't be attributed to any project. Backfill behaviour (intentional, matches the reporter's "forward-only" note): users with AMS spool tracking — the reporter's case — have per-run PrintLogEntry.filament_used_grams from the tracked spool delta, not the plate-1 estimate, so project stats become correct immediately after the rollup fix with no reslice required. Users without tracking fall back to the archive estimate; their stats undercount until they reprint with the fixed parser. The Archive card still reads PrintArchive.filament_used_grams directly, so old archives keep their plate-1-only numbers until a reslice/rescan repopulates file_metadata. Same-shape fix carried forward: system.py::system_info (the System Info page's lifetime totals) summed PrintArchive.print_time_seconds / filament_used_grams with the identical bug — reprints collapsed to one archive, multi-plate files reported plate-1-only. The route now sums from PrintLogEntry.duration_seconds / filament_used_grams like the project rollup, so every run contributes its measured per-run actual. Same-shape fix in the time-accuracy metric (archives.py::get_archive_stats): the metric computed estimate / actual per run where estimate = PrintArchive.print_time_seconds. Post-parser-fix multi-plate archives have file-level estimate but per-run actual = one plate's duration → ratio ≈ N×100% for an N-plate file (300% for the reporter's 3-plate case), which would drag the printer-level average to noise. The calc now clamps each row to the [50%, 200%] plausibility band before contributing to the average; single-plate accuracy is fully included (the case the metric is designed for), multi-plate plate-by-plate runs and one-off outliers (manual intervention, purge waste blowing the estimate) are excluded. Tests: 4 new in test_archive_service.py::TestMultiPlateSliceInfoSum — three-plate file sums prediction + weight (the reporter's exact numerics: 7140+6000+6300 → 19440s, 19.2+20.0+18.8 → 58.0g); single-plate path preserves plate_number + objects + bed_type; multi-plate ignores per-plate object lists; malformed per-plate values are skipped without poisoning the sum. 4 new in test_projects_api.py::TestProjectStatsPerRun — 3 reprints show as 3 jobs with summed totals (matches the reporter's exact 3-run scenario); orphan log entries don't bleed into any project; mixed-outcome archive splits cleanly between completed_prints (quantity-weighted) and failed_prints (run-counted); list-view quick stats agree with per-project stats. 1 new in test_archive_run_aggregation.py — the accuracy band filter excludes multi-plate plate-by-plate runs (estimate 18000s / actual 6000s = 300%) so a single-plate file's near-100% reading stays the printer's average. Two pre-existing assertions updated to reflect the corrected semantics: archive_count and total_archives now count runs, so files attached but never printed (status "archived") contribute 0 — that's the right answer, not a regression. Full backend suite + ruff clean.
  • Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the PrinterState dataclass as a dict (#1584, reported via in-app bug report) — Reporter saw GET /api/v1/webhook/printer/{id}/status return 500 Internal Server Error with a valid API key carrying the read_status scope, while GET /api/v1/system/info returned 200 with the same key — so auth and routing were fine, the handler itself was crashing. Cause: printer_manager.get_status(printer_id) returns a PrinterState dataclass (backend/app/services/bambu_mqtt.py), not a dict. The route at webhook.py:266-270 called status.get("connected", False), status.get("state"), status.get("current_print"), status.get("progress"), status.get("remaining_time") — every one raised AttributeError, which Starlette surfaced as a generic 500. Reporter's id-1 (printer exists) returned 500; non-existent ids returned 404 — exactly because the early Printer not found branch fired before reaching the crash. Same shape in two adjacent routes: webhook_stop_print (POST /printer/{id}/stop) and webhook_cancel_print (POST /printer/{id}/cancel) checked status.get("connected") / status.get("state") for their precondition gates. 8 crash sites total across the three routes. Fix: every status.get("X", default) replaced with attribute access (status.X if status else default); Pydantic response schema unchanged. PrinterState's dataclass defaults cleanly cover the status is None branch (printer registered but never connected — the route now returns 200 with connected=false, state=null, … rather than crashing). Tests (backend/tests/integration/test_webhook_printer_status.py): 7 new — status route returns 200 with the dataclass attributes mapped into the response (regression for the exact #1584 shape); status route returns 200 with sensible defaults when get_status() returns None; status route returns 404 for a non-existent printer (control case proving the auth path is unaffected); stop route returns 503 when disconnected (pre-fix would have 500'd here); stop route returns 409 when state is not RUNNING; cancel route returns 503 when disconnected; cancel route returns 409 when state is not RUNNING/PAUSE. Runtime-verified end-to-end against a live PG-backed instance before and after: same key + same printer id, 500 before the patch and 200 with the correct payload after. Full backend suite + ruff clean.
  • Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention)test_no_unsafe_path_joins.py::test_route_path_arithmetic_is_safe_joined_or_marked AST-walks every Path-arithmetic site in api/routes/ + services/ and demands either safe_join_under(...) or a # SEC-PATH-OK: <reason> marker. The marker-detection helper only scanned the BinOp's own line range (lineno..end_lineno), but the project's convention puts the marker on the line of the wrapping closing paren — one past end_lineno. The backstop flagged 30 already-marked, already-safe sites as findings, masking the fact that the post-GHSA marker work is complete. The helper now peeks one line past end_lineno IF that line begins with a continuation token (), ], }, ,), capturing exactly this convention without giving a free pass to a marker on a wholly unrelated next statement. 5 new tests in TestMarkerDetection pin the contract: marker on the BinOp line recognised; marker on the closing-paren line recognised; an unrelated marker on a later statement does NOT silence; a marker on a non-continuation line right after the BinOp does NOT silence; no marker anywhere is still flagged. Integration test now passes against the existing tree — 30 findings → 0 — with no changes to any guard / sanitisation in routes or services.
  • Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by lloydjohnson) — Reporter saw deleted local AND cloud profiles still appearing in the slice menu after removing them. Two distinct causes wired together. Local half (real bug): LocalProfilesView's import and delete mutations invalidated ['localPresets'] (the Local Profiles management view's own query) but not ['slicerPresets'] — the SliceModal reads from the unified /slicer/presets endpoint via a separate React Query key (SliceModal.tsx:425, staleTime: 60_000), so a freshly-deleted preset kept rendering in the dropdown until the modal's 60 s staleTime elapsed plus a refocus / remount. The backend was correct end-to-end (delete_local_preset removes the DB row, get_db() auto-commits, _fetch_local_presets reads fresh from DB with no backend cache). Both mutations now also invalidate ['slicerPresets'] so the next modal open shows the current set. Cloud half (by-design backend cache + new opt-in bypass): _fetch_cloud_presets keeps a 5-minute per-(user, token) in-process cache balancing "users see their freshly-saved presets quickly" against "a busy install doesn't hit Bambu Cloud once per modal open" (slicer_presets.py:69). The user deletes cloud presets in Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook to invalidate on — the cache only refreshes when the TTL expires. Rather than shorten the TTL (which would effectively rate-limit the cloud for every user), the listing endpoint gains an opt-in ?refresh=true query param that bypasses BOTH the cloud cache and the 1-hour bundled-preset cache for that one call; the fresh result is still written back so subsequent normal callers still hit cached responses. New SliceModal "Refresh" button: lives in the preset section header next to the cloud-status banner, calls getSlicerPresets({refresh: true}) and writes the fresh slots into the ['slicerPresets'] cache via queryClient.setQueryData (so the spinner disappears immediately rather than triggering a second refetch). Spins the RefreshCw icon while in-flight; disabled during a slice enqueue so users can't fire it twice. i18n: real translations for slice.refreshPresets + slice.refreshPresetsTitle (action label + tooltip) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5007 leaves × 9 locales. Tests: 2 new backend in test_slicer_presets.py (refresh=True re-hits Bambu Cloud even with a warm cache + still writes the fresh result back for the next normal call; same shape for _fetch_bundled_presets); 1 new frontend in LocalProfilesView.test.tsx asserts the delete flow invalidates ['slicerPresets'] in addition to ['localPresets'] via a spied QueryClient. Full backend suite + frontend vitest + ruff + eslint + i18n parity green.
  • STL thumbnail noise on first generation: matplotlib cache + font_manager scan (reported by maziggy) — On first STL upload, three matplotlib-internal log lines surfaced: WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory (Bambuddy's $HOME isn't writable for the default config path so matplotlib fell back to /tmp/matplotlib-XXXXXX), INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf (matplotlib doesn't support the COLR/COLR1 emoji format; this is per-font), and INFO [matplotlib.font_manager] generated new fontManager (the cache was rebuilt). Because the fallback was /tmp, every host reboot lost the cache and the font scan ran again. Fix is in stl_thumbnail.py before the matplotlib import: (a) _configure_matplotlib_cache() sets MPLCONFIGDIR to settings.base_dir / .cache / matplotlib (mkdir'd if missing) so the cache persists across container restarts and the writable-dir warning never fires; respects an externally-set value so operators who chose their own path aren't overridden; best-effort with a debug fallback if settings can't be imported or the mkdir fails. (b) logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module import demotes the per-font INFO scan so the first cold start (before the cache is populated) doesn't surface a multi-line matplotlib preamble. Tests: 3 new in test_stl_thumbnail.py — the font_manager logger is at WARNING after module import; _configure_matplotlib_cache creates the directory under base_dir and sets MPLCONFIGDIR to point at it; an externally-set MPLCONFIGDIR is preserved verbatim.
  • Bulk-upload ZIPs of stub / empty STL files no longer spam the log with thousands of warnings (reported by maziggy) — Uploading a ZIP containing many minimal STL stubs (e.g. the 24-byte solid test\nendsolid test shape) emitted one WARNING [backend.app.services.stl_thumbnail] Failed to load STL or empty mesh: <path> per file. The warnings were technically correct — trimesh.load(...) returned a valid Mesh with zero vertices, the safeguard at stl_thumbnail.py:54 matched, and the function returned None so the library entry got created without a thumbnail — but the volume turned a successful ZIP upload into a journal full of WARNING lines. Two-step fix: (1) the per-file message at stl_thumbnail.py:55 demoted from logger.warning to logger.debug; this is a per-file content observation, not an actionable error, and the caller already handles None correctly. The branch now catches only the rare "large enough but trimesh still can't parse it" case, still visible in debug logs without spamming production. (2) New module constant MIN_USABLE_STL_BYTES = 200 (binary STL with one triangle = 80B header + 4B count + 50B triangle = 134B; ASCII STL with one triangle ≈ 150B; 200 is a safe floor below any real STL). Three thumbnail call sites in library.py (extract_zip_file ZIP entry path, single-file upload, _backfill_external_stl_thumbnails) pre-skip files below this size BEFORE calling generate_stl_thumbnail, so stubs / placeholders / corrupted files never enter the trimesh pipeline at all. What this does NOT change: behaviour is identical for any real STL — generation still runs, MAX_VERTICES still triggers simplification at 100k vertices for the 256×256 thumbnail render, large files still get thumbnails. Tests: 2 new in test_stl_thumbnail.py — one verifies MIN_USABLE_STL_BYTES sits above the smallest binary (134B), the smallest ASCII (150B), and the reporter's 24-byte stub case; the other writes the verbatim 24-byte stub from the bug report, calls generate_stl_thumbnail, and asserts no WARNING-level "empty mesh" record appears in caplog. Full backend suite green; ruff clean.
  • Bambu Cloud sign-in failures caused by an upstream Cloudflare challenge now surface an actionable message instead of "Invalid response from Bambu Cloud" (#1575, reported by cliveflint) — Reporter hit "Invalid response from bambulabs when trying to sign in with authenticator pass code" on a Pi (UK network). Log showed three back-to-back POST /api/sign-in/tfa calls all returning Cloudflare's "Just a moment..." HTML interstitial instead of JSON; backend/app/services/bambu_cloud.py::verify_totp caught the json.JSONDecodeError and returned the opaque "Invalid response from Bambu Cloud" message. Root cause is Cloudflare-side, not Bambuddy: a curl from this machine with the same honest Bambuddy/1.0 (+https://github.com/maziggy/bambuddy) UA at 2026-06-02 returned a clean HTTP/2 400 {"code":5,"error":"Login failed"} JSON — same UA, same headers, different network. CF's bot management appears to flag conditions (per-IP / TLS-fingerprint / rate / transient mitigation window) that don't reproduce from us. No reliable way to prevent the challenge from our side without browser impersonation, which is explicitly off the table per the 2026-05-12 compliance audit. Fix is diagnostic, not bypass: new _detect_cloudflare_challenge(response) -> str | None helper inspects the failed-parse response for CF markers ("Just a moment..." in body, "challenges.cloudflare.com" in body, HTTP 403 with cf-mitigated header, HTTP 503 with cf-ray header) and returns a message that attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few minutes, and tells the user that signing in to bambulab.com from a browser on the same network usually clears the challenge. Wired into all three JSON-parse sites: login_request, verify_code, and verify_totp — previously only verify_totp had a defensive catch; login_request and verify_code let the parse error bubble to BambuCloudAuthError with "Expecting value..." as the detail, which surfaced as a generic 401 in the UI. Tests: 8 new in TestCloudflareChallengeDetection (backend/tests/unit/services/test_bambu_cloud.py) — direct helper tests for each of the four CF markers, a negative case (real JSON 400 with cf-ray header from the actual successful curl response above is NOT misclassified as a challenge so the application-level "Login failed" still surfaces), an attribution check (message must name "Cloudflare" and "bambulab.com" so users can act on it), and full-stack tests covering all three call sites with the verbatim interstitial fragment from the reporter's log. The existing test_verify_totp_cloudflare_blocked updated to assert the new actionable message. Full 5486-test backend suite green; backend ruff clean.
  • OIDC auto-provisioning now reads the standard email claim for User.email when Email Claim is set to a non-email identity claim (#1569, reported by anderl1969) — Reporter configured Authentik with Email Claim = preferred_username to drive username from the preferred_username claim and expected the standard email claim (which the ID token also carries) to populate the user's email field. Result: username was correctly set from preferred_username, but User.email came out empty. Cause: backend/app/api/routes/mfa.py::_resolve_provider_email reads only claims[provider.email_claim]. With email_claim="preferred_username" and preferred_username="jdoe", the value fails the SEC-2 email shape check (no ``) and returns None. The auto-create-users branch then constructs `User(email=None, …)` and stores `UserOIDCLink(provider_email=None)` even though `claims["email"]` carries a perfectly valid `jdoeexample.com`. Fix: new helper `_resolve_standard_email_for_user_record(provider, claims, provider_sub)` reads the standard `email` claim independently and applies the same Fall A/B logic (shape check, `require_email_verified` strict / permissive split, explicit `email_verified=False` drop). The auto-create-users branch in `oidc_callback` now resolves `user_email_for_storage = provider_email or _resolve_standard_email_for_user_record(...)` and uses that for both `new_user.email` and the `UserOIDCLink.provider_email` record. Scope is deliberately narrow: the fallback is invoked only when `provider.email_claim != "email"` AND the primary resolver returned `None` AND the auto-create-users branch is taken. The auto-link-existing-accounts gate above remains on the primary `provider_email` — it does NOT consult the fallback. This preserves every existing GHSA-shape guard: Fall-B (`email_claim='email'` + `require_email_verified=False`) is still rejected at schema level when paired with auto-link; Fall-C (custom claim) auto-link still depends on the custom claim's shape, never on the standard `email` claim. New email fallback path runs the same shape + `email_verified` enforcement as Fall-A/B for the standard `email` claim, so an attacker-controlled IdP that sets `email_verified=False` or sends a malformed value gets dropped exactly like it would on the primary path. Tests: 4 in `TestOIDCStandardEmailFallback` (`backend/tests/integration/test_mfa_api.py`) — `email_claim=preferred_username` with both claims present → username from `preferred_username`, email from standard `email`; `email_claim=preferred_username` with no standard `email` claim → email stays `None` (behaviour unchanged); standard `email` with `email_verified=False` → fallback drops, email stays `None`; `email_claim="email"` (default) with `email_verified` absent → fallback path does NOT fire (Fall-A semantics preserved). Full 5478-test backend suite green. Backend ruff clean.
  • 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 pinnin

Changelog truncated — see the full CHANGELOG.md for the complete list.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.