Note
This is a daily beta build (2026-06-12). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Fixed
- Print-modal "off" toggles for
flow_caliandnozzle_offset_calinow actually suppress the calibration stage (live-tested on H2D 01.x) — The Re-print / Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted the user's "off" choice and flowed it correctly through to theproject_fileMQTT publish — Bambuddy sentextrude_cali_flag: 2andnozzle_offset_cali: 2per our reading of "1 = run, 2 = skip" inherited from the #1478 / #1682 work. Live test on an H2D running firmware 01.x: with both toggles off in Bambuddy's modal, the printer'sstgqueue (the pre-print stage list firmware publishes via push_status) still included stage 8 ("Calibrating dynamic flow") and stage 39 ("Nozzle offset calibration") — and physically ran them at print start. The2value did NOT suppress the stage despite our earlier "skip and reuse stored PA" reading. Root cause: the encoding for the "off" wire value is0, not2. The2value appears to mean "skip the explicit calibration pass but still apply / verify the stored PA value via the calibration stage" — close to a no-op in terms of K-factor but the printer still queues the stage and runs the per-print physical sequence.0is what actually drops the stage from thestgqueue. A real BambuStudio Send-dialog capture on the same firmware (proxy-mode VP echo) also showed0for both fields when calibrations are unchecked, contradicting the #1478 commit message which read0as "never sent by BambuStudio." Fix:bambu_mqtt.py::start_print—extrude_cali_flagis now1 if flow_cali else 0(was2), andnozzle_offset_caliis1 if (nozzle_offset_cali and is_dual_nozzle) else 0(was2). The dual-nozzle gate stays — single-nozzle prints continue to force-skip the nozzle-offset calibration their head doesn't support (#1682).1(run) is unchanged on both fields. Verification: live re-test on the same H2D with both toggles still off —stg: [29, 13, 4, 14, 3](cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 dropped out cleanly. What's NOT fixed:vibration_caliis a JSONfalsebool in both Bambuddy's and BambuStudio's wire format, and the H2D firmware queues stage 3 ("Vibration compensation") regardless of the bool value — this is firmware-side and not solvable at our dispatch layer with the current field. Captured as a follow-up to investigate whether a parallelvibration_cali_flaginteger field exists. Tests:test_bambu_mqtt.py—test_p2s_uses_boolean_formatflippedextrude_cali_flag == 2→== 0;test_nozzle_offset_cali_default_is_skip,test_nozzle_offset_cali_ignored_on_single_nozzle,test_nozzle_offset_cali_false_on_dual_nozzleflipped== 2→== 0; docstrings updated to reflect the #1721 finding. The1 if user_wantsbranch in both tests for the "on" case is unchanged. 281/281 bambu_mqtt tests green; full backend suite 5941/5941 green with-n 30; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean perfeedback_run_all_ci_checks). - Support-bundle log noise: VP bridge nudge + SD-card cleanup (#1721 adjacent, observed on reporter's A1) — Two warnings polluting every A1 support bundle on a healthy print. Neither was the cause of #1721's timelapse complaint — both are adjacent noise. (1)
request_status_update: not connected—mqtt_bridge.py::_resolve_clientcalls_request_version+request_status_updateimmediately after attaching a raw-message handler so the bridge cache populates without waiting for the next periodic pushall. The bind frequently races the real printer's MQTT TLS handshake — a slicer-side reconnect re-resolves the client before the underlying session has reconnected, especially on A1 firmware which reconnects more aggressively than X1/H2/P.request_status_updatelogs[serial] request_status_update: not connectedat WARNING on the not-connected return path. The nudge is a best-effort optimisation; the fall-through (next periodic pushall) populates the cache anyway, so the WARNING fires on routine, expected, recoverable state. Fix: gate both nudges oncurrent.state.connectedat the bind site. When the client comes up, the next_resolve_clienttick re-enters this branch on identity change OR the periodic pushall inbambu_mqtt.pyfills the cache — same end state, no benign WARNING. The WARNING inbambu_mqtt.py:3224is unchanged: it's still a real signal for the other callers (/printers/{id}/refresh-statususer API, bug-reporter helper) where "you asked for a refresh on a dead client" is genuinely worth logging. Newtest_post_bind_nudge_skipped_when_target_not_connectedintest_vp_mqtt_bridge.py::TestBridgeLifecyclepins the contract. (2)SD card cleanup failed after 3 attempts ... (file may linger on SD card)— The post-finish helper inmain.pydeletes the uploaded file from the printer's SD card to prevent the ghost-print-on-power-cycle behaviour (#374, #1542). It tries up to three candidate paths (derive_remote_filename(archive.filename), then{subtask_name}.3mf, then{subtask_name}.gcode), each up to 3 times with 2 s backoff, then logs WARNING if all fail.delete_file_asyncreturnedbool—Truefor success,Falsefor ANYTHING else (FTP 550 file-not-found, network error, auth fail, transient FTP error). The A1 firmware (and most other Bambu firmwares post-print) cleans the SD-card upload itself before our cleanup runs, every candidate FTP-DELE returns 550, all three retries × three candidates × 2 s sleeps fire, then WARNING. That WARNING shouldn't exist on a healthy print where the printer self-cleaned. The same shape exists in_cleanup_forced_timelapse(#1397) walking the four timelapse dirs. Fix:bambu_ftp.pynow exports aDeleteResultenum (DELETED/NOT_FOUND/FAILED).BambuFTPClient.delete_filedetects the 550 case viaisinstance(e, ftplib.error_perm) and str(e).startswith("550")(same pattern already used in the download path for the symmetricFileNotOnPrinterErrorsentinel from #972).delete_file_asyncnow returnsDeleteResult. Both post-finish cleanup helpers (main.py::on_print_finishedSD branch +_cleanup_forced_timelapse) only WARN when at least one candidate returnedFAILED; an all-NOT_FOUNDoutcome logs DEBUG ("nothing to delete — printer likely self-cleaned"). The cleanup helper also no longer burns the 2 s × 3 retry budget on aNOT_FOUNDresult (550 will never recover by waiting); onlyFAILEDtriggers backoff.DELETE /printers/{id}/files/...returns 404 (not 500) onNOT_FOUND, more accurate for the user-facing UI. Three other production callers (print_schedulerpre-upload delete, twobackground_dispatchfire-and-forget cleanups) are unchanged at the call site — they discard the return value. Tests:test_delete_fileandtest_delete_file_asyncintest_bambu_ftp.pyswitched to the enum (3 cases each). 2 new regression tests intest_cleanup_forced_timelapse.py:test_forced_no_warning_when_every_dir_returns_not_foundpins the #1721 path (every candidate dir → 550 → no WARNING, one DEBUG summary),test_forced_warns_when_any_dir_returns_failedpins the counterpart (any FAILED keeps the WARNING — that's the signal the maintainer wants).caplogasserts the log record's level + content directly. Full backend suite 5941/5941 green; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean perfeedback_run_all_ci_checks). No migration, no new i18n keys, no schema changes. - Virtual printer external spool (
vt_tray) went "invalid" right after a slicer filament pick (#1622 round 5, reported by shaddowlink) — On a P1S in non-proxy VP mode, the reporter picked a filament for the external spool slot in BambuStudio's Device tab and the slot immediately rendered as invalid (color only, no profile, no K-profile, no nozzle temps), but recovered after a virtual-printer reload. AMS slot picks worked correctly. Wire dumps (BAMBUDDY_VP_DUMP_WIRE=1) captured the asymmetry: the bridge's outgoing 1 Hz cached-as-base push deliveredvt_tray = {tray_info_idx, tray_color}— 2 fields — where a real P1S sends ~20 (tray_type,state,remain,k,n,cali_idx,nozzle_temp_min/max,tray_uuid,xcam_info, ...). The same_out.jsonshowed AMS slots with the full 24-field dict because_merge_ams_dictdeep-merged them. Root cause: Bambu firmware sends a partialvt_trayincremental right after acknowledging anams_filament_settingforams_id=255(external spool) — carrying just the fields the slicer's pick changed. The round-4 per-field accumulate (#1622 / da79944) carried over prev keys NOT present in new, butvt_trayIS present in new, so the cached dict was REPLACED wholesale with the 2-field partial. The next 1 Hz cached-as-base push handed the slicer the stripped vt_tray; BambuStudio rendered the slot as invalid. Reloading the VP forced a reconnect → pushall → full vt_tray restored, and the cycle repeated on the next pick. Fix:mqtt_bridge.py::_on_printer_rawnow applies the same per-field accumulate one level deeper: for every top-level key whose prev AND new are both dicts, overlay new onto prev rather than replace.amsis explicitly excluded (already deep-merged by_merge_ams_dict). The same overlay protectsdevice,online,upgrade_state,ipcam,upload,netagainst future firmware partials with the same shape; thenet.infoIP rewrite path is unaffected because_rewrite_net_info_ipsruns againstnew_state["net"]before caching and the rewritten list overrides the cached one on overlay (onlynet.confand friends, when sent withoutinfo, draw from prev now). Tests: newtest_partial_vt_tray_update_overlays_onto_cached_full_dictregression case intest_vp_mqtt_bridge.py::TestPushStatusCacheconstructs the exact P1S wire shape — pushall with the full ~20-field vt_tray, followed by the{tray_info_idx, tray_color}partial that shaddowlink's dump captured — and assertstray_type,state,remain,k,n,cali_idx,nozzle_temp_min/max,tray_uuid,idall survive while the two incoming fields take their new values. All 53 bridge tests stay green; 287/287 across the broader VP test surface (mqtt_bridge / mqtt_server / vp_wire / virtual_printer); ruff clean. Bridge code path only; no migration, no new i18n keys, no frontend touch. - Library G-code preview returned raw ZIP bytes as
text/plainfor sidecar-sliced rows (#1709, root cause + fix from yanglei1980) —slice_and_persistwrites its output as a.gcode.3mf(a ZIP container with embedded G-code) but persisted the LibraryFile row withfile_type="gcode". The G-code preview endpoint atlibrary.py::get_gcodeshort-circuits onfile_type == "gcode"and streams the on-disk bytes withmedia_type="text/plain", so every preview of a sidecar-sliced row handed the embedded viewer the raw ZIP body (PK\x03\x04…) instead of toolpath text — the viewer rendered nothing. External-folder scans (#1600) already typed.gcode.3mfrows correctly and hit the unzip branch, so the bug was specific to the sidecar slice path. Plain.gcodeuploads were unaffected (their on-disk bytes really are text). Fix: (1) forward —slice_and_persistnow persistsfile_type="gcode.3mf", matching what_classify_file_typereturns for the.gcode.3mfextension and what external-scan rows already use; (2) back-compat —get_gcodealso routes to the unzip branch when the filename ends with.gcode.3mf, so rows already written under the bug self-heal on first preview without a DB migration. UI gates: three frontend call sites that gated badge colour or the preview-eye icon onfile_type == "gcode"were extended to also accept"gcode.3mf"—FileManagerPage.tsxbadge + viewer-affordance gate,ProjectDetailPage.tsxbadge — so the new typing doesn't regress visuals. The print / queue / slice action buttons use filename-based helpers (isSlicedFilename,isSliceableFilename) that already accept.gcode.3mf, so they need no change. Tests: newtest_library_get_gcode_recovers_legacy_gcode_type_for_3mfregression case intest_library_api.pyconstructs a row withfile_type="gcode"+.gcode.3mffilename pointing at a real ZIP, asserts the response istext/plain, containsG28, and does NOT start withPK— pins the legacy-row recovery path. Existingtest_library_get_gcode_endpoint_accepts_compound_file_typecontinues to cover the forward path. Full backend suite 5920/5920 green; ruff clean; frontend ESLint +npm run buildclean; FileManagerPage / ProjectDetailPage / FileManagerExternalFolder vitests 69/69 green; i18n parity unchanged (no new keys). PR #1709 closed for CONTRIBUTING.md non-compliance (branched from main, no issue, template incomplete); root cause + fix shape preserved here ondev. - Cloud + Orca Cloud preset resolver: pin
typeandfromto CLI-accepted values (#1712 follow-up, reported by maziggy on the Mecha Mewtwo slice) — Removing bundle mode (entry above) routed every slot through the cross-tier preset resolver. Cloud-tier presets surfaced two latent shape mismatches that bundle dispatch had been masking by materialising preset JSONs from.bbscfg-on-disk. (1)typefield: Bambu Cloud labels presets withtype: "printer"/"print"/"filament", but the BambuStudio CLI's--load-settingsparser only accepts"machine"/"process"/"filament". The user's first failing slice producedoperator(): unknown config type print of file preset.json in load-settingswith exit code -5; the sidecar surfaces this as a generic "The input preset file is invalid and can not be parsed." (2)fromfield: Bambu Cloud's filament detail endpoint routinely ships presets with emptyfrom(or nofromat all). The CLI's compatibility check rejects either withoperator(): file ... 's from unsupported(the double space in stderr = empty value). Same -5 exit, same generic "input preset invalid" surface. The sidecar'snormalizeFromFieldalready maps"User"/"System"→"system", but it doesn't touch empty / missing values. Fix:_resolve_cloudand_resolve_orca_cloudnow forcetype = _SLOT_TO_PROFILE_TYPE[slot]andfrom = "system"on the payload beforejson.dumps, mirroring what_resolve_standardalready does for the standard-tier stub. Both fields are unconditionally rewritten — idempotent on already-correct payloads, and pinning to "system" is consistent with how Bambuddy presents these post-flatten presets to the CLI (no parent walk needed, the cloud detail comes back fully expanded). Tests: newtest_cloud_rewrites_type_field_for_cli(7 parametric cases covering all six type-name variants Bambu Cloud emits plus the missing-type case),test_cloud_pins_from_field_to_system(4 cases: empty, already-system, GUI User, GUI System), andtest_cloud_synthesises_from_field_when_missing(the actual Mecha Mewtwo failure shape) pin the resolver-level contract. Existing happy-path assertions for_resolve_cloud/_resolve_orca_cloudupdated to include the new fields. 28/28 preset-resolver tests green, full backend suite 5919/5919 green, ruff clean. What this can NOT recover: if Bambu Cloud later starts emitting afromvalue other than empty / "User" / "System" that genuinely means something (e.g. "project"), Bambuddy will silently flatten it to "system" too. We accept that trade-off because the alternative is leaving "input preset invalid" failures on every cloud slice, and "system" matches how the sidecar's own resolver normalises the post-flatten state.
Removed
- Slicer Bundle (.bbscfg) import (#1712, reported by IndividualGhost1905) — Bundle import never delivered what users expected. BambuStudio's "Export Preset Bundle" only includes user-customised presets; system processes / filaments are deliberately excluded by BS. So a fresh-install user who only used stock processes (the common case) got back a bundle containing their printer + maybe four custom filaments + zero processes. Importing that bundle into Bambuddy and then opening the SliceModal flipped into bundle mode — which constrained the dropdowns to bundle contents only — and surfaced "no presets" for process, blocking slicing on STL (3MF still worked because the embedded process JSON bypasses the dropdown). The first round of #1712 (
d459b6ea, 2026-05-XX) addressed cross-tier visibility / dedup / banner behaviour but didn't touch the bundle-mode dropdown trap. Investigating the second round made it clear the bundle import wasn't unlocking anything the existing tiers don't already cover — custom presets reach Bambuddy through Bambu Cloud sync, Orca Cloud sync, or Single Preset Import; standard presets come from the sidecar's/profiles/bundledroute automatically — so bundle mode was a fourth code path delivering no unique value while gating users on a slot they couldn't populate. What was removed. Backend:POST/GET/DELETE /slicer/bundles*routes,SliceRequest.bundlefield +SliceBundleSpecschema, the bundle-dispatch fork inlibrary.py::_run_slicer_with_fallback(cross-class slice-all loop, normal slice branch,_resolve_target_printer_modelshort-circuit), the bundle-context query params onGET /library/files/{id}/filament-requirementsandGET /archives/{id}/filament-requirements, the bundle-fingerprint key inslice_preview.py's LRU cache (back to(kind, source_id, plate_id, content_hash)),SlicerApiService.import_bundle/list_bundles/get_bundle/delete_bundle/slice_with_bundle, theBundleSummary/BundleNotFoundErrortypes. Frontend:BundlePicker+BundleStringDropdowncomponents,isBundleModestate and every branch on it inSliceModal.tsx,selectedBundleId/bundleProcessName/bundleFilamentNamesstate, the bundle-mode auto-pick effect, the bundle dispatch shape inbuildSliceBody, thebundlesQueryitself,SlicerBundle/SliceBundleSpectypes,listSlicerBundles/importSlicerBundle/deleteSlicerBundleAPI methods. The bundle-derived path inbuildCompatibilityIndexis also gone — the function now only takes the printer-model registry and returns{bambuModelByShortCode}.presetCompatibilitykeeps its two remaining paths: the slicer's owncompatible_printerslist on local-imported presets (authoritative when set) and theBBL <code>name-based fallback against the printer-model registry. Tests:TestBundleRoutes/TestBundleClientMethods/TestSliceWithBundle/TestBundleAwarePreview/TestBundleDispatchShapeclasses deleted acrosstest_slicer_presets.py/test_slicer_api.py/test_slice_preview.py/test_slice_request_schema.py/test_library_slice_api.py; the SliceModal's "Bundle tier" describe block and the bundle-only assertions inslicerPrinterMatch.test.tsdeleted;SlicerBundlesPanel.test.tsxremoved;TestNozzleClassGuardsimplified (no more bundle vs preset request distinction). What replaces the Settings panel.SlicerBundlesPanelis kept under the same name and slot inSettingsPagebut now renders a static notice (title: "Slicer Bundles (removed)") explaining the removal and pointing users at Single Preset Import / Bambu Cloud / Orca Cloud, with the slicer sidecar covering stock presets automatically. The notice is permanent and can be removed in a future cleanup. i18n.settings.slicerBundles.*block replaced withsettings.slicerBundlesRemoved.{title,description,alternatives}translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) perfeedback_translate_dont_fallback.slice.bundle/slice.bundleNone/slice.bundleAllRequiredkeys removed across all locales. Parity check 5106 leaves × 11 locales green. Migration. Hard cutover, no automatic preset migration. Users who previously imported bundles will see them disappear from Settings → Slicer Bundles after this drops; their printer preset still lives on the sidecar bundle store but is no longer surfaced. Standard presets from the sidecar's BBL tree cover stock slicing; users who need their customs re-upload them via Single Preset Import or sync via Bambu Cloud / Orca Cloud. Why this resolves #1712. shaddowlink's failing path was: import bundle for H2D → bundle has 0 processes (BS-side limitation) → SliceModal flips into bundle mode → process dropdown empty → can't slice STL. Post-removal: same import isn't possible, but the cross-tier preset picker shows H2D processes from the sidecar's standard tier (which always had them — bundle mode was the thing hiding them), filtered byBBL H2Dcompatibility. STL slicing works without any user action. Tests: full backend suite 5907/5907 green; ruff clean; frontend ESLint clean;npm run buildclean; vitest 158 files / 2118 tests green; i18n parity 5106 leaves × 11 locales green.
Added
- Support bundle now includes redacted cached push_status per connected printer — The existing support bundle (
GET /support/bundle) shippedsupport-info.json+bambuddy.log— useful for triage, but missing the one thing that consistently blocks per-model work: the raw shape of the printer's MQTT push_status payload. Bambu firmware ships per-model config in a different shape for every family — AMS Backup detection was deferred in85fbd7fcbecause the H2D's bit-26 ofprint.cfgdoesn't translate to the X1C / P1S / P2S layout and we had no ground-truth samples to map them; the same gap surfaces every time avt_tray/vir_slot/mappingshape varies across firmware (the P2Stray_nowfix, the H2Dvir_slotparsing, the round-5vt_trayoverlay fix from #1622 last week all needed wire samples to land). What's new: the bundle now contains apush-status/printer-{i}.jsonfile per connected printer, indexed againstsupport-info.json["printers"]. Each file carries{model, firmware_version, captured_at, raw_data}whereraw_datais the live cached push_status fromBambuMQTTClient.state.raw_data. Disconnected printers (no MQTT state, orraw_dataempty) are skipped — there's nothing to capture and an empty file just adds noise. Redaction (two-pass): a structural pass via the new_redact_raw_push_statushelper drops user-private top-level keys anywhere in the tree (subtask_name,gcode_file,gcode_file_prepare_percent,subtask_id,task_id,project_id,design_id,profile_id,model_id,gcode_state) — Bambu's per-print filename/cloud-ID surface — and rewrites everynet.info[*].ipentry to"0.0.0.0", mirroring the LAN-topology leak fixed for the virtual-printer bridge in #1429. What's deliberately preserved:print.cfg,print.option,ams.*,vt_tray,vir_slot,mapping,ams_extruder_map, hardware fields (nozzle_diameter, temperatures, layer counters). These are the fields per-model work depends on. The structural pass then runs throughsanitize_log_contentwith the same DB-derivedsensitive_stringsmap the log path uses (printer names, serials, IPs, access codes, usernames, Bambu Cloud email) — belt-and-suspenders against any user-named string that leaked into a tray UUID or a sub-brand field. The redactor returns a NEW dict and never mutates the livestate.raw_data(the dispatcher reads it on every tick; mutation would race the next push). Why always-on instead of opt-in: the bundle endpoint is already gated on "debug logging must be enabled" — generating the bundle is an explicit user act, the file downloads to the user's machine before they choose to send it, and forcing a second toggle adds friction without changing the threat model. Once a handful of bundles arrive from new-model users we'll have what we need to unblock AMS Backup awareness in the print-queue deficit check, plus future per-model shape variance. Tests: 5 new unit cases intest_support_helpers.py::TestRedactRawPushStatuspin the contract — drops the 9 user-private keys, rewritesnet.info[*].ipwhile preservingmasksiblings + siblingnetkeys, preservesprint.cfg/ams/vt_tray/vir_slot/mapping/ams_extruder_map, does not mutate input, handles non-dict input gracefully (returns{}for None / list / str). Full support test surface 79/79 green (test_support_helpers.py+test_support_api.py); full backend suite 5937/5937 green with-n 30; ruff clean across the backend; frontend untouched but rebuild + i18n parity confirmed clean perfeedback_run_all_ci_checks. No migration, no new i18n keys, no schema changes, no frontend changes. - Re-print / Schedule modal: cross-extruder AMS slot picks on dual-nozzle (#1722, reported by privatsturm) — On a dual-nozzle setup (e.g. H2D with AMS A+C wired to the left extruder and AMS B wired to the right), the per-filament slot dropdown in the Re-print and Schedule modals used to hide every slot whose extruder didn't match the filament's slicer-assigned nozzle. A filament the slicer had assigned to the left extruder would only let the user pick from A or C; a right-assigned filament could only pick from B. Users who'd intentionally loaded the required filament into the "other" AMS — for example, AMS B (right side) carrying a colour the slicer had planned to print on the left — couldn't select it, even though the printer can physically run that AMS through its wired extruder. Three slice-output diffs (BambuStudio Desktop, OrcaSlicer Desktop, Bambuddy sidecar) all produced identical filament_map values for the same source 3MF, so the slicer wasn't the source of the asymmetry — Bambuddy's UI filter was. Behaviour: every loaded slot is now offered for every filament row in the Re-print and Schedule modals' specific-printer flow, regardless of which extruder it's wired to. The L/R badge on the filament row stays as a visual hint to what the slicer planned; the dropdown now trusts the user to pick based on their physical setup. Single-nozzle printers and FTS-equipped setups are unchanged — both short-circuited the filter already and continue to. Printer firmware accepts or rejects the resulting
ams_mappingat start-print, so a physically-impossible pick fails loudly rather than silently. Implementation:FilamentMapping.tsx:248-254carried a guardf.extruderId === item.nozzle_idon the slot dropdown'sloadedFilamentsfilter; the guard is now removed. The single-nozzle and FTS short-circuits stay. Tests:'still applies the per-nozzle filter when FTS is null'flipped to'offers cross-extruder slots in the dropdown without FTS (#1722)'— same scenario (no FTS, AMS 0 on right, filament asking for left), but now asserts both slots ARE listed. The FTS-installed case (#1162) and the rest of the FilamentMapping suite stay green. Backend untouched; no schema, no i18n. 5/5 FilamentMapping vitests green; 1043/1043 full component sweep green; frontend build clean. - VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation) — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set
BAMBUDDY_VP_DUMP_WIRE=1and Bambuddy writes the bridge's cached push_status (<log_dir>/vp_wire/<vp_name>_in.json) and the periodic 1Hz copy that gets sent to the slicer (<log_dir>/vp_wire/<vp_name>_out.json) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's_out.jsonagainst a known-good VP's_out.json(e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives inbackend/app/services/virtual_printer/_debug.pywith call sites inmqtt_server.py::_send_status_report(cached branch only — synthetic fallback is uninteresting for this triage) andmqtt_bridge.py::_on_printer_raw(immediately after the merge that produces_latest_print_state). 21 unit tests intest_vp_wire_dump.pypin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back tovp, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. - VP slicer↔printer command-flow trace (#1622 round 2) — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (
BAMBUDDY_VP_DUMP_WIRE=1) now also appends every slicer-originated publish ondevice/<vp_serial>/requestAND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to<log_dir>/vp_wire/<vp_name>_cmd.jsonl, one JSON line per event with UTC iso timestamp, direction (slicer_to_bridge/printer_to_slicer), MQTT topic, a<channel>.<command>grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) andpushall/get_version(handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. Newappend_eventhelper in_debug.pymirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture asdump_wire; bytes payloads are utf-8 decoded then json-parsed with the same\x00-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to{"raw": "..."}so every line stays valid JSON. Eight additional unit tests intest_vp_wire_dump.pypin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. - VP bridge-synthesised reply trace (#1622 round 3) — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues
extrusion_cali_set(push K/n directly) and the printer respondsfail, on H2D and on the P1S second round the slicer takes theextrusion_cali_selflow (select byfilament_id/cali_idx) and the printer respondssuccess. Both flows traverse the bridge cleanly —ams_filament_settinground-trips withresult=successand the cached push_status carriestray_info_idx=GFA11,tray_type=PLA-AERO, K/n, andcali_idx=-1intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick_setvs_sel? Likely candidates are theinfo.get_versionanswer Bambuddy synthesises (slicer fingerprints onsw_ver/hw_ver/moduleto decide its command flow) or the first cachedpushallresponse the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL hadslicer_to_bridgeandprinter_to_slicerdirections but nobridge_to_slicerdirection for the bridge's own synthesised replies. Same env flag (BAMBUDDY_VP_DUMP_WIRE=1) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to<log_dir>/vp_wire/<vp_name>_cmd.jsonlunder directionbridge_to_slicer. Capture lives inmqtt_server.py::_publish_to_report— the single chokepoint every synthesised reply already passes through — gated on a newlog_event: bool = Trueparameter; the 1Hz periodic-push path threadslog_event=Falseso the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from_send_status_reportIS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests intest_vp_mqtt_bridge.py::TestWireFormatpin the event-on-default and skip-when-log_event=Falseposture;test_vp_wire_dump.pyalready covers the underlyingappend_eventshape and the new direction is documented in_debug.py's docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. - Windows installer build pipeline scaffolded — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer
.exethat doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile'spython:3.13-slim-trixie), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. Architecture: install targetC:\Program Files\Bambuddy\, data targetC:\ProgramData\Bambuddy\data\(preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running asLocalSystemwith autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut openshttp://localhost:8000, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. Why this shape over a PowerShellinstall.ps1: the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected%APPDATA%, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. Files:installers/windows/build.pystages everything underinstallers/windows/build/staging/,installers/windows/bambuddy.issis the Inno Setup 6 script,installers/windows/service/install-service.bat+uninstall-service.batwrap NSSM.build.pyhard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind--allow-non-windows. CI:.github/workflows/windows-installer.ymlruns on tag push (v*) and manual dispatch, useswindows-latest, downloads Inno Setup via Chocolatey, runsbuild.py+ ISCC, uploads the.exeas both a workflow artifact and a release asset. Scope clarification: this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installingopencv-python-headless/curl_cffi/asyncpg/cryptography/bcryptagainst embedded Python (the_pthfile edits inbuild.pycover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSMAppEnvironmentExtraline-continuation in cmd.exe. Signing: v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). What's explicitly NOT in v1: Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log underC:\ProgramData\Bambuddy\logs\service-stderr.log). Seeinstallers/windows/README.mdfor the full build pipeline. - Bambu Lab A2L support (#1684) — Internal model code
N9, serial prefix26A19(5 chars, same shape as H2C's late31B8B). Capabilities resolved from BambuStudio'sresources/profiles/BBL/machine/Bambu Lab A2L.jsoncross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudiouse_double_extruder_default_texture: trueflag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only),Low-Rate-Kameraon the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. Registry updates:PRINTER_MODEL_MAP+PRINTER_MODEL_ID_MAP+LINEAR_RAIL_MODELSinutils/printer_models.py;MODEL_TO_API_KEY+API_KEY_TO_DEV_MODEL+API_KEY_TO_WIKI_PATHinfirmware_check.py(wiki path follows the established/en/a2l/manual/a2l-firmware-release-historypattern; the existing 404 handling in_fetch_all_versions_from_wikimakes this safe to ship before Bambu publishes the page);VIRTUAL_PRINTER_MODELS+MODEL_SERIAL_PREFIXESinvirtual_printer/manager.py(prefix26A19Awith the same revision-letter padding as X2D's20P90A);MODEL_PRODUCT_NAMESinvirtual_printer/mqtt_server.py;mapModelCode+ Add-Printer / Edit-Printer model dropdowns inPrintersPage.tsx(new "A2 Series" optgroup);mapModelCodeinSpoolBuddyAmsPage.tsx. Camera and dual-nozzle code paths need no edits:supports_rtsp()correctly falls through to chamber-image for A2L becauseN9is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple;is_dual_nozzle_model()correctly returns False because A2L is not inDUAL_NOZZLE_MODELS. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. Tests: 12 new cases intest_printer_models.py::TestA2LModelpinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership inLINEAR_RAIL_MODELSand exclusion fromCARBON_ROD_MODELS/STEEL_ROD_MODELS. - One-shot
device.*identification probe in MQTT push parser (#1684 enabler) — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTTdevice.dev_model_name(e.g. A1 isN2S, H2C isO1C, X2D isN6). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model —get_versionwas also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. Fix: at the top of the existingdevice.*parsing block inbambu_mqtt.py, emit one INFO log per client session dumpingdev_model_name/dev_product_name/dev_id/project_nameif any are present; otherwise fall back todevice.keys()so a future Bambu rename (e.g.model_namewithout thedev_prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a_device_id_loggedflag matching the existing_nozzle_fields_loggedpattern at line 2095 — no spam at every push_status. 3 unit tests inTestDeviceIdentificationProbepin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Fulltest_bambu_mqtt.pysuite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. - Archives page banner: reactive install-step-4 nudge for the slicer-side setting — Companion to the new
external_storagediagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" viahome_flagbit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row withextra_data.no_3mf_available=True(main.py:2770) — that's the signal this banner watches. New backend endpointGET /archives/no-3mf-warningreturns{has_fallback: bool}— true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot vialocalStoragekeyarchiveNo3MFWarningDismissed(matches the existingLayout.tsxupdate-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query isenabled: !dismissedso the endpoint isn't polled after dismissal. 5 backend integration tests (TestNo3MFWarning) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (title,body,docsLink,dismissLabel) underarchives.no3mfBannertranslated to all 11 locales — no English fallbacks. - Connection diagnostic now verifies install step 4 ("Store sent files on external storage") — Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a
.gcode.3mfon the printer's SD card, every archived print falls back to no-thumbnail / no-metadata, and the cause is invisible until the user notices the archive is empty. The trap with detecting this: on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle moved onto the printer itself and is pushed on MQTThome_flagbit 11 (Bambuddy already parses this intostate.store_to_sdcard). On older versions it's a purely slicer-side preference invisible to the printer. An FTP upload-probe approach was tried first — it always passed regardless of the slicer toggle because the/cachedirectory is always writable from Bambuddy's perspective; the slicer toggle only controls what BambuStudio chooses to do, not what the printer accepts from other clients. Confirmed empirically against an X1C + H2D with the slicer option toggled off (probe still succeeded,home_flagbit 11 stayed True). Fix: newexternal_storagecheck readsstate.store_to_sdcarddirectly. Pass when the printer reports the bit on, fail when off, skip when no live MQTT state or the field has never been populated (older firmware that doesn't pushhome_flag). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; theskiptext explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits betweenport_ftpsandmqtt_auth. 5 new tests (TestExternalStorageCheck) cover pass-on-true, fail-on-false, skip-on-disconnect, skip-on-pre-add (no state), skip-on-missing-field. The reactive symptom-side detection — a one-time banner the first time the archiver recordsextra_data.no_3mf_available=Trueafter a slicer-initiated print — is planned as a separate follow-up to cover the slicer-only setting case. Wiki updated on the System page (features/system-info.md) and the Troubleshooting page (reference/troubleshooting.md). i18n: 4 new keys (title, pass, fail, skip) localised to all 11 locales (de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW) — no English fallbacks. - "Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by hasmar04) — Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing
preferred_slicersetting drove both, so picking one forced the other. The slicer-URI flow on Workflow → Slicer literally swapped the BambuStudio handler for the OrcaSlicer one whenever the user switched the API choice. Fix: newopen_in_slicersetting ('bambu_studio' | 'orcaslicer' | null) drives only the desktop "Open in Slicer" URI handoff; the in-app SliceModal + sidecar URL routing inlibrary.py,archives.py,slicer_presets.pycontinue to usepreferred_slicerexactly as before. Default isnull— the frontend falls back topreferred_slicerso existing installs behave identically until a user changes it (no migration, no churn). Storage lives in the existingapp_settingskey/value table; the PUT path serialises a Python None as the literal string"None", and the GET path normalises it back via a new branch in_build_settings_responsematching the existingdefault_printer_idconvention — without that normalization the frontend can't tell "explicit override absent" from "explicit override set to a bogus value". Frontend: Settings → Slicer card relabels the existing dropdown's description ("Slicer used for in-app slicing via the API sidecar"), adds a new "Open in Slicer" dropdown below it with three options — "Same as API slicer" (the inherit-from-preferred default), "Bambu Studio", "OrcaSlicer".ArchivesPage(5openInSlicerWithTokencall sites),MakerworldPage(the URI handoff branch whenuseSlicerApi=false), andModelViewerModal(4openInSlicer(...)call sites) all switched from readingsettings?.preferred_slicertosettings?.open_in_slicer ?? settings?.preferred_slicer. MakerworldPage's "Slice in {{slicer}}" button label additionally branches onuseSlicerApi: when on, the label reflects the API slicer; when off, the desktop slicer — so the button text always matches what the button actually does. The OrcaSlicer "known CLI bugs" warning stays attached to the API dropdown (where it belongs — it's about the sidecar's CLI). i18n: 3 new keys in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) —settings.openInSlicerLabel,settings.openInSlicerInherit,settings.openInSlicerDescription— plus an updatedsettings.preferredSlicerDescriptioneverywhere (the old wording "Choose which slicer application to open files with" became wrong once the field stopped driving the desktop handoff). No English fallbacks per the project's hard rule. Tests: 3 new inTestOpenInSlicerOverridepin the contract — default is null, override persists across GET, explicit reset to null round-trips correctly without leaving the"None"string leak. Full backend suite green (5798/5798); frontend ESLint + build clean; vitest on SettingsPage + MakerworldPage 48/48 green; i18n parity 5095 leaves × 11 locales green. - Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by CMW-ISS) — Reporter on a multi-printer farm with 40+-plate runs needed to walk to the printer with the right physical plate; the archive card had recently grown a bed-type badge, but the queue and the scheduling modal didn't. They were having to open the source 3MF in the slicer to look up which plate each queued / scheduled job needs. Backend: new
extract_bed_type_from_3mf(file_path, plate_id)helper inutils/threemf_tools.py, alongside the existingextract_filament_usage_from_3mfshape — readsMetadata/slice_info.config, finds the<plate>with the matchingindex, returns itscurr_bed_type. Whenplate_idis None it returns the first plate's value (matches the archive-level capture convention).PrintQueueItemResponsegains abed_type: str | Nonefield;_enrich_responsepopulates it fromarchive.bed_type/library_file.file_metadata["bed_type"]as the file-level default, then overrides per-plate via the new helper whenitem.plate_idis set. This matters becausearchive.bed_typeis captured at ingest as the FIRST plate's value only (seeservices/archive.py:235) — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, even though the user's plate 17 actually needs Engineering. The per-plate override re-reads the 3MF and returns the truth./archives/{id}/plates(and the library-file equivalent) now includebed_typein each plate object so the PrintModal's plate selector can render the badge inline. Frontend: queue card meta row gains a bed badge after filament weight — uses the existinggetBedTypeInfo(bed_type)helper fromutils/bedType.ts(the same one the archive card uses, so all 11 canonical bed labels + icons are covered including the BambuStudio / OrcaSlicer spelling drift). PrintModal's per-platePlateSelectorshows the bed badge under each plate's filament line; the modal header carries a bed badge for the selected (or sole) plate, surfaced before the user hits Schedule.PlateInfo+PlateMetadatatypes both get an optionalbed_typefield. No new i18n keys needed —getBedTypeInforeturns the canonical English plate name as the human label, matching the archive card's existing convention. Tests: 8 new unit cases intest_threemf_tools.py::TestExtractBedTypeFrom3mfpin the helper (single-plate, multi-plate per-plate, no-plate-id defaults to first, unknown-plate-id → None, plate-without-bed-type → None (no fall-through to another plate's value), missing slice_info, invalid file, whitespace trim). Full backend suite green (3848/3848); frontend build clean; ESLint clean; vitest on touched pages 81/81; i18n parity 5092 leaves × 11 locales green.
Added
- Print Log page: per-row failure-cause classification (#1687 part 4, reported by IndividualGhost1905) — Reporter clarified after part 1 shipped that what he actually wanted for point 2 was failure-cause grouping on the log (spaghetti, jam, bed-adhesion, etc.), not the archive tags I'd pointed him at. Archive
tagsdescribe the model (home decor, toys); the log row needs to describe what went wrong on a single print event. Different surface, different lifetime. What was already there:PrintLogEntry.failure_reason: String(100)already exists, gets mirrored fromarchive.failure_reasonwhen the user edits the archive (seearchives.py:1421for the mirror that ships with #1444), and the Failure Analysis widget already groups by it. So the storage and the aggregation were both done — the only gaps were (a) the Print Log table couldn't render the value because the GET serialiser silently dropped it fromPrintLogEntrySchema, and (b) orphan log entries (failures with no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal can't reach them. Fix: four pieces. (1)print_log.pyGET endpoint now includesfailure_reason(andarchive_id,created_by_id) in the serialised response — pre-fix it was silently None in every response even when the column was populated. Regression guard added. (2) NewPATCH /print-log/{entry_id}endpoint accepting{failure_reason, status}, gated onrequire_ownership_permission(ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN)— same ownership shape as the per-row delete that already shipped. Backend validatesfailure_reasonagainst the same canonical vocabulary the Archive Edit modal uses (11 enumerated keys + empty-string-clears + theothercatch-all); unknown values return 400 rather than getting stored as raw garbage (the i18n layer renders the value as a key, so an unrecognised one would surface as a literal string in the UI). Status validated against the 5-value{completed, failed, stopped, cancelled, skipped}set. Empty-stringfailure_reasonstores back as NULL so the column'snullable=Trueintent is preserved end-to-end. (3)FAILURE_REASON_KEYSconstant moved to an export fromEditArchiveModal.tsxso the new editor reuses the exact same vocabulary as the archive editor — backend and frontend stay in lockstep. (4) Frontend: pencil icon added beside the existing trash icon on every Print Log row, gated onarchives:update_own/archives:update_all. Click opens a compact two-field modal (status + failure reason dropdowns). Save invalidates bothprint-logandarchives-statsquery keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason is also rendered as a sub-label under the status badge in the table, mirroring the per-archivePrintLogTable.tsxconvention so the two views agree. i18n: 10 new keys (editEntryTitle,editEntryDescription,entryUpdated,entryUpdateFailed,archives.permission.noEdit, plus a 5-keystatusesblock) translated across all 11 locales — no English fallbacks perfeedback_translate_dont_fallback. Tests: 8 new backend integration cases — GET surfacesfailure_reason(regression guard for the silent-drop bug), PATCH sets / clears / rejects unknown failure_reason, PATCH updates status, PATCH rejects unknown status, PATCH returns 404 on missing ID, PATCH works on orphan entries (archive_id IS NULL) — the actual reason this endpoint exists. Full backend suite 5843/5843 green; ruff clean. Frontend vitest 2108/2108 green; ESLint + build clean. i18n parity check 5110 leaves × 11 locales green. - Print Log page: per-row delete (#1687 part 1, reported by IndividualGhost1905) — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. Fix: every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on
archives:delete_own(own rows) orarchives:delete_all(any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates overPrintLogEntrythe filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. Backend: newDELETE /print-log/{entry_id}mirrorsdelete_archive's ownership flow viarequire_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN); owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. Frontend: newdeletePrintLogEntryAPI helper, per-row mutation that invalidates bothprint-logandarchives-statsquery keys so the totals re-render without a manual refresh. i18n: 4 new keys (deleteEntryTitle,deleteEntryConfirm,entryDeleted,entryDeleteFailed) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Tests: 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidentaldelete(PrintLogEntry)without awhere). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered byEditArchiveModal's tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning).
Fixed
- Virtual printer cache drained capability/lifecycle fields between pushalls, greying out Device-tab UIs (#1622 round 4, reported by shaddowlink) — Reporter on a P1S in archive mode saw the AMS-slot filament dropdown empty and the "Manage calibration data" UI disabled in BambuStudio's Device tab, while the same panels worked correctly on his H2D. After three rounds of triage on the printer-side payload (which traced clean — bridge passes
vt_traybyte-identical,tray_info_idxresolves, AMS slots populate), the actual asymmetry surfaced in the bridge cache dumps: P1S cachedprintstate contained 17 top-level keys; H2D contained 99. The missing fields were exactly the capability/lifecycle gates BambuStudio reads to decide which Device-tab UIs to enable (cali_version,print_type,gcode_state,mc_print_stage,mc_stage,device,cfg,home_flag, themc_*family, fan speeds — ~80 fields). Root cause: Bambu firmware sends a full top-level field set in pushall responses (onpushallrequest / printer reconnect) and ~1 Hz incrementals carrying just what changed (typically temps, fan, wifi, status)._on_printer_rawinmqtt_bridge.pycached the latest push asnew_state = copy.deepcopy(print_data)— replacing the prior cache wholesale — then re-merged only a hand-picked allowlist (_SLICER_VISIBLE_STICKY_KEYS) of 14 keys back from prev. The allowlist covered the #1371 / #1387 / #1228 / #1558 failure modes but missed capability/lifecycle fields entirely, so every 1 Hz incremental drained ~80 fields out of the cache and the slicer's gated UIs flipped off as soon as the cache thinned. The code comment claimed the cache "mirrors the same preservation pattern Bambuddy uses for its own internal state in bambu_mqtt.py" but it didn't: internal state is updated per-field (if "X" in data: self.state.X = ...), never drops what it's seen, and accumulates monotonically. Fix: replace the allowlist-preserve with per-field accumulate. For every key in the prior cache, carry over verbatim when the incoming push omits it; let new values overwrite when present. The_merge_ams_dictdeep-merge for partialamsblobs stays (#1387 / #1371 regression guards still pass)._SLICER_VISIBLE_STICKY_KEYSis removed entirely — the new logic is a strict superset of every case the allowlist handled. Why most P1S users don't hit it: timing. The typical workflow is connect → BS issues pushall → cache fills → click Device tab within seconds → UI works. shaddowlink's sequence kept BS idle long enough between pushalls that the cache thinned to incremental-only state before he clicked. X1C users hit the same drain but don't notice — older BS capability spec doesn't gate the same UIs oncali_version/mc_print_stage. H2D escaped detection because his captures happened to land close to a pushall reply (cache still fat). Tests: newtest_incremental_push_preserves_non_allowlisted_capability_fieldsregression case intest_vp_mqtt_bridge.py::TestPushStatusCacheconstructs a full push withcali_version/print_type/gcode_state/mc_print_stage/mc_stage/device/cfg/home_flag, follows it with a temps-only incremental, and asserts every capability field survives. All 51 existing bridge cache tests stay green — same behaviour for the allowlist subset, plus the formerly-dropped fields. Bridge code path; no migration, no new i18n keys.
— The Print Queue's filament-override panel rendered every "Original" row as{type} ({colorName})— just the raw 3MF<filament type="...">attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name fromgetColorName(hex). A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (tray_info_idx, e.g.GFA01) on each<filament>element —backend/app/api/routes/archives.py:3634/3665already returns it in the/archives/{id}/filament-requirementsresponse — butFilamentReqsDataatfrontend/src/components/PrintModal/types.ts:178didn't carry the field, soFilamentOverride.tsxcouldn't see it. The resolution path was also already in place:_BUILTIN_FILAMENT_NAMESatbackend/app/api/routes/cloud.py:568maps Bambu factory SKUs (GFA01→ "Bambu PLA Matte"), exposed as/cloud/builtin-filaments;/cloud/filament-id-mapreturns the same shape for user custom presets (P*prefix).KProfilesView.tsx:791already merges those two for its own labels. Fix: addtray_info_idx?: stringto theFilamentReqsData.filamentstype.FilamentOverridenow loads both maps viauseQuery(['builtin-filaments'])+useQuery(['filament-id-map'])(both shared caches the rest of the app already populates,staleTime: 5 min) and merges them into a singleidx → namelookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the rawreq.typestays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color fromgetColorName(hex). "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. Color disambiguation (round 2): the sub-brand half above is necessary but not sufficient —getColorName(hex)resolved through/api/inventory/colors/map, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex#000000has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpointGET /api/inventory/colors/by-material?hex=X&material=Y(backend/app/api/routes/inventory.py:get_color_by_material) preserves the material context: same case-insensitive hex match as/colors/map, then amaterialfilter on top. When no entry matches the requested material it falls back to the same priority order as/colors/map, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage).FilamentOverride.tsxderives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches oneuseQueryper slot viauseQuerieskeyed on(hex, material), and usesdata.color_name || getColorName(hex)so a slow query never blanks out the placeholder. Five new tests intest_color_catalog_extras.pypin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returnscolor_name=null(no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back togetColorName(hex). Tests overall: 20FilamentOverride.test.tsxcases green; 12test_color_catalog_extras.pyintegration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. Same fix applies to printer-mode FilamentMapping (round 3): the schedule modal's "Specific Printer" branch rendersFilamentMappinginstead ofFilamentOverrideand was reading the same raw fields (item.type+ genericgetColorName(item.color)) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery fromFilamentOverride.tsxinto a shared hookuseFilamentLabelsinfrontend/src/components/PrintModal/useFilamentLabels.tsso the two panels can't drift on label content;FilamentOverrideandFilamentMappingnow both calluseFilamentLabels(filamentReqs?.filaments)and read positional{ resolvedName, colorLabel }per slot. The hook also exports theextractMaterialHinthelper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads{resolvedName}instead of raw{item.type}, and the colour swatch tooltip readsRequired: {resolvedName} - {colorLabel}instead ofRequired: {item.type} - getColorName(item.color). New vitest caserenders sub-brand + material-disambiguated colour on the required side (#1718)mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a newuseFilamentLabels.test.tsx(11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and theenabled: !!colorquery gate). The earlier "case-insensitive on both inputs" backend test (intest_color_catalog_extras.py) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. What this fix can NOT recover: for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name fromgetColorName(hex)— still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged. - Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by SamNuttall) — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders
FilamentOverridewhich carries the checkbox, but picking a single printer rendersFilamentMappinginstead — a separate component that had no force-match UI. The dispatcher inprint_scheduler.py:535already honoursforce_color_matchregardless of how the queue item was created (the flag survives end-to-end on thefilament_overridespayloadbuildFilamentOverridesArrayconstructs inPrintModal/index.tsx:613), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. Fix:FilamentMappingaccepts new optionalforceColorMatch+onForceColorMatchChangeprops mirroringFilamentOverride's shape; it renders the same<Palette>-iconed checkbox under each filament row when a handler is provided.PrintModal/index.tsx:1100passes the existingforceColorMatchstate and asetForceColorMatchsetter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existingprintModal.forceColorMatchkey already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). Tests: newrenders the per-slot force-color-match checkbox in printer mode (#1717)case clicks the checkbox and assertsonForceColorMatchChange(slotId, true)fires; companionomits the force-color-match checkbox when no handler is providedcase pins the absent-handler branch. Existing FTS dropdown-filter tests stay green.FilamentMapping.test.tsx4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - In-app updater fails when DATA_DIR is on a separate mount from the install (#1715, reported by francescocozzi) — Native installs that follow the systemd template
WorkingDirectory=/opt/bambuddywithEnvironment="DATA_DIR=/srv/bambuddy/data"(or any layout whereDATA_DIRis not a subdirectory of the install path) couldn't apply in-app updates. Every git step in_perform_update(remote get-url,remote set-url,fetch,reset --hard) usedcwd=settings.base_dir, andsafe.directorywas pointed atbase_dirtoo. On the standard install (DATA_DIR=INSTALL_PATH/data) this happened to work by accident — git walks up from a subdirectory of the repo to find.git— but on a separate-mount layout the data dir is not under the install, the walk-up has nowhere to go, and every operation returns "fatal: not a git repository." Even on the standard installsafe.directory={base_dir}was wrong (it must equal the repo root git discovers, not the data dir), surfacing on hardened systemd units as "fatal: detected dubious ownership." Fix: route every git subprocess in_perform_updateand_origin_points_at_repothroughcwd=settings.app_dir(the working tree), and setsafe.directory={app_dir}to match.app_diris now resolved once at the top of_perform_updateinstead of lazily re-resolved before the pip step. Thebase_dirparameter on_origin_points_at_repois renamed toapp_dirso the signature documents the contract. The pip-install step keepscwd=app_dir(unchanged — that step was already correct). Tests: newtest_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mountintegration case constructs a sibling-paths layout (tmp/opt/bambuddy+tmp/srv/bambuddy/data— the exact #1715 shape), mocksasyncio.create_subprocess_execto capture every call's cwd, and pins (a) every git subprocess runs withcwd=app_dir, (b) the embeddedsafe.directory=config equalsapp_diron every git call. The existing pip-cwd test stays green (pip's cwd was alreadyapp_dir). Existing SSH-origin-preserve + origin-rewrite + reset-target tests stay green (they don't assert on git cwd). Fulltest_updates_api.py21/21 green; ruff clean. Credit: root cause + fix shape from francescocozzi via PR #1716 (couldn't be merged as-is — that branch had drifted off an olderdevand pulled in unrelated upstream commits including a version regression). - SliceModal preset-lookup precedence + cross-tier dedup + signed-out banner (#1712, reported by IndividualGhost1905) — After the Orca Cloud integration shipped (2026-06-04), every user — including Bambu-Cloud-only / Bambu-Studio-preferred users — got Orca Cloud as the top tier across the SliceModal preset picker, the per-preset auto-pick scoring, the dropdown's optgroup rendering, the AMS slot picker's filament sort, and the backend dedup precedence. A Bambu-Cloud / X1C user reported seeing his Bambu Cloud profiles disappear from auto-pick because Orca Cloud's empty tier shadowed them. The cross-tier dedup (introduced with #1150 and inherited as-is by the Orca change) compounded the problem: a name that existed in multiple tiers showed in only ONE group, so a user with a local-imported and Orca-synced "Bambu PLA Basic" never saw the Orca copy as a picker option — even though they curate both sources. And the cloud-status banner (
CloudStatusBanner) nagged signed-out users with a permanent "Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets" at the top of every SliceModal open — even after a user had explicitly logged out of Orca Cloud. The Bambu Cloud banner had the symmetric problem. Fix — order: precedence islocal > orca_cloud > cloud > standardacrossSliceModal.tsx(SLICE_MODAL_TIER_ORDER+TIER_BONUS+ dropdown tier list),ConfigureAmsSlotModal.tsx(sourceOrder), and docstrings inslicer_presets.py/schemas/slicer_presets.py/client.ts. Local imports win (the user did them on purpose), Orca Cloud comes next, Bambu Cloud, bundled fallback last. The order drives auto-pick + visual group order; it does NOT hide profiles. Fix — no dedup, full lists:_dedupe_by_nameis replaced by_enrich_cloud_metadata, which returns every tier's full preset list across all three slots (printer / process / filament) — a name in local AND orca_cloud AND cloud renders in EACH of their groups so the user can pick any source. The only work the function still does is filament-metadata backfill: a Bambu Cloud filament without its ownfilament_type/filament_colourinherits values from a same-named local / orca_cloud / standard entry so it can still score inpickFilamentForSlot. Printer + process presets carry their metadata inline and need no enrich. Frontend code already iterates tiers in priority order and surfaces every entry — no change needed there once the backend stops filtering. Fix — banner:CloudStatusBannernow silently returns null onnot_authenticatedin addition took— applies symmetrically to both Bambu and Orca cloud banners.expired(token broke) andunreachable(network / service down) still surface — those are real breakage states a previously-signed-in user needs to see. Sign-in lives on the Profiles page; the modal doesn't need to advertise it. Theslice.cloud.notAuthenticated/slice.orcaCloud.notAuthenticatedi18n keys stay in the locale files (dormant) so re-enabling later doesn't need a re-translation pass. Fix — ConfigureAmsSlotModal source badges: before this change, the per-row source badge fired three branches independently —localgot a green "Local" badge,builtingot an amber "Built-in" badge, and a blue "Custom" badge appeared on top of those whenisUserwas true. Since ALL Orca Cloud entries are markedisUser: trueand Bambu Cloud user presets also get the same flag, the result was visually inconsistent: Orca Cloud rows showed only "Custom" (no source identification, no way to tell them from Bambu Cloud user presets), Bambu Cloud built-in rows had NO badge at all, and the "Custom" badge collided with the actual source. Replaced with a single source badge per row: green "Local", purple "Orca Cloud" (new), bambu-blue "Bambu Cloud" (new), amber "Built-in". One badge per row; one colour per source; theisUserdistinction within the Bambu Cloud tier is dropped (the preset name itself carries the "is this user-authored" signal). Same change in both render blocks (the filament-list code is duplicated in the modal — kept the duplication local rather than refactoring out a helper component in this PR to keep the diff tight). i18n: 2 new keys (configureAmsSlot.orcaCloud,configureAmsSlot.bambuCloud) translated to all 11 locales — both are brand names, already on the per-localeIDENTICAL_TO_EN_ALLOWEDlists so the parity check is satisfied without per-locale variants. The dormantconfigureAmsSlot.customkey stays in the locale files. Tests:TestEnrichCloudMetadatareplacesTestDedupeByName(5 cases): regression guard pinning that a name in all four tiers appears in EACH (not just local), tier order preserved within a tier, Bambu Cloud filament metadata backfilled from local, backfill falls through to orca / standard when local doesn't carry the name, backfill does NOT overwrite Bambu Cloud's own metadata when present. The "renders a sign-in banner when cloud_status is not_authenticated" case flipped to assert no banner appears, with the test name updated to call out the #1712 reason. Backendtest_slicer_presets.py47/47 green;SliceModal.test.tsx34/34 green;ConfigureAmsSlotModal.test.tsx24/24 green; ruff clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - Telegram (and other image-bearing) finish notification on a reprint-from-archive showed the original print's finish photo instead of the new run's (#1707, reported by kycrna) — P2S user reprinted an archived job and observed the Telegram notification arriving with the photo of the original print (white box) attached to the completion message for the new run (black box). Root cause: reprints reuse the source archive row —
register_expected_printstores the sourcearchive_idin_expected_prints, and the on-print-start expected-archive promotion branch atmain.py:2207-2245updates the row's status / started_at / printer_id / subtask_id but never resetarchive.timelapse_path. Two failure modes cascaded from the stale path: (a)_scan_for_timelapse_with_retriesearly-returns atmain.py:3062withif archive.timelapse_path: return— the reprint's new timelapse MP4 sitting on the printer's SD card was never downloaded, the archive'stimelapse_pathkept pointing at the original run's local file; (b)_capture_finish_photo_from_timelapsepollsarchive.timelapse_pathand immediately found the original video, extracted ITS last frame asfinish_<fresh-timestamp>_<uuid>.jpg, and handed those bytes to_background_notificationsasimage_data— which then went out to Telegram via thesendPhotopath. The filename was new (so log lines and the archive'sphotoslist looked correct), but the pixels were the original run's finish frame. Surface was specific to the timelapse-prefer path: withdata.timelapse_was_activetrue and no external camera,prefer_timelapse_sourcewas True, which is the exact configuration on P2S with timelapse-on for both runs. External-camera, buffered-frame, and fresh-RTSP fallback paths grab the current camera frame, so users on those paths saw correct photos and the bug stayed hidden. Fix: at expected-archive promotion, capture and cleararchive.timelapse_pathto None before the commit, andos.unlinkthe stale on-disk video so reprints don't accumulate orphaned MP4s in the archive directory. Photos list is left alone — accumulating one finish photo per run across the archive's lifetime is the right behaviour. The unlink is wrapped inOSError-catching best-effort logging so a missing file (manual delete, archive purge, container rebuild with bind-mount drift) doesn't break promotion. The clear-and-unlink runs unconditionally whentimelapse_pathis set, so even if a user has been reprinting under the buggy build for months, the next reprint self-heals. Tests: 3 new cases intest_reprint_clears_stale_timelapse.pyexercise the fullon_print_startcallback through the expected-archive branch — happy path (path cleared + file unlinked), no-prior-timelapse (no-op, promotion still succeeds), missing-stale-file (best-effort unlink doesn't raise). Fulltest_print_start_expected_promotion.py+test_print_start_assigns_printer_id_to_vp_archive.pysuite (28/28) stays green; ruff clean. - Connection diagnostic no longer flags
external_storage: failon A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by MartinNYHC) — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reportedexternal_storage: failin the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. Root cause: theexternal_storagecheck atservices/printer_diagnostic.py:179-189readsstate.store_to_sdcard, which is parsed from MQTThome_flagbit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes asFalseand the check fell through tofailinstead ofskip. Fix: newNO_EXTERNAL_STORAGE_MODELSfrozenset inutils/printer_models.pyenumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus ahas_external_storage(model)helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships with a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits toskipbefore readingstore_to_sdcardwhenprinter.modelis in the set. What this does NOT change: X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluatestore_to_sdcardexactly as before — the home-flag-bit-off →failpath is still the right signal for them. The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change. Tests: 8 new cases —TestHasExternalStorage(5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true;TestExternalStorageCheckgainstest_skips_on_a1_no_external_storage_slot,test_skips_on_a1_mini_no_external_storage_slot, andtest_still_fails_on_x1c_when_toggle_off(regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Fulltest_printer_models.py+test_printer_diagnostic.py+ archives integration suite green (172/172); ruff clean. - AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+") — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data:
slot_preset_mappingsrow for(printer_id=1, ams_id=1, tray_id=2)waspreset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15— three months stale. Root cause:slot_preset_mappings.preset_nameis first in the PrintersPage display chain (PrintersPage.tsx:3624) and overrides the spool's ownslicer_filament_nameplus the cloud catalogcloudInfo.name. The internal-mode manual-assign path (inventory.apply_spool_to_slot_via_mqtt) kept this row in sync, but the internal-mode RFID auto-assign path (spool_tag_matcher.auto_assign_spool) skipped it entirely. The Spoolman-mode sync path (main.auto_sync_spoolman_ams_trays) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. Fix — three writers in lockstep via one shared helper. Newbackend/app/services/slot_preset_writer.pyexposes a primitiveupsert_slot_presetplus two convenience wrappers:upsert_slot_preset_for_spoolfor internalSpoolORM objects (local-preset numeric ids →local_{n}, cloud ids run throughfilament_id_to_setting_id) andupsert_slot_preset_for_spoolman_spoolfor Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block ininventory.py:396-438, the RFID auto-assign tail inspool_tag_matcher.py:auto_assign_spool, and the per-tray-sync branch inmain.py:auto_sync_spoolman_ams_trays— now go through the helper. Self-heal: existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. What this also covers perfeedback_inventory_modes_parity: the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. Tests: newtest_slot_preset_writer.py(6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. Newtest_spool_tag_matcher.pycases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists,local_{n}formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean. - Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by IndividualGhost1905) — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (
filamentRunout) which is what the new backend PATCH validates against, but the olderEditArchiveModalwas still saving the localised label ("Filament runout") as the value — two formats landing in the samePrintLogEntry.failure_reasoncolumn from two different UI surfaces. The Failure Analysis widget atfrontend/src/pages/StatsPage.tsx:817and the per-archive run history sub-table atfrontend/src/components/PrintLogTable.tsx:81both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. Fix — three sites in one drop: (1)StatsPage.tsxand (2)PrintLogTable.tsxnow wrap the value int('editArchive.failureReasons.${reason}', { defaultValue: reason })— same pattern already used atArchivesPage.tsx:3874for the Print Log table. ThedefaultValuefallback keeps legacy translated-text rows rendering as-is, no regression. (3)EditArchiveModal.tsxnow saves the camelCase key (<option value={reasonKey}>) instead of the localised label, matching the new editor's wire format. On modal open, a reverse-lookup against the current locale resolves any legacy translated-text value back to its key so the dropdown pre-selects the right option — every save thereafter converts that row forward to the key format, so the data set self-heals over time without a migration script. AddedhtmlFor/idlinkage to the failure-reason<label>/<select>pair as a side benefit (letsgetByLabelTextin tests reach the control, plus a small a11y improvement). What this also fixes invisibly: German / Japanese / Turkish users who classified rows under one UI language and then switched languages would have seen their historical buckets fragment in the Failure Analysis widget (each translation = its own group). With keys as the storage format, language switch no longer reclassifies anything. Tests: 5 new vitest cases — StatsPagetranslates camelCase failure-reason keysandrenders legacy translated-text failure reasons unchanged; EditArchiveModalpreselects the option when the stored value is already a camelCase key,reverse-looks-up a legacy translated value back to its key, andsends the camelCase key on save, not the translated label; PrintLogModaltranslates camelCase failure_reason keys. The existingshows failure_reason under failed runscase (which checks legacy text path) keeps passing under the defaultValue fallback. Full vitest 58 / 58 across touched files. ESLint clean; frontend build clean (vite 9.61s); i18n parity 5118 leaves × 11 locales green (no new keys — reuseseditArchive.failureReasons.*). - System page boot time was rendered with a doubled timezone offset (#1690 follow-up, reported by IndividualGhost1905) — After the original #1690 fix landed in 0.2.4.6, the reporter on UTC+3 (Turkey) confirmed uptime was correct but boot time displayed +3 hours ahead of reality. Root cause:
backend/app/api/routes/system.pybuiltboot_timeas a NAIVE LOCAL datetime viadatetime.fromtimestamp(psutil.Process(1).create_time())and serialised it with.isoformat(), which emits no timezone marker (e.g."2026-06-09T11:22:05"). The frontend'sparseUTCDate()helper atfrontend/src/utils/date.ts:206is documented to append'Z'when no tz marker is present, treating the string as UTC, thentoLocaleStringconverts UTC → local — applying the local offset on top of an already-local timestamp. Uptime was unaffected because it's computed entirely backend-side asdatetime.now() - boot_time, two naive-local values whose delta is correct regardless of the missing tz info. Fix: make both boot_time and the uptime anchor tz-aware UTC —datetime.fromtimestamp(ts, tz=timezone.utc)on the main path and thepsutil.boot_time()fallback, anddatetime.now(timezone.utc)in the uptime subtraction.isoformat()then emits"+00:00"and the frontend's parseUTCDate uses the marker as-is. Same naive-datetime pattern surfaced in two adjacentgenerated_atfields — the storage-usage cache snapshot insystem.pyand the support bundle root insupport.py. Neither is rendered as a wall-clock timestamp in the frontend today, but both now emit tz-aware UTC for consistency so any future surface that does render them won't recreate this bug. Tests: newtest_boot_time_isoformat_carries_utc_markerregression case asserts the boot_time string ends in+00:00(orZ) — without that marker the frontend double-converts, which is exactly the reporter's symptom. Existingtest_boot_time_uses_pid1_create_timeandtest_boot_time_falls_back_to_psutil_boot_time_on_pid1_failurestill pass under the tz-aware values because1700345600is2023-11-18T20:53:20+00:00UTC, so the date-prefix assertion is unaffected. Full system API suite 21/21 green; support API 72/72 green; ruff clean. - A1 / A1 Mini internal-code map was swapped in
PRINTER_MODEL_ID_MAP(surfaced while scoping A2L support, #1684) —backend/app/utils/printer_models.pymappedN1 → "A1"andN2S → "A1 Mini", but every other registry that names these codes —firmware_check.py(N2S → "a1"),virtual_printer/manager.py(both the model map and the serial-prefix map:N2S → "03900A"is the A1's039prefix,N1 → "03000A"is the A1 Mini's030),printer_manager.pyA1_MODELS— consistently uses the opposite (correct) direction. Any path that resolved an A1-family printer by internal code rather than serial prefix would silently misclassify. Fix: swapPRINTER_MODEL_ID_MAPtoN1 → "A1 Mini",N2S → "A1"; the matching comment inLINEAR_RAIL_MODELSwas also wrong and got the same swap (the frozenset's contents don't change — both codes were already in it — so this is cosmetic, but kept the file self-consistent). New regression test classTestA1SeriesModelIdspins both directions so a future re-flip fails loudly. Functional impact in practice is small (most A1 detection runs off the serial prefix), but the inconsistency was a footgun for any future caller that trustednormalize_printer_model_id. Backend printer-model suite 46 / 46 green; ruff clean. - PostgreSQL restore from a SQLite backup no longer deadlocks against the print scheduler (reproduced 2026-06-09 restoring a native install's backup into a fresh Docker+Postgres deploy) — Reporter (Maziggy) backed up the native install, brought up the new Docker image against an external Postgres, hit Restore in Settings → Backup. ~2 seconds in, the restore aborted with
asyncpg.exceptions.DeadlockDetectedError: Process X waits for AccessExclusiveLock on relation 109940; Process Y waits for RowExclusiveLock on relation 110182. Root cause: the existingclose_all_connections()step before the DB swap only disposes the SQLAlchemy engine's connection POOL — the asyncio tasks that USE the engine keep running. Theprint_scheduler.run()loop (30 s cadence) andsmart_plug_manager._snapshot_loop()(30 s cadence) wake up after the dispose, callasync_session(), lazily reopen a pool connection, and start a normal transaction that grabsRowExclusiveLockonprint_queue/smart_plug_energy_snapshots. The restore'sDROP TABLE IF EXISTS public.<tbl> CASCADEpass in_import_sqlite_to_postgresneedsAccessExclusiveLockon every public table — AB/BA lock-order conflict, classic Postgres deadlock, restore transaction rolled back. The log confirms:13:44:53,669restore begins →13:44:53,680print_scheduler fires queue check →13:44:55,607smart_plug_manager fires snapshot →13:44:55,607deadlock detected. The existing code already pausedvirtual_printer_managerbefore restore for file-lock reasons; the other timer-based DB writers were missed. Fix — two layers. (1) Beforeclose_all_connections(), pause the four most active timer-based DB writers via their existing stop affordances:print_scheduler.stop(),smart_plug_manager.stop_scheduler(),notification_service.stop_digest_scheduler(),await background_dispatch.stop(). Thenawait asyncio.sleep(1.0)to let in-flight loop iterations commit and release their sessions before the engine pool gets disposed. We don't restart the services on success because the restore handler already tells the user to restart Bambuddy to pick up the new DB. (2) Belt-and-braces inside_import_sqlite_to_postgres: prependSET LOCAL lock_timeout = '10s'to the begin-block before theDROP TABLE CASCADEpass, so any residual writer that slips through the pause window (per-printer MQTT clients writing reactively to state changes, the hourly AMS history recorder firing inside the restore window, etc.) surfaces a fastlock_timeouterror instead of producing a fresh deadlock or hanging the restore for 30+ seconds.SET LOCALis transaction-scoped so the global default applies to every other DB caller. Scope clarification: there are ~12 background services started at lifespan startup; the four paused here are the ones with the tightest cadences. Slower-cadence services (github_backup_service,local_backup_service,library_trash_service,archive_purge_service, AMS history, runtime tracking, SpoolBuddy watchdog, camera cleanup) all fire on hour-or-longer intervals and are statistically very unlikely to land inside a few-second restore window; the lock_timeout layer catches them if they do. Tests:test_restore_sqlite_wal_safety.pyandtest_settings_api.pyintegration suites (53 tests) stay green on the edited handler; ruff clean; runtime smoke (from backend.app.services.X import Y+hasattr+iscoroutinefunctioncheck) confirms all four stop signatures match the patch's sync/async mix. - Configure Slot now keeps the active K-profile on reopen for assigned-but-unconfigured slots (#1689 follow-up, reported and patched by Spionkiller01) — After the original #1689 fix shipped, Spionkiller01 found a residual case: on a slot that's physically loaded but unconfigured (filament inserted, but the printer hasn't bound a preset yet —
tray_type="",tray_info_idx="", noslot_preset_mappingsrow), the first open of Configure Slot showed the right K-profile, but closing it with the X and reopening it dropped back to "default 0.020". Clicking "Configure slot" (Apply) once persisted it, but the user shouldn't have to. Root cause: the original #1689 cali_idx safety net was unreachable on this code path.matchingKProfilesinConfigureAmsSlotModal.tsx:751early-returned[]whenselectedPresetInfowas null — andselectedPresetInforesolves to null exactly when there's no resolvable slot preset (unconfigured slot, no mapping row). The "always include the slot's currently-active K-profile by cali_idx" branch lives past the main name+id matcher, so it never ran from the no-preset path. On first open a freshly-cached preset briefly let the safety net trigger; on reopen the live slot state had no preset, returned[], the auto-select effect saw no candidates, the modal fell back to default 0.020. Fix (verbatim from Spionkiller01's H2C-tested diff, with the existing extruder guard): split the early return into two — still short-circuit on missing kprofilesData, but whenselectedPresetInfois null andslotInfo.caliIdx > 0, find the active profile byslot_id === activeIdx(extruder-matched when known) and return it as a single-item list. The auto-select effect downstream then pre-selects it on reopen with no extra change. Strictly additive: with a resolvable preset present the existing matcher runs untouched; withcaliIdx === 0 || nullthe function still returns[](no unrelated profiles leak in). Tests: new vitest casesurfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up)exercises the path withtrayType='', nosavedPresetId, andcaliIdx=6against a K-profile fixture atslot_id=6— asserts the dropdown surfaces it. Verified the test fails without the patch (stash → run filter → fail; pop → run → pass). The existingcaliIdx === 0guard test continues to pass under the new branch. Full ConfigureAmsSlotModal vitest 24/24 green. Credit: Spionkiller01 for spotting the residual edge case after merge, producing the diff, and testing live on an H2C —Co-Authored-Byon the commit. - K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by Spionkiller01 with concrete H2C testing; #1689 also reported by IndividualGhost1905) — Two related symptoms on different UI surfaces, same root cause. #1688: spool form's PA-profile suggester (
frontend/src/components/spool-form/PAProfileSection.tsxviaisMatchingCalibrationinspool-form/utils.ts) only matched K-profiles by parsing the profile name for material/brand/variant. Spools already storeslicer_filament(the slicer preset's id) and K-profiles already carryfilament_id, but both were ignored — so a user's custom K-profile whose name doesn't agree with the slicer preset's name got silently dropped from the suggestion list even when the underlying filament_id was identical. #1689: ConfigureAmsSlotModal's K-profile filter (matchingKProfiles) ran the same name-only logic on the slot's selected preset — a spool assigned under "Generic PLA" with a custom K-profile actively bound on the printer landed in the modal as "K profile not assigned, default 0.020 will be used", while the printer-card hover-card correctly showed the active profile. The hover-card and the Configure Slot modal disagreed because they used different lookup paths; the modal's path was the one with the name-parse filter. The shared root cause: spool preset ids and K-profile filament_ids look different but are equivalent after normalisation. Spools storeslicer_filamentas the cloud setting_id form ("GFSG98_09" —_09is the variant suffix, the "S" infix marks it as a setting_id); K-profiles storefilament_idas the bare form ("GFG98"). Plain===doesn't match; both need normalising first. This conversion already exists in the other direction atbuildFilamentOptions(filament_id → "GFS" + filament_id.slice(2) for setting_id), so the inversetoFilamentIdhelper isn't speculative — it's just the matching reverse. Fix — one shared helper, two surfaces: new exports infrontend/src/components/spool-form/utils.ts—toFilamentId(id)normalises both shapes by dropping the "_NN" variant suffix and stripping the "S" in "GFS" (so both "GFSG98_09" and "GFG98" yield "GFG98");isGenericFilamentId(id)flags Bambu's genericGFx99ids (GFL99 = generic PLA, GFG99 = generic PETG, etc.) which are shared across many physical filaments and must NOT id-match (they over-match and obscure brand-specific profiles — name fallback handles those correctly). Then: (1)isMatchingCalibrationaccepts a newslicer_filament?: stringformData field, tries id-match first (with generic exclusion), falls through to the existing name parse —PAProfileSectionalready passes the fullformDataso no caller edit needed. (2)ConfigureAmsSlotModal.selectedPresetInfonow also resolves afilamentId(viatoFilamentId(cp.setting_id)for cloud presets;toFilamentId(builtinFilamentId)for builtin; empty for local/orca paths that fall through to name match);matchingKProfilesadds the id-match check at the top of the per-profile predicate, then keeps the existing name logic, then always unshifts the slot's currently-active K-profile (byslot_id === slotInfo.caliIdx, gated onactiveIdx > 0so caliIdx=0/null doesn't leak unrelated profiles in, and extruder-matched when known) — covers the #1689 case where the spool was bound under a generic preset but the active profile lives under a different filament_id entirely. The "always include active" branch is Spionkiller01's #1689 diff verbatim, gated more tightly. SpoolBuddy coverage: both K-profile surfaces in the kiosk UI reuse the shared components —SpoolBuddyWriteTagPagerenders<PAProfileSection>(auto-fixed viaisMatchingCalibration),SpoolBuddyAmsPagerenders<ConfigureAmsSlotModal>(auto-fixed viamatchingKProfiles). No kiosk-specific edits required; the shared helpers carry the fixes through. (SpoolBuddyCalibrationPageis scale calibration, unrelated;InventorySpoolInfoCardis display-only.) What this does NOT change: spools without a slicer_filament, K-profiles without a filament_id, and generic GFx99 ids all fall through to the existing name-based matching path — strictly additive precedence, no behaviour change for the name-only cases that already worked. The new id-match never causes a miss the old code would have caught. Tests: 21 new vitest cases —isMatchingCalibration.test.ts(18 cases) pins thetoFilamentIdround-trip in both directions (GFSG98_09 → GFG98 and back is identity-preserving for the cloud→K-profile flow), the genericGFx99exclusion, falsy/non-Bambu id pass-through (numeric local-preset id, Orca UUID), and the id-match-wins-over-name behaviour including the spool's reported"GFSG98_09" ↔ K-profile "GFG98"real-data scenario.ConfigureAmsSlotModal.test.tsx(3 cases) pins the modal-level behaviour: a custom K-profile name surfaces when filament_id matches (#1688 in-modal), the slot's active profile is always included even with no name/id match (#1689), and thecaliIdx == 0guard prevents unrelated profiles from leaking in via the safety net. Full frontend vitest suite: 2108 / 2108 green. ESLint clean on touched files; frontend build clean. Credit & dispatch: Spionkiller01 diagnosed both issues with concrete data (theGFSG98_09 ↔ GFG98normalisation case is theirs), tested both patches live on an H2C, and explicitly offered to PR. Landed verbatim with adjustments (shared helper, tighter active-profile guard) andCo-Authored-By. IndividualGhost1905 also reported #1689 independently and identified its connection to #1688. - Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by TCL987, fix patched in reporter's fork) — Reporter on X1C, Docker install, left a Bambuddy tab open past the 24 h JWT lifetime. After expiry: navigation between pages still worked, but every API request silently failed, leaving the UI looking like every list was empty. A manual refresh was needed to land on
/login. Root cause:AuthContext.userstays stale after the JWT clears. When a 401 with a token-invalidating message (Token has expired,Could not validate credentials,User not found or inactive,Invalid API key,API key has expired) lands infrontend/src/api/client.ts:154-167, the handler callssetAuthToken(null)to drop the token from sessionStorage / localStorage — butAuthContext.useris a React state value that was populated once at mount viacheckAuthStatus()→/auth/me, andsetAuthToken(null)doesn't reach into AuthContext's React tree.ProtectedRoute(App.tsx:101) only redirects whenuser === null, so the protected tree keeps rendering, every subsequent request goes out with no Authorization header, the backend 401s, and the UI shows nothing. A page refresh remountsAuthProvider,checkAuthStatus()finds no token,setUser(null)fires, the redirect runs — which is what the reporter ended up doing every 24 h. The 3 othersetAuthToken(null)call sites all live insideAuthContextitself and pair withsetUser(null)directly, so no cross-module signal was needed for them; theclient.ts:165site was the only one missing the React-tree notification. Fix (mirrors the reporter's fork patch deec96d): aftersetAuthToken(null)inclient.ts, dispatch awindow.dispatchEvent(new CustomEvent('auth:expired'))(guarded ontypeof window !== 'undefined'for SSR / test safety).AuthContext's mountuseEffectadds awindow.addEventListener('auth:expired', handleAuthExpired)listener whose handler callssetUser(null)after amountedRef.currentguard, and removes the listener in the effect's cleanup so unmount → remount doesn't double-bind.ProtectedRoutethen seesuser === nullon the next render and runs<Navigate to="/login" replace />immediately, no manual refresh needed. What this intentionally does NOT change: generic401 Authentication requiredresponses (without a token-invalidating message) still don't clear the token or fire the event — they're treated as transient timing issues, exactly asclient.ts:155's pre-existing comment documents. So a one-off 401 from a race during login won't redirect a working session. Listener cleanup means tests / dev hot-reload don't accumulate handlers. Tests: 4 new vitest cases —client.test.tsgains "dispatches 'auth:expired' event on 401 with invalid token message" and "does not dispatch 'auth:expired' on 401 with generic auth error" (both usevi.fn()listeners onwindowto assert the event fires/doesn't fire).AuthContext.test.tsxgains a newauth:expired event (#1698)describe block — "clears user when an auth:expired event is dispatched" simulates the login → expiry → event → user-null flow end-to-end viasetAuthToken('valid-token')(the canonical setter; writing to sessionStorage post-import wouldn't propagate to the module-levelauthTokenvariable initialised at import time), and "does not crash when the event fires after unmount" pins themountedRefguard so the listener can't trigger a state-update-after-unmount warning. Full frontend vitest suite: 2087 / 2087 green. ESLint clean on touched files. Frontend build clean. Credit to TCL987 for diagnosing this and shipping the working fix on their fork before opening the issue. - Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by volodymyr-doba) — Reporter on P1S printed a single lid (~190 g grey PETG) from
gridfinity-storage-box-5x4x6.gcode.3mf(a multi-plate file with 5×box + 5×lid plates) and the spool's Usage History recorded 242 g of grey + 31 g of black — the whole file's filament total, not the dispatched plate. The print took 5 h 47 m which matches the lid alone, and the queue card correctly previewed 190 g, but the spool got debited for everything. Root cause: usage tracking parsed the 3MF without a plate filter.extract_filament_usage_from_3mf(file_path, plate_id)inbackend/app/utils/threemf_tools.pyalready supports filtering and the queue's pre-flight capacity check atapi/routes/print_queue.py:254/:286passesitem.plate_id, but the two completion-time recorders did not:_track_from_3mfinservices/usage_tracker.py:907(internal Filament Inventory) andstore_print_datainservices/spoolman_tracking.py:223(Spoolman mode) both called the extractor with no plate_id and summed every plate. Perfeedback_inventory_modes_parityboth modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (api.reprintArchive/api.printLibraryFilewithplate_id: selectedPlateinPrintModal/index.tsx:739/750) hits the same bug because it never goes through the queue — caught before merge by tracing the frontend dispatch surface end-to-end. Fix — two complementary captures: (1)PrintSessiongains aplate_id: int | Nonefield;on_print_startqueriesPrintQueueItemfor the printer's currently-printing row and recordsqueue_item.plate_idonto the session — covers the queue path. (2)register_expected_printinmain.pyaccepts a newplate_idparameter and stores it in a parallel_print_plate_ids: dict[int, int]dict (mirror of_print_ams_mappings);background_dispatch.py's 2 register sites andprint_scheduler.py's 1 register site now pass plate_id (the dispatch already resolved it via_resolve_plate_id; reordering the resolve to run before register is a no-op since the resolver is pure). At expected-print promotion,main.pyinjects_print_plate_ids[archive_id]into_active_sessions[printer_id].plate_id(only when the session has no plate_id yet — queue captures win), mirroring the existingams_mappinginjection pattern. The dict drains onon_print_completeand on TTL eviction of the matching_expected_printsentry — same lifecycle as_print_ams_mappings. (3)_track_from_3mfaccepts a newplate_idkwarg, threads it fromsession.plate_id, and passes it toextract_filament_usage_from_3mf. (4)store_print_dataaccepts aplate_idkwarg; the 3 call sites inmain.pypass_get_start_plate_id(archive_id)(new helper, parallel to_get_start_ams_mapping); withinstore_print_datathe caller value wins, falling back toqueue_item.plate_idfor the queue path. The PrintArchive'sfilament_used_gramsstays file-level summed by design (#1593's contract — the archive describes the file, not the run); only the per-run usage attribution becomes plate-aware. What this intentionally does NOT touch: for direct Print of a single-plate file,_resolve_plate_idreturns 1 → registered asplate_id=1, which extracts plate 1 = the whole file — identical to the prior no-filter behaviour. The change is observable only for multi-plate 3MFs where a specific non-first plate was dispatched. Tests: 9 new acrosstest_usage_tracker.py+test_spoolman_tracking.py+test_print_start_expected_promotion.py— plate_id propagation through_track_from_3mf; absence leaves itNone; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract;register_expected_printstoresplate_idin_print_plate_ids;_get_start_plate_idreads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existingtest_prefers_explicit_ams_mapping_over_queue_mappingupdated for the new unconditional queue lookup (was conditional, now always queries to capture plate_id). Full 5830-test backend suite green. Ruff clean across the entire backend, not just touched files. - AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by kleinwareio) — On a 3-AMS P1S the reporter's screenshot showed AMS-C slots labelled "Empty" even though spools were physically loaded; OrcaSlicer's Device view showed the same slots as loaded. Root cause: the compact label below the AMS slot circle in PrintersPage rendered
tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever the printer firmware hadn't been told which material is in the slot. The codebase already had agetEmptySlotKindhelper that distinguishes'physical'(firmware confirmed empty via state 9/10) from'reset'(tray_type absent but firmware hasn't confirmed empty — i.e. spool loaded, just unassigned). The hover-card / circle border already used that distinction (line 814+ comment); the compact label did not. Fix: label now branches onemptyKind—'physical'keeps "Empty" (the firmware-confirmed empty case),'reset'shows "?" (matching the slicer's own convention for "loaded but unknown material"). External / VT tray label is unchanged (external trays have no "configured/unconfigured" distinction — they're either loaded or not). The SpoolBuddy kiosk'sAmsUnitCardwas carrying the same bug and got the same fix (mirror ofgetEmptySlotKind, "?" vs "Empty" label, tooltip "Spool loaded — slot not configured"). i18n: newams.slotUnconfigured: '?'key added to all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — value is universal so it's identical in every locale. The existingams.emptySlotReset = 'No filament assigned'tooltip surface in FilamentHoverCard already covers the "what does this mean" question on hover, so no new tooltip key needed for the main card. Tests: AmsUnitCard vitest gainsshows "?" for loaded-but-unconfigured slot (#1694)pinning both branches in one render (one slot withstate: 9→ "Empty", one with no state → "?"). Existing AMS tests stay green (9/9 SpoolBuddy AmsUnitCard; AMS load/unload page tests untouched and green); i18n parity green (5100 leaves × 11 locales); frontend build clean; ESLint clean. - Virtual Printer MQTT no longer disconnects idle OrcaSlicer at keepalive×1.5 (#1548 round 2, reported by hollajandro) — Round 1 (commits b663605 + 4ffefa6) shipped the keepalive parser + 1.5× idle disconnect per MQTT spec §4.4 and a per-minute status-push diagnostic. Reporter's follow-up pcap proved the round-1 logic was correct as designed, but exposed the actual root cause: the same OrcaSlicer install which stays connected to a real Bambu P1S indefinitely sends zero MQTT packets after the initial CONNECT / SUBSCRIBE / pushall / get_version burst — no PINGREQ at all — so any §4.4-compliant server disconnects it at
keep_alive × 1.5. Real Bambu firmware does not enforce §4.4 (verified: the reporter's identical Orca install holds an idle session against real hardware on the same network), so spec compliance is itself the regression. Fix: after CONNECT/auth, drop the application-level read timeout entirely (read_timeout = None) and setSO_KEEPALIVEon the underlying socket so the OS TCP stack detects truly dead connections within a few minutes. The 60 s pre-CONNECT timeout is preserved — a client that opens TCP but never sends CONNECT still gets reaped to prevent half-open resource leaks. Negotiated keepalive is still parsed and now logged at INFO ("MQTT client … authenticated (negotiated keepalive=Xs, idle disconnect disabled)") for support-bundle visibility. Tests: TestHandleClientIdleConnection addstest_idle_client_stays_open_past_one_and_a_half_times_keepalive(negotiates keep_alive=2, sits idle for 4 s, asserts handler still running and writer not closed — direct round-1 inversion),test_so_keepalive_set_on_socket_after_connectpinssetsockopt(SOL_SOCKET, SO_KEEPALIVE, 1)runs on the wrapped socket the moment auth succeeds. PINGREQ test docstring updated since there's no longer a timeout for it to "reset". All 33 VP MQTT server tests green; ruff clean. After this ships, OrcaSlicer should stay connected to the VP indefinitely while idle and reconnect cleanly on real network drops. - System page now reports the container's uptime / boot time, not the host's (#1690, reported by IndividualGhost1905) — Reporter on Proxmox LXC observed that System → Uptime / Boot Time matched the Proxmox host's values, not the container's. Root cause:
psutil.boot_time()reads/proc/stat:btime, which on shared-kernel containers (Docker, LXC) is the host kernel's boot time — leaking the host's lifecycle into Bambuddy's UI. Fix: read PID 1's create_time instead —psutil.Process(1).create_time()returns the POSIX timestamp of the init/entrypoint process, which in a container is the container's start time, and on bare metal / VMs is the host init (effectively identical topsutil.boot_time()within a sub-second). Defensivepsutil.Error/OSErrorfallback to the oldpsutil.boot_time()for the rare case where /proc/1/stat is unreadable (locked-down container, custom seccomp policy). No frontend / i18n change — the field shape is unchanged, only the value is now correct on container installs. Tests: 2 new integration cases — one pins that the route readsProcess(1).create_timeand that the response uses that timestamp (notboot_time), the other pins the fallback path via a realpsutil.NoSuchProcess(1)so the endpoint still returns 200 with the best-available answer. All 8 pre-existing system-info tests updated to also mock the new code path; full system API suite 20/20 green; ruff clean. - Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by Bgabor997) — Creating or editing a filament preset on the Profiles page (BL Cloud, Orca Cloud, and Local Profiles all open the same shared editor) only offered 11 base materials (PLA, ABS, PETG, TPU, PA, PA-CF, PET-CF, PC, ASA, PVA, HIPS). Reporter on P1S wanted to tag a custom preset as PLA-CF — the dropdown source had no entry, so the saved preset's
filament_typewas wrong and the printer received the wrong material code at dispatch. Root cause:backend/app/data/filament_fields.json(served byGET /cloud/fields/filamentand consumed byProfilesPageviagetCloudFields) shipped a curated subset that pre-dated Bambu's CF/GF lineup expansion. Other surfaces in the codebase already named the canonical list (utils/filament_ids.pyGENERIC_FILAMENT_IDS,spool-form/utils.tsMATERIALS, the Bambu filament-id catalog incloud.py), so the gap was specifically in the editor's allowed-values JSON. Fix: expanded thefilament_typeselect to 25 BambuStudio-aligned options grouped by family — PLA (+ CF/GF/AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS. No frontend, no i18n (material codes are universal). K-profiles editor unaffected — it picksfilament_id, notfilament_type. Tests: 15 unit cases intest_filament_fields_options.pypin every newly-added variant (PLA-CF, PLA-GF, PLA-AERO, PETG-CF, ABS-GF, ASA-CF, ASA-GF, PCTG, PAHT-CF, PA6-CF, PA6-GF, PPS, PPS-CF, PPS-GF) plus the baseline-must-still-be-present guard so a future curation pass can't silently drop them. - Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by Geoff-S) —
bambuddy.serviceshipped withProtectHome=true, which makes/home/*invisible to the service namespace. When the user installed into/home/bambuddy/(instead of the default/opt/bambuddy/), theExecStart=/home/bambuddy/venv/bin/uvicornpath couldn't be resolved at exec time and the unit failed withstatus=203/EXEC: Unable to locate executable. TheReadWritePaths=$INSTALL_PATHdirective doesn't reliably re-expose/home/*subpaths for executable resolution. Fix:install/install.shnow detectsINSTALL_PATH == /home/*and emitsProtectHome=read-onlyfor that case; the default/opt/bambuddy/install keeps the stricterProtectHome=true. The manualdeploy/bambuddy.servicetemplate defaults toProtectHome=read-onlywith a comment explaining when to tighten it totrue.read-onlykeeps/homeimmutable to the service (no security regression — the service can read its venv but not write anywhere outside theReadWritePathsallowlist). - VP settings card now shows the target printer's serial in proxy mode — On a proxy-mode VP, the runtime services (SSDP advertisement, MQTT bind identity, certificate subject) all use the target printer's actual serial via
target_printer_serial or self.serial(manager.py:235, 941, 957), but the/api/v1/virtual-printersresponse — which feeds the VP settings card — always returned the self-generated suffix-based serial from_get_serial_for_model(model_code, vp.serial_suffix). The card therefore displayed a serial that didn't match what the bridge actually advertises and what the slicer sees, breaking the visual "one identity per VP" mental model. Fix:_vp_to_dict(api/routes/virtual_printers.py:77) is now async and acceptsdb; whenvp.mode == VP_MODE_PROXY and vp.target_printer_id, it issues a singleSELECT serial_number FROM printers WHERE id = vp.target_printer_idand substitutes the result into the responseserialfield. Archive / queue / review modes keep the self-generated serial — those modes synthesise their own identity and never speak the target's. Defensive fallback when the target row is missing (printer deleted mid-config, manual SQL tweak, race between delete-printer and read-
Changelog truncated — see the full CHANGELOG.md for the complete list.