Note
This is a daily beta build (2026-07-02). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Fixed
- Server-side slice of a PLA-model / PVA-support 3MF silently loses the PVA — supports print in PLA, archive card hides the PVA tag (#1881, reporter JonasFovea) — Reporter uploaded a multi-material H2D Pro 3MF configured with PLA in slot 1 and PVA as support material in slot 2, hit Slice, picked a profile for both slots in the SliceModal. Result: the resulting
.gcode.3mfcarried a single filament (PLA) with no visible supports in the 3D preview, and the archive card never displayed the PVA badge for the source 3MF either. Local BambuStudio slice of the identical file with default settings worked. Three distinct bugs on the same happy path, discovered in sequence as each fix uncovered the next. Bug A —substitute_unused_plate_filamentsoverwrites support-material slots. The frontend does receive both filaments (log lineget_filament_info called with 4 IDs: ['GFA01', 'GFA00', 'GFG02', 'GFU00'];GFUis Bambu's PVA prefix), the SliceModal renders two dropdowns viaextract_project_filaments_from_3mf, the user picks a PVA profile for slot 2 and the payload arrives at_run_slicer_with_fallbackintact. Before the sidecar call,_run_slicer_with_fallback(backend/app/api/routes/library.py:3521) invokessubstitute_unused_plate_filaments— the helper that replaces "not used by this plate" slot entries with slot 1's profile so BambuStudio's loaded-filament temperature-spread validator doesn't reject the job (temperature difference of the filaments used is too large, exit 194). That helper delegates toextract_plate_extruder_set_from_3mf(backend/app/utils/threemf_tools.py:878) to enumerate which slots the plate actually references. The extractor walks three sources —<object>top-levelextrudermetadata, per-<part>overrides, andpaint_colortriangle quadtree leaves — all object-geometry-derived. Support material is a process setting, not object geometry: BambuStudio writessupport_filament/support_interface_filament/enable_supportintoMetadata/project_settings.configand the slicer's process pass generates the support paths at slice time. The three geometry sources return{1},substitute_unused_plate_filamentssees slot 2 as "unused", overwrites the user's PVA profile with slot 1's PLA profile, and the sidecar gets[PLA_json, PLA_json]. Silent, no error, no warning, no log line. Bug B —_extract_filament_infostrips support filaments from the tag list. Independent from A:backend/app/services/archive.py:377was readingfilament_is_supportand excluding any slot where the flag was"1"fromfilament_type/filament_color. That's what drives the ArchiveCard's material badges. Result: even a correctly-sliced PLA+PVA.gcode.3mfwould show only "PLA Basic" on the card. Bug C — process preset overrides source'senable_support,support_filament,support_interface_filament,support_type. Uncovered on the first test slice after A + B shipped: the sliced archive still had only PLA. Comparingproject_settings.configin the source vs the sliced output: source hasenable_support: '1'+support_interface_filament: '2', sliced output hasenable_support: 0+support_interface_filament: 0. Bambuddy passes the picked process preset via BambuStudio CLI's--load-settings, which is authoritative — every field in the loaded JSON overrides the source 3MF's embeddedproject_settings.config. Bambu's shipped process presets ("0.20mm Standard BBL H2D" etc.) shipenable_support: 0because supports are a per-print decision, not a per-quality one. So even with the substitute-fix (A) sending both filaments to the CLI,slice_info.configin the output showssupport_used="false"and only one<filament>entry consumed — PLA. The PVA slot loads but never gets referenced. This inverts BambuStudio GUI's semantics where the loaded project's settings are authoritative and the process preset is the inheritance backbone — but Bambuddy's--load-settingsflow has preset winning over project, so any per-project setting the user configured before exporting the 3MF (supports on, PVA-for-interface, etc.) gets discarded on re-slice. Fix A. New helperextract_support_filament_slots_from_3mf(zf)inthreemf_tools.pyreadsenable_support/support_filament/support_interface_filamentfromproject_settings.configand returns the set of slots that must stay "used" for a plate print. Gated onenable_support(accepts BambuStudio's stringly-typed"1"/"0", real booleans, and empty falsy variants); slot value0means "same as model" and is ignored; slot value> 0is added to the set.substitute_unused_plate_filamentsunions this into the geometry-derived set — so a support-only slot is now correctly seen as "used" and the user's PVA profile survives to the slicer. When supports are DISABLED (orsupport_filament == 0), the substitution still runs and homogenises the loaded-filament array, preserving the pre-existing #1493 fix for the temperature-spread validator. Fix B. Removed thefilament_is_supportfilter from_extract_filament_info. All configured filament types (PLA, PVA, etc.) now land onfilament_type/filament_colorin slot order with dedup on repeats. Sliced.gcode.3mffiles are unaffected because they promote_slice_filament_type(parsed fromslice_info.configwhich lists what the print actually consumed) over the project-settings fallback atarchive.py:150-155; the filter change only affects unsliced source 3MFs that a user uploaded to the Archives page. Fix C. New helper_patch_process_support_settings(process_json, source_3mf_bytes)reads the source'sproject_settings.configand overlays four support-related fields onto the picked process preset JSON before--load-settingssees it:enable_support,support_filament,support_interface_filament,support_type. Deliberately targeted — the scope is what fixes #1881 without opening the semantic can of "should every project setting override every preset field" (which would need to reconcile #1201's sentinel handling, all the bed-type / prime-tower / brim / raft edge cases, and users who deliberately pick a preset to escape a broken 3MF's settings). Widening to more fields as follow-up if more "preserve X" reports come in. Silently no-ops on STL / STEP (noproject_settings.config), on malformed sources, and on malformed presets — the slice then runs with the preset's own defaults, matching pre-fix behaviour on those paths. Verification. Ran the patch against the reporter's actual 3MF from Bambuddy's live DB (archive #262): source hasenable_support: '1'+support_interface_filament: '2'+support_type: 'normal(manual)', patched preset gets those exact values while itslayer_height: '0.20'stays untouched. Tests. Nine new cases intest_threemf_tools.py::TestExtractSupportFilamentSlotsFrom3mfpin the helper (headline PLA+PVA scenario, distinct body/interface slots,enable_supportoff, slot-0 == same-as-model, JSON boolenable_support, integer vs string slot values, missing project settings, malformed JSON, non-numeric slot). Two new cases inTestSubstituteUnusedPlateFilamentsend-to-end the substitution —test_support_material_slot_preservedreproduces the reporter's exact model_settings+project_settings shape and asserts slot 2'spva_support.jsonis NOT overwritten;test_support_disabled_still_substitutes_unusedis the regression guard that turning supports off preserves the pre-existing homogenisation behaviour. Three new cases intest_archive_service.py::TestThreeMFParserSupportMaterialcover Bug B — reporter's PLA+PVA source ships both types + both colours tofilament_type/filament_color, single-support-only degenerate case, duplicate types deduped while duplicate colours kept for the multi-colour path. Newtest_slice_process_support_patch.py::TestPatchProcessSupportSettings(9 cases) pins Fix C: reporter's exact scenario (source wins, layer_height preserved), source-off beats preset-on (symmetric direction), partial-source only patches keys it defines, no-project-settings passthrough, malformed source passthrough, malformed project-JSON passthrough, non-dict project settings passthrough, malformed preset passthrough, non-dict preset passthrough. 337/337 across all affected test files green, ruff clean. Scope. Backend-only. One new helper inthreemf_tools.py(~40 LOC), one 3-line union inslicer_3mf_convert.py, one function body simplification inarchive.py, one new helper inlibrary.py(~35 LOC), one call-site in_run_slicer_with_fallback. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier who tried server-side slicing a multi-material PVA-support 3MF: the slice will now actually include PVA supports on the next attempt after upgrade. Users who uploaded source 3MFs with PVA-for-support: the archive card will show both materials after a Bambuddy restart (badges are derived at parse time, so re-uploading refreshes the display; existing rows keep whatever they parsed with). - Bambu Studio on macOS won't reconnect to the VP after machine sleep — zombie writer pinned in
_clientsfor hours (#1872, reporter avvidme) — Reporter on H2C + macOS 26.5.1 + BS 2.8.0.50: after every sleep/wake cycle Bambu Studio couldn't see the VP or connect to it. Only workaround was quitting BS and rebooting Bambuddy. The physical printer's own cloud / LAN link recovered in ~5 s from the same sleep, so the delta is in the VP's session-handling. Log evidence (bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a.log). 14:04:06 shows a healthy1Hz status push: 60 pushes/min to [IP]:54698— full-rate 1 Hz for the last minute pre-sleep. Then 5 min of SSDP output only — no push summary for :54698, no OSError, no disconnect line. At 14:09:16 a brand-new TCP source port :54861 connects, authenticates, subscribes — so the MQTT server is not rejecting reconnects. At 14:10:17, the first DEBUG line after the reporter enabled debug logging isMQTT drain timeout for device/…/report — client may be busy— smoking gun. Root cause._publish_to_reportatmqtt_server.py:1149-1152caughtasyncio.wait_for(writer.drain(), timeout=5)TimeoutErrorat DEBUG and returned silently. Timeouts are notOSError, so the push loop'sexcept OSErrorat:441never saw them — the client sat inself._clientsuntil the OS's default TCP keepalive detected the dead peer, which on Linux istcp_keepalive_time=7200 s(2 h) + 9 probes × 75 s = ~2 h 11 min. That's exactly the 5 min silence in the log; drain likely stayed under the 5 s ceiling because the kernel TX buffer had room, so the DEBUG line didn't even fire until much later. Meanwhile the loop was iterating with a stalled client sitting in the dict every tick. Fix — two hunks. (1) On drainTimeoutError, close the writer (best-effort, catchExceptionso an already-broken writer doesn't mask the raise) and raiseBrokenPipeError. The eviction path is indirect but reliable:BrokenPipeErroris anOSErrorsubclass, so it's caught by every_send_*wrapper's outerexcept OSErrorat:1036/:1118/:1236and logged at ERROR — push_counts increments on this tick, no direct eviction. BUTwriter.close()was called inside_publish_to_report, so on the next push-loop tick,writer.is_closing()at:431returns True → the client is appended todisconnected→ popped from_clients,_client_serials,push_countson the same tick. Real eviction latency: ~1 s (one 1 Hz tick), down from ~2 h. Same mechanism as the pre-existing hard-disconnect path (RST → OSError swallowed in_send_*→ transport marks closing → next-tick eviction), just extended to also cover the "silent stall" case that has no OS-level RST. (2) Tighten the Linux TCP-keepalive schedule right afterSO_KEEPALIVE=1at:600:TCP_KEEPIDLE=60,TCP_KEEPINTVL=15,TCP_KEEPCNT=4— dead-peer detection in ~2 min instead of ~2 h.getattr(socket, ...)guards keep the code cross-platform: macOS hasTCP_KEEPINTVLbut notTCP_KEEPIDLE(it exposesTCP_KEEPALIVEunder a different constant), other platforms silently skip whichever knobs their kernel doesn't expose. What I initially got wrong. First-pass hypothesis was "no MQTT session takeover on sameclient_id". Wrong._handle_connectat:762parses the protocol client_id but discards it (assignment commented out), andself._clientsis keyed on socket peerf"{addr[0]}:{addr[1]}", so each reconnect gets a distinct key — no takeover race actually exists. The log fixed this: the "not seen" symptom was BS-side (macOS UDP receive socket recovering slowly from sleep, plus BS'sclient_idstill holding the old socket state) but the server-side amplifier was the zombie writer keeping push loop attention. Tests. 3 new cases intest_vp_mqtt_server.py.TestSendPublishDrainTimeoutEviction::test_drain_timeout_raises_broken_pipe_and_closes_writerpatchesasyncio.wait_forto raiseTimeoutErrorimmediately and assertsBrokenPipeErrorpropagates ANDwriter.close()was called — pins both halves of the contract...._still_closes_writer_when_close_failscovers the best-effortclose(): even if the writer is already broken and.close()raises,_publish_to_reportmust still raiseBrokenPipeError— silent swallowing here would put us right back to the pre-fix zombie state.TestHandleClientTCPKeepaliveTuning::test_handle_client_source_names_the_tuning_constantsusesinspect.getsourceto pin that_handle_clientreferencesTCP_KEEPIDLE,TCP_KEEPINTVL,TCP_KEEPCNT— a socket-module regression or a stripped-down platform can then be diagnosed from a support bundle. Full VP MQTT + VP manager suites 179/179 green, ruff clean. Scope. Backend-only. No new i18n key, no new permission, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with any macOS slicer client: fix takes effect on next Bambuddy restart, no reconfiguration needed. On non-Linux hosts (e.g. Bambuddy running on macOS or FreeBSD for development), the keepalive-schedule tightening is a no-op — the drain-timeout eviction still applies. - Non-proxy VP camera passthrough is dead for A1 / P1 targets — OrcaSlicer Liveview fails with
[2:-10061](#1868, reporter tom4711-2) — Symptom on a P1S target in server/non-proxy VP mode: the VP starts 3000, 3002, 8883, 990 and 322, but nothing on 6000, so OrcaSlicer's Liveview button fails. Reporter confirmed a rawsocatforwarder<VP-IP>:6000 → <P1S-IP>:6000immediately restores the stream — the target camera works, the VP just isn't publishing it. Root cause.backend/app/services/virtual_printer/manager.py:1098-1118hardcoded the camera-passthroughTCPProxytolisten_port=322 / target_port=322regardless of the target printer's model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 / A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on port 6000 — the 322 listener the manager opens for those targets has no upstream, and the slicer's connection to<VP-IP>:322yields "connection refused". Proxy mode is unaffected becauseSlicerProxyManager(tcp_proxy.py:1596) already opens 6000 for file-transfer, and Bambu reuses the same port for chamber-image, so the passthrough coincidentally works there. Fix. Read the target printer's model fromprinter_manager.get_client(target_id).modelat the same point the target IP is read, then callget_camera_port(target_model)— the same source of truth used byroutes/camera.pyand covered bytest_printer_models.py::TestSupportsRtsp/TestGetCameraPort— to pick 322 or 6000. TCPProxy listen_port and target_port both follow the same value. Rename the log tag from"RTSP"tof"Camera-{camera_port}"so support-bundle grep tells you at a glance which protocol the VP is fronting for. The internal_rtsp_proxyattribute name is unchanged to keep the diff tight; the comment above the block spells out that it doubles as chamber-image passthrough on A1/P1. Model comes from the target printer's client instance (target_client.model), NOTself.model— the VP's spoofed identity has no bearing on how the physical printer serves its camera. Tests. NewTestVirtualPrinterCameraPassthroughintest_virtual_printer.py— 4 cases pin the branch:test_rtsp_model_p2s_opens_port_322andtest_rtsp_model_x1c_opens_port_322guard the RTSP path stays on 322 (regression against the fix accidentally routing everything to 6000);test_chamber_image_model_p1s_opens_port_6000andtest_chamber_image_model_a1_opens_port_6000are the direct #1868 guards — P1S / A1 targets must expose 6000. The P1S case additionally asserts NO 322 listener was opened, since a stale 322 on an A1/P1 install would confuse anyone port-scanning the VP. Test harness monkeypatchesTCPProxyand every peer service (VirtualPrinterFTPServer,SimpleMQTTServer,MQTTBridge,BindServer,VirtualPrinterSSDPServer,SSDPProxy) to lightweight MagicMocks with an already-setasyncio.Eventon.ready—start_server()awaits.ready.wait()on those four barriers before returning, so an unset event would deadlock the test. 143/143 in the file green (was 139 + 4 new). Scope. Backend-only, one-hunk change inmanager.pyplus 4 tests. No i18n key, no permission change, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with A1 / A1 Mini / P1P / P1S targets in non-proxy VP mode: the fix takes effect on the next Bambuddy restart, no reconfiguration needed. The workaroundsocatforwarder can be removed after upgrade. - Editing a queue item assigned to "Any of model X" left the printer selection area blank —
PrintModalinitialisesassignmentModefromqueueItem.target_model: items created with a specific printer got'printer'mode (renders the printer list), items created with model-based assignment got'model'mode. ButPrintModal/index.tsx:1102-1106was passingonAssignmentModeChange={!isEditing ? setAssignmentMode : undefined}(same foronTargetModelChange/onTargetLocationChange), which flippedmodelAssignmentAvailabletofalseinPrinterSelectorand hid every model-mode control — the mode toggle atPrinterSelector.tsx:390, the model dropdown at:431, the location filter at:459. Combined with theassignmentMode === 'printer'gate on the printer list at:519, edit-mode for a model-assigned item rendered an empty container. Users couldn't retarget the item or even see what model it was assigned to; the only way to change it was delete + re-queue. Fix. Drop the!isEditinggate on all three props — the underlying submit path already handles both directions cleanly (printer_id: null, target_model, target_locationwhen saving in model mode atindex.tsx:788-802;printer_id, target_model: null, target_location: nullwhen saving in printer mode at:844-857), so un-gating the UI just surfaces the machinery that was already there. Users can now (a) see the current target model + location on a model-assigned item, (b) change the target model or narrow / widen the location filter, and (c) flip the item between "Specific Printer" and "Any of Model" without deleting + re-queueing. Edit is only offered onpendingitems (QueuePage.tsx:2137), so there's no race with an in-flight dispatch when the assignment mode flips. Scope. Frontend-only, one-hunk change to the props on the existingPrinterSelectorinvocation. No new i18n key (the model / location / mode strings already existed for create mode). No backend change. Existing 61/61PrintModal.test.tsxcases green — the tests that passinitialSelectedPrinterIds={[1]}for the create-with-printer flow are unaffected because the!initialSelectedPrinterIds?.lengthgate atindex.tsx:1088still hides the wholePrinterSelectorfor the post-upload dispatch dialog. - Finish photo captures the wrong plate on A1 / A1 Mini when SwapMod plate-swap End G-code is injected (#1867, reporter qoatzelcoat) — The "Print Complete" snapshot arrived after the user's SwapMod plate-swap moves had already ejected the printed part, so the notification always carried an image of the swapped (empty) plate. Root cause. The stage-22 ("Filament unloading") edge that normally fires the finish-photo pre-capture never fires on A1 Mini firmware (confirmed in the reporter's log:
FINISH PHOTO MOMENT (FINISH fallback) — stage-22 never fired; capturing at FINISH-state transition). The fallback path inbambu_mqtt.pywaits forgcode_state → FINISH, and Bambu Studio runs the user-defined End G-code before that state transition — so by the time the fallback captures, SwapMod has already moved the plate. Fix. Added a newlayer_num → total_layer_numedge trigger inside_parse_print_datathat fires the finish-photo moment the instant the last object layer completes, before any end G-code runs. Guarded by the existing_was_running/_finish_photo_capturedone-shot so subsequent stage-22 and FINISH-state ticks become no-ops for the same print. Works on every Bambu variant (A1, A1 Mini, P1P/S, X1C, H2S/H2D) without model detection; on AMS printers the last-layer edge fires slightly before stage-22 would have, which also fixes SwapMod-style setups on those printers rather than only on the A1 family. Test coverage:TestLastLayerFinishPhotoTriggerinbackend/tests/unit/services/test_bambu_mqtt.py(7 cases — fires on last layer, edge-only, skipped on stage-22 replay, skipped on the FINISH fallback, ignorestotal=0bootstrap frames, ignores non-running catch-up messages).
Added
- Indonesian Rupiah (IDR) currency support (#1869, reporter qoatzelcoat) — Added
IDRwith theRpsymbol tofrontend/src/utils/currency.ts; appears in Settings → Cost Tracking and formats spool/print costs asRp <amount>. Backend already accepts any 3-letter ISO code (filament.currency/settings.currencyare freeformString(3)), so no schema change or migration was needed.
Added
- Preheat & Heat Soak before queued prints — per-item override + per-filament chamber targets (#1468, reporter embed-3d) — New scheduler stage that heats the bed (and the chamber, on printers that support it) and holds at temperature before each queued print starts, intended for engineering filaments (PA, ABS) where adhesion and warp depend on a warm chamber. Why it doesn't already work in the slicer. BambuStudio / OrcaSlicer can emit
M191(wait-for-chamber-temp) in start-G-code, but Bambu firmware silently ignoresM191, so any "wait for chamber" line in the slicer's start sequence is a no-op. The reporter confirmed this by trying the MakerWorld chamber-heating G-code in OrcaSlicer and finding the chamber-heating step wouldn't fire. Implementing this at the orchestration layer — Bambuddy waiting onstate.temperaturesbetween FTP upload andstart_print— is the right architectural place; the slicer side is a dead end. Where it fires.print_scheduler._preheat_and_soak(), called from_start_print()immediately before the FTP upload section (print_scheduler.py:2306-ish). Best-effort: any failure (printer drops, gcode refused, no bed temp in metadata) logs and returns rather than failing the queue item — the normal upload + start path runs straight after. Hardware-tier behaviour (three branches; the OP collapsed two of them and we kept them distinct): (1) Active chamber heater —H2C / H2D / H2D Pro / H2S / X2D / X1E(supports_chamber_heater()true) — dispatchesM141 Sxfor the configured target then pollsstate.temperatures["chamber"]against it. (2) Chamber sensor only —X1C / P2S(supports_chamber_temp()true butsupports_chamber_heater()false) — noM141, polls the chamber sensor and considers the chamber phase satisfied when bed radiation has driven the sensor to target. Radiant warm-up to ABS-friendly temps on a cold X1C is 20-30 min — themax_wait_secondscap (default 900 s, range 60-3600) is a hard ceiling so a cold room can't stall the queue indefinitely; falls through to the soak phase if the chamber never converges. (3) No chamber sensor —P1S / P1P / A1 / A1 Mini— no chamber wait possible (thechamber_tempervalue these models report is meaningless perprinter_manager.supports_chamber_temp), so only the bed phase + soak timer apply. Bed target is read from the archive's parsedbed_temperaturemetadata (the samebed_temperature_initial_layer/bed_temperaturefieldarchive.py:438already extracts from the 3MF); if missing the preheat stage skips and logs rather than guessing a default that might wreck a non-PLA print. Settings — Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card. Master toggle (preheat_enabled, default off — disabled installs see no behavioural change),preheat_chamber_target(°C, 0-60, default 0 = chamber phase disabled; PA: 50, ABS: 45, PETG-CF: 40),preheat_max_wait_seconds(60-3600, default 900),preheat_soak_seconds(0-1800, default 300). The numeric fields auto-disable in the UI when the master toggle is off so they read as "config that's not currently doing anything." A static helper line under the inputs spells out the three hardware tiers so users don't have to consult the wiki to know what their printer will do. Tests. Eight new cases inbackend/tests/unit/test_scheduler_preheat.py: disabled-setting skip (no M140 / M141 dispatched), no-bed-temp-in-archive skip,H2Ddispatches bothM140andM141,X1Cdispatches onlyM140(the explicit chamber-sensor-but-no-heater regression guard — wiring this tosupports_chamber_temp()alone would have falsely firedM141on the entire X1 family),P1Signores its meaninglesschamberreading and lets only the soak timer run,preheat_chamber_target=0keeps the bed phase but skips chamber even on a heater-capable printer, lost-client mid-flow returns silently, lost-state mid-wait exits the poll loop gracefully and still soaks.asyncio.sleeppatched toAsyncMockso the soak phase doesn't actually wait — assertions are on what was scheduled, not wall-clock. 8/8 green. Wiki.bambuddy-wiki/docs/features/monitoring.mdgains a new "Preheat & Heat Soak" subsection under the queue/scheduling area documenting the three hardware tiers and the four-setting interface, so users with X1C or P1S know upfront what the feature can and cannot do for their printer. i18n. 13 new keys × 11 locales (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere — no English fallback. Scope. Backend (scheduler + schema + settings route) + frontend (settings card + AppSettings type) + tests + wiki. No DB migration (uses the existing key/valueSettingstable). No new permission (Settings → Workflow already gates on the same admin scope). The OP's "select option per print" UX is not part of this drop — preheat is a global default applied to every queued item that has a parseable bed temperature; per-queue-item override would need aPrintQueueItemschema migration plus print-modal and queue-modify UI work that would have widened the change beyond the scope agreed with the user. Rework on user review. First cut shipped a global-only design: one singlepreheat_chamber_targetint in Settings → Workflow, no per-print override. User flagged two gaps on review: (1) you can't enable preheat for a single queue item, (2) different filaments need different chamber temps but the setting was a single value. Both are fair: PA needs 50, PETG-CF wants 40, PLA wants 0, but the global single-int forced one number across all of them. Reworked the data shape: replaced the singlepreheat_chamber_targetint withpreheat_filament_targets(JSON map of normalised filament type → °C, user-editable in the same card via the newPreheatFilamentTargetsEditorcomponent) and added two columns toPrintQueueItem—preheat_override(inherit/on/off, defaultinherit) andpreheat_chamber_target_override(nullable int, beats the filament-map derivation). The scheduler's resolution order:item.preheat_override == 'off'skips entirely;'inherit'falls back to the globalpreheat_enabledmaster toggle;'on'forces the stage even when the global is off. Chamber target:item.preheat_chamber_target_override(explicit °C) > max ofpreheat_filament_targets[normalize(t.tray_type)]across loaded AMS slots > 0. Mixed PA+PLA load picks PA's 50 (max-across-slots, NOT lowest-common-denominator — PA's chamber requirement is the binding constraint, PLA doesn't suffer from being warm). PLA-only prints derive 0 and skip the chamber phase automatically without the user touching anything. The per-print UI lives inPrintModal's "Print Options" panel — tri-state segmented control (Inherit / On / Off) plus an optional chamber-target override input (shown only when override ≠ Off, blank = use filament map). Same control in edit-queue-item mode so you can flip preheat on an already-queued print. Tests grew from 8 cases to 15 across three categories — override resolution (3), chamber-target derivation (5), hardware-tier branching (5), plus 2 helper-fn tests — all green. DB migration added:preheat_override VARCHAR(10) DEFAULT 'inherit'andpreheat_chamber_target_override INTEGER NULLonprint_queue, idempotent via_safe_execute. Existing rows behave exactly as before (inherit + null = use global). i18n grew from 13 keys to 19 × 11 locales — real translations for the new override radio + per-filament editor strings, no English fallback. FrontendPrintQueueItemTS interface andPrintQueueItemCreate/PrintQueueItemUpdateshapes updated to carry the new fields end-to-end. PrintOptions.tsx now usesoptions[key as 'bed_levelling']for the boolean rows sinceoptions[key]would type-error against the new non-boolean preheat keys — minor TS-only adjustment, no behavioural change. Airduct flap follows the resolved chamber target — bidirectional, idempotent. Reported by user mid-test on H2D: preheat ran M141 for ABS but the cooling/heating airduct flap stayed in cooling, the open exhaust vent actively fought the heater and the chamber crawled toward target. The H-series (H2C/H2D/H2D Pro/H2S), X2D, and P2S all have a motorised flap with two modes: cooling (modeId=0, open exhaust, vents heat — right for PLA/PETG/TPU) and heating (modeId=1, closed exhaust, recirculates warm air — right for ABS/ASA/PC/PA). Bambu's firmware does not auto-switch the flap based on M141; whatever mode the user last left it in persists. So a PLA→ABS workflow inherits PLA's cooling-mode flap and the heater never wins; conversely an ABS→PLA workflow inherits ABS's heating-mode recirculation and runs PLA's chamber hot. Fix. Newsupports_airduct(model)helper inprinter_manager.pymirrors the frontend whitelist (P2S,X2D,H2C,H2D,H2D Pro,H2Splus their internal codes). In_preheat_and_soak, after the bed dispatch and BEFORE the chamber M141: read the printer's currentstate.airduct_mode, derive the desired mode from the resolved chamber target (chamber_target > 0→ heating,chamber_target == 0→ cooling), and only fireset_airduct_modewhen current ≠ desired. The bidirectional switch is the load-bearing part per Martin: when the resolved chamber target is 0 (PLA-only print, or per-item override disables chamber), the flap MUST switch to cooling even on a heater-capable printer that was previously running ABS, otherwise the closed-flap recirculation cooks PLA. The idempotency check (readstate.airduct_mode, compare against desired before sending) keeps the flap motor from cycling needlessly when it's already where we want it. Gating is onsupports_airduct(model)only — distinct fromsupports_chamber_heater(model): X1E has a chamber heater but no flap (skipped), P2S has a flap but no active heater (still flipped, because even a passive-sensor printer benefits from the right airflow for its filament); the intersection that needs both is H2C/H2D/H2D Pro/H2S/X2D and they all work. No post-print restoration per Martin's preference — once a preheat sets the flap, it stays there for the print's duration (the print itself wants the same mode the preheat picked) and for any subsequent prints until the next preheat decision flips it. Best-effort: anyset_airduct_modefailure logs and continues; M141 still fires regardless so a stuck flap doesn't kill the print. Tests. Four new cases intest_scheduler_preheat.py:test_h2d_chamber_heat_switches_airduct_to_heating(the OP scenario — cooling→heating before M141 for ABS),test_h2d_chamber_zero_switches_airduct_to_cooling(heating→cooling for a PLA print on a previously-warm flap),test_h2d_airduct_already_correct_idempotent(no command sent when current mode matches desired),test_x1c_no_airduct_flap_never_fires_set_airduct(gate regression guard — X1C has chamber sensor + chamber heater logic adjacent but no flap, must not leak the command). 21/21 in the file now.
Added
- API keys can log maintenance — new "Manage Maintenance" scope for HA-style automations (#1832 follow-up, reporter MorganMLGman) — Follow-up on the closed #1832 thread: reporter noted that maintenance had no checkbox in the API-key permissions UI and wasn't clearly denied in the wiki, unlike the other admin-only surfaces. Root cause was that
MAINTENANCE_CREATE / _UPDATE / _DELETEsat on_APIKEY_DENIED_PERMISSIONSinbackend/app/core/auth.py, so every API-key call toPOST /maintenance/items/{id}/perform(mark maintenance as done — the useful endpoint for a "cleaned nozzle every N hours" Home Assistant automation) returned403 "API keys cannot be used for administrative operations."Fix. Newcan_manage_maintenancescope flag onapi_keys, following the same shape ascan_manage_libraryandcan_manage_inventory. Wires the three maintenance write permissions into_APIKEY_SCOPE_BY_PERMISSIONunder the new flag; drops them from the denylist. Covers the per-printer maintenance CRUD (assign/remove items, edit intervals, log completion), the type-catalog CRUD (system types + custom types), and — viaMAINTENANCE_UPDATEon the perform endpoint — the load-bearing "reset counter" call.MAINTENANCE_READstays undercan_read_status. Backfill choice. Distinct from thecan_manage_library/can_manage_inventorymigrations, which mirroredcan_queueto preserve existing capability from the prior denylist-model. Maintenance writes were EXPLICITLY denied for every API key pre-migration, so no existing integration relies on them — backfilling tocan_queuewould silently widen scope on upgrade for every queue-capable key without unlocking any real use case. Existing rows backfill to FALSE; new keys created via the Settings UI default to TRUE (matches the safe-on-by-default pattern for the two prior scopes). Users opt in per key from Settings → API Keys → Edit. CLI. Bundled SpoolBuddy kiosk key getscan_manage_maintenance=False— kiosks don't need it, keep them minimally scoped. Tests. RBAC matrix inbackend/tests/integration/test_auth_apikey_rbac.pyextended:valid_flagsset +_each_scope_flag_has_at_least_one_permissionparametrize decorator +_FakeApiKey.__init__all pick up the new flag; three new_SCOPE_CASESforMAINTENANCE_CREATE / _UPDATE / _DELETE; the cross-scope leakage guard (other_flagsset at line 355) widened from four flags to six so acan_manage_librarytoggle can't accidentally allow aMAINTENANCE_UPDATEcall (and vice versa). 78/78 intest_auth_apikey_rbac.py+ adjacent auth suites green. Frontend. New checkbox in the Settings → API Keys create form (between Manage Inventory and Allow Cloud Access), new teal badge in the key list, TS types extended (APIKey,APIKeyCreate,APIKeyUpdate). i18n. Three new keys (settings.manageMaintenance,settings.manageMaintenanceDescription,settings.maintenanceBadge) × 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere per convention. Wiki.bambuddy-wiki/docs/features/api-keys.md— new row in the permissions table, new bullet in the Principle-of-Least-Privilege list ("Home Assistant maintenance-log automation: Read Status + Manage Maintenance"), and a paragraph in Upgrade Notes explaining the FALSE backfill so existing users don't see a silent scope widening. Migration. Idempotent_safe_executeALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE+ one-shotUPDATE api_keys SET can_manage_maintenance = FALSEguarded by column-existed-before check, mirroring the two prior scope migrations. Works on both SQLite and Postgres — no dialect-specific column type needed since it's just a BOOLEAN.
Fixed
- AMS same-material runout/backup switch: Spoolman now splits usage across origin + backup spool (#1793, reporter ojimpo) — Reporter's H2S ran an AMS backup switch mid-print (single-slot 72.56g job, tray 0 depleted at layer 37 → auto-switched to tray 1 → finished). Bambuddy's Spoolman writer charged the ENTIRE 72.56g to the origin spool via the
(via tag)path and separately credited a small~30g via remain-deltato the backup — net double-count plus origin spool driven past itsinitial_weight. Reporter's forensic trace on 0.2.5b2 confirmed:Tray change during print: tray=1 at layer=37fires atbambu_mqtt.py:1863and gets appended tostate.tray_change_log, butspoolman_tracking.report_usagenever consulted it — every mid-print switch fell through to single-tray attribution. #1771's fix (a53dc20ca) corrected the split math inside the tray-switch branch of the internalusage_trackerwriter; the branch itself was never ported tospoolman_tracking, so users on Spoolman have never had a working split even after #1771 shipped. Fix — extract the shared split math and use it from both inventory writers so the two paths cannot drift. New pure-logic helperbackend/app/utils/tray_split.py::compute_tray_split_grams— takestray_changes,total_weight,slot_id, optionallayer_usage, density/diameter/total_layers/last_layer_num, returns per-segment[(seg_idx, global_tray_id, grams)]. Preference order matches usage_tracker exactly: (1) G-code cumulative extrusion between segment boundaries, (2) linear layer-ratio withdenom = total_layers or last_layer_num(P1S firmware resetstotal_layer_numto 0 at completion —last_layer_numis the durable denominator, #1771's cascade), (3) equal-split when no denominator survives. Last segment always absorbs rounding drift so the sum equalstotal_weightexactly, no phantom grams created or lost.usage_tracker.py:1085-1150's inline split loop now calls the helper; the caller-side "convert global_tray_id → (ams_id, tray_id) → resolve spool → charge grams" side effect stays where it was.spoolman_tracking.report_usagegains a new_report_spool_usage_split_by_tray_changespeer that mirrors_report_spool_usage_for_slots's Spoolman side effects (tag →find_spool_by_tag→ slot-assignment fallback →use_spool) but iterates SEGMENTS instead of raw slots. Split path activates whenlen(state.tray_change_log) > 1at completion AND the print used exactly one nonzero slot (len(nonzero_slots) == 1) — same gate asusage_tracker.py:1002. Multi-colour prints naturally cycle trays for every colour change, so splitting each slot's grams across everytray_change_logentry would attribute slot 1's usage to segments where slot 2's tray was loaded and vice versa. Multi-slot prints fall through to the existing single-tray path with its stableslot_to_traymapping. Single-entry log (start-of-print seed only, no mid-print switch) also falls through, so nothing changes for prints that didn't traverse an AMS switch. Double-count fix. After the split path attributes segment N toglobal_tray_id, that ID entershandled_global_tray_ids— so Path 2 (remain-delta fallback for slots 3MF didn't cover) skips it. Pre-fix, the OP's backup spool got a redundantremain-deltacredit on top of the origin overcharge; post-fix, remain-delta only fires for trays the split path genuinely didn't touch. Sample A math end-to-end. Reporter's 72.56g single-slot print withtray_change_log=[(0, 0), (1, 37)]and no gcode layer usage: seg 0 (tray 0, layers 0-37) gets72.56 × 37/100 = 26.85g→ spool 8; seg 1 (tray 1, layers 37-end) gets72.56 - 26.85 = 45.71g→ spool 7. Origin no longer exceedsinitial_weight; backup carries the segment actually printed from it. Sample B (paused-then-switched print at layer 371 on a 263.47g single-slot job) uses the identical code path — pause/resume doesn't flip theself._was_running and not self._completion_triggeredgate atbambu_mqtt.py:1860, so the tray-change log survives the pause window. One fix covers both. Tests. 4 integration cases inbackend/tests/unit/services/test_spoolman_tray_split.pypin the Sample A shape, the Path 2 double-count guard, the multi-slot no-split gate, and the single-tray-change-entry fallthrough. Plus 8 pure-math cases inbackend/tests/unit/utils/test_tray_split.pypinning the algorithm — empty log → empty result, single segment → charge everything to that tray, two-segment linear split, gcode-preferred-over-linear branch (with a regression guard onslot_id → filament_id = slot_id - 1because dropping the -1 sends the split to a filament_id that isn't in the gcode and dumps everything onto the last segment — the exact #1771 shape), three-segment last-absorbs-rounding,total_layers=0 & last_layer_num>0cascade, equal-split when no denominator, and cross-validation that captured-layer scenarios match the total_layers path. 4 integration cases inbackend/tests/unit/services/test_spoolman_tray_split.pypinning the Sample A shape end-to-end — the seamless-switch case (twouse_spoolcalls, sum=72.56, origin gets 26-28g, backup gets 44-47g), the double-count guard (Path 2 gets valid slot-assignment IDs for both trays so a broken guard would surface as 4use_spoolcalls instead of 2), the multi-slot no-split gate (multi-colour print with 5 tray-changes must fall through to single-tray attribution, not fan slot 1's grams across slot 2's segments), and the single-tray-change-entry fallthrough (must NOT split a normal single-tray print). Extended existingtest_usage_trackerregressions still pass 112/112 — refactoring the inline split path to use the shared helper is behaviour-preserving.pytest -n 30 backend/tests/ -k "spoolman or usage_tracker or tray_split or partial": 798/798 green. Compat.report_usageusesgetattr(tracking, "layer_usage", None) or {}andgetattr(tracking, "filament_properties", None) or {}— attribute-safe against SimpleNamespace stubs in tests AND against pre-migration ORM rows loaded without those columns. Scope. Backend-only. Two-file port (usage_tracker refactor + spoolman_tracking split path) + one new helper module + 11 new tests. No DB migration (fields already existed onActivePrintSpoolmanfrom earlier work). No new permission. No new i18n key. No frontend change — thespool_usage_loggedWebSocket already carries per-segment results, so the UI updates without a client-side change. - P1S / P1P printer card no longer shows a bogus "Door Closed" badge (#1866, reporter MartinNYHC) — The enclosure-door badge in
frontend/src/pages/PrintersPage.tsxgated visibility on a model whitelist that includedP1SandP1P, but neither has a door sensor: P1S has an enclosure door with no hall sensor behind it, P1P has no enclosure at all. Backendbambu_mqtt.py:2876-2907unconditionally parses bit 23 of thestatfield for every non-X1 model, and the bit stays 0 on P1S/P1P firmware — so the card rendered a permanent green "Door Closed" chip that couldn't reflect real state. Fix. DropP1SandP1Pfrom the frontend whitelist. Whitelist now reads['X1C', 'X1', 'X1E', 'X2D', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S']— the models that ship with a hall sensor for the door (and, on X1 family, top glass). Corrected the staleX1/P1S/P2S/H2*comment infrontend/src/api/client.ts:473(TypeScriptPrinterStatusinterface) andbackend/app/services/bambu_mqtt.py:295(PrinterStatedataclass) to match reality — those comments were what led the whitelist astray in the first place. Backend parsing left as-is: the bit read is cheap and if Bambu ever ships firmware that wires the P-series enclosure into a door sensor, the state gets picked up for free without a Bambuddy change. Scope. Two-line frontend whitelist change plus two comment corrections. No test change (no unit tests existed for the frontend gate). No i18n key change (printers.door.open/printers.door.closedstill used for the models that keep the badge). No DB migration. No new permission. No backend behavioural change. - Bambu Cloud custom filament preset lookup restored — SpoolBuddy "Assign to AMS" now surfaces the real custom profile in BambuStudio (#1815, reporter Bgabor997) — Symptom: SpoolBuddy scans a tag for a spool whose slicer_filament is a Bambu Cloud user preset (PFUS-prefix, "PFUS12f68b29a18aa4" etc.), Bambuddy applies the assignment, and BambuStudio's AMS panel shows "Generic PETG" / "Generic PLA" instead of the user's custom profile. Manual AMS-card configure with the same profile worked. Root cause.
BambuCloudService.get_setting_detailatbackend/app/services/bambu_cloud.py:393hitsGET /v1/iot-service/api/slicer/setting/{setting_id}without the?version=XX.YY.ZZ.WWquery parameter — Bambu's API answersHTTP 400 "field 'version' is not set"for every call. The plural GET five methods above (get_slicer_settings, line 362) does sendparams={"version": _SLICER_API_VERSION}and the source comment at line 69-77 documents precisely this contract for the endpoint subtree; the singular GET and the sibling DELETE (delete_setting, line 545) were left uncovered when the_SLICER_API_VERSIONplaceholder landed in #1013's compliance rework 2026-05-12.slicer_filament_resolver.resolve_slicer_filamentswallowed the 400 as a warning and fell through tonormalize_slicer_filament; the caller ininventory.py:146-165then generic-material-fell-back tray_info_idx toGFL99/GFG99, and BambuStudio's AMS panel reads the printer'stray_info_idxecho → Generic. Why nobody caught this in 50 days. Two rescue paths mask the bug in 99% of real assigns: (1) if the target slot already carries a P-prefix filament_id from any prior configure,current_tray_info_idxreuse atinventory.py:147-156reuses it; (2) if the spool has a storedspool_k_profilematching a livestate.kprofilesentry on the printer,printer_kp.filament_idrealigns tray_info_idx at line 216-230 (Spool assign: realigning tray_info_idx 'GFL99' → 'P…' (source=printer)). Reporter's spool 54 → tray 2 assign at 2026-06-30 09:21:18 was the rare case with neither rescue — fresh spool, no prior K-profile calibration, slot didn't hold a valid P-prefix — and fell all the way to generic. Every other PFUS assign in his same log shipped a valid P-prefix. Owner reproduced the WARNING line in his own log on the H2D after a Reset-Slot + rescan cycle; his display was rescued by the K-profile realign path, not by cloud. Fix.get_setting_detailanddelete_settingnow both sendparams={"version": _SLICER_API_VERSION}— same neutral"1.0.0.0"placeholder the plural GET has always used (Bambu accepts any XX.YY.ZZ.WW value, doesn't validate against a release manifest, so no impersonation). Once cloud returns 200, the resolver readsdetail["filament_id"]at line 108-113 and the P-prefix lands on tray_info_idx directly — no reliance on slot reuse or K-profile realign. The endpoint-subtree comment at line 69-77 updated to name the singular GET, DELETE, and POST variants explicitly so the next sibling method added to this class doesn't silently regress.get_setting_detailalso includes the truncated response body in the raisedBambuCloudErrormessage, so a future contract change is self-diagnostic from support-bundle logs (this bug cost 50 days precisely because the error was an opaque"400"). Adjacent surfaces that were also silently broken and are now fixed.preset_resolver.resolve_preset's cloud branch,cloud.py's three UI-facing routes (get_setting_detailat:534,import_settingchain at:784, forecast at:1120),cloud.py's delete-cloud-preset route (:1016), and internallyBambuCloudService.update_settingat:483(which callsget_setting_detailthendelete_settingthen POSTs a replacement — every UI-driven edit of a cloud preset was 400ing at step 1). Tests. Three new cases inTestSlicerSettingVersionParam(backend/tests/unit/services/test_bambu_cloud.py) pinning the contract as a unit-testable invariant so the next sibling method can't silently omit the param:test_get_setting_detail_sends_version_paramassertsparams.get("version")is truthy on the GET call;test_get_setting_detail_error_includes_response_bodypins the reporter's exact 400 body shape ("field 'version' is not set") making it into the raised exception message;test_delete_setting_sends_version_parammirrors the invariant for DELETE. 26/26 intest_bambu_cloud.py(was 23 + 3 new).ruff check backend/app/services/bambu_cloud.py backend/tests/unit/services/test_bambu_cloud.pyclean. Scope. Backend-only. Two API-call edits, one comment edit, three tests. No DB migration. No new permission. No frontend change. No new i18n key. Users on 0.2.5b1 and earlier who hit this: the fix takes effect on the next Bambuddy restart with no data migration required — reassigning the affected spool via SpoolBuddy after upgrade lands the correct tray_info_idx and the AMS panel in BambuStudio updates to the actual custom profile name. - Cancel during queue dispatch actually cancels — no more "pressed cancel and the print started" (#1853, reporter guy-blotnick) — Symptom: user queued a batch of 10 print jobs of the same item across two P2S printers (Windows installation, v0.2.4.8), clicked Cancel on a pending row, and the print started anyway. Repeated consecutively. Support-bundle log scan reported 15×
sqlite3.OperationalError: database is lockedfrom the printer sensor history recorder in the same 8-minute window — a tell-tale that something was holding the SQLite WAL writer lock for the full 15 sbusy_timeout. Root cause (the race)._start_print()inbackend/app/services/print_scheduler.pycarried a check-then-act window of several seconds between "scheduler'scheck_queuesnapshotted this row as pending" and "scheduler sent the MQTT start_print command". The scheduler readsitemvia its session, then does FTP delete + FTP upload of the 3MF (5-30 s on a typical archive) before the unconditionalitem.status = "printing"; await db.commit()at line 2792. If the user pressed Cancel in that window,/cancel(a separate session) sawstatus == 'pending', flipped tocancelled, and returned 200 — but the scheduler's stale in-memory write overwrote the cancellation in the very next commit. Thenprinter_manager.start_printshipped to the printer and the print commenced. Thecancel_queue_itemroute atprint_queue.py:1245correctly guards against late cancels (status not in ("pending",)→ 400) but is useless if the scheduler races back topending → printingAFTER the cancel commit. Root cause (the lock contention amplifier)._start_print()didawait db.flush()at line 2555 immediately after writingitem.archive_id = archive.idandawait db.delete(library_file)(the library-file-to-archive promotion path) —flush()opens the SQLite write transaction but doesn't commit, holding the WAL writer lock through the FTP upload below. Every other writer in the process (sensor history task every 60 s, runtime tracking every 30 s, MQTT state UPDATEs from the live H2D/P2S, the user's own concurrent /cancel commits) blocks behind that lock for the full upload duration. With 15 sbusy_timeoutand FTP uploads regularly exceeding 15 s on larger 3MFs, the sensor history task hit its first lock error → logged + slept 60 s → next tick still blocked → repeat. The lock contention also widened the cancel-race window (the user's cancel commit was queued behind the scheduler's held write), making the race that much easier to lose. Fix is three guards layered in defence-in-depth. (1) Atomic CAS at the pending→printing transition. Replaceditem.status = "printing"; await db.commit()withUPDATE print_queue SET status='printing', started_at=NOW() WHERE id=:id AND status='pending'. Ifrowcount == 0the user already won the race; log the abort, best-effortdelete_file_asyncthe file we just FTP'd up to the printer's SD card (no leftover that would surface in BambuStudio's file picker), send aqueue_item_failed{reason: "cancelled_mid_dispatch"}WebSocket event so the user sees their cancel actually took effect, and return WITHOUT callingprinter_manager.start_print. The in-memoryitem.status/item.started_atare synced from the CAS values so the rest of_start_printreads consistent state for notifications. (2) Early refresh + bail before FTP I/O. Right after the printer-connectivity check (~line 2487) the scheduler nowawait db.refresh(item)and returns early ifitem.status != "pending". Saves the wasted 5-30 s FTP upload when the user cancelled BEFORE the scheduler tick reached this row (the snapshot is taken at the top ofcheck_queuebut iterated through serially; with 10 items in the batch the last item can be picked up minutes after the snapshot, by which time it may already becancelled). Not load-bearing for correctness — guard (1) catches the same case at the CAS point — but cuts wasted FTP bandwidth, printer SD writes, and downstream cleanup work. (3)flush()→commit()before FTP. Replaced theawait db.flush()at line 2555 withawait db.commit()so the library-file-to-archive promotion's writes (item.archive_idset,library_filedeleted) commit cleanly before the FTP block. SQLite WAL writer lock releases immediately; concurrent writers — the sensor history task, the user's /cancel commit, the MQTT UPDATE path — stop queueing behind the scheduler's session. The flush-not-commit pattern existed because the original code wanted to roll back the archive promotion if a later step failed, butarchive_service.archive_print()(called four lines above) had already committed thePrintArchiverow in its own session, so the rollback was only ever rolling back theitem.archive_idpointer back to NULL — which doesn't actually undo the archive creation. The new behaviour matches reality: archive is created (committed), pointer is set (committed), FTP runs without writer-lock contention. Subsequent failure paths still mark the item asfailedcorrectly; they don't try to un-create the archive. Tests. Three new cases inbackend/tests/unit/test_scheduler_cancel_race.py:test_cancel_during_ftp_upload_aborts_before_mqttsimulates the headline scenario (cancel commits in a separate session insideupload_file_async's mock side-effect, then the CAS sees rowcount==0 →start_printmock asserted not-called → row stayscancelled→started_atstaysNone→ twodelete_file_asynccalls, one pre-upload sweep and one post-CAS cleanup);test_cancel_before_ftp_upload_skips_dispatchmirrors the early-bail path (row pre-cancelled before_start_printruns → upload never awaited →start_printnever called);test_happy_path_still_dispatchesis the regression guard so the CAS doesn't accidentally block normal dispatch on a row that was always pending. 3/3 green + 140/140 in the wider scheduler suite (test_scheduler_cleanup_library.py,test_scheduler_preheat.py,test_scheduler_dispatch_hold.py,test_scheduler_watchdog.py,test_scheduler_ams_mapping.py) regression-clean. Scope. Backend-only. No DB migration. No schema change. No new permission. No new i18n key. No frontend change — the existingqueue_item_failedWebSocket toast already renders for the newcancelled_mid_dispatchreason via its generic display path. Thedatabase is lockederrors are addressed structurally by guard (3); a more invasive session-lifetime restructure inside_start_printwas considered and deferred — flush→commit catches the dominant offender (library-file-promoted dispatches, which the OP's 10-item batch hits per copy) without widening the change beyond what #1853 needs. Inject auto-print G-codecheckbox can be ticked in PrintModal create mode (#1852, reporter Lamcois) — Symptom: in v0.2.4.8 the user opens the print dialog for a single archive, sees the Inject auto-print G-code toggle next to the gcode-snippet section, clicks it, and the checkbox visually flips back to unchecked instantly. Submitting and then editing the queued item lets the same toggle be ticked normally — so the bug was only in the create-mode flow. Root cause.PrintModal/index.tsx:947-955carried auseEffectthat resetscheduleOptions.gcodeInjectiontofalsewhenevermode === 'create'AND(effectiveQuantity <= 1 || !settings?.gcode_snippets). The reset's stale code-comment claimed "the checkbox only renders for create + snippets configured + quantity > 1" — but the actual render gate inScheduleOptions.tsx:277is just{hasGcodeSnippets && (...)}with no quantity check. So with snippets configured + quantity = 1 (the OP scenario): user clicks the checkbox → React updates state totrue→ the parent's useEffect immediately sees the gate'seffectiveQuantity <= 1condition and resets it tofalse→ the checkbox appears un-clickable. Edit-queue-item mode worked becausemode !== 'create'short-circuited the reset before it could fire. Fix. Drop theeffectiveQuantity <= 1clause from the reset. The legitimate cleanup that survives — the!settings?.gcode_snippetshalf — handles the actual edge case the effect was guarding against: an admin removing every snippet while the modal is open, in which case the checkbox's render gate hides the control but the boolean would otherwise still betrueon submit. The scheduler's_start_print(print_scheduler.py:2306) readsitem.gcode_injectionper queue item regardless of batch size, so there's no underlying reason to block injection on single prints — that gate was inserted in error and never matched the render condition. Tests. New regression case infrontend/src/__tests__/components/PrintModal.test.tsx:quantity 1 + snippets configured: checkbox toggles cleanly (#1852)opens the modal in create mode at the default quantity = 1, asserts the checkbox starts unchecked, clicks it,waitForconfirms the displayedcheckedstate staystrueafter re-render (the pre-fix reset would have flipped the displayedcheckedback tofalse), and confirms thegcode_injection: trueflag actually reaches the queue API on submit. 61/61 inPrintModal.test.tsx(was 60 + 1 new). Existing batch-mode case (injection ON queues all copies and dispatches none immediately) still passes — my fix doesn't affect the quantity > 1 multi-copy fan-out path. Scope. Frontend-only, one-line behavioural change inside an existing effect. No backend change, no i18n key, no permission.Ignore and Resumenow actually ignores the fault — wrong-plate HMS no longer re-pauses 1-2 s after click — Symptom (continuation of #1869): clickingIgnore and Resumeon a wrong-plate0500_8051HMS cleared the modal, the printer left PAUSE, then ~1-2 s later re-detected the wrong plate and re-paused with the identical HMS code — the modal popped back open and the user could not actually print on the "wrong" plate (the whole point of the Ignore button). Root cause: Bambuddy'sexecute_hms_action(backend/app/services/bambu_mqtt.py:5496-5523) redirectedIGNORE_RESUMEonstate == "PAUSE"to a plainresumecommand, with a code comment claiming BambuStudio's "err-bearing shape" was "silently rejected by Bambu firmware" (verified during #1830). That diagnosis was wrong on two counts. (1)IGNORE_RESUMEdoesn't map toresumeat all in BambuStudio. Source-of-truth from BambuStudiosrc/slic3r/GUI/DeviceErrorDialog.cpp:600-602andsrc/slic3r/GUI/DeviceManager.cpp:1450-1462(commit4019d2e): the button dispatchescommand_hms_ignore, whose wire shape is{"print": {"command": "ignore", "err": "<decimal>", "param": "reserve", "job_id": "<job_id>", "sequence_id": "..."}}. The firmware treatscommand: "ignore"as "suppress this check on the next attempt AND auto-resume the paused print" — semantically distinct fromcommand: "resume"which means "I fixed the problem, re-check normally" and is exactly why the wrong-plate check re-fired. (2)erris a DECIMAL string of the int, not the hex shortcode. BambuStudio passesstd::to_string(m_error_code)wherem_error_codeis the 32-bitintvalue of the print_error code — so for0x05008051the wire string is"83918929", not"05008051". The #1830 H2D test that "verified" the err-bearing shape was silently rejected almost certainly sent the hex string, which the firmware couldn't match against the active int fault → silent rejection looked like the entire shape was broken, when the actual problem was the format of one field. Fix. Replace thehms_ignore(persistent)helper with two distinct helpers matching BambuStudio's source:hms_ignore_command()publishescommand_hms_ignore's shape (command: "ignore", decimalerr,param: "reserve",job_id);hms_idle_ignore(persistent)publishescommand_hms_idle_ignore's shape (command: "idle_ignore", decimalerr,type: 0|1). WireIGNORE_RESUME,IGNORE_NO_REMINDER_NEXT_TIME, andDONT_REMIND_NEXT_TIME(alias) tohms_ignore_command()— BambuStudio routes all three to the samecommand_hms_ignore(DeviceErrorDialog.cpp:596-602), the "don't remind" half is the firmware's job. WireNO_REMINDER_NEXT_TIMEtohms_idle_ignore(persistent=False)— BambuStudio dispatches it viacommand_hms_idle_ignore(..., 0)(DeviceErrorDialog.cpp:588-590), distinct from the resume-bearingignorecommand. The decimal-int conversion lives at the helper layer (str(int(print_error, 16))with a defensive fallback to the raw input on parse failure so the route can surface 502 rather than raising mid-dispatch). Thejob_id=Nonecase sends an empty string, matching BambuStudio'sstd::stringempty default. Resume / stop unchanged — the user has independently confirmed plainresumeand plainstopwork forPROBLEM_SOLVED_RESUMEandSTOP_PRINTINGon their printer; BambuStudio'scommand_hms_resume/command_hms_stopdo carry the sameerr+param: "reserve"+job_idfields, but changing a working shape without a field test risks regressing a path the user has confirmed, so the plain shape stays. The previous stale comments about "verified silently rejected" are removed and replaced with citations to BambuStudio's exact source lines. Tests. Six cases inTestExecuteHmsActionDispatch(backend/tests/unit/services/test_hms_actions.py) rewritten to assert the BambuStudio shape:test_ignore_resume_sends_bambustudio_ignore_command_pausedpins the full payload (the exact #1869 trace),test_ignore_resume_state_independentconfirms no PAUSE/RUNNING branch (BambuStudio's dispatch is unconditional),test_ignore_no_reminder_uses_ignore_command_not_idle_ignorepins theDONT_REMIND_NEXT_TIME→command: "ignore"route,test_no_reminder_next_time_uses_idle_ignore_type_zeropins the still-correctNO_REMINDER_NEXT_TIME→idle_ignoreroute (decimalerrnow),test_ignore_accepts_16_char_full_code_as_decimalcovers the 64-bit hms[]-array fault shape,test_ignore_with_no_job_id_sends_empty_stringpins the empty-string sentinel. 35/35 intest_hms_actions.py, 12/12 in HMS-touchingtest_printers_api.pyintegration cases,ruff checkclean. Scope. Backend-only. Same modal, same API contract, same dispatch route — just a different MQTT command shape on the wire for the ignore branch. No DB migration, no permission, no i18n key.- HMS error-modal action buttons look like buttons and stop falsely 502-ing on wrong-plate
Ignore and Resume— Two compounding issues in the HMS error modal surfaced when a user forced an HMS error for a wrong build plate and tried to dispatch the per-fault actions. (1) Buttons read as inert badges. The action<button>infrontend/src/components/HMSErrorModal.tsx:1042usedhover:${buttonHoverColor}— a template-literal interpolation Tailwind's JIT scanner can't see as a literal string. The per-severityhover:bg-red-500/10/hover:bg-orange-500/10/hover:bg-blue-500/10classes never reached the compiled CSS, so the hover treatment never fired. The button also reused the same${bgColor}+${color}as the severity badge two lines above (line 1020), with no border or weight differentiation, so visually it read as another label. And there was nodisabled={mutation.isPending}and noLoader2placeholder, so during the 2.5 s ack wait the click sat inert with zero feedback. (2)IGNORE_RESUME502-rejected on wrong-plate whilePROBLEM_SOLVED_RESUMEsucceeded — even though both dispatch the IDENTICALresumepayload.execute_hms_actioninbambu_mqtt.py:5511-5513redirectsIGNORE_RESUMEtohms_resume()on PAUSE state (firmware silently rejectsidle_ignorefor paused prints), so the wire shape is the same. The race was in the route's ack-detection atprinters.py:3848:acked = client.state.state != pre_gcode or len(client.state.hms_errors) != pre_hms_count. For wrong-plate the printer briefly resumes, immediately re-detects the bad plate, and re-pauses with the same hms code within ~1-2 s. Inside the 2.5 s sample windowstate.stateround-trips PAUSE → IDLE/RUNNING → PAUSE andlen(hms_errors)lands back at the pre-publish count → both deltas read zero → false 502 even though the firmware fully ack'd. The user's two-out-of-three success pattern (abort + problem-solved-resume worked, ignore-and-resume 502'd) was the same race resolving differently across runs — sampling caught the brief non-PAUSE in two and missed it in the third. Fix (1). Drop the deadbuttonHoverColorfield fromgetSeverityInfoand the per-card destructure. Rewrite the action button with a static Tailwind class stringbg-white/10 hover:bg-white/20 active:bg-white/30 text-white border border-white/20 hover:border-white/30so the JIT actually picks up the hover / active / border-hover utilities; the contrast against any severity-tinted container reads as a clear affordance. Wiredisabled={!hasPermission('printers:control') || activateActionMutation.isPending}so all action buttons in the modal lock during a single in-flight command (prevents racing concurrent dispatches). Add a<Loader2 className="w-4 h-4 animate-spin" />that renders only on the button whose(action, print_error)matchesmutation.variables, so the user sees exactly which button is pending. Fix (2). Swap the ack probe from "fault-state diff" to "did the printer push anything back".client._last_message_time(bumped in the MQTTon_messagehandler atbambu_mqtt.py:939on every inbound message, regardless of payload) is the robust signal —execute_hms_actionalways publishes apushallafter the command, so an accepting printer always responds with at least one status push inside the 2.5 s window. The wrong-plate re-pause case still ack's because the printer DID respond; only a genuinely-offline / firmware-silently-dropped command leaves_last_message_timeuntouched, which is exactly the 502 path #1830 wanted to surface. Tests. Updated three existing cases inTestExecuteHMSAction(backend/tests/integration/test_printers_api.py) — happy-path success, 16-char-full-code, and the 502 no-ack — to mock_last_message_timeadvance instead of(state, hms_errors)mutation. New casetest_execute_hms_action_ignore_resume_repauses_within_window_still_ackspins the wrong-plate IGNORE_RESUME shape: dispatcher returns True,state.stateround-trips PAUSE→PAUSE,hms_errorslength round-trips to the same N,_last_message_timeadvances → route returns 200, not 502. 8/8 inTestExecuteHMSActiongreen. Frontendnpm run buildclean,npm run test:run173 files / 2288 tests green, i18n parity clean across all 11 locales. ESLint clean on the modal file. Scope. No DB migration. No new permission. No new i18n key. No new backend route. The action handlers, MQTT publish shapes, andexecute_hms_actiondispatcher are unchanged. - Slice failure UI now surfaces the slicer's real diagnostic, not Bambu Studio's
input preset file is invalidplaceholder (#1851, reporter srausser) — When the BambuStudio CLI rejects a slice for a content reason (preset-vs-printer compat failure, missing fields, range validation), it exits -5 and writes Bambu Studio's catch-all error_stringThe input preset file is invalid and can not be parsed.toresult.json. The actual per-incident diagnostic — e.g.filament preset Generic PLA BBL H2C (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle.— only lives in the stdout dump as[error] run NNNN: <reason>. The sidecar packed both into the response (messagecarried the placeholder,detailscarried the stdout dump),_format_sidecar_errorjoined them, but_slicer_rejection_messageinbackend/app/api/routes/library.py:3295trimmed at the first\nstdout:/\nstderr:cut point — discarding the real CLI error before it reached the SliceJob'serror_detail. The reporter only knew an H2C-bound preset had slipped into slot 1 because they checked the container logs by hand; the UI'sAlertModalshowed the unhelpful placeholder verbatim. Fix._slicer_rejection_messagenow mines the response body with_CLI_ERROR_LINE_RE(\[error\]\s*(?:run\s+\d+:\s*)?(.+?)$, MULTILINE) BEFORE the stdout/stderr trim removes it. When the headline reason matches the bundledThe input preset file is invalid and can not be parsed.placeholder — or when the headline is empty — the mined[error]line is substituted in its place; when the headline carries a useful reason already (Some objects are located over the boundary of the heated bed.,The temperature difference of the filaments used is too large., etc.) the headline is kept and the[error]line is ignored to avoid duplicating the same text. The regex tolerates both[error] run NNNN: <msg>and the bare[error] <msg>shape the CLI uses on different code paths, and matches against the FULL pre-trim response so anything in stderr is also covered. Tests. Three new cases inTestSlicerRejectionMessage(backend/tests/integration/test_library_slice_api.py):test_replaces_input_preset_invalid_placeholder_with_cli_error_linepins the exact #1851 H2C/A1 trace from the report;test_keeps_meaningful_reason_even_when_cli_error_line_presentensures bed-boundary and other already-specific reasons aren't clobbered by an unrelated stdout[error];test_cli_error_line_without_run_prefixcovers the bare[error] <msg>shape. Existing four cases stay green. 7/7 in the class. Scope. Backend-only. The frontend already readsstate.error_detailverbatim into the AlertModal (SliceJobTrackerContext.tsx:188), so the better message flows through with no UI change. No DB migration, no new permission, no new i18n key. - Slice modal's filament auto-pick hard-skips printer-mismatched presets when any compatible alternative exists (#1851 root cause, reporter srausser) —
pickFilamentForSlotinfrontend/src/components/SliceModal.tsx:123scored every filament against the plate slot's (type, colour) requirement plus a-100penalty when the preset'scompatible_printerslist /BBL <token>name resolved to a different printer than the user picked (#1325). The soft penalty was dominant in nominal-data scenarios but the contract — "never auto-fill a slot with a printer-incompatible preset while a compatible one exists" — was implicit, not enforced; any future scoring change (a higher type-match weight, an extra metadata bump, a Bambu Cloud filament shape change that erasescompatible_printers) would silently flip the picker back into the soft regime. The propagation amplifier: when the picked plate doesn't use every project slot (here: H2C source plate uses slot 4 only; slots 1-3 are unused),substitute_unused_plate_filamentsinbackend/app/services/slicer_3mf_convert.py:238rewrites every unused slot to slot 1's content — so any printer-mismatch in slot 1 silently propagates across the whole filament array, and one bad auto-pick poisons the entire slice. Fix. The scorer now partitions candidates into two buckets — compatible/unknown vs mismatch — and returns the best-scoring compatible/unknown candidate whenever the compatible bucket is non-empty; the mismatch bucket is only consulted when zero compatible alternatives exist (preserves graceful-degrade for preset registries that genuinely have nothing for the selected printer). Identical to the existingpickProcessDefaulttwo-pass'match'→'unknown'shape, applied to filaments. No metadata-scoring change. The four shared picker helpers (pickFilamentForSlot,pickProcessDefault,pickDefault,findPreset/findPresetByNameplus theSLICE_MODAL_TIER_ORDERconstant) moved out ofSliceModal.tsxinto a newfrontend/src/utils/slicePresetPicker.tsso the contract is unit-testable and the modal file only exports React components (thereact-refresh/only-export-componentslint rule fails fast-refresh on non-component exports from a.tsxfile). Tests. Three new cases in a dedicatedpickFilamentForSlot — printer-compat contract (#1851)describe block infrontend/src/__tests__/components/SliceModal.test.tsx: the OP trace (A1printer +Generic PLA BBL H2Cperfect colour vsBambu PLA Basic BBL A1colour mismatch → A1 wins), the all-mismatch graceful degrade (only H2C preset present → H2C still returned, dropdown not empty), and the no-printer-context transient (printerName === nullduring first render → no compat filter, plain score-best wins). 35/35 SliceModal tests + 25/25 utils/slicerPrinterMatch tests green. Scope. Frontend-only, single helper, one new export for testability. No new i18n key. No backend change. The unused-slot substitution stays as-is — it's only correct when slot 1 is correct, which the picker now enforces. - Uncataloged-but-actionable HMS faults now render in the UI (#1840, reporter Boa-Thomas) — H2C printers (firmware
01.02.00.00) emit HMS faults whose short codes aren't in the bundledERROR_DESCRIPTIONSmap — e.g.0500_809C, which pauses the print and carriesIGNORE_RESUME/PROBLEM_SOLVED_RESUMEactions the user needs to dispatch.filterKnownHMSErrorsinfrontend/src/components/HMSErrorModal.tsx:900(and the inline modal-local copy at line 929) gated visibility purely on catalog membership (ERROR_DESCRIPTIONS[shortCode] !== undefined), so the entire error never rendered: no problem pip, no per-card count, no errors-panel entry, no action buttons. Backend correctly captured + dispatched the fault (verified via REST and WebSocket); frontend silently dropped it. The catalog gate isn't dead code — it's also a noise filter for transient post-cancel echoes like0C00_001B(seePrintersPageBucketing.test.ts) — so deleting it would re-introduce the FAILED-after-cancel "1 problem forever" regression. Fix.filterKnownHMSErrorsnow keeps an error if EITHER it's inERROR_DESCRIPTIONS(existing behaviour, preserves bucketing for noise) OR it carriesactions.length > 0(actionable fault from any source — surface so the buttons can render). The modal's inline filter is replaced with a call to the shared helper so badge counts and modal contents agree by construction. For uncataloged errors, the description falls back tot('hmsErrors.unknownCode')("Unknown HMS code — see the Bambu Lab wiki for details."); the existing[XXXX-YYYY]short-code header, severity badge, action buttons, and wiki link all work without a catalog entry. The action-dispatch path is unchanged —full_codealready flows through correctly from #1830, soIGNORE_RESUMEetc. land on the firmware the moment the user clicks. The reporter's secondary observation aboutseverity === "error"is a false positive — that comparison lives inSystemHealthPanel.tsx(log-health findings, string-typed severity), not the HMS path which correctly switches on the numeric 1–4 scale. Severity6falls into the defaultInfobranch — acceptable for an unrecognized level and out of scope here. Tests. New case inPrintersPageBucketing.test.ts:'classifies PAUSE + uncataloged HMS WITH actions as "error"'pins the H2C scenario (0500_809C+IGNORE_RESUME/PROBLEM_SOLVED_RESUME→ bucketerror). Existing case'classifies FAILED + only unknown HMS as "finished"'(uncataloged WITHOUT actions = noise) stays green — the gate distinguishes the two by action presence. 18/18 frontend tests inPrintersPageBucketing.test.ts+HMSErrorModal.test.tsxgreen. i18n. One new keyhmsErrors.unknownCode, real translation in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), parity check clean. Scope. Frontend-only. No backend change. No DB migration. No new permission. The fix is data-shape agnostic — any future printer whose HMS dictionary diverges from the bundled catalog will now surface actionable faults without a Bambuddy release. - First-layer notification photo no longer shows pre-print calibration state (#1837, reporter MartinNYHC) — On P1S (and any Bambu printer with a long pre-print calibration sequence) the "First Layer Complete" notification fired during PREPARE, not after layer 1 was actually printed — the attached photo showed a lowered bed + parked toolhead + clean plate, because the firmware ticks
layer_numduring homing / auto-bed-leveling / bed-surface scan / nozzle clean before the first real extrusion. Reporter's log timeline made it explicit: print start at 13:54:27, notification fired at 14:10:13 with[SNAPSHOT] Capturing fresh frame,gcode_state: RUNNINGnot seen until 14:44:28 — i.e. the notification went out ~30 minutes before the print actually started. The trigger inmain.py:6044only gated on2 <= layer_num <= 5with no check that the printer was actually printing. Fix. The trigger now requiresstate.state == "RUNNING"ANDstate.mc_print_sub_stage in (None, 0)—0is the "Printing" stage in the canonical BambuSTAGE_NAMESmap (bambu_mqtt.py:376), so the non-zero pre-print sub-stages (1Auto bed leveling,9Scanning bed surface,10Inspecting first layer,13Homing toolhead,14Cleaning nozzle tip, …) all skip.Noneis preserved as a no-opinion fall-through for any firmware that doesn't pushmc_print_sub_stageso unknown-firmware installs keep their existing behaviour._first_layer_notifiedis only set once the gate passes, so calibration-phaselayer_numticks are non-consuming — the next on_layer_change edge after the printer enters real printing fires the notification. The trigger window widens from[2, 5]to[2, 10]so that if calibration consumes severallayer_numslots before RUNNING, the deferred edge still falls inside. Tests. Manual verification via the issue reporter's installation; no new unit tests added (the on_layer_change closure is wired inside an event-handler factory and isn't a unit-testable pure function — would require a substantial fixture rewrite for a one-condition guard that's already covered by integration of the printer-state machine). Scope. Backend-only, single-file change. No DB migration. No new permission. No frontend change. No new i18n key. The window widening doesn't risk firing a stale notification on prints whoselayer_numadvances past 10 during PREPARE — the RUNNING + sub-stage gate ensures the notification only fires when the printer is actually printing, regardless of how many ticks PREPARE consumed. - Multi-nozzle prints no longer collapse all filaments onto one nozzle (#1825, reporter needo37) — The single-active-extruder shortcut added in #851 (for #827) at
threemf_tools.py:354runsbeforethe per-filamentgroup_idmapping, and fires wheneverextruder_nozzle_statsreports exactly one extruder as having a nozzle installed. On the H2D / H2D Pro / X2D (2-nozzle) and H2C (3+-nozzle tool-changer), this field is data-driven from the slicer profile's enumerated nozzle volume types — when an HT-AMS or High-Flow nozzle's type isn't enumerated in the slice's profile (common with asymmetric extruder setups, e.g. HT-AMS feeding the right nozzle on an H2D), the slicer emits e.g.['Standard#1', 'Standard#0']even though the print genuinely uses both extruders.sum(active_extruders) == 1triggered → every filament was force-assigned tophysical_extruder_map[active_idx], the authoritative per-filamentgroup_idwas discarded, and the Filament Mapping panel showed both filaments badged L with the auto-match hard filter (print_scheduler.py_compute_ams_mapping_for_printer~line 1239) blocking the wrong-nozzle tray as "Type not found". Bug is parser-side and model-agnostic — triggers purely on 3MF data shape, not on the attached AMS hardware: regular dual-AMS H2D installs typically slice to['Standard#1', 'Standard#1'](sum==2) and never enter the buggy branch, which is why this bug was invisible on the most common dual-AMS setup. Physical nozzle routing was not affected — the actual extrude path comes from the sliced gcode + the verbatimnozzle_mappingfrom the project_file (#1780), not from this parse — so the bug surfaced as auto-match failure + wrong L/R badge, not wrong-nozzle extrusion. Fix. Gate the single-active shortcut onlen(distinct_group_ids) <= 1fromslice_info.config. The slice_info parse is hoisted above the shortcut check (and reused by Priority 1) so the gate adds zero extra I/O. When the slice contains ≥2 distinct group_ids, the shortcut skips and the existinggroup_id-based Priority 1 mapping runs. The gate only narrows the shortcut path — it can't widen the buggy collapse onto any previously-working slice. The same condition generalizes to H2C and any future N-nozzle printer for free (no nozzle-count branching). Tests. Two new cases inTestExtractNozzleMappingFrom3MF:test_single_active_under_report_with_multi_group_falls_throughpins the #1825 regression (['Standard#1','Standard#0']+ group_ids{0,1}→{1:1, 2:0}not{1:1, 2:1});test_single_active_with_single_group_still_uses_shortcutpreserves the #851 behaviour (same stats + onlygroup_id=0→ shortcut still fires →{1:1, 2:1}). Existingtest_single_active_extruder_maps_all_slotsandtest_two_active_extruders_falls_throughstay green. Suites.pytest -n 30 backend/tests/unit/test_scheduler_ams_mapping.py backend/tests/unit/test_scheduler_filament_deficit.py backend/tests/unit/test_scheduler_filament_override.py backend/tests/unit/test_fallback_archive_mqtt_filament.py backend/tests/integration/test_archives_api.py backend/tests/integration/test_library_api.py272/272 green.ruff check backend/clean. Scope. Backend-only, parse layer. No DB migration. No new permission. No frontend change. The L/R-only badge limitation on 3+-nozzle printers (H2C tool-changer) called out in the report is a separate cosmetic follow-up and not part of this fix. - Assign-spool picker note now visible on mobile (#793 follow-up, reporter EmcetPL) — The original fix for #793 added the spool note as an HTML
title=tooltip on each picker button inAssignSpoolModal.tsx(lines 417 + 492).title=only surfaces on hover, which doesn't exist on touch devices — a phone user tapping a card just selects it, the note never appears. Users who store their tracking ID in the note field were blind on mobile. Fix. Render the note as a small muted truncated line directly under the weight on both the internal-inventory branch and the Spoolman branch:text-[10px] text-bambu-gray/70 mt-1 truncate, kept inside thetruthy &&guard so empty notes don't add a blank row. The existingtitle={spool.note}is preserved on the new<p>element so desktop hover and mobile-browser long-press still surface the full untruncated text for notes that overflow the truncate. Keeps the 2-col mobile grid density unchanged (one extratext-[10px]line is ~12 px), no new state, no popover/modal, no new touch target. Mirrored across both branches per the inventory-parity rule so internal and Spoolman pickers stay shape-equal. Frontendnpm run buildclean.npx vitest run AssignSpoolModal.test.tsx AssignToAmsModal.test.tsx23/23 green. Scope. No backend change. No new permission. No new i18n key (the note text is user-authored, not translatable). - API keys with Manage Library permission can now rename / delete / move library files (#1832, reporter MorganMLGman) —
require_ownership_permissiongates API keys onall_permonly (line 1668) — the comment block at line 1659 says OWN and ALL "both map to the same scope flag" for queue / archives / etc., so checkingall_permis the correct gate. Library deliberately broke that invariant by puttingLIBRARY_UPDATE_ALL/LIBRARY_DELETE_ALLin_APIKEY_DENIED_PERMISSIONSwhile only the OWN variants were allowlisted undercan_manage_library. Net effect: every library curation route (DELETE/library/files/{id}, PUT/library/files/{id}rename, POST/library/files/move) returned403 "API keys cannot be used for administrative operations"for keys withcan_manage_library=True, contradicting the wiki docs that explicitly list "rename and delete your own library entries" under that scope. OnlyPOST /library/files/{id}/sliceworked (it doesn't go throughrequire_ownership_permission). The "ALL stays admin-only because it crosses the user boundary" comment was internally inconsistent: API keys have no per-row ownership identity (user=None), so the route'sfile.created_by_id != user.idownership check wouldAttributeErroron a key acting under OWN anyway — the only path that ever worked wascan_modify_all=True, whichall_permdenial blocked outright. Fix. FoldLIBRARY_UPDATE_ALLandLIBRARY_DELETE_ALLinto_APIKEY_SCOPE_BY_PERMISSIONmapping tocan_manage_library(matching thecan_queueprecedent — bothQUEUE_UPDATE_OWNandQUEUE_UPDATE_ALLmap tocan_queuefor the same per-key-identity reason). Remove both from_APIKEY_DENIED_PERMISSIONS.LIBRARY_PURGEdeliberately stays denied — it bypasses the soft-delete window and is genuinely destructive, the kind of cross-boundary op the denylist exists for. Tests. 5 new cases inTestLibraryPermissionspinning the route-level contract —test_apikey_with_manage_library_can_delete_file,test_apikey_with_manage_library_can_rename_file,test_apikey_with_manage_library_can_move_file,test_apikey_without_manage_library_still_blocked(regression guard that the fix widens the allowed-permission set, not the per-key scope check), andtest_apikey_with_manage_library_still_cannot_purge(LIBRARY_PURGE stays admin-only). The matrix drift-detection intest_auth_apikey_rbac.pyupdated to includeLIBRARY_UPDATE_OWN,LIBRARY_UPDATE_ALL,LIBRARY_DELETE_ALLundercan_manage_libraryand removesLIBRARY_DELETE_ALLfrom_ADMIN_CASES.pytest -n 30 backend/tests/unit backend/tests/integrationgreen (6494). Ruff clean. Scope. No DB migration. No schema change. No frontend change. The wiki entry for API key permissions at/features/api-keys/#available-permissionsnow matches actual behaviour. - Administrators system group self-heals to include every current permission on upgrade — covers
printer_sensor_history:readand every future new permission — Fresh installs bootstrap the Administrators group withALL_PERMISSIONS(every value in thePermissionenum), so a fresh install always has the full set. On upgraded installs,seed_default_groups()inbackend/app/core/database.pypreviously only backfilled the specific permissions explicitly listed in one-off migration blocks (library:purge,archives:purge, the OWN/ALL read-flag split,orca_cloud:auth,pipelines:*, …). Any permission added to the enum without a matching block silently stayed missing on existing admin rows, leaving admins gated out of the feature it controlled. The most recent gap wasprinter_sensor_history:read(Read Printer Sensor History was never granted to upgraded admin groups, so the Sensor History charts read as 403 for admins on installs seeded before that permission existed). Fix. Replaced the per-permission admin backfills with a single sync block: for the Administrators system group, append every value inALL_PERMISSIONSthat isn't already on the row. Additive only — custom permissions added by hand (e.g. plugin permissions, hand-edited rows) are preserved. The legacy admin-only backfills (library:purge/archives:purgeblock, the OWN/ALL read-flag block includingorca_cloud:authand the legacyarchives:read/library:read/queue:readUI gates, and the Administrators branch of the pipeline backfill) are retired since they're subsumed by the sync. Non-admin backfills (Operators / Viewers OWN-tier read flags, Operatorsorca_cloud:auth, pipelines for non-admin groups, MakerWorld +printers:clear_platecross-group adders) are untouched. Tests. Three new cases intest_read_permission_backfill_migration.py:test_administrators_printer_sensor_history_read_backfilled(the exact regression reported),test_administrators_sync_covers_every_current_permission(generic invariant — everyALL_PERMISSIONSvalue lands on Administrators after the sync, catches any future new permission without a one-off test), andtest_administrators_sync_is_additive_only(hand-added custom permissions are preserved). 12/12 backfill-migration tests + 102/102 broader permission tests green; ruff clean. - Slicer Pipelines runs dashboard — native browser
<select>filters replaced with themed dropdowns — The Pipeline / Status / Target filter row on the Print Queue → Pipelines tab now uses a bambu-themedFilterDropdown(button trigger styled like the existing filter chips, floating menu, optgroup-style headers for the Target picker's Specific printer / Printer class sections, hover + selected states with a check mark, closes on outside click and Escape) instead of the browser's native selects, which rendered as washed-out grey strips that fought the rest of the page palette. Same value/onChange contract — no behaviour change, just visuals. Also fixes a react-hooks/exhaustive-deps warning inSlicerPipelinesPanel.tsx: the inlinelist?.pipelines ?? []was returning a fresh empty array on every render, invalidating both downstreamuseMemocaches (target-options computation and filtered pipeline list) on every re-render. Wrapped in its ownuseMemokeyed onlist?.pipelinesso the reference is stable when the data is stable. - HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification) — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned
200 OK. Three independent bugs combined into one user-facing failure. (1) Wrong command shape for resume / stop.hms_resume()andhms_stop()sent the documented-but-not-actually-used{"err": <short>, "param": "reserve", "job_id": <subtask_id>, ...}shape that BambuStudio never produces. Bambu firmware rejects this silently — verified by injecting candidate shapes ondevice/<sn>/requestagainst a live H2D paused on a wrong-plate HMS: theerr-bearing shape held PAUSE → PAUSE for the full window, the plain{"print":{"command":"stop","param":"","sequence_id":"0"}}transitioned PAUSE → FAILED in 1.7s, the same plainresumetransitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, noerr, nojob_id, noparam:"reserve". (2)IGNORE_RESUMEmapped to the wrong command for paused prints. The original mapping dispatchedidle_ignorefor bothIGNORE_RESUMEandNO_REMINDER_NEXT_TIME.idle_ignoreis BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless oferr.hms_ignore()now branches onself.state.gcode_state == "PAUSE": paused → dispatch plainresume(which is what the button actually means on a paused print), running/idle → keepidle_ignorewith thetype=0/1persistence flag.DONT_REMIND_NEXT_TIMEon PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. (3) 64-bithms[]-array faults truncated to a non-matchingerr(#1830 §(1)). The hms[] parser at line 2740 built the short code asf"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}", discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g.0C00_0300_0002_000C, the truncated0C00000Cdoesn't match what the firmware compares against inidle_ignore. NewHMSError.full_codefield carries the canonical hex identifier — 16 charsf"{attr:08X}{code:08X}"for hms[]-sourced faults, 8 charsf"{print_error:08X}"for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoeserror.full_codeback asHmsActionBody.print_errorinstead of recomputing the short code; the schema's pattern relaxes to^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$to accept both lengths. (4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).execute_hms_actionreturned True the moment the publish succeeded, so any of the three bugs above produced200 OKwhile the printer ignored the command and the modal kept popping. The/hms/execute-actionroute now snapshots(gcode_state, print_error, hms_errors count)before dispatch, awaitsHMS_ACTION_ACK_WAIT_SECONDS(default 2.5s, module-level so tests override), and returns502 "Printer did not acknowledge HMS action within 2.5s"if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. Empirical verification. A test harness ondevice/0948BB540200427/requestconfirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raisesprint_error=0x05008051("Detected build plate is not the same as the Gcode file"), the printer entersgcode_state=PAUSE, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. Tests.test_hms_actions.pyshape assertions rewritten —test_resume_is_plain_no_err_no_job_id,test_stop_is_plain_no_err_no_job_id,test_ignore_resume_dispatches_resume_when_print_paused,test_ignore_resume_uses_idle_ignore_when_not_paused,test_dont_remind_dispatches_resume_when_paused,test_dont_remind_uses_idle_ignore_type_one_when_not_paused,test_idle_ignore_accepts_16_char_full_code. NewTestHMSFullCodeclass intest_bambu_mqtt.pypins the parser contract —test_hms_array_path_populates_16_char_full_code,test_print_error_path_populates_8_char_full_code,test_hms_array_catalog_lookup_tries_16_char_first,test_hms_array_catalog_falls_back_to_8_char. New integration cases intest_printers_api.py—test_execute_hms_action_no_printer_ack_returns_502,test_execute_hms_action_accepts_16_char_full_code. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between).pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.pygreen (509 + 181).ruff checkclean. Frontendnpm run buildclean. Scope. No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existinghmsErrors.actionFailedstring, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". TheHMSError.full_codefield defaults to""so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's||fallback.
Added
- Slicer Pipelines — multi-copy batches, class targeting, fanout strategies, runs dashboard, retry-failed, live WS updates (#1425 PR C — completes the v3 design) — The PR A/B drop turned slice-modal preset bundles into one-click dispatches with a pinned target printer. PR C closes the original issue with full production-batch semantics: an operator picks a saved pipeline, types in a number of copies, and Bambuddy slices once and distributes the prints across a fleet according to the pipeline's chosen fanout strategy. The runs dashboard surfaces every active and historical run with filters, per-row expandable per-copy status, cancel-in-flight, and retry-failed-copies. WebSocket pushes keep the dashboard and the in-Settings "Last run" chip live without polling. Backend.
PipelineRunCreateRequest.copies(Pydanticge=1, le=1000) replaces the implicit 1 from PR B; the orchestration loop creates onePipelineJobrow per copy.SlicerPipelineUpdateacceptstarget_kind(specific_printer/printer_class),target_model_class(Bambu model code: A1 / A1 Mini / P1P / P1S / P2S / X1 / X1C / X1E / H2D / H2D Pro / H2C / X2D), andfanout_strategy(max_parallel/round_robin/fill_one_first). A newpipeline_max_copiessetting (default 50, Pydanticge=1, le=1000) gates the copies input in the Run-with-pipeline modal and is enforced again atPOST /runtime so an API caller can't bypass the cap. PR C also addsPipelineRun.parent_run_id(nullable FK to itself, ON DELETE SET NULL) so retry runs link back to the run whose failed copies they re-attempt. Eligibility for class targeting. The matcher inservices/pipeline_eligibility.pynow branches onpipeline.target_kind: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates everyPrinterwhosemodelmatchespipeline.target_model_class, runs the per-printer slot-by-slot check for each via astatus_lookupclosure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-levelprinter_reports: list[PerPrinterReport]withokderived asanyacross the candidates. New issue kinds:no_class_matches(the install has zero printers in the chosen model class) andclass_not_set(target_kind isprinter_classbut no model was picked). The lenient-policy story is the same — operators canRun anywaypast blocking issues, andPipelineRun.eligibility_overriddenis set so the audit trail shows it. Orchestration + fanout. A new_pick_assignments(pipeline, copies)helper returns[(printer_id_or_None, target_model_or_None), …]of length copies per the picked strategy.max_parallelsetstarget_model=pipeline.target_model_classon every queue item and leavesprinter_id=None— the existing print scheduler's model-based dispatch picks any idle matching printer per item; the result is that multiple printers grab work in parallel without any new scheduler code.round_robinenumerates eligible printers (is_active=True, model matches) ordered by id and assigns copyitoeligible[i % len(eligible)]— each item gets a fixedprinter_id, the wear distributes evenly.fill_one_firstpins every copy toeligible[0]so a one-printer fleet stays one-printer even when others come online mid-run; the documented trade-off is that a printer failure freezes the queue at that printer until the operator intervenes. All three flows reuse the same slice-once path; the slice runs throughslice_dispatch.enqueueexactly as PR B did so the persistent progress toast renders end-to-end for batches just like single-copy runs. Routes.GET /pipeline-runs?limit&offset&pipeline_id&statusis the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status.POST /pipeline-runs/{id}/retry-failedcounts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a freshPipelineRunCreateRequestwithcopies=that countandforce=True(operator already accepted eligibility on the parent), routes it through the existingrun_pipelinehandler, and stampsparent_run_idon the result. Returns 400 when the parent's source or pipeline was deleted, or when there are no failed copies to retry.POST /pipeline-runs/{id}/cancelextends PR B's cancel to cascade across N queue entries — only the ones still inpending/queuedare touched so in-flight prints continue on the printer (operator must Stop on the machine). WebSocket. Newpipeline_run_updatedevent type carries the full materialisedPipelineRunResponseand fires on every state transition (queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled). Per-user routing viaws_manager.broadcast_to_user(run.created_by, …)so each operator sees their own runs without cross-user noise; auth-disabled installs broadcast to all connections (PR B's pattern). The frontend'suseWebSocketswitch handles it by invalidating both['pipeline-runs-all'](the dashboard) and['pipeline-runs', pipeline_id](the per-pipeline "Last run" chip in Settings). The dashboard still polls every 15 s as a belt-and-suspenders for missed messages. Run status roll-up. A new_roll_up_run_statusfunction computes the run-level status from the per-job statuses at read time: all-completed →completed, any in-flight →in_progress, some completed + some failed → the newpartial_failurestatus (this is what gets the Retry-failed button), all failed →failed. The persisted snapshot is still written on terminal transitions for the dashboard's status filter to remain useful.copies_completed/_failed/_cancelled/_in_progresscounts ride on the response so per-row "1/3 · 2 failed" summaries don't need a second query. Frontend. The Settings → Workflow → Pipelines pipeline editor grows three new controls in the edit form: a radio fortarget_kind(Specific printer / Printer class), a model-class picker filtered to the models present on at least one installedPrinterrow (so users can't pick "H2C" if they only have X1Cs), and a fanout-strategy radio with the three options labelled with their use cases. The read-only row reflects class targeting with a "X1C · Round robin" line in place of the printer name.RunWithPipelineModalgrows a number input for copies bounded bysettings.pipeline_max_copies, accepts class-targeted pipelines (the "Apply pipeline" button is enabled when the pipeline has either a pinned printer OR a class target), and the pipeline-list row shows "Any X1C" instead of a printer name for class pipelines. The "Run pipeline" Setting → Workflow → Queue & Dispatch sub-tab gets a new "Slicer Pipeline limits" card with the max-copies input (bounded 1–1000 client-side, server enforces the same). New dashboard page at/pipelines/runs(sidebar entry under Print Queue, gated onpipelines:read). Lists every run across every pipeline with two dropdown filters (pipeline + persisted snapshot status) and pagination at 25 per page. Each row shows pipeline name, status chip (partial_failureis amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing eachPipelineJob's assigned printer + status + error message. In-flight runs get a Cancel button; partial-failure / failed runs get a Retry-failed button. i18n. ~43 new keys acrossnav.pipelineRuns,pipelineRuns.*(title / filters / pagination / job-status chips / toasts),settings.pipelines.field.*(targetKind / fanout / class),settings.pipelines.runs.status.partial_failure,settings.pipelineLimits.*,library.runWithPipeline.*(copies / copiesHint / classTarget / issue.noClassMatches / issue.classNotSet), andcommon.previous/common.next— translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5516 leaves per locale, no English fallback.Copies/{{n}} copies/max {{n}}added to the French + Italian cognate allowlists where they're genuine. Tests. Six new backend cases intest_pipeline_runs_api.pycovering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequentialcopy_index, class eligibility with two X1C candidates returns a 2-entryprinter_reportsarray, class eligibility with no matching printers in install returnsno_class_matches, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stampsparent_run_id. Plus the existing 16 PR A/B cases were lightly updated whereclass_not_setis now a valid no-target signal alongsideprinter_not_set. Five new frontend cases inPipelineRunsPage.test.tsxpin the dashboard's empty state, list rendering, Cancel button on in-flight runs, Retry-failed button on partial-failure runs, and per-row expand to show jobs. Three updated frontend cases (RunWithPipelineModal.test.tsx) assert the new four-arg signature onrunPipeline(pipelineId, source, force, copies). One updatedSettingsPage.test.tsxsidebar-order test reflects the newpipelineRunsnav entry betweenqueueandprojects. Suites.pytest -n 30 backend/tests/6539/6539 green;npx vitest run2284/2284 green (173 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. Scope. PR C closes the v3 design — no further pipeline PRs are queued. The existing print scheduler's model-based dispatch (PrintQueueItem.target_model+target_location+required_filament_types) is the only thing that makes class targeting actually distribute work; PR C just plugs into it. Thefill_one_firststrategy's "one printer fails, queue stalls" trade-off is documented in the editor's option-row hover-hint and in the orchestrator code comment — it's the correct behaviour for "I want one printer to finish a batch end-to-end" and the wrong behaviour for "I want resilience"; the right strategy for resilience ismax_parallel. Cross-printer-class pipelines (e.g. one pipeline targeting "any X1C OR P1S") remain out of scope — make two pipelines, one per class. - Slicer Pipelines — Archive entry point + progress toast for pipeline-driven slicing (#1425 PR B follow-up) — Two real gaps from the PR B drop. (1) The Run-with-pipeline button only existed in the file manager — operators who keep their working files in archives had to copy them out to the library to use a pipeline. (2) Triggering a slice via a pipeline produced a silent multi-second-to-minute wait — the manual SliceModal flow has the sticky
Slicing X — Generating G-code 75%persistent toast, the pipeline path went throughasyncio.create_taskdirectly and never registered withSliceJobTracker. Fix. (1)POST /slicer-pipelines/{id}/check-eligibilityandPOST /slicer-pipelines/{id}/runnow acceptsource_archive_idas an alternative tosource_library_file_id(XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via_resolve_sourcewhich readsarchive.source_3mf_pathwith a fallback toarchive.file_path.PipelineRun.source_archive_idis a new nullable FK column (Postgres + SQLiteALTER TABLEinrun_migrations— idempotent via_safe_execute).PipelineRunResponseechoes the field. ArchiveCard's context menu picks up aRun with pipelineitem alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated onuseSlicerApi+pipelines:run. Path-safety:Path(base_dir) / archive.source_3mf_pathcarries aSEC-PATH-OKmarker citing the upload-time validator at_resolve_source_3mf_path(same comment style asroutes/archives.py:3955); theLibraryFile.file_pathsite gets the same treatment. (2) The pipeline orchestrator is now theruncallable of aslice_dispatch.enqueuecall — the same dispatcher the manualSliceModalflow uses — instead of a bareasyncio.create_task. The SliceJob's lifecycle (pending → running → completed/failed) drives the existing progress toast end to end: same persistent toast, sameGenerating G-code 75%weave from the sidecar's--pipechannel, same auto-replace with a transient success/error toast on terminal.PipelineRun.slice_job_idis set on the run row before the route returns 202, so the frontend can calluseSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename)fromRunWithPipelineModal'srunMutation.onSuccess— same one-call surface thatSliceModal's slice mutation already uses. (3)RunWithPipelineModal'ssourceprop is now{kind: 'libraryFile' | 'archive', id, filename}(mirrorsSliceModal.SliceSource);api.checkPipelineEligibility+api.runPipelinetake a discriminated-union source argument and route to the right backend field.PipelineRunTS type growssource_archive_id. Tests. Three new backend cases intest_pipeline_runs_api.py— archive-source happy path (creates a PrintArchive row + on-disk file, posts withsource_archive_id, verifies the response carriessource_archive_id+slice_job_idfrom a stubbedslice_dispatch.enqueue), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patchbackend.app.services.slice_dispatch.slice_dispatch.enqueue(the new mock target) instead of the removed_run_pipeline_orchestrationhelper, and the run-happy-path now assertsslice_job_id == 9001arrives on the response. One new frontend case inRunWithPipelineModal.test.tsxpins the archive flow end to end (checkPipelineEligibilitycalled with{kind: 'archive', id: 7}, thenrunPipelinewith the same). The existing fast/slow path tests were updated to wrap inSliceJobTrackerProvider(the newuseSliceJobTrackerhook requires it) and to assert the new discriminated-union source argument. Suites.pytest -n 30 backend/tests/6533/6533 green;npx vitest run2279/2279 green (172 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. Scope. No new i18n keys — both fixes reuse the existing PR B keys. No new permission. The archive flow only branches at the source-resolution layer; everything downstream (eligibility, slice, queue dispatch) is the same code path the library flow uses. PR C scope (multi-copy + class targeting + fanout) is unchanged. - Slicer Pipelines — Run a pipeline on a file with one click (#1425 PR B) — PR A landed the bundle (save & apply preset slots in the SliceModal). PR B turns that bundle into an actual one-click dispatcher: file-manager rows now carry a
Run with pipeline ▾button that slices the source through the pipeline's pinned printer/process/filament/bed-type combo and enqueues the print on the pipeline's pinned target printer. Scope. Single-target dispatch —target_kind='specific_printer'only. Multi-copy batch + class targeting + fanout strategies are PR C; the schema columns are already in place from PR A so PR C is code-only. Backend. Two new SQLAlchemy models —PipelineRun(one row per Run-pipeline click, carries the slice_job + sliced_library_file ids + snapshot status) andPipelineJob(one row per copy; PR B always 1, PR C variable). Soft-link to slicer_pipelines viaondelete='SET NULL'so run history survives a pipeline delete; same for source_library_file.statuson the run is a persisted snapshot that gets terminal transitions written (slice failure, cancel, completion); in-flight reads roll up the live state of the linked queue entry via_compute_run_status— that keeps the status accurate (pending → printing → completed) without a background watcher writing on every queue tick. Eligibility matcher atservices/pipeline_eligibility.py— given a pipeline + the livePrinterStatefromprinter_manager.get_status, returns a structured report with typed issues:printer_not_set,printer_not_found,printer_disabled(fromPrinter.is_activeshipped with #1476),printer_offline,filament_type_mismatch,filament_color_mismatch,ams_slot_missing,filament_unverified(cloud/standard tier presets can't be statically read here; surface as info, not a block). Canonical filament-type map mirrorsprint_scheduler._canonical_filament_typesoPLA Basic/PLA Matte/ etc. all collapse toPLAfor the type comparison; colour normalises to six-hex-digit lowercase. Eligibility is lenient with confirmation — the report drives the frontend confirmation modal, but the user canRun anyway(setseligibility_overridden=Trueon the run row so the audit trail shows which runs bypassed pre-flight). Routes. Two new routers —pipeline_run_create_routermounted under/slicer-pipelines(POST/{id}/check-eligibility, POST/{id}/run, GET/{id}/runs?limit=N) andpipeline_run_routerat/pipeline-runs(GET/{id}, POST/{id}/cancel).POST /runreturns 202 with the run shape; orchestration happens in a fire-and-forgetasyncio.create_taskthat opens its own DB session (the request's session is closed by the time it runs) and walks: status='slicing' →slice_and_persistwith the pipeline'sSliceRequest→ on successstatus='dispatching'+ insertPrintQueueItemwithprinter_id=target_printer_id, library_file_id=sliced_library_file_id. The existing scheduler picks the queue entry up on its next tick.POST /runwith eligibility issues and noforcereturns 409 with the report insidedetailso the frontend can render the same confirmation modal it would for an explicit pre-flight;force=truebypasses the 409 but a missingtarget_printer_idstill 400s (defence in depth — the UI can't enqueue the print without a target).POST /cancelis idempotent on terminal states and cascades to the linked queue entry when its status is stillpending/queued(in-flight prints continue — operator must Stop on the printer itself). SlicerPipeline.target_kind / target_printer_id become writable viaPUT /slicer-pipelines/{id}— the schema accepts both fields, the route treatstarget_printer_id=0as "clear" (the empty-<option>HTML coercion) and a positive value as a literal FK. Frontend. SlicerPipelinesPanel in Settings → Workflow → Pipelines extends its edit form with a target-printer<select>(populated fromapi.getPrinters()); pipelines without a target render an amber "Set a target printer to run this" hint in the row + a "Set a target printer before running this pipeline" warning at the bottom. Last-run summary appears inline per row — smallLast run: completed · 27/06/2026, 14:23line driven byGET /slicer-pipelines/{id}/runs?limit=1with a 15 srefetchIntervalso the chip ticks while a run is in flight.RunStatusBadgecolour-codes the seven states. New componentRunWithPipelineModalatcomponents/RunWithPipelineModal.tsx— two-step dialog: step 1 lists the user's pipelines (each row shows the pinned target printer; pipelines without a target are disabled with aNo target printer sethint), step 2 is the eligibility confirmation. Fast path: ok=true skips step 2 entirely and fires the run straight from the pipeline pick. Slow path: shows per-issue text via theIssueTextmapper — eg.Filament slot 1: expected PLA, AMS has PETGforfilament_type_mismatch,AMS slot 2 not available on this printerforams_slot_missing— thenRun anywayposts withforce=true. FileManagerPage integration: FileCard's action menu picks up aRun with pipelineentry (gated on the newpipelines:runpermission); list-view rows get a matching inline Play-icon button so list users have the same entry point as card users. Both flow into the samesetRunPipelineFile(file)state which renders the modal. The action is only offered on slice-eligible files (3MF / STL / STEP) and only whenuse_slicer_apiis on — matches the existing Slice button gating, since a non-slice-eligible file can't reach the slice step in any case. Frontend types: client.ts growsPipelineEligibilityReport,PipelineRun,PipelineJob,PipelineRunListResponse, plus six newapi.*methods (checkPipelineEligibility,runPipeline,listPipelineRuns,getPipelineRun,cancelPipelineRun, and the updatedupdateSlicerPipelinewhich now acceptstarget_kind+target_printer_id). ThePermissionunion also getspipelines:read | pipelines:write | pipelines:run— these were on the backend Permission enum from PR A but had been missed in the frontend union (caught when TS rejectedhasPermission('pipelines:run')). i18n. ~36 new keys acrosslibrary.runWithPipeline.*(modal title / confirm / source-hint / pipeline-hint / target-hint / Run-anyway / 8 issue-kind strings / 2 toast / empty-state / no-target hint) andsettings.pipelines.field.targetPrinter/field.noTarget/noTargetHint/noTargetWarning/runs.lastRun+ sevenruns.status.*strings — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5473 leaves per locale, no English fallback. The stringslicingwas added toIT_COGNATES(genuine cognate — same word in Italian). Tests. 13 new backend integration cases intest_pipeline_runs_api.pycovering PUT target write + clear-via-0 + check-eligibility (printer_not_set / printer_disabled cascade with offline / fully-clear AMS-match) + run flow (409 on issues+!force / 400 on force+!target / 202 on clean path with creation of run+job) + list/get 404s + cancel (404 / marks queued / idempotent on terminal). Slicing itself is stubbed viapatch(..._run_pipeline_orchestration)so CI runs without a live sidecar. 4 new vitest cases inRunWithPipelineModal.test.tsxpin the modal's two-step flow: empty state, disabled pipeline-without-target, fast-path (issues empty → modal closes immediately afterrunPipeline(..., false)), slow-path (issues shown →Run anywayposts withforce=true). Suites.pytest -n 30 backend/tests/6530/6530 green;npx vitest run2278/2278 green (172 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. What's out of scope for PR B. Multi-copy (copies > 1), class targeting (target_kind='printer_class'), fanout strategies, the Pipeline Runs dashboard — all PR C. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice step inside the pipeline run fails the same way the standalone slice route does, the run rolls up tostatus='failed'with the slicer's error string inerror_message. The print queue's existing AMS / filament check + the printer-side error path remain authoritative for what actually happens at the machine — pipeline eligibility is a pre-flight, not a hard guard. - Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A, requested by TheUltimateC0der) — Top feature in the first sponsor vote. The SliceModal forces the user to pick four slots every time: printer / process / filament(s) / bed type. For fleet production that's tedious and error-prone — operators want a named "Production PLA" bundle they can apply with one click on every file and every printer. PR A scope. Definitions only. The new model
slicer_pipelinesmaterialises the bundle plus future-PR columns (target_kind,target_printer_id,target_model_class,fanout_strategy) so PR B (single-target dispatch) and PR C (multi-copy batch with capability-matched fanout) are code-only, not migrations. The bundle is independently useful in PR A as an ergonomic improvement: pipelines are picked from the SliceModal, applied to the four slots, then sliced through the existing flow. No new dispatch behaviour yet. Backend. ModelSlicerPipeline(models/slicer_pipeline.py), Pydantic schemasSlicerPipelineCreate/Update/Responsereusing the existingPresetRefshape fromschemas/slicer.py, CRUD routes at/api/v1/slicer-pipelines/(GET list,POST create,GET/PUT/DELETE by id). Soft-delete viais_deletedso PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first byid DESC(more reliable thancreated_atunder back-to-back inserts whose DateTime precision can tie). Routes use explicitawait db.commit()aft
Changelog truncated — see the full CHANGELOG.md for the complete list.