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) —
ThemeModegains a third value'system'alongside the existing'dark'/'light'. The provider listens towindow.matchMedia('(prefers-color-scheme: dark)'), tracks the OS preference in real time, and exposes a newresolvedMode: 'light' | 'dark'to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cyclesdark → light → system → darkwith the icon hinting at the next stop (Sun→Monitor→Moon); the existing logo selection and the dark/light "active" panel highlight in Settings switched frommodetoresolvedModeso 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-modeso 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' persistedtheme-modeis untouched — anyone ondarkorlightstays there and simply gains an extra stop in the cycle; new installs default todark. Review-caught fixes shipped in the same PR: (a) the project's__tests__/setup.tsmockedwindow.matchMediawithvi.fn().mockImplementation(...), whichvi.restoreAllMocks()in three test files reset to "return undefined" — pre-PR nothing calledmatchMediaat 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) => ({...}) })) sorestoreAllMockscan't touch it. (b)themeToggleHinthad previously only been updated inen.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 → systemnow intercepts where users previously gotdark → light → dark), with the persisted-preference-unchanged caveat made explicit. (d) New i18n keynav.switchToSystemwith real translations across all 9 locales ('Switch to system mode'/'Zum Systemmodus wechseln'/'システムモードに切替'etc.). Tests: 11 new inThemeContext.test.tsx(systemPreference inits frommatchMedia.matches, change event updates state, resolvedMode follows explicit mode vs systemPreference permodevalue, dark class applied based on resolved mode,toggleModecycles dark→light→system→dark); 1 new inLayout.test.tsx(toggle button title attribute walks the cycle); 4 new inSettingsPage.test.tsx(all three buttons render, active green border keys offmode, click switches mode, click fires toast). 26 previously-broken tests inAddNotificationModal.test.tsx+NotificationProviderCardStockAlerts.test.tsx+CameraTokensPage.test.tsxpass again post-setup.tsfix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 fromswitchToSystem). 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 = 5and_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0are module-level for ops tunability. 5 unit tests intest_vp_mqtt_server.py::TestAuthRateLimitpin 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 recordssequence_id → originating client_idinSimpleMQTTServer._pending_requestson the way out and looks it back up inpush_raw_to_clientson 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 intest_vp_mqtt_server.py::TestPendingRequestRoutingcover 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
O1EandO2DtoVIRTUAL_PRINTER_MODELSand matching09400Aserial prefixes toMODEL_SERIAL_PREFIXESso the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D'sO1D. 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_serverspawned each child sub-service (FTP, MQTT, Bind, SSDP) as aasyncio.create_taskand returned immediately.is_runningthen reportedTrueeven though the child sub-services' sockets were still in the gap betweenasyncio.create_task(...)and the innerasyncio.start_serverreturning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could seerunning=passwhileport_ftps=fail. Each child now exposes aready: asyncio.Eventthat's set after the actual socket bind, andstart_serverawaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs aSub-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
invalidlabel (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 fromrequired: falsetorequired: 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 nowrequired: trueinstead 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 singleComponentdropdown only carriedBambuddy / SpoolBuddy / Both— useless for area triage. Replaced with TWO required dropdowns:Product(Bambuddy / SpoolBuddy) andArea(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, anactions/github-scriptv7step parses the Area dropdown out of the rendered issue body (matching the### Area\n\nValueblock GitHub forms produce) and applies the matchingarea:*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 acore.warningso missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15area:*labels need to be created once viagh label create(see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause theaddLabelscall to throw, so this prerequisite is load-bearing. Printer Model dropdown verified againstPRINTER_MODEL_MAPinbackend/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 Pythonyaml.safe_loadfor 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_STORaccumulated every chunk in alist[bytes]and calledwrite_bytesat the end. Peak RSS for a multi-GB.gcode.3mf(multi-plate dense prints) was ~2× the file size — chunks held + theb''.joinof them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk tofile_path.open("wb")inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same150 → 226sequence, 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'tLISTduringSTORso this isn't observable. Same change adds aMAX_UPLOAD_BYTES = 4 GiBhard 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 intest_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'sSlicerProxyManager.FTP_DATA_PORT_MIN/MAXstays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to50000-51000:50000-51000—docker-compose.yml,install/docker-install.ps1warning, 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 lostupgrade_state,xcam,hw_switch_state,nozzle_diameter,nozzle_type,onlineandams_statusafter a single tick — BambuStudio's Send pre-flight reads several of these (upgrade_state.dis_state/force_upgradein 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 acopy.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 compareproxy_ipsagainst the running instance'starget_printer_ip/target_printer_serial. The user had to manually toggle the VP to refresh. Nowsync_from_dbre-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 matchsetting via the UI silently no-op'd becausesync_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 checksqueue_force_color_matchso the running instance gets restarted on toggle. - VP MQTT client session errors elevated from DEBUG to WARNING — The outer
except ExceptioninSimpleMQTTServer._handle_clientwas 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_pushemits1Hz 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/wsbroadcast 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 usedrequire_permission_if_auth_enabledwhich 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*_READendpoint outside the denylist; (b)require_any_permission_if_auth_enabled(inventory.py) andrequire_ownership_permission(print_queue.py,archives.py,library.py,library_trash.py) returnedNonefor any valid key with zero scope check, granting full ownership-modify access to ~10 ownership-gated routes; (c) every newPermissionenum value added tocore/permissions.pysince 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_permissionsnow consumes a new_APIKEY_SCOPE_BY_PERMISSIONallowlist that maps every non-adminPermissionto exactly one scope flag on theAPIKeyrow; 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(gatesLIBRARY_UPLOAD/LIBRARY_UPDATE_OWN/LIBRARY_DELETE_OWN/MAKERWORLD_IMPORT— distinct trust level from queue management; rejected the "fold library upload into can_queue" shortcut) andcan_manage_inventory(gatesINVENTORY_CREATE/INVENTORY_UPDATE/INVENTORY_DELETE/INVENTORY_FORECAST_WRITE— required because SpoolBuddy kiosks write NFC scans, scale readings, and/spoolbuddy/devices/{id}/system/command+/updatevia INVENTORY_UPDATE under the prior denylist gap; 15+ kiosk routes depend on this scope).CLOUD_AUTHis now routed through the existingcan_access_cloudflag (was unmapped → would have admin-denied; the router-level_cloud_api_key_gatealready 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 toapi_keyswithDEFAULT TRUE, one-shot backfilled to mirrorcan_queue(gated on a new_api_keys_column_existscheck 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 mirroringcan_queuepreserves 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 grantedcan_manage_inventory=Truebecause the kiosk itself is the legitimate writer (NFC scan, scale reading, /system/command). Structural drift backstop: newtest_every_permission_has_a_classificationfails CI on any futurePermissionadded tocore/permissions.pywithout an entry in_APIKEY_SCOPE_BY_PERMISSIONor_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_permissionsmatrix 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 therequire_any=Truesemantics; 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 cosmeticapiKeyName/save-toast flow is unchanged;api/client.tsAPIKey/APIKeyCreate/APIKeyUpdatetypes 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 thanINVENTORY_UPDATEfor/system/commandand/update(large blast radius across 15+ route decorators, andcan_manage_inventorymatches the trust dimension correctly); did not consolidate the bespokerequire_energy_cost_updateinto the new allowlist (its narrow-scope semantics — bypass the SETTINGS_UPDATE denylist viacan_update_energy_cost— predates this work and is still the right shape for that one electricity-price endpoint). - Trivy DS-0026 (
Dockerfile.testmissing HEALTHCHECK): silenced viaHEALTHCHECK NONE— The test image runspytestand exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise.HEALTHCHECK NONEis 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: bothFTPSession.cmd_PASSandSimpleMQTTServer._handle_connectused 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.0are module-level for ops tunability. See Added section for full description. - VP
access_codeno longer leaked in DEBUG logs — Pre-fix:PUT /virtual-printers/{id}loggedbody.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_STORnow rejects an upload that crossesMAX_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/filetraced two attacker-controlled strings being joined tolibrary_dirwith no resolve + containment check: (a)linked_folders[*].namefrom the request'sproject.json("Vector A" — an absolute path in this field collapsedlibrary_dir / "/anywhere"toPath("/anywhere")because pathlib discards the left side when the right is absolute, letting the nextwrite_bytesland anywhere the backend could write), and (b) per-entryzf.namelist()paths from the ZIP itself ("Vector B" — ZIP filenames carry..segments by spec and the joinlibrary_dir / folder_name / relative_pathhad no per-component check). Concrete escalation: drop a.pthfile into the venv'ssite-packagesdirectory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite~/.ssh/authorized_keysor~/.bashrcon native installs. Fix is structural, not just patch the diff (per [[feedback_dont_dismiss_preexisting]]). Newbackend/app/utils/safe_path.py::safe_join_under(parent, *parts)helper joins under a trusted parent, resolves both sides, assertsis_relative_to(parent.resolve()), and rejects up-front empty / null-byte / absolute path components. Wired intoimport_project_fileat both vectors. Adjacent fix from the routes audit:GET /api/v1/archives/{id}/photos/{filename}had NO validation onfilenameand FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check againstarchive.photos(which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route throughsafe_join_underfor defence-in-depth on top of the membership check. Second adjacent fix from the services audit:ArchiveService.attach_timelapse(archive_id, data, filename)inbackend/app/services/archive.py:1456wrotearchive_dir / filenamewherefilenameultimately 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 onPOST /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; thef.get("name") == filenamegate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk.attach_timelapsenow routes throughsafe_join_under(..., http=False)and returnsFalse(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 underbackend/app/api/routes/ANDbackend/app/services/forPath / Nameshapes (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).namebasename-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.pyexternal upload,archives.pytimelapse output,projects.pyattachment download/delete,settings.pybackup extractall) carry the same marker shape. Fifth CI backstoptest_route_path_arithmetic_is_safe_joined_or_marked(backend/tests/unit/test_no_unsafe_path_joins.py) AST-walks every Python file inbackend/app/api/routes/ANDbackend/app/services/and fails the build on any<directory-variable> / <bare-variable>join that doesn't either route throughsafe_join_underor 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 forsafe_join_undercovering 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 againstPOST /api/v1/projects/import/fileexercising the full FastAPI stack with the verbatim shape from the report (absolute path infolder_name→ 400 + filesystem assertion that the target file doesn't exist;..infolder_name→ 400;..inrelative_path→ 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests againstArchiveService.attach_timelapseexercising the compromised-printer threat model (filename with..segments → returns False + no file at the escape target; absolute filename → returns False + no file at/tmp; legitimatetimelapse_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/typeshard-coded every field on theMaintenanceTypeconstructor by name (name,description,default_interval_hours,interval_type,icon,is_system) and silently droppedwiki_url, even though the Pydantic schema accepted it and the response model echoed it back asnull. PATCH was fine because it useddata.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: addwiki_url=data.wiki_urlto the constructor call atroutes/maintenance.py:206. Frontend nit also addressed in the same drop (#1596 nit section):MaintenancePage.tsx:1131updateTypeMutation's inlinePartial<{...}>shape listedname | default_interval_hours | interval_type | icononly. The value reached the API correctly at runtime becauseapi.updateMaintenanceTypeacceptsPartial<MaintenanceTypeCreate>(which includeswiki_url), but the local type was misleading — anyone reading the mutation would wrongly concludewiki_urlwasn't part of the update payload. Extended the inline shape to includewiki_url?: string | null. Tests: one new integration test intest_maintenance_api.py::test_create_custom_type_persists_wiki_url— POSTs a custom type with awiki_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.3mffiles now show thumbnails, and every ingest path stores the same canonicalfile_typefor sliced outputs (#1600, reported by maziggy) — Reporter noticed external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classifiedLibraryFile.file_typedifferently for the same.gcode.3mffamily. The upload, ZIP-extract, and in-process paths usedos.path.splitext(filename)[1]which returns.3mfforfoo.gcode.3mf, storedfile_type="3mf", and matched the thumbnail-extraction gate atlibrary.py:1467(if file_type == "3mf":). The external-folder scan path explicitly detected the compound and setfile_type="gcode.3mf"— preserving the "sliced output" identity — but then skipped bothif file_type == "3mf":(mismatch) andif file_type == "gcode":(also mismatch), so the file landed withthumbnail_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): newclassify_file_type(filename)helper inlibrary.pyis now the single source of truth — returnsgcode.3mffor sliced outputs andext[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-processsave_3mf_from_bytes()helper (routes/library.py:471— used by MakerWorld import). The external-scan thumbnail gate is widened toif file_type in ("3mf", "gcode.3mf"):so a sliced output now goes through ThreeMFParser (a.gcode.3mfIS a 3MF zip withMetadata/plate_1.pngthumbnail; the parser doesn't care about the trailing extension). The gcode-download endpoint atGET /api/v1/library/files/{id}/gcode(routes/library.py:4390) had the same drift in reverse — its gate waselif file.file_type == "3mf":so a row stored withfile_type="gcode.3mf"(the external-scan path's pre-unification behaviour, and now the canonical going forward) was rejected with HTTP 400. Widened toelif file.file_type in ("3mf", "gcode.3mf"):so both ingest histories work. One-shot DB migration inbackend/app/core/database.py::run_migrationsbackfills 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 thefile_type='3mf'predicate, so re-runs at every boot are no-ops) and dialect-neutral (LOWER+LIKEare 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 at3mf, new uploads atgcode.3mf— which would (a) double-bucket sliced outputs in the dashboard stats query atroutes/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 untouched —FileManagerPage.tsxandProjectDetailPage.tsxalready 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 intest_library_classify_file_type.pycovering the helper across every compound / casing / no-extension case; 3 new intest_library_file_type_backfill_migration.py(legacy.gcode.3mf/3mfrow backfilled, mixed-case filenames upgraded viaLOWER(), unrelated.bak-suffixed compound substring left untouched, plain.3mf/ raw.gcode/.stluntouched, idempotent on re-run); 2 new integration tests intest_library_api.py(upload of.gcode.3mfnow storesfile_type="gcode.3mf"via the unified path; the gcode-download endpoint accepts a row withfile_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 cause —backend/app/services/virtual_printer/mqtt_bridge.py::_on_printer_rawcaches the real printer'spush_statusand rewritesnet.info[*].ipfrom 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_clienton client-identity change, and_resolve_clientearly-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 butip_addresswas 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 stayedNone, the rewrite block was skipped, the cache filled with the real printer IP, the sticky-keys preservation in the same function kept that poisonednetvalue 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 whoseipequalled_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_clientnow calls a new_refresh_ip_encoding()helper on every refresh tick, even when the client identity is unchanged — re-readscurrent.ip_address, re-encodes if either side changed, self-heals onceip_addressbecomes 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_statevia 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-zeronet.info[].ipentry 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_lebeing 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 valueimmediate, and "Queue" had always savedprint_queue. Both reporters' support bundles showedmode: immediatewhile 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 nowarchive/review/queue/proxymatching the button labels 1:1. Backend rename: newbackend/app/models/virtual_printer.py::VP_MODE_*constants +normalize_vp_mode()helper accepts legacyimmediate/print_queueand translates to canonical.VirtualPrinter.modedefault flipped toarchive.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, andsync_from_db's change-detection all consume canonical values vianormalize_vp_mode().backend/app/api/routes/virtual_printers.py::create_virtual_printerandupdate_virtual_printeraccept both forms on input and normalise to canonical before storage;backend/app/api/routes/settings.py::get_virtual_printer_settingsnormalises on read so frontend mode-button highlighting works for legacy stored values;update_virtual_printer_settingsaccepts and normalises on write.backend/app/schemas/settings.py::AppSettings.virtual_printer_modedefault flipped toarchivewith updated description. One-shot DB migration:backend/app/core/database.py::run_migrationsrewrites everyvirtual_printers.modeandsettings.virtual_printer_moderow fromimmediate→archiveandprint_queue→queue. 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 (plainUPDATE ... WHEREon 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 (legacysettingsrows →virtual_printerstable on first multi-VP boot) gets the sameimmediate→archive/print_queue→queuetranslation; the historicalqueue→reviewalias is preserved because it predates the rename and reflected the user's intent at the time (the old wirequeuemeant "pending review", not "add to print queue"). Frontend rename:VirtualPrinterSettings.tsx,VirtualPrinterCard.tsx, andVirtualPrinterAddDialog.tsxall switched their button click handlers andLocalMode/Modetype aliases from'immediate' | 'review' | 'print_queue' | 'proxy'to'archive' | 'review' | 'queue' | 'proxy'. Each file gained its ownnormalizeMode()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 twoprinter.mode === 'queue' ? 'review' : printer.modelegacy mappings inVirtualPrinterCard.tsx::useEffectand the error-recovery path have been replaced withnormalizeMode()— they were the source of the test failure I caught mid-implementation wheremode: '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::VirtualPrinterModeis 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; theupdateSettingsbody type narrows to canonical-only to steer new code. Mode handler is NOT the dispatch bug:manager.py::_archive_fileis the handler forarchivemode 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. Tests —backend/tests/unit/test_vp_mqtt_bridge.py: 2 new in the bridge-rewrite class —test_net_info_ip_rewritten_for_unknown_secondary_interfacecovers 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_cacheis the primary #1429 regression — bridge binds to a client withip_address="", first push lands and poisons the cache with the real-printer IP (the pre-fix state), the printer'sip_addressthen becomes known, the next_resolve_clienttick arms the encoding AND sweeps the cachednet.info[].ipso 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 — legacyimmediate→archiveandprint_queue→queuerewrites under SQLite, canonical values pass through untouched; legacyvirtual_printer_modesetting 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_queueasserts canonical,test_update_mode_legacy_print_queue_normalises_to_queueandtest_update_mode_legacy_immediate_normalises_to_archiveassert legacy → canonical translation on storage. The pre-existingtest_update_mode_legacy_queue_maps_to_review(predating the rename, asserted the oldqueue→reviewalias) is removed; the newtest_update_mode_to_archivecovers canonical archive setting. All other VP tests were updated to canonical —test_virtual_printer.py(43 occurrences),test_vp_diagnostic.py(1),test_virtual_printer_api.pymocks (5) renamed; thesync_from_db_restarts_on_mode_changetest had to be repaired by hand because the sed pass made both sidesarchive(defeating the change detection); now usesarchive→reviewto actually exercise the change branch. Frontend:VirtualPrinterCard.test.tsx,VirtualPrinterSettings.test.tsx,VirtualPrinterDiagnosticModal.test.tsxupdated to canonical fixtures and assertions; the legacyqueue maps to reviewtest inVirtualPrinterSettings.test.tsxreplaced with two tests — legacyimmediatelights up the Archive button, legacyprint_queuelights up the Queue button, both via the new client-sidenormalizeMode()helper. The fiveInventoryPage*.test.tsxfiles that hardcodedvirtual_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 12parallel; ruff clean; frontendnpm run buildclean (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 hadhas_bind_ip=truein 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.3mfarchives + 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 showedPrint Jobs: 1 / 1 parts printed, plate-1's1h53m / 58g / $1.09; Archive card said3 printsbut rendered plate-1's57.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) calledroot.find(".//plate")and pulledprediction/weightfrom that one element — so for any multi-plate file the archive's file-levelprint_time_seconds/filament_used_gramsreflected plate 1 alone. The per-plate/platesendpoint already loopedfindall(".//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 aggregatedPrintArchive, not the per-run log:compute_project_statsand thelist_projectsquick-stats block (backend/app/api/routes/projects.py) summedPrintArchive.print_time_seconds / filament_used_grams / cost / energy_*WHERE project_id = X. A reprint reuses the source archive row and only adds a newPrintLogEntry, 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 offprint_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_infonow loopsfindall(".//plate")and sumsprediction→print_time_secondsandweight→filament_used_gramsacross 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_typekeeps the first plate's value as a best-effort archive default. Malformedprediction/weightvalues on individual plates skip cleanly rather than poison the sum. Stats fix:compute_project_statsand thelist_projectsquick-stats block both switch to an inner joinprint_log_entries → print_archivesWHERE archives.project_id = X.total_archivesbecomesCOUNT(PrintLogEntry.id)(actual runs, not files);failed_printsbecomes the count of runs infailed/aborted/cancelled/stopped;completed_itemsbecomesSUM(PrintArchive.quantity)filtered to runs withstatus='completed'(each run contributes its archive's quantity);total_print_time_hours / total_filament_grams / estimated_cost / total_energy_*come fromPrintLogEntrycolumns. Orphan log rows (archive_id IS NULLafter archive deletion viaON 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-runPrintLogEntry.filament_used_gramsfrom 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 readsPrintArchive.filament_used_gramsdirectly, so old archives keep their plate-1-only numbers until a reslice/rescan repopulatesfile_metadata. Same-shape fix carried forward:system.py::system_info(the System Info page's lifetime totals) summedPrintArchive.print_time_seconds/filament_used_gramswith the identical bug — reprints collapsed to one archive, multi-plate files reported plate-1-only. The route now sums fromPrintLogEntry.duration_seconds/filament_used_gramslike 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 computedestimate / actualper run whereestimate = 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 intest_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 preservesplate_number+ objects + bed_type; multi-plate ignores per-plate object lists; malformed per-plate values are skipped without poisoning the sum. 4 new intest_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 betweencompleted_prints(quantity-weighted) andfailed_prints(run-counted); list-view quick stats agree with per-project stats. 1 new intest_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_countandtotal_archivesnow 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}/statusreturn500 Internal Server Errorwith a valid API key carrying theread_statusscope, whileGET /api/v1/system/inforeturned 200 with the same key — so auth and routing were fine, the handler itself was crashing. Cause:printer_manager.get_status(printer_id)returns aPrinterStatedataclass (backend/app/services/bambu_mqtt.py), not a dict. The route atwebhook.py:266-270calledstatus.get("connected", False),status.get("state"),status.get("current_print"),status.get("progress"),status.get("remaining_time")— every one raisedAttributeError, which Starlette surfaced as a generic 500. Reporter's id-1 (printer exists) returned 500; non-existent ids returned 404 — exactly because the earlyPrinter not foundbranch fired before reaching the crash. Same shape in two adjacent routes:webhook_stop_print(POST /printer/{id}/stop) andwebhook_cancel_print(POST /printer/{id}/cancel) checkedstatus.get("connected")/status.get("state")for their precondition gates. 8 crash sites total across the three routes. Fix: everystatus.get("X", default)replaced with attribute access (status.X if status else default); Pydantic response schema unchanged.PrinterState's dataclass defaults cleanly cover thestatus is Nonebranch (printer registered but never connected — the route now returns 200 withconnected=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 whenget_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 notRUNNING; cancel route returns 503 when disconnected; cancel route returns 409 when state is notRUNNING/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_markedAST-walks every Path-arithmetic site inapi/routes/+services/and demands eithersafe_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 pastend_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 pastend_linenoIF 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 inTestMarkerDetectionpin 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/presetsendpoint 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_presetremoves the DB row,get_db()auto-commits,_fetch_local_presetsreads 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_presetskeeps 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=truequery 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, callsgetSlicerPresets({refresh: true})and writes the fresh slots into the['slicerPresets']cache viaqueryClient.setQueryData(so the spinner disappears immediately rather than triggering a second refetch). Spins theRefreshCwicon while in-flight; disabled during a slice enqueue so users can't fire it twice. i18n: real translations forslice.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 intest_slicer_presets.py(refresh=Truere-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 inLocalProfilesView.test.tsxasserts 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$HOMEisn'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), andINFO [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 instl_thumbnail.pybefore the matplotlib import: (a)_configure_matplotlib_cache()setsMPLCONFIGDIRtosettings.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 intest_stl_thumbnail.py— the font_manager logger is at WARNING after module import;_configure_matplotlib_cachecreates the directory underbase_dirand setsMPLCONFIGDIRto point at it; an externally-setMPLCONFIGDIRis 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 testshape) emitted oneWARNING [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 atstl_thumbnail.py:54matched, 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 atstl_thumbnail.py:55demoted fromlogger.warningtologger.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 constantMIN_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 inlibrary.py(extract_zip_file ZIP entry path, single-file upload,_backfill_external_stl_thumbnails) pre-skip files below this size BEFORE callinggenerate_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 intest_stl_thumbnail.py— one verifiesMIN_USABLE_STL_BYTESsits 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, callsgenerate_stl_thumbnail, and asserts noWARNING-level "empty mesh" record appears incaplog. 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/tfacalls all returning Cloudflare's "Just a moment..." HTML interstitial instead of JSON;backend/app/services/bambu_cloud.py::verify_totpcaught thejson.JSONDecodeErrorand returned the opaque "Invalid response from Bambu Cloud" message. Root cause is Cloudflare-side, not Bambuddy: a curl from this machine with the same honestBambuddy/1.0 (+https://github.com/maziggy/bambuddy)UA at 2026-06-02 returned a cleanHTTP/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 | Nonehelper inspects the failed-parse response for CF markers ("Just a moment..."in body,"challenges.cloudflare.com"in body, HTTP 403 withcf-mitigatedheader, HTTP 503 withcf-rayheader) 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, andverify_totp— previously onlyverify_totphad a defensive catch;login_requestandverify_codelet the parse error bubble toBambuCloudAuthErrorwith"Expecting value..."as the detail, which surfaced as a generic 401 in the UI. Tests: 8 new inTestCloudflareChallengeDetection(backend/tests/unit/services/test_bambu_cloud.py) — direct helper tests for each of the four CF markers, a negative case (real JSON 400 withcf-rayheader 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 existingtest_verify_totp_cloudflare_blockedupdated to assert the new actionable message. Full 5486-test backend suite green; backend ruff clean. - OIDC auto-provisioning now reads the standard
emailclaim forUser.emailwhenEmail Claimis set to a non-email identity claim (#1569, reported by anderl1969) — Reporter configured Authentik withEmail Claim = preferred_usernameto drive username from the preferred_username claim and expected the standardemailclaim (which the ID token also carries) to populate the user's email field. Result: username was correctly set frompreferred_username, butUser.emailcame out empty. Cause:backend/app/api/routes/mfa.py::_resolve_provider_emailreads onlyclaims[provider.email_claim]. Withemail_claim="preferred_username"andpreferred_username="jdoe", the value fails the SEC-2 email shape check (no ``) and returnsNone. 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.3mffiles 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.3mffrom 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 classifyfile_typedifferently.backend/app/api/routes/library.py:1343-1348(the shared-folder scan path) does a compound-extension check and tags the filegcode.3mf; the upload path at the same file's1588does a singleext[1:]and tags it3mf. Thenfrontend/src/components/ModelViewerModal.tsx:71-73hadhasModel = normalizedType === '3mf' || 'stl'andhasGcode = normalizedType === 'gcode' || '3mf'— neither matchedgcode.3mf, so the capabilities object landed with both flags false and the modal rendered an empty bed.FileManagerPage.tsx:858also gated the Preview-3D context action onfile_type === '3mf' || 'gcode' || 'stl', so for shared-folder files the entry didn't even appear, and the type pill at765-770had no colour case forgcode.3mfso it fell through to the generic gray. Fix (frontend-only, no backend churn):ModelViewerModal.tsxintroduces anisThreeMfFamily = 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 returnedsetPlatesData(null)for the shared-folder file.FileManagerPage.tsxaddsgcode.3mfto the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compoundgcode.3mfclassification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. ThecanOpenInSlicerandsliceableTypechecks atModelViewerModal.tsx:269, 277-280are deliberately left alone — a sliced output isn't openable in the slicer, andsliceableTypealready explicitly excludes.gcodeand.gcode.3mfper 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.3mfexport contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates viagcode_*.gcodeentries 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 unsupportedgcode.3mfcapabilities branch (the change is additive —3mfandstlandgcodebehaviours 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
.3mffrom the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check atbackend/app/services/bambu_mqtt.py:3022was meant to catch the simple disconnect-then-finish case via_previous_gcode_statepreserved 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.3mflingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. Fix: a new connected-edge reconciliation pass — newreconcile_stale_active_prints(printer_id)inbackend/app/main.pyqueries archives instatus="printing"for the printer at MQTT (re)connect time and synthesiseson_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 differentsubtask_idthan the archive — Bambu firmware mints a freshsubtask_idfor 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 butsubtask_nameis 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 synthesisedon_print_completereuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call seesstatus != "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 ofon_printer_status_change— whenstate.connectedflips 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 asasyncio.create_taskso 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 aprint_stopMQTT command to the printer, which is invasive and explicitly out of scope. Tests: 21 intest_reconcile_stale_active_prints.py—TestIsActiveArchiveStalecovers 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.TestReconcileStaleActivePrintscovers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesisedon_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)inbackend/app/main.pyonly looked atdata["ams"], but the dict thaton_print_startactually receives at runtime is the wrapper shape{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt_payload>, "ams_mapping"}thatbackend/app/services/bambu_mqtt.py:2971-2980constructs — sodata["ams"]was undefined on every real call and the helper silently returned{}, leaving the fallback archive'sfilament_type/filament_colorNULL. 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 resolvesdata["raw_data"]["ams"]first (the callback shape) and only falls back todata["ams"]when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dictraw_data(e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. Tests: 5 new inTestOnPrintStartCallbackShape(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; missingraw_datareturns{}instead of raising; junkraw_data(string) doesn't shadow a present innerams. 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_spoolcallsapply_spool_to_slot_via_mqttafter upserting the SpoolAssignment row, which publishes bothams_filament_setting(tray_info_idx, tray_sub_brands, color, temps) andextrusion_cali_sel(K profile) over MQTT, andbackend/app/api/routes/spoolman_inventory.py::assign_spoolman_slotdoes 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 casemain.py::on_ams_changedeferred-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.tsxno longer fire the mismatch popup for profile-only mismatches —if (materialMatchResult !== 'exact')replaces the oldmaterialMatchResult !== 'exact' || !profileMatches, and the'profile'member is dropped from themismatchTypeunion (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 newinventory.assignReconfigureNotei18n 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 14AssignSpoolModal+ 7AssignToAmsModaltests 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 inapply_spool_to_slot_via_mqtt's tray_info_idx / setting_id resolution for his specific spool shape — would need his spool'sslicer_filamentvalue 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
000000HSL-bucketed to "Black". Spoolman already supported 8-digitRRGGBBAAhex; 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.ts—hexToColorName,getColorName,resolveSpoolColorName, andisLightColornow short-circuit to"Clear"when the input is 8 chars with alpha00, before either the catalog lookup or the HSL fallback can mislabel transparent as black;isLightColorreturnstruefor clear so text contrast matches the light/mid-gray checkerboard underlay the swatch paints. (b)frontend/src/utils/amsHelpers.ts::normalizeColorno longer unconditionally strips the alpha byte — it preserves#RRGGBBAAwhen alpha <FFso the AMS-side colour reaches CSSfill=/backgroundColoras a translucent value instead of a solid one; opaque colours still emit#RRGGBBandnormalizeColorForCompare(which DOES strip alpha) is unchanged so type/colour matching for auto-mapping is unaffected. (c)backend/app/api/routes/printers.py::get_available_filamentsno longer truncatestray_colorto 6 chars before emitting it on/printers/available-filaments— both the AMS andvt_traybranches now pass the full#RRGGBBAAthrough; 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.tsgained a{ name: 'Clear', hex: '00000000' }entry toQUICK_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.tsxreworked the hex draft contract: previously the hex input was hardcoded to 6 chars and every commit path unconditionally appended'FF', so even pasting00000000got truncated to000000FF(solid black). Now: the draft accepts up to 8 hex chars; a 6-char commit appendsFF, an 8-char commit passes through verbatim; on blur a 7-char draft (RGB + one alpha nibble) right-pads the nibble to0instead of jumping back to 6-char-pad-RGB; theselectColor()helper that the preset swatches call only appendsFFwhen the preset is 6 chars, so the newClearswatch lands as00000000informData.rgbainstead of00000000FF.currentRgbais canonicalised to 8 chars uppercase andisSelected()matches on the full rgba soClear(00000000) doesn't collide withBlack(000000FF) in the swatch highlight. (f) Two new shared helpers infrontend/src/utils/colors.ts—getSwatchStyle(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 SVGfill=props and other single-string colour contexts where the consumer can interpret 8-char hex natively). Applied to every simple-swatch site that previously didstyle={{ 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 inColorSection.tsx(recent / catalog / fallback), the spool checkbox swatch inLabelTemplatePickerModal.tsx, the per-card colour dot + the SVGSpoolCircleinSpoolBuddyInventoryPage.tsx, the assigned-spool indicators inSpoolBuddyAmsPage.tsx(both internal and Spoolman branches), the four selected-spool summary swatches + the simple-view's spool dot inSpoolBuddyWriteTagPage.tsx, the lead-spool indicator inForecastPanel.tsx, the header swatch inAssignToAmsModal.tsx, the two spool-list dots inAssignSpoolModal.tsx(internal + Spoolman columns), and theSpoolIconfed byInventorySpoolInfoCard.tsx/TagDetectedModal.tsx/SpoolInfoCard.tsx/LinkSpoolModal.tsx(which now pass the full 8-char rgba — SVGfill=interprets translucent values correctly).FilamentSwatch.tsx's tooltip title fallback also widened so the on-hover hex code shows#00000000for a Clear spool instead of misreporting it as#000000. What is intentionally NOT changed: the native<input type="color">value inSpoolBuddyWriteTagPage.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 opaqueFF(which is correct behaviour: the user explicitly picked a colour via the picker, not transparency). The colour-sort comparator inLabelTemplatePickerModal.tsx::colorSortKeykeeps 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 inbackend/app/services/label_renderer.pykeeps its 6-char alpha-strip in_hex_code_labelbecause the printed text on a physical label can't show transparency —_color_from_hexdoes 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_filamentinbackend/app/services/spoolman.pystill strips alpha when looking up the Spoolman catalog because Spoolman's filament catalog schema only supports 6-charcolor_hex— a transparent AMS spool synced into Spoolman will now match against a000000(Black) Bambu Lab filament entry instead of the pre-fix synthetic "PLA Basic" cream entry (RGBF5E6D3); 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 atbackend/app/services/spoolman.py::parse_ams_traythat silently replaced AMS-reported00000000withF5E6D3FF("Light cream/natural color"). That rewrite was a workaround from when the swatch renderer couldn't show alpha —filamentSwatchHelpers.ts::buildFilamentBackgroundalready 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 true00000000value; the swatch renders the checkerboard;getColorNameresolves to "Clear". (h)backend/app/services/spool_tag_matcher.py::create_spool_from_trayshort-circuits the colour-catalog lookup whenrgbais alpha=00 and storescolor_name="Clear"directly — without this, a Bambu-RFID transparent spool would resolve against the#000000row in the catalog (orBlackvia the HSL fallback) before the frontend's name resolver ever sees it, defeating the alpha-aware fix incolors.ts. Tests —src/__tests__/utils/colors.test.ts: 5 new assertions covering alpha=00 → "Clear" forhexToColorName,getColorName(including precedence over a catalog entry on the same RGB), andresolveSpoolColorName; one existing assertion changed from12345600(which now correctly resolves to "Clear") to123456FFto 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 noFFappend, and a 7-char draft on blur pads to 8 with a trailing0instead of jumping back to RGB. 17 colours tests, 9 hex-input tests, 53 useFilamentMapping tests, 14 FilamentOverride tests, 10 FilamentSlotCircle tests, 6/printers/available-filamentsintegration 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 andinventory.py:119/spoolman.py:887-889already passed00000000through verbatim (the 6→8 charFFpad only fires whenlen == 6), so no parallel mutation is needed on the Spoolman-mode write path. Existing inventory rows that were already rewritten toF5E6D3FFstay 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_clientused a hardcodedtimeout=60on every per-packet read, and_handle_connecttwo 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_connectnow parses the 2-byte big-endian keepalive value from the CONNECT payload and returns it alongside the auth bool (tuple[bool, int])._handle_clientuses that to set its per-packet read timeout to1.5 × keep_aliveafter a successful CONNECT, orNone(no timeout) when the client opted out withkeep_alive == 0per 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 intest_vp_mqtt_server.py—TestHandleConnectKeepalive(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 withkeep_alive=180is still alive past the old 60 s boundary;keep_alive=2closes 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 realasyncio.StreamReaderand 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 hadarchive.filename = "Cube (1).gcode.3mf.gcode.3mf"— the.gcode.3mfsuffix had been appended twice somewhere during the file's import. The dispatcher'sarchive.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 inmain.pyderived its delete target fromsubtask_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. Newderive_remote_filename(filename)helper inbackend/app/utils/filename.pyiteratively strips trailing.gcode.3mf/.3mfsuffixes until the bare stem remains, then appends a single.3mfand underscore-replaces spaces (the firmware parsesftp://{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_archiveand_run_print_library_fileinbackend/app/services/background_dispatch.py, and the queue dispatch inbackend/app/services/print_scheduler.py. (2) cleanup uses the same algorithm as upload. The post-print SD cleanup inmain.pynow fetchesarchive.filenamewhenarchive_idis resolved and triesderive_remote_filename(archive.filename)FIRST, with the legacy/{subtask_name}.3mfand/{subtask_name}.gcodepaths 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.3mffilename is not addressed here — the iterative strip inderive_remote_filenamedefends 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 awhile Truestrip loop withendswith/ 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 truthyMagicMockon every iteration and the slicestem[:-10]returned anotherMagicMock— the loop never reached theelse: breakbranch. Each iteration allocated a freshMagicMockuntil the LXC cgroup OOM-killer reaped the pytest worker at 61 GB anon-rss (visible injournalctl -kasoom_memcg=/lxc/109withCONSTRAINT_MEMCG). Fixed by adding anisinstance(filename, str)guard that raisesTypeErrorinstead 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-strarchive.filename, the cleanup logs a warning via its outertry/exceptinstead of OOMing the backend. Tests: 10 inTestDeriveRemoteFilenameintest_filename_validation.py(single.gcode.3mfstrip, single.3mfstrip, bare stem appends.3mf, space→underscore, the literalCube (1).gcode.3mf.gcode.3mfreproducer from #1542 →Cube_(1).3mf, doubled.3mf.3mf, mixed.gcode.3mf.3mf, raw.gcodepreserved as.gcode.3mfsince.gcodealone is a valid sliced file, idempotence — running the helper on its own output is a no-op, Unicode stem preserved, type guard —MagicMock/None/intinputs all raiseTypeErrorwith 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'sTestAbortedStatusNormalisation— 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 becauselibrary.py:4011only 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 with553 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: newbackend/app/utils/filename.pyexportingvalidate_print_filename(name)andInvalidFilenameError— 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_fileatlibrary.pyreplaces the path-separator-only check; (b)upload_fileatlibrary.pyrejects bad multipart-upload filenames before they're persisted; (c)print_library_fileadds 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_queueatprint_queue.pysame 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 inFileManagerPage.tsxnow 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: newfileManager.invalidFilenameCharkey 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 intest_filename_validation.py(parameterised over every char inINVALID_FILENAME_CHARS, the exactL|R.3mfreproducer 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/Metadataalso returning 550, then the fallback archive being created withfile_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 indata["ams"]["ams"]/data["ams_mapping"]. Fix: new_extract_filament_data_from_mqtt(data, ams_mapping)helper inbackend/app/main.py(placed next to the existing_get_start_ams_mapping) walksdata["ams"]["ams"][*].tray[*]to build a global-tray-id → (tray_type, tray_color) map, then narrows to slots referenced byams_mappingif present (slicer order preserved; -1 entries for VT-tray skipped), or falls back to every loaded slot otherwise. Output is a comma-separatedfilament_type+filament_colorin the same shape the 3MF extractor produces — so the inventory page, Quick Stats filament rollup, andlen(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 fallbackPrintArchive(...)constructor now passesfilament_type=/filament_color=from the helper. What this is NOT: not per-filament gram usage (that needs the 3MF'sslice_info.configor a deep AMS layer-delta integration viausage_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 intcp_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 intest_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; anams_mappingfilters to and reorders by the slicer's order; VT-tray sentinels (-1) are filtered; dual-AMS layouts resolveunit*4 + traycorrectly 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 emitsfilament_typeonly; 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 stampstare_offset+last_calibrated_aton the device row), the device list query already polls every 10 s, buthandleTareinfrontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsxwas 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 snapshotsdevice.last_calibrated_atwhen TARE is pressed, sets anawaitingTareSincestate, 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 whenlast_calibrated_atadvances 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 auseRefthat 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 plaincurl -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.pywas the one outbound httpx client in the codebase that didn't set the project-standardBambuddy/1.0 (+https://github.com/maziggy/bambuddy)UA — it leakedpython-httpx/<version>instead. Brings it in line withbambu_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: cloudflareorcf-mitigatedheader, 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 plainforbidden: invalid auth tokenbody) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. Tests: 3 new inTestNtfyOutboundintest_notification_service.py— (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 withServer: cloudflare+Just a moment...body produces the actionable error and does not echo<!DOCTYPEto the user; (c) a 403 with a plain text auth-failure body keeps the originalHTTP 403: forbidden: invalid auth tokenso we don't hide real errors. 110/110 in the notification suites green underpytest -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:2596creates a "fallback"PrintArchiverow withfile_path="". The twoArchives → Source 3MF Uploadroutes computed the destination directory as(settings.base_dir / archive.file_path).parent / "source"— which on a fallback row collapsed toPath('/app/data') / '' = Path('/app/data'), whose.parentisPath('/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 finalsource_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/datamount; 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)inbackend/app/api/routes/archives.pycentralises the destination computation. Normal archives still nest the source under<archive_file_dir>/source/<filename>. Fallback archives (emptyfile_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 viasettings.base_dir / archive.source_3mf_path. The helper also defensively asserts the resolved directory is insidebase_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_3mfandupload_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 inTestUploadSourceThreeMFinbackend/tests/integration/test_archives_api.py— (a)test_fallback_archive_source_upload_lands_under_base_dircreates an archive withfile_path="", uploads a minimal valid 3MF, asserts 200 status, that the returnedsource_3mf_pathis relative (not/app/source/...), that the file physically exists under the patchedbase_dir, and that the path is the deterministic fallback location keyed offarchive.id; (b)test_normal_archive_source_upload_unchangedis the same flow against an archive with a populatedfile_path, asserting the existingarchives/test/source/<filename>.3mflayout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 intest_archives_api.pygreen underpytest -n 30. Backend ruff clean. Note: existing orphan files at/app/source/<filename>.3mffrom 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 upstreamnfc/tag-scannedroute is exclusive (always-Spoolman whenspoolman_enabled=true, after the #1119 / nfc-routing fix). When the user's local DB still held a staleSpoolrow 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 localweight_used, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line loggedSpoolBuddy updated spool 2 weight: …g on scale, …g used(the local-branch log format) and theSpoolBuddy 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_weightnow routes exactly likenfc_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 inTestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman— creates a localSpoolwith the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman'supdate_spoolwas called with the correct remaining weight, and (b) the local row'sweight_usedandlast_scale_weightare unchanged after arefresh()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 underpytest -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_runtimebackground task inbackend/app/main.pycounted bothRUNNINGandPAUSEstates equally towardruntime_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 inmain.pyand 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, andlast_runtime_updateis 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 acrossmain.py,models/printer.py:23, and the twoapi/routes/maintenance.pyroute docstrings that all previously described the field as covering "RUNNING and PAUSE states". Out of scope: retroactive backfill of existingruntime_secondsvalues — 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 intest_runtime_tracking_pause.pypinnin
Changelog truncated — see the full CHANGELOG.md for the complete list.