Note
This is a daily beta build (2026-06-27). 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
- 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. - 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()after the mutation (matches theroutes/library.pypattern) so the response shape returns the committed row. Permissions. Three newPermissionvalues:PIPELINES_READ,PIPELINES_WRITE,PIPELINES_RUN. PR A only consumes the first two;RUNis defined now so PR C doesn't need to backfill.AdministratorsandOperatorsget all three;ViewersgetPIPELINES_READ. A backfill block inseed_default_groups()adds them to existing groups on upgrade (mirrors thelibrary:purge/archives:purgepattern from earlier). All three are added to_APIKEY_DENIED_PERMISSIONSso they fail closed for any API-key surface — PR B / PR C may movePIPELINES_RUNontocan_queueonce the dispatch lands. Frontend. Settings → Workflow tab is split into two sub-tabs mirroring the Authentication tab's pattern: Queue & Dispatch (the existing Workflow content) and Pipelines (the new manager). The Workflow sidebar entry stays single — no expandable submenu — and the sub-tab choice is reflected in the URL (?tab=queue&sub=pipelines) for deep-linking. SlicerPipelinesPanel lists saved pipelines with inline rename, soft-delete, and a stale-preset warning when a referenced preset no longer resolves against the unified-presets listing (e.g. anorca_cloudpreset deleted in OrcaSlicer; the pipeline still saves, the warning prompts a re-save from the SliceModal). Full pipeline creation lives in the SliceModal rather than Settings — the user has already done the four-slot work there. The modal grows anApply pipeline ▾dropdown plus aSave as pipelinebutton above the existing preset dropdowns. Apply fills all four slot states (printerPreset,processPreset,bedType,filamentPresets[]); the filament list right-pads from current state so a pipeline with fewer entries than the current source's slot count keeps the existing tail (lets the same pipeline apply across single-color and multi-color files). Save captures the four-slot picks under an inline-named pipeline. Stale-preset warning shows on the Settings list, not blocking apply, so an old pipeline with a one-deleted-preset can still be re-applied and re-saved with the new pick. i18n. ~30 new keys acrosssettings.pipelines.*andslice.pipelines.*plussettings.tabs.queueDispatch/queuePipelines, translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5437 leaves per locale, no English fallback.Pipeline/Pipelines/Filament {{n}}added toIDENTICAL_TO_EN_ALLOWEDfor the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). Tests. Backend: 11 integration cases intest_slicer_pipelines_api.pycovering empty list, create + round-trip, get-by-id, partial PUT preserves untouched fields, filament list replaces wholesale, soft-delete hides from list + GET-by-id, 404s on missing, schema rejection of empty filament list + invalid PresetRef source, newest-first ordering. Frontend: 3 new SliceModal cases (apply-pipeline dropdown disabled-empty / apply-sets-state / save-as-pipeline-round-trip) plus 2 SettingsPage cases (sub-tab nav renders + Pipelines deep-link). Existing SliceModal tests adjusted via apresetSelects()helper that filters out the new Apply-pipeline combobox so historicalselects[0]indexing into printer/process/filament remains stable. Suites.pytest -n 30 backend/tests/6517/6517 green;npx vitest run2274/2274 green (171 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. Scope. No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (thetarget_kind='specific_printer'path), PR C adds multi-copy batch with capability matching + the three fanout strategies (max_parallel/fill_one_first/round_robin). TheRun pipelineaction mentioned in the original issue is PR B/C and intentionally not exposed in this drop. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice fails, the pipeline doesn't pre-validate. - Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up) —
#1625(Unify print dispatch through the scheduler) moved every print's FTP push to the printer into the server-side scheduler tick, which means the user's click no longer carries an XHR withprogressevents — the old browser-side upload modal had nothing to show because there was no browser-side upload anymore. Users only saw the queue item flip to "active" with no visibility into the multi-second to multi-minute FTP push + the H2D/H2D Pro 80–210 sproject_filedigestion window before the printer actually started extruding. Fix. The legacy bg-dispatch toast rendering from0b43ac0d:frontend/src/contexts/ToastContext.tsxlines 510–650 is ported back in place verbatim — same DOM tree, same Tailwind classes, sameformatFileSizebytes line, same uppercase status chip, same collapse chevron, sameawaitingPrinterderivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacybackground-dispatchaggregate event. Materialization only on actual upload start. The toast appears when the FTP push to the printer starts (queue_item_uploading), NOT onPOST /queue— a draft that emitted at queue-add time made the toast jump to "Dispatched" before any upload had happened. Four backend lifecycle WS events drive the rendering:queue_item_uploading(start of FTP, carriesprinter_name+total_bytesfromfile_path.stat().st_size),queue_item_upload_progress(throttled byte-level updates — first call always emits + emit when ≥200 ms elapsed OR ≥256 KB transferred since last emit, plus always emit atbytes_transferred >= total_bytes; this matches the legacybackground_dispatch.py:614-615gates 1:1 so the bar feels identical on small AND large files; a single shared_UploadProgressBridgeinstance bridges from the FTP executor thread back to the asyncio loop viarun_coroutine_threadsafe),queue_item_acked(watchdog confirmed printer transitioned out ofpre_state),queue_item_failed(any error, with areasonkey the toast looks up asdispatchToast.failed.{reason}for upload-vs-start-command differentiation, generic fallback). Noqueue_item_dispatchedevent — the legacy bg-dispatch path keptstatus='processing'from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely fromupload_progress_pct >= 99.9(the legacyuploadDoneAwaitingPrintertrick at line 568-572). An explicitdispatchedevent would push the status chip out ofPROCESSINGprematurely — which is exactly what the first screenshot-iteration showed. Per-user routing. Newws_manager.broadcast_to_user(user_id, msg)filters connections bywebsocket.state.bambuddy_principal_user_id— resolved once at WS connect time via aselect(User.id).where(User.username == principal)lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs routeuser_id=Noneto all connections, matching the legacy single-user toast behaviour. The watchdog success path receivescreated_by_idvia a new kwarg so the static_watchdog_print_startmethod can still emit theackedevent without re-fetching the queue item. Backend. ~110 LOC across 3 files:core/websocket.py(broadcast_to_user+ four event helpers,bambuddy_principal_user_idfilter on each connection),api/routes/websocket.py(principal username → User.id resolve at connect, stashed onwebsocket.state.bambuddy_principal_user_id),services/print_scheduler.py(_UploadProgressBridgethread-safe throttle class,queue_item_uploadingemitted before FTP withprinter.name,progress_callback=plumbed into both thewith_ftp_retryand directupload_file_asyncbranches via**kwargs,queue_item_failedat the FTP-fail spot, watchdog success path emitsackedon both Phase A and Phase B exits). Frontend. Rendering ported in place tocontexts/ToastContext.tsx(dispatchDatafield onToast, ingestuseEffectmapping the fourbambuddy:dispatch-toastevent types to legacyDispatchToastJobshape, terminal-state auto-dismissuseEffect; legacy rendering block reused 1:1 minus the cancel button — BG dispatch's/background-dispatch/{id}DELETE doesn't exist in the scheduler model and adding it is out of scope).hooks/useWebSocket.tsforwards the fourqueue_item_*cases viawindow.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail })), matching the existingplate-not-empty/unknown-tagpatterns. i18n. 11 keys × 11 locales underdispatchToast(de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW):untitled/startingPrints/progressSummary(header{{complete}}/{{total}} complete • Processing: {{processing}}—Dispatched: Xfrom the legacy summary was dropped because the scheduler has no pre-upload "dispatched" state) /expandDetails/collapseDetails/awaitingPrinter/status.{processing|completed|failed}/failed.{generic|upload_failed|start_command_failed}/dismiss. Locale parity check 5401 leaves per locale, no English fallback. Tests. Backendtest_ws_broadcast_to_user.pypins the routing contract (filter by user_id, fan-out on None, payload shape withprinter_nameforuploading, server-side pct compute including divide-by-zero);test_upload_progress_bridge.pypins the throttle (first call always emits, 256 KB byte gate honoured even when time gate would skip, completion always emits, no-op on zero bytes, no-op when no loop). Frontend__tests__/contexts/DispatchToastContext.test.tsxpins the materialization-on-uploading invariant (stray progress / acked event before anyuploadingdoes NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip stayingPROCESSINGthrough the whole upload (regression guard for the screenshot-reported "Dispatched: 1 immediately" bug), 3.5 s auto-dismiss when terminal, concurrent jobs sharing one wrapper, collapse + dismiss buttons. Suites.pytest -n 30 backend/tests/unit/test_ws_broadcast_to_user.py backend/tests/unit/test_upload_progress_bridge.py backend/tests/integration/test_print_queue_api.pygreen;vitest run src/__tests__/contexts/49/49 green;ruff check backend/clean;npm run buildclean. Scope. No DB migration. No new permission. Thebambuddy:dispatch-toastwindow event is internal to the frontend bundle, not a public hook — third-party plugins should not subscribe to it. The 0–30 s scheduler-tick pickup wait is unchanged; this fix only addresses visibility of what happens once the upload starts. Tiny test files that upload in a single FTP chunk will still jump straight to "Awaiting printer…" because the first-and-last progress callback is one and the same event — same edge as the legacy bg-dispatch behaviour on sub-256 KB files. - Sponsor-prompt thresholds lowered to fire for typical new installs — The in-app sponsor toast in
useSponsorPromptwas calibrated for power users: the lowest print milestone was100, the lowest archive milestone was50, the lowest filament-cost milestone was100. A check of recent Matomo data showed the toast firing very rarely (?from=app-toast-prints-100= 4 visits,?from=app-toast-archives-50= 3 visits in a 7-day window) — most installs simply never reach those bars, especially with the install base ~doubling since March. Calibration widened:PRINT_MILESTONESnow(10, 25, 100, 500, 1000, 2500, 5000),ARCHIVE_MILESTONESnow(5, 10, 50, 250, 1000),COST_MILESTONESnow(25, 50, 100, 500, 1000). The existing priority order (anniversary → prints → archives → cost → version-update) and 14-day cross-family cooldown are unchanged, so a user still sees at most one toast per fortnight. The "fire highest unseen milestone" logic in_check_prints/_check_archives/_check_costis unchanged — a user already at 200 prints still getsprints-100first (they crossed it earlier in the timeline). The existing toast copy uses{count}/{total}interpolation in all 11 locales — no new i18n keys needed; "You've completed 10 prints with Bambuddy" reads as fluently as the 100 variant. Tests.test_failed_prints_dont_countandtest_fires_when_cost_sum_crosses_100rebalanced (5 completed prints instead of 50; 5 prints × 21 cost-each instead of 30 × 3.5) so they still test "below the lowest threshold" semantics with the new lower bars. Newtest_fires_at_lowest_thresholdpinsprints-10as the new minimum trigger.pytest -n 30 backend/tests/unit/test_sponsor_prompt_service.py backend/tests/integration/test_sponsor_prompt_api.pygreen (25/25).ruff checkclean. Scope. No DB migration. No new permission. No frontend change. The change is opt-in by virtue of the existing toast cooldown — installs that already saw a recent toast see no behaviour change; installs that never crossed the old 100-print bar become eligible the first time they pass 10 prints (subject to the 14-day cooldown after any other family fires first). - Autologin via SSO + disable local login (#1589, requested by einstux) — Two related additions for operators who run their own OIDC SSO and want exactly one auth path. Global setting
local_login_enabled(default True, preserves pre-#1589 behaviour) — when False,POST /api/v1/auth/loginrejects username + password credentials with HTTP 401 (same wording as wrong-password to avoid leaking "local disabled" to credential-stuffing tools),POST /api/v1/auth/forgot-passwordrejects with HTTP 403 (the reset wouldn't grant access anyway), and the LoginPage hides the credentials form + Forgot Password link, leaving only the OIDC provider buttons. Env-var recovery pathBAMBUDDY_LOCAL_LOGIN=true(also accepts1/yes, case-insensitive) bypasses the gate on both routes and flips the reportedlocal_login_enabledflag on/auth/advanced-auth/statusback to True so the LoginPage matches what the route actually accepts — a server admin whose SSO provider is unreachable can recover the install with one env var, no DB editing. LDAP keeps its ownldap_enabledswitch and is not affected by this gate — a delegated directory has its own policy and lockouts and is closer to SSO than to local credentials. Per-OIDC-provideris_autologinflag — when set on an enabled provider, the LoginPage redirects unauthenticated visitors directly to that provider's authorize URL on mount instead of rendering the login form. At most one provider can carry the flag at a time (app-layer invariant enforced in both create and update routes: setting it on one provider clears it on every other). Two-layer fallback for autologin — the LoginPage racesgetOIDCAuthorizeUrlagainst a 5-second timeout; on success the browser navigates to the IdP, on timeout or fetch error the redirect is aborted, the page renders normally, and a sticky amber banner explains "Autologin to failed, pick a provider". A bookmarkable/login?fallback=localquery param always skips the autologin redirect — paired with theBAMBUDDY_LOCAL_LOGIN=trueenv-var on the server, this is the documented "SSO is broken, let me back in" path. Two safety refusals on disabling local login: settings PUT returns HTTP 400 ("no OIDC provider is enabled") when no enabled OIDC provider exists, and HTTP 400 ("you would lock yourself out") when the calling admin has noUserOIDCLinkrow. Either failure mode would otherwise lock everyone out of the install. Backend.local_login_enabled: bool = Trueadded toAppSettings+AppSettingsUpdateschemas and to the_BOOL_KEYSallowlist inroutes/settings.py.OIDCProvider.is_autologin: boolcolumn via_safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...)— SQLiteDEFAULT 0, PostgresDEFAULT falseper the project's existing boolean-migration pattern. NewOIDCProviderResponse.is_autologinfield threaded throughfrom_attributes=True._local_login_env_bypass()reads at call time (not import time) so tests can monkeypatch the env between cases./auth/advanced-auth/statusextended withlocal_login_enabledandautologin_provider_idso the LoginPage decides UI in one query —autologin_provider_idfilters onis_enabled=True AND is_autologin=Trueso disabling a provider stops the autologin redirect even if the flag stays set. Frontend.LoginPage.tsxadds the autologinuseEffect(skips redirect when?fallback=localis in the URL, when an OIDC token is already in the fragment, or when anoidc_errorquery param is present from a previous round trip), the autologin-failed banner, and a "Local sign-in disabled" notice that replaces the form when the flag is off.SettingsPage.tsxexposes thelocal_login_enabledtoggle in the OIDC tab card above the existing provider list;OIDCProviderSettings.tsxadds the per-provider Autologin toggle in the form's flags row.AppSettings,AdvancedAuthStatus,OIDCProvider, andOIDCProviderCreateTypeScript interfaces extended to match. i18n. 6 new keys (login.autologinFailed,login.localDisabledNotice,settings.localLogin.disable,settings.localLogin.disableHint,settings.oidc.form.autologin,settings.oidc.form.autologinDesc) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5375 leaves per locale, no English fallback. Tests. 6 new integration cases intest_local_login_gate.py: login default allows local, login rejected when flag off and no env bypass (with generic 401 wording asserted), env-var bypasses the gate, forgot-password rejected when flag off, status surfaces both new fields, env bypass flips the reported flag back to True. Full nearby suites green:test_auth_api.py44/44,test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py159/159. Backendruff checkclean. Frontendnpm run buildclean. Scope. No new permission — the existingSETTINGS_UPDATEpermission gates the toggle. The migration is a singleADD COLUMNper backend; thelocal_login_enabledsetting lives in the existing settings key-value table and needs no migration. Default behaviour is unchanged: fresh installs and upgrades see no difference until an admin explicitly enables the toggle or sets a provider as autologin. - Printer card AMS row: external tray height matches regular AMS slots — On dual-nozzle printers (H2C / H2D) the External card carried an extra
Ext-L/Ext-Rcaption underneath each tray to disambiguate which extruder it fed. That caption added one text line of vertical height to the External card only, so the entire bottom row of the printer card's AMS panel (External alongside AMS-C / HT-A) was visibly taller than the row above it (AMS-A / AMS-B). Fix: the L/R distinction now lives inside the slot's colour circle in place of the 1-based slot index (so the left external tray readsL, the right readsR), and the bottom caption is removed. Single-nozzle externals — a single tray with no left/right distinction — keep the1index. TheFilamentSlotCircleslotNumberprop is widened fromnumbertonumber | stringto carry the L/R label; the two regular-AMS callsites that pass a numeric index keep working unchanged. TheExt-L/Ext-Rstrings are still used as the slot's "location" label in the filament hover card (so context is preserved when hovering for details) — just not as a separate caption on the visible row. Frontendnpm run buildclean. Existing 10FilamentSlotCircletests stay green (the new optional string accept-shape is backward-compatible). - Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE — Two small but load-bearing fixes against the new cam-wall view. (1) Offline tile chip. A disconnected printer (
status.connected === false) was still assignedlivemode byCameraWall.modeByPrinter— it consumed aMax live streamsbudget slot AND rendered the redLIVEchip on top of theWifiOffplaceholder. The allocator now treats!connectedlike off-screen — assignspaused, leaves the live budget intact. The existingCameraTilerendering (WifiOfficon, darkOffchip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. (2) Shared-broadcaster teardown./api/v1/printers/{id}/camera/stopis the unmount cleanup for every camera consumer (CameraTile,EmbeddedCameraViewer, popupCameraPage). It used to unconditionallyshutdown_broadcaster(f"printer-{id}")+ kill every ffmpeg in_active_streamswhose key starts with{printer_id}-. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's<img>errored out and showedNo signaluntil the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown callsunsubscribe(queue), and when the count reaches 0 the broadcaster's own_grace_then_stopwaits_GRACE_SECONDS(5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown./camera/stopwas just a fast-cleanup shortcut for the single-viewer case. Fix. Newget_subscriber_count(key)accessor incamera_fanout.pyexposes the broadcaster'ssubscriber_count(the private list-len already used internally). The/camera/stoproute now readsget_subscriber_count(f"printer-{printer_id}")BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns{"stopped": 0, "skipped": true}early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the naturaliter_subscriber.finally → unsubscribepath, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its/camera/stopPOST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. Tests. Newtest_stop_camera_stream_skips_shutdown_when_subscribers_remainintest_camera_api.pypatchesget_subscriber_countto return 2 and asserts/camera/stopreturns{stopped: 0, skipped: true}, does NOT callshutdown_broadcaster, and does NOT terminate any_active_streamsffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers —get_subscriber_countreturns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Fulltest_camera_api.py43/43 green.ruff check backend/clean. Frontendnpm run buildclean. Scope. No API contract change — the existing{"stopped": int}shape is preserved, the new"skipped"field is additive. No new permission. No DB migration. No i18n change. - Cam Wall: per-tile print/printer status overlay — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle
Status overlayswitches betweenOff,Compact, andFull(defaultFull). Compact paints a colour-coded state chip in the top-left corner —Printing/Paused/Finished/Error— bucketed using the sameclassifyPrinterStatusrules that drive the printer-card badges, withIdledeliberately suppressed so a wall of cold printers stays visually quiet. Full adds a bottom info strip on tiles whose state isPrintingorPaused: the active file'ssubtask_name ?? gcode_file, the rounded progress percent,Layer N/Mwhen both are known, and the remaining time formatted by the existingformatDuration(remaining_time * 60)helper fromutils/date.ts— so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existingfilterKnownHMSErrorsfromHMSErrorModal), the chip flips to the redErrorcolour with alucide-reactAlertTriangleicon inline. The whole overlay layer is gated byconnected— disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. Zero new network cost.CameraWall.tsxalready ranuseQueries({ queryKey: ['printerStatus', id], ... })against every printer for the connected flag; the patch widens theuseMemoto expose the fullPrinterStatuspayload and threadsstate,progress,remaining_time,layer_num,total_layers,subtask_name,gcode_file, and the filtered HMS error count into eachCameraTile— same shared React Query cache thePrinterCardflow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. Settings. Per-user, persisted inlocalStorageundercamWallStatusModealongside the existingcamWallMaxLiveandcamWallSnapshotSeckeys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelledOff/Compact/Full. DefaultFullbecause the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. CameraTile contract. All new props (statusMode,printerState,progress,remainingMin,layerNum,totalLayers,printName,hmsErrorCount) are optional with safe defaults, so the 5 existing vitest cases inCameraTile.test.tsxcontinue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located inCameraTile.tsx(mirrorsPrintersPage.classifyPrinterStatusforRUNNING/PAUSE/FINISH/FAILED) so the tile renders correctly even if called outside the cam-wall scheduler. Temperatures intentionally not surfaced. Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. i18n. 7 new keys underprinters.camWall(layer,timeLeft,statusMode.{off,compact,full},settings.statusOverlay,settings.statusOverlayHint) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existingprinters.status.{printing,paused,finished,error,idle}keys so no new translation work was needed for the bucket vocabulary. Parity scriptcheck-i18n-parity.mjsadds two legitimate-cognate exceptions:Compactfor French (same word) andOfffor Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. Scope. No backend change. No new request. No new permission. No DB migration. The toggle defaults toFull, so installs see the overlay the first time they open Cam Wall — flipping toOffreverts to the original camera-only behaviour. - Cam Wall view on the Printers page — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (
Cards↔Cam wall). Reuses the existing per-printer FTP / RTSPS proxy on/api/v1/printers/{id}/camera/stream, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up toMax live streams(default 4) at any moment — everything else falls back to per-tile snapshot polling against/api/v1/printers/{id}/camera/snapshotat a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. Architecture.frontend/src/components/CameraTile.tsxis the leaf — three modes (live/snapshot/paused), a single<img>element withloading="lazy", anonErrorno-signal fallback, and auseEffectcleanup that POSTs/camera/stop(withkeepalive: true) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same/camera/stopdiscipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer.frontend/src/components/CameraWall.tsxis the scheduler — anIntersectionObserver(threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then auseMemowalks the printer list in sort order and assigns the first N visible tiles tolive, the rest of the visible set tosnapshot, and off-screen tiles topaused. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same['printerStatus', id]React Query cache eachPrinterCardalready populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existingSettings → camera_view_modepreference — opens the floatingEmbeddedCameraViewerwhen set toembedded, otherwise pops the/camera/:idwindow with the saved size/position fromcameraWindowState. Settings. Both knobs are per-user, persisted inlocalStorage(camWallMaxLive,camWallSnapshotSec) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded[1, 16]for max live and[2, 60]seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated oncamera:view; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). i18n. 13 new keys (printers.pageView.cards,printers.pageView.camWall,printers.camWall.{noPrinters,noSignal,live,snap,off,summary},printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. Tests. 5 new vitest cases infrontend/src/__tests__/components/CameraTile.test.tsxcover live URL emission withfps=8, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the/camera/stopPOST firing when the tile transitions out of live. Scope. No backend change. No DB migration. No new permission. The existingEmbeddedCameraVieweris untouched — Cam Wall is purely additive. TheprinterPageViewtoggle defaults tocards, so installs see no behaviour change until a user picks Cam Wall. - AMS drying badge now shows the active cycle's filament + target temperature — During an active drying cycle the AMS card on the printers page renders
Drying · PETG 65°C · 11h 35m left(the loaded-filament line under the slots) instead of the bareDrying · 11h 35m left. Bambu's per-tick AMS push only carries thedry_timecountdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them.BambuMQTTClient.send_drying_command(mode=1, ...)now caches{ams_id: {filament, temp}}on the client; the cache is cleared onmode=0and on the per-AMSdry_timefalling-edge to 0 (same detector that drives the smart-plug-after-drying callback).PrinterManager.get_drying_targets(printer_id)exposes it,printer_state_to_dictandroutes/printers.py::get_printer_statusthread it onto each AMS dict asdry_target_temp+dry_filament, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray'stray_type+ RFID-recommendeddrying_tempwhen no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n keyprinters.drying.targetSummary={{filament}} {{temp}}°C, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests inTestSupportsDryingCommand(cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests inTestDryingTargetExposure(cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). Note about Bambu's printer display. A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (filament: "PETG"sent,result: success, filament: PETG, temp: 65ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than thefilamentfield of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice. - Continue auto-drying while a print is running on capable hardware — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New Settings → Print Queue → "Continue drying while printing" toggle (default OFF) lets the same scheduler evaluator also run on the busy printer set. Backend:
supports_drying_while_printing(model, firmware)inprinter_manager.pyis a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded because the wiki is silent for them, and on those models the firmware would reject the command anyway viadry_sf_reason=[0](TaskOccupied)). The capability is gated on both display names ("H2D","X1C", ...) and internal SSDP / MQTT model codes ("O1D","O1E","O2D","O1C","O1C2","O1S","N6","BL-P001","N7","N9") — the printer'smodelfield can carry either, the existingsupports_dryingprecedent uses both._check_auto_dryinginprint_scheduler.pynow resolves model + firmware up front for every printer and computesmid_print = busy AND toggle_on AND supports_drying_while_printing; whenmid_printis True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity /dry_sf_reason/ drying-presets / mode-1 send path takes over. Safety: drying temp is capped atmax(40, preset_temp - 5)for mid-print drying — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit whenprint_drying_enabledis on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change:routes/printers.py::start_dryinghas no Bambuddy-sideis_idlegate; the "printer busy" rejection comes from firmwaredry_sf_reason=[0], which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced onPrinterStatus.supports_drying_while_printingso the frontend can light up the AMS card affordances correctly. Settings. Newprint_drying_enabled: bool = Falseinschemas/settings.py, added to the boolean allowlist inroutes/settings.py(_BOOL_KEYS), and threaded through the existing dirty-detection / save call inSettingsPage.tsx. i18n. 2 new keys (settings.printDryingEnabled,settings.printDryingEnabledDescription) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. Tests. 7 new cases inTestSupportsDryingWhilePrintingcover every supported display name + internal code, below-min firmware, excluded models (P1*,A1,A1 MINI,X1,X1E), missing firmware,Nonemodel, case-insensitivity, and the strict unknown-model default (False — unlikesupports_dryingwhich leniently allows unknowns). 4 new scheduler integration cases inTestMidPrintDryingcover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Fullpytest -n 30green (4251/4251 in 49 s). Backendruffclean. Frontendnpm run buildclean. Scope. No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter viadry_sf_reasonso being too permissive here costs nothing. - Batch / mass edit on the Filament tab (#1795, requested by RoBoT24-web) — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. Frontend. A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new
BulkEditSpoolsModalis the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per(printer, extruder, nozzle_diameter)and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. Clearing fields in bulk is intentionally NOT supported (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. Same dropdown controls the per-spool editor uses. Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a newSearchableSelectcomponent matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native<select>anywhere in the modal. Material / sub-type / brand options merge the canonicalMATERIALS/KNOWN_VARIANTS/DEFAULT_BRANDSconstants fromspool-form/constants.tswith whatever's already in inventory. Slicer-preset dropdowns fetch the same sources as the per-spool form (Bambu Cloud presets when signed in, Orca Cloud profiles, local presets, built-in filaments) via threeuseQuerycalls gated onisOpenso closed modal pays no fetch cost; results pipe through the sharedbuildFilamentOptions(...)helper so the option list is byte-identical to what the per-spool editor shows. Storage location is asearchableClosedSearchableSelect over actualapi.getLocations()rows mapped tolocation_id(the FK), matching the per-spool form's behaviour (rather than the legacy free-textstorage_locationcolumn, which would have written to a different column than the per-spool editor). Backend. Four new endpoints per inventory mode, eight total:POST /api/v1/inventory/spools/bulk-update,bulk-delete,bulk-archive,bulk-restore(built-in) and the matching/api/v1/spoolman/inventory/spools/bulk-*(Spoolman). All gated on the existingINVENTORY_UPDATE/FILAMENTS_UPDATEpermissions used by the per-spool routes. The built-in update endpoint runs the sameprepare_internal_spool_payload(...)path as the per-spool PATCH (location resolution, weight-lock auto-stamp on explicitweight_used— both inherited identically). The Spoolman update endpoint loops the existing per-spoolupdate_spoolroute function so the complex filament re-linking / extra-dict / extra-lock / shared-filament-detection rules stay byte-identical to single-spool edits — the bulk route is just a fan-out, not a parallel reimplementation. Per-spool failures inside the loop are collected and returned as{updated, errors: [{id, status, detail}]}so one bad ID never aborts the batch. The built-in archive endpoint reports{archived, already_archived, not_found}so the UI can distinguish "no-op because already archived" from "missing row." Both modes broadcast a singleinventory_changedWS event at the end of the batch instead of one per row, so the table refresh is a single re-fetch. Spoolman bulk-delete / archive / restore now also catch non-HTTPException mid-batch — earlier these three caught onlyHTTPException; a mid-batchhttpx.ConnectError/TimeoutError/KeyErroraborted the route with a 500, the loop's accumulated state was lost, and theinventory_changedbroadcast was skipped so the table didn't refresh past the partial state.bulk_update_spoolsgot this right out the gate; the audit pass added the sameexcept Exceptionarm to the other three so a transient Spoolman blip surfaces in the per-row errors array instead of obliterating the whole batch. All-failed and partial-failure are surfaced to the user. The first cut of the fouronSuccessmutation handlers only read the success count, so a response of{updated: 0, errors: [50 entries]}(e.g. every selected ID was deleted by another user before the click landed) showed a green "0 spools updated" toast and silently cleared the selection. The handlers now branch on three outcomes — all-succeeded (existing success toast), partial ({ok, failed}warning toast), and all-failed (red error toast + selection preserved + modal stays open so the user can retry). Same shape for delete / archive / restore.bulkResetConsumedCounterMutation.onSuccessnow closes the confirm modal + clears the selection — earlier inconsistency with the other three bulk mutations left the confirm dialog open after the action. Invalid RGBA hex is now flagged inline instead of being silently dropped from the patch. Typing "RED" or "FF00" in the colour field now paints the input red with helper text and disables the Apply button via a newhasDroppedTickedFieldguard that detects any ticked field whose value gets normalised away — without this guard the user clicked Apply, the rgba was silently omitted, and the success toast still fired for the other fields. Backend tests. 17 new integration cases. 10 intest_inventory_bulk.pycovering update applying to multiple rows, unknown IDs reported innot_found, empty update body rejected with 400, weight-lock auto-stamp parity with per-spool PATCH, emptyidsrejected with 422, bulk delete with mixed valid/invalid IDs, archive settingarchived_aton multiple rows + skipping already-archived, restore the symmetric inverse. 7 intest_spoolman_inventory_bulk.pycovering the Spoolman update callingupdate_spool_fullonce per ID with the same payload, per-spool exception collected without aborting the batch (404 on one ID + 2 successes returns{updated: 2, errors: [{id, status: 404, ...}]}), empty update rejected, emptyidsrejected, bulk delete fan-out, bulk archive callingset_spool_archived(spool_id, archived=True)for each ID, bulk restore the inverse. Fullpytest -n 30green (6384/6384 in 68 s). Frontend behaviour. Selection state is per-page-session — leaving the Inventory tab and coming back clears the set, mirroring the existing label-printer scope. The action toolbar collapses into the existingConfirmModalfor destructive operations (Delete isvariant: 'danger'; Archive / Restore / Reset usage are'warning'). Errors surface via the existinguseToast. API client. AddedbulkUpdateSpools / bulkDeleteSpools / bulkArchiveSpools / bulkRestoreSpoolsand the fourbulkXSpoolmanInventorySpoolsequivalents — matches the per-mode pattern already used forbulkResetSpoolConsumedCounter. i18n. 42 new keys under the newinventory.bulk.*namespace (33 toolbar / modal / confirm + 4 partial-failure toasts × 4 actions + invalid-hex inline helper + 1 useCustom autocomplete affordance), translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5345 leaves per locale, no English fallback. Scope. No DB migration. No new permission. SQLite + Postgres parity verified — the bulk endpoints use the same model + ORM paths as the per-spool routes. Grid (card) view does NOT get checkboxes in this drop — the reporter explicitly requested the Filament-tab list (table view); adding card checkboxes can ship as a follow-up if asked. - "Auto-add unknown RFID spools" toggle + global confirmation modal for unknown spools (requested by maziggy after a wave of duplicate-inventory reports) — New setting under Settings → Filament → Filament Tracking. Default is ON (current behaviour preserved); turning it OFF stops Bambuddy from auto-creating an inventory entry when an unknown RFID tag is read by the AMS. Use case: users who manually pre-register new spools on delivery (so the inventory record carries their notes / weight / cost) were getting silent duplicates the first time they loaded each spool — the auto-matcher requires exact material+colour+subtype+brand, and pre-created records rarely match strictly enough. Backend gates the auto-create in BOTH inventory modes (
backend/app/main.pyfor the built-in inventory loop andbackend/app/services/spoolman.py::sync_ams_trayfor the Spoolman path; auto-sync + both manual sync routes inbackend/app/api/routes/spoolman.pythread the flag). When suppressed, the existingunknown_tagWS event fires so the frontend can surface the slot. Confirmation modal. A new global modal pops up on the next page render whenever an unknown RFID is detected — shows the printer / AMS-X label / slot number, the spool's material + colour swatch, and asks the user whether to add it now ("Add to Inventory" / "Cancel"). Mounted inLayout.tsx(inside<ProtectedRoute>) so the prompt appears regardless of which page the user is on, but never on SpoolBuddy kiosk / login / setup routes. Multiple concurrent unknown spools queue and present one-at-a-time; the frontend won't double-queue the same slot. Backed by two new explicit endpoints:POST /api/v1/inventory/spools/from-slot(built-in inventory) andPOST /api/v1/spoolman/spools/from-slot(Spoolman), gated onINVENTORY_UPDATE/FILAMENTS_UPDATErespectively. Both look up the slot's current tray data server-side and create + auto-assign the spool atomically. SpoolBuddy frontend is unchanged — its existinghandleQuickAddToInventoryflow already covers the kiosk's separate path. Backend dedup that prevents nag and survives a failed broadcast._unknown_tag_last_broadcast: dict[printer_id, dict[(ams_id, tray_id), (tag_uid, tray_uuid)]]inmain.pyensures the same (slot, tag) pair only broadcasts ONCE per MQTT-push cycle, no matter how often the firmware re-asserts the slot state. The slot's empty-tray-data MQTT push clears that slot's entry, so remove-then-reinsert reliably re-prompts. Successful matches (get_spool_by_tag,find_matching_untagged_spool, auto-create) also clear the entry so a future tag swap on the same slot re-prompts. The dedup-set runs AFTERawait ws_manager.broadcast(...)completes, so a crash mid-await doesn't poison the dict and permanently silence the slot (an earlier draft set the dedup before the await — bit on aNameErrorregression during development). Tray data shipped with the event, not looked up. The WS payload now carriestray_type,tray_color,tray_sub_brands, andtray_countstraight from the live MQTT message, so the modal renders the correct material / colour without depending on the React QueryprinterStatuscache (which lagged the WS event by several seconds during the first end-to-end test and showedPLA / #FF0000instead of the actual filament). Frontend hookuseUnknownTagPromptreads them out of the event detail directly. SharedgetAmsLabel. Moved fromPrintersPage.tsx(and a near-duplicate inConfigureAmsSlotModal.tsx) tofrontend/src/utils/amsHelpers.ts. Both consumers now import the shared version; the canonical implementation producesAMS-A / AMS-B / HT-A / Externalrather than the bareAMS 3my first draft of the modal emitted. Spoolmanfrom-slotno longer reports success when the slot binding fails. Earlier in this audit pass a swallowedtry/except Exception: rollback + logleft the route returning{"success": True}even when the slot-assignment INSERT was rolled back; the user saw the "Spool added" toast while the modal re-fired on the next MQTT push. Now raises HTTP 500 with the underlying error so the frontend surfaces it.AppSettingsTypeScript interface. Addedspoolman_enabled: boolean,auto_add_unknown_rfid: boolean, andspoolman_url: string— the backend has always returned these on/api/v1/settings/(verified bytest_settings_api.py:144which assertsresult["spoolman_enabled"] is True), but the TS type omitted them and required a runtime cast; now strictly typed. i18n. 9 new keys (settings.autoAddUnknownRfid,settings.autoAddUnknownRfidDesc,inventory.addToInventory,inventory.addToInventoryPending,inventory.addToInventorySuccess,inventory.addToInventoryFailed,inventory.unknownSpoolTitle,inventory.unknownSpoolMessage,inventory.unknownSpoolSlot) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5301 leaves per locale. Scope. No DB migration (setting lives in the existingsettingskey-value table; same shape on SQLite and Postgres). No new permission. ManualSync AMSactions also honour the setting — the skipped-spool result list reportsAuto-add disabled; add to inventory manuallyso the user knows the slot was intentionally skipped, not failed. Verification. Full backendpytest -n 30: 6367/6367 in 79 s. Focused integration suite (settings / Spoolman / slot-assignments / slot-concurrency): 122/122. Frontendnpm run buildclean. Backendruffclean. End-to-end smoke-tested live on an H2D + dual AMS setup: insert → modal pops with the correctAMS-B Slot 4+ real material / colour; Cancel → no re-prompt; remove + reinsert → modal returns; Add → spool lands in inventory + slot tile shows it. - Spoolman weight tracking for no-3MF "Untitled" prints (#1820, requested by ojimpo) — Closes a long-standing parity gap between Bambuddy's two inventory modes. When a Bambu print starts that Bambuddy can't fetch a
.gcode.3mffor — typically an unsaved BambuStudio project, where the printer reportssubtask_name: 名称未設定("Untitled") and FTP returns 550 for every candidate path — the existing flow created a fallback archive but Spoolman saw no weight change for that print. The internal-inventory side already handles this via the Path 2 AMS remain%-delta fallback inusage_tracker.on_print_complete(line 517). Spoolman now mirrors the same shape.store_print_datanow capturestray_remain_start(per-slotremain%+tray_uuidat print start) on every print — keyed"<ams_id>-<tray_id>", slots with invalidremain(e.g. -1, AMS hasn't read the spool yet) silently dropped, VT external trays encoded asams_id=255to match internal — and no longer early-returns when the 3MF is missing: it creates anActivePrintSpoolmanrow withfilament_usage=Nonecarrying only the snapshot, so the completion path has something to work with.report_usagekeeps its 3MF path as the primary writer and adds_report_remain_delta_for_slotsfor any slot the 3MF path didn't cover (no-3MF entirely OR partial coverage where slice_info omitted a slot). The fallback resolves each slot to its Spoolman spool via the existingspoolman_slot_assignmentstable, looks up the curatedFilament.weightfrom the spool's filament record, and writes(start_remain - current_remain) × weight / 100grams viaclient.use_spool(...). Notray_weightfrom MQTT — the failure mode #1119 documented (non-RFID spools have no MQTTtray_weight, so remain% × tray_weight gave garbage and silently mis-tracked) is dodged the same way internal inventory dodges it: by reading the user-curated reference weight from the inventory store rather than trusting MQTT's raw field. RFID gate not needed — Spoolman's curatedFilament.weightis present for RFID and non-RFID spools alike. Mid-print spool swap detection — whentray_uuiddiffers between start snapshot and completion read, the slot is skipped rather than mis-attributed. We don't know how much of the print went to which spool; preserving correctness is better than guessing. Double-charge guard — slots already written by the 3MF path land in ahandled_global_tray_idsset that the fallback consults before charging, so a 3MF-covered slot can't also pick up a remain delta. #1119 invariant preserved — the deprecated AMS-remain%-based GLOBAL writer is still gone. This is per-slot, per-print, gated on a valid start/currentremainAND a resolvable Spoolman spool. No new setting, no toggle: the parity rule [[feedback_inventory_modes_parity]] applies — same shape as internal inventory, which is unconditional. No-op default — installs with no Spoolman slot assignments, no RFID-readable AMS, or no print-time remain% (printer offline at start, AMS still loading) see no behaviour change. DB.active_print_spoolmangets a new nullabletray_remain_start TEXTcolumn via_safe_execute(ALTER TABLE … ADD COLUMN), and the existingfilament_usage TEXT NOT NULLis relaxed to nullable — for SQLite viawritable_schema = ON+sqlite_masterpatch +schema_versionbump (same surgical pattern used forusers.password_hashNULL relaxation a few hundred lines below), for Postgres viaALTER COLUMN … DROP NOT NULL. SQLite + Postgres parity verified. CREATE TABLE updated to emit the new shape on fresh installs. Tests. 11 new unit cases intest_spoolman_no3mf_remain_fallback.py: 5 for_snapshot_tray_remain(valid remain captured, invalid remain skipped, VT tray encoding, empty raw_data, missing uuid defaulted to ""), 3 forstore_print_datano-3MF behaviour (row created with snapshot when no 3MF + valid remain; no row when neither 3MF nor remain; 3MF path also captures snapshot for partial-coverage fallback), 3 forreport_usageremain-delta (writes(start-end) × Filament.weight / 100to resolved spool; skips swapped spool whentray_uuidchanged; skips slots already handled by 3MF). Fullpytest -n 30green on the Spoolman + tracking + archive + on-print suites (1205/1205). Backendruffclean. - NTP-gate state exposed on the appliance endpoint —
GET /api/v1/system/appliancegains atime_syncedfield returning"ok","warning", ornull. Source:/run/bambuddy/time-synced, written by the appliance'sntp-gate.shonce chronyd reports sync (or after a 3-minute timeout with a"warning"marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. Newbackend/app/core/local_config.py::read_ntp_gateis defensive on every failure mode (file absent →None, OSError →None+ warning log, empty / unknown content →None, binary garbage survives viaerrors="replace"). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once"ok"comes through. 8 new unit cases forread_ntp_gate(absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (time_syncedisnull) — the appliance is the only consumer for now. - Appliance locale defaults endpoint —
GET /api/v1/system/appliancereturns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into/etc/bambuddy/local.tomlduring firstboot. Newbackend/app/core/local_config.py::read_local_tomlparses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns{hostname, timezone, locale}withnullfor any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend,i18n/index.tsruns a one-shotapplyApplianceLocale()hook after init: gated by abambuddy_appliance_locale_consumedlocalStorage flag so it runs exactly once per appliance, fetches the endpoint, andi18n.changeLanguage(...)s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo:bambuddy-appliance); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh viahostnamectl/timedatectland don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required). - Unified print dispatch through the queue scheduler (#1625, by EdwardChamberlain) — Every print Bambuddy starts now goes through the print queue's scheduler rather than the standalone
background_dispatch.pypath that previously ran in parallel for File Manager prints, archive reprints, and printer-card upload-and-print. Same end-state (a print on the printer), one code path. Effect on users: every print is now queueable, cancellable, visible on the queue page, attributable to the user that started it, and runs through the existing filament-deficit check and print-queue ownership model. The "stealth print" that didn't show up in the queue because it bypassed the scheduler is gone. Architecture. File Manager Print, archive Reprint, and printer-card upload-and-print all now POST to the existing queue routes (POST /api/v1/queue/itemswith an immediate ASAP scheduled_time) — the scheduler picks it up on the next tick and runs the same dispatch path the existing queue used to. The retiredbackground_dispatch.pyroute +services/background_dispatch.pyworker + their two test files are removed. The scheduler already had every featurebackground_dispatchdid (per-printer locking, status broadcast, error path) plus the deficit / ownership / queue-position machinery, so this is consolidation rather than a rewrite. Permission scope changes. Documented in the Security section below (#1625 introduced thequeue:createrequirement on File Manager / archive reprint / upload-and-print). i18n. Newqueue.actions.startPrintkey added across all 11 locales (the FileManagerPage button's accessible-name on the new path). Parity check holds. Tests. Allbackground_dispatchtest files removed (the routes they covered no longer exist);test_dispatch_force_timelapse.py,test_scheduler_force_timelapse_wiring.py, andtest_cleanup_forced_timelapse.pyconsolidated onto the scheduler sinceforce_timelapsenow lives there exclusively. Followup #1625-followup (this drop, listed under Fixed below) caught three issues from the post-merge audit — ownership gate mismatch on/queue/{id}/startand/queue/{id}/stop, an ASAP TOCTOU race on empty-scope inserts, and a missing duplicate-position validator on/queue/reorder— none of which were introduced by this PR but all of which became more impactful once every print routed through the queue. Scope. No DB migration. No new permission (queue:createalready existed; this PR widens its surface). No frontend behaviour change for users with full permissions — the queue surface absorbs prints that previously skipped it. - HMS error actions — Resume / Stop / Check Assistant from the dashboard (#1743, by Ichicoro, requested in #1419 by Ichicoro) — Bambu's HMS error dialog goes from read-only to actionable. The error modal on the printer card now renders the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me etc. buttons that BambuStudio and Bambu Handy show, and each click sends the matching MQTT command back to the printer. Closes the long-standing UX gap that forced users to physically walk to the printer (or open Bambu Handy) just to acknowledge a paused print. Data source. A bundled
backend/app/data/hms_actions.jsonmaps every known printer-model + error code to its list of allowable actions; populated from Bambu's publice.bambulab.com/hms/GetActionImage.phpendpoint viascripts/update_hms_actions.py. The action-ID-to-name mapping (RESUME_PRINTING, CHECK_ASSISTANT, FILAMENT_EXTRUDED, …) matches BambuStudio's open-source enum verbatim — including theCANCLEtypo, kept on purpose because Bambu's catalog spells it that way and silently fixing it would break the lookup. Backend. Newbackend/app/services/hms_actions.pydefines anHMSActionStrEnumandget_actions_for_error_code(device, error_code)lookup; loaded once at module import viaPath(__file__).resolve().parent.parent / "data" / ...so the JSON resolves regardless of CWD (systemd unit, Docker entrypoint, pytest frombackend/).BambuMQTTClient._parse_datalooks up the action list at HMS-parse time on both error sources — the structuredhms[]branch and the per-printprint_errorshort-code branch — and attaches it toHMSError.actionstogether with ajob_idsnapshot fromself.state.subtask_idso the action survives a subsequent job change. NewPOST /api/v1/printers/{id}/hms/execute-actionroute (HmsActionBodyschema; permissionPRINTERS_CONTROL) dispatches the click. Dispatcher (BambuMQTTClient.execute_hms_action). Amatchstatement maps eachHMSActionto its MQTT command —resume/stopwitherr+param=reserve+job_idfor the HMS-aware actions;idle_ignorewithtype=0(one-time) vstype=1(persistent) so Bambu's "Don't Remind Me" / "No Reminder Next Time" actually disable the warning across prints;ams_controlwithparam=done/resume/abortfor filament-load dialogs; bareclean_print_error(matches the existingclear_hms_errorsshape — no leakedprint_errorbody field);clean_print_error+uiopchained forDBL_CHECK_OK;refresh_nozzle,buzzer_ctrl mode=0(fire alarm),auto_stop_ams_dry,close_air_filtfor the standalone actions. UI-only actions (CHECK_ASSISTANT,JUMP_TO_LIVEVIEW,OK_JUMP_RACK,REMOVE_CLOSE_BTN,LOAD_VIRTUAL_TRAY,CANCLE,DBL_CHECK_CANCEL) intentionally publish nothing — they exist for label parity with BambuStudio's modal where the printer's own screen drives them. Unknown actions fall through toreturn False+ warn log so the route surfaces them as 4xx rather than silently no-opping. Every command pairs with apushing.pushallecho so the state stream refreshes on the next tick and the modal closes correctly. Schema hardening.HmsActionBody.print_errorvalidated asmin/max_length=8+pattern=r"^[0-9A-Fa-f]{8}$";actionandjob_idlength-capped. Stray input can't reach the dispatcher'smatch. Frontend.HMSErrorModal.tsxrenders a wrap-flex row of buttons under each error description, sized to fit on the printer-card panel without overflowing on narrow viewports. The mutation calls the new endpoint, invalidates the printerStatus query, and shows the newhmsErrors.actionSuccess/hmsErrors.actionFailedtoast. The button label is the translated action name fromhmsErrors.actions.<ACTION_NAME>— never the raw enum — so a forgotten translation falls back to the English action name rather than a key string. i18n. 33 action labels + 2 toast keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). No English fallback per [[feedback_translate_dont_fallback]]. Parity check 5381 leaves per locale. Tests. 31 new cases intest_hms_actions.py— 5 catalog-lookup (known A1 error returns actions; unknown device → empty; unknown error → empty; underscore-form0300_8070doesn't match the catalog's no-separator key; enum StrEnum value contract holds including theCANCLEtypo) and 26 dispatcher cases that pin everyHMSActionbranch to its exact MQTT payload — resume carries err+param+job_id;IGNORE_RESUME/NO_REMINDER_NEXT_TIMEuseidle_ignore type=0;IGNORE_NO_REMINDER_NEXT_TIME/DONT_REMIND_NEXT_TIMEusetype=1(persistent variants — were incorrectly bucketed together in an earlier draft);clean_print_errorbody is bare;DBL_CHECK_OKchains clean + uiop_close; uiop'serris the already-string short code (notf"{x:08X}"against a str, which would TypeError); plain resume vs HMS-aware resume distinguished; UI-only actions publish nothing; unknown action returns False;pushing.pushallecho fires after every command. Fullpytest -n 30green (6471/6471). Backendruffclean. Frontendnpm run buildclean. Scope. No DB migration. No new permission. The HMS modal is opt-in by user click — installs with no HMS errors see no behaviour change. The 9009-line catalog is shipped as a single static JSON; regenerating it later is apython scripts/update_hms_actions.pyrun away.
Security
- Print permission scope change for queue-only dispatch — All UI print-entry points now route through the print queue instead of direct background dispatch, which changes the permissions needed for some actions. File Manager Print now requires
queue:create(previouslyprinters:control). Printer-card upload-and-print now requires bothlibrary:uploadandqueue:create. Archive reprint buttons now requirequeue:createplus the existing archive reprint ownership permission (archives:reprint_ownorarchives:reprint_all). Installations with custom groups/API keys that grantedprinters:controlfor immediate printing but did not grantqueue:createmust addqueue:createto keep those print actions available. Grantqueue:createcarefully: ASAP queue items are eligible for immediate dispatch, so it is now the permission that authorizes starting queued prints from File Manager, Archives, and upload-and-print flows. - Vite 7 → 8 major bump — Bambuddy's frontend now builds with Vite 8 (
^7.3.2→^8.0.16) and the matching plugin-react release (vitejs/plugin-react^5.1.1→^5.2.0). Headline architectural change: Vite 8 swaps Rollup for Rolldown as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitiveesbuildfloor to 0.28.1, which closes the last open advisory in the audit chain. Bambuddy-side surface audited:vite.config.tsuses only stable contracts that survived the v8 cut —defineConfig, theConnecttype, the customserveGcodeViewerconfigureServermiddleware plugin (proxies/gcode-viewer/*to the repo's siblinggcode_viewer/directory in dev), theserver.proxywith WebSocket upgrade for/api/v1/ws,build.outDir/emptyOutDir/chunkSizeWarningLimit, andresolve.aliasfor ``.base: '/'regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean. - Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime:
dompurify3.4.0 → 3.4.10.package.jsonfloor raised from^3.4.0to^3.4.10so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list withselectedcontent+command+commandfor(all valid modern HTML, harmless for our two default-allow-list call sites), andProjectPageModalis unaffected anyway because it sets an explicitALLOWED_TAGS/ALLOWED_ATTRwhitelist. Build / lint / test tooling (transitive, dev-only):babel/core7.29.0 → 7.29.7 (pulled byvitejs/plugin-reactandeslint-plugin-react-hooks),vite7.3.2 → 7.3.5,markdown-it14.1.1 → 14.2.0 (pulled bytiptap/extension-link→tiptap/pm→prosemirror-markdown; Bambuddy never callsmarkdown-it.renderdirectly so the change is transparent),js-yaml4.1.1 → 4.2.0 (pulled byeslint),form-data4.0.5 → 4.0.6 +ws8.20.1 → 8.21.0 (both pulled byjsdomin the test runtime). All bumps inside existing semver ranges exceptdompurify. No source changes required. dompurify3.4.10 → 3.4.11 — Follow-up patch closes a moderate-severity advisory affectingsetConfig()callers: the previous hook clone-guard added in 3.4.7 could be bypassed viasetConfig(), leaving a permanentALLOWED_ATTRpollution that the nextsanitize()call inherited. Bambuddy's exposure is nil —git grep DOMPurify.setConfigreturns zero hits across the entire codebase; all three sanitisation sites (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx) callDOMPurify.sanitize(html)orDOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})directly, never throughsetConfig(). The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silencenpm auditso future audit-fix runs don't auto-bundle unintended changes. Mechanical lockfile bump only: the existing^3.4.10range already permitted 3.4.11, sopackage.jsonis unchanged;package-lock.jsonupdates the resolved URL + integrity hash for the one entry. Verification:npm auditreports 0 vulnerabilities,MakerworldPage.test.tsx's 12 DOMPurify sanitisation cases pass,npm run buildclean.- Precautionary floor pins for pydantic-settings 2.14.2 + msgpack 1.2.1 — pip-audit surfaced two advisories that are not reachable in shipped Bambuddy but were flooring at vulnerable versions.
pydantic-settings2.0.0 → 2.14.2 inrequirements.txtclears GHSA-4xgf-cpjx-pc3j (NestedSecretsSettingsSourcewithsecrets_nested_subdir=Truefollows symbolic links pointing outside the configuredsecrets_dir, reading out-of-tree files into settings values and bypassing the documentedsecrets_dir_max_sizecap; affected>=2.12.0,<2.14.2). Exposure: nil.grep -rn "NestedSecretsSettingsSource\|secrets_nested_subdir\|secrets_dir" backend/returns zero hits — Bambuddy uses pydantic-settings only for env-var-backed config, never for the secrets-dir loader.msgpack1.2.1 floor-pinned inrequirements-dev.txtnext topip-audit>=2.7.0to clear GHSA-6v7p-g79w-8964 (anUnpackerinstance reused after catching an error can crash with SEGV; under repeated unpacking of untrusted input from an external source, this is a DoS vector). Exposure: nil.grep -rn "import msgpack\|from msgpack" backend/returns zero hits — msgpack enters Bambuddy's tree only as a transitive ofCacheControl, which is itself pulled bypip-audit(the very tool that produced the report). Not a runtime dep of the shipped app. Both pins are taken as defence-in-depth / audit hygiene so the nextpip-auditrun is clean and a future reachable advisory in either package isn't masked by the existing noise. No code change, no behavioural change, no test change. - Backend dependency security floor raises (cryptography / python-multipart / starlette) — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in
requirements.txtlifted to the documented fix releases, plus one transitive co-bump for resolver compatibility.cryptography46.0.7 → 48.0.1 floor (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). Release-notes audit (done before bump): v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved tocryptography_decrepit); v48.0.0 droppedPUBLIC_KEY_TYPES/PRIVATE_KEY_TYPEStype aliases. Bambuddy's grep is clean across every one of those:core/encryption.pyuses Fernet (AES-128-CBC + HMAC),services/spoolbuddy_ssh.pyuses ed25519,services/virtual_printer/certificate.pyuses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us.python-multipart0.0.27 → 0.0.31 floor (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). Behavioural changes audited: 0.0.30 stopped recognising RFC 2231/5987 extendedfilename*/name*parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (utils/http.py:17) but doesn't parse them on the request side, and clients that include bothfilename=andfilename*=keep working via the plainfilename=fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only&as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses&.starlette1.1.0 → 1.3.1 floor — clears CVE-2026-54282/54283 (FormParsermax_part_size/max_fieldslimits now actually enforced after being declared-but-ignored in earlier releases;StaticFiles.lookup_pathrejects absolute paths;FileResponseclamps oversized suffix range requests;URL.replace()IndexError fix). Critical pre-bump check: the newly-enforcedmax_part_size=1MBdefault would have broken every file upload (UploadFile = File(...)ininventory.py:1127,projects.py:886/1053/1780,library.py:1787,local_presets.py:82,external_links.py:166,local_backup.py) if it applied to file streams. Inspected theMultiPartParser.on_part_datasource: the size check atif self._current_part.file is None:only fires for text form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. Side rename:backend/app/api/routes/mfa.py:470/1364/1428replaces 3 references ofstatus.HTTP_422_UNPROCESSABLE_ENTITY(deprecated in starlette 1.3.x) withHTTP_422_UNPROCESSABLE_CONTENT. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix).pyopenssl26.0.0 → 26.3.0 floor — NOT a security fix; required because pyOpenSSL<26.3.0capscryptography<47in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no directfrom OpenSSL ...imports — pyOpenSSL is pulled transitively byasyncssh+pywebpush. Verification:pip-auditclean,pip checkclean,ruff check backend/clean, backendpytest -n 306167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.
Added
- AMS Filament Backup is now first-class across the deficit check, the printer card, and a new BambuStudio-style backup modal (#1762, reported by jpcast2001 + Arn0uDz) — Four tightly coupled changes that close the gap reporter jpcast2001 hit on the dual-AMS X1C farm. Reporter scenario: PLA Basic in AMS-1 slot 1 with low remaining grams, the same PLA Basic in AMS-2 slot 1 with plenty — Bambuddy still blocked the print with an "insufficient filament" warning because per-slot accounting never noticed the backup peer. Reporter disabled
disable_filament_warningsas a workaround; Arn0uDz hit the same shape on a different printer and noted "Print Anyway" didn't unstick them either. The single global AMS Filament Backup toggle that shipped in 0.2.5b1 (#1766) was the prerequisite for the firmware-level switch, but every Bambuddy surface still treated each slot as isolated. What's new on each surface:
Backup-aware deficit aggregation (the load-bearing fix).backend/app/services/filament_deficit.py::compute_deficit_for_queue_itemnow readsPrinterState.ams_filament_backupviaprinter_manager.get_statusand, when backup is ON, poolsremaining_gramsacross every same-material assigned spool on the printer before deciding whether to block. Material identity uses the firmware's actual rule: same Bambu filament preset ID (Spool.slicer_filament, e.g.GFA00) AND same colour (with1A1A1AFFnormalised to match1A1A1A— alpha stripped, hex uppercased). The preset identifies the filament profile (PETG HF, PLA Basic, etc.); the colour pins the variant. Three PETG HF spools in different colours all share the same preset but absolutely don't back each other up — the firmware would correctly swap PETG HF but the print would change colour mid-run. User-tagged spools without a preset get a unique-per-spool key so they never pair with anything else, matching the firmware: Bambu's backup logic relies on the preset, and grouping on cosmetic material+colour match alone would let two visually-identical but materially-different spools be treated as backups. Same(catalog-id, colour)rule on the Spoolman side viafilament.id+color_hex; spools linked to different filament catalog entries never pool even if their material+colour strings match. Dual-extruder scoping is load-bearing here: H2D / H2C / X2D firmware cannot cross extruders even when bit 18 ofprint.cfgis set, so the pool is per-extruder-side viaPrinterStatus.ams_extruder_mapplusis_dual_nozzle_model(). Single-extruder printers collapse everything to one pool and ignore the map. The check still emits per-slotFilamentDeficitrows when the total required of a material on an extruder side exceeds the total available of that material, so the UI's "slot X is short" message still resolves to a specific slot the user can act on — it just doesn't fire spuriously when the firmware will actually save them.
BambuStudio-style backup modal opens from the badge. The existing AMS Backup badge on the Filaments section header (#1766) now opens a dedicated modal instead of toggling state on click. The modal renders one SVG ring graphic per backup pair — each ring filled with the filament colour, the material name + rotation count (N× ↻) in the centre, and member slot labels distributed around the colour band on rounded contrast-aware pills (semi-opaque black on bright fills, semi-opaque white on dark) so the labels stay legible regardless of the spool colour. Closely modelled on Bambu Studio's "Auto Refill" widget. Lone slots are intentionally suppressed — the ring graphic is the answer to "which slots will save me when this one runs out"; everything else is visual noise. On dual-extruder printers (H2D / H2C / X2D), each ring carries a compactR/Lbadge in the top-left corner instead of section headers — and the badge ONLY appears when the extruder map carries TWO distinct values across the AMS units, so single-nozzle printers misflagged as dual or printers with routing data not yet reported collapse cleanly to no-badge rendering. Modal closes on Esc keypress (window-level listener registered while open, cleaned up on close), click-outside, or the close button. Theme-aware via CSS variables (var(--bg-secondary)/var(--text-primary)/ etc.) matchingAMSHistoryModal, so the modal follows whichever background variant the user has picked (neutral / warm / cool / oled / slate / forest). The badge itself stays in the Filaments section header where #1766 put it — itsonClickwas rewired from "directly toggle the backup state" to "open the modal", with the samesetAmsFilamentBackupmutation hooked to the toggle inside the modal. The newcomputeBackupGroups(amsUnits, amsExtruderMap, isDualNozzle): BackupGroup[]helper inutils/amsHelpers.tsis the modal's data source — it returns one entry per non-empty slot, sorted with pairs first, then by material name, then by global tray id for deterministic rendering. Identity uses the same strict(preset, colour)rule as the backend. HT AMS (single-tray modules withams_id ≥ 128) participate in groupings viagetGlobalTrayId, so an HT slot can pair with a regular AMS slot when both hold the same preset and colour. Defensive dedup byams.id(first occurrence wins) hardens the helper against duplicate entries that have been observed in the wild on VP-aggregated switch printers and MQTT partial-update edge cases — without the dedup, a single physical slot could render in two different rings.
**Active-print per-slot mapping pill
Changelog truncated — see the full CHANGELOG.md for the complete list.