github maziggy/bambuddy v0.2.5b2-daily.20260629
Daily Beta Build v0.2.5b2-daily.20260629

pre-release7 hours ago

Note

This is a daily beta build (2026-06-29). It contains the latest fixes and improvements but may have undiscovered issues.

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.

Added

  • Preheat & Heat Soak before queued prints — per-item override + per-filament chamber targets (#1468, reporter embed-3d) — New scheduler stage that heats the bed (and the chamber, on printers that support it) and holds at temperature before each queued print starts, intended for engineering filaments (PA, ABS) where adhesion and warp depend on a warm chamber. Why it doesn't already work in the slicer. BambuStudio / OrcaSlicer can emit M191 (wait-for-chamber-temp) in start-G-code, but Bambu firmware silently ignores M191, so any "wait for chamber" line in the slicer's start sequence is a no-op. The reporter confirmed this by trying the MakerWorld chamber-heating G-code in OrcaSlicer and finding the chamber-heating step wouldn't fire. Implementing this at the orchestration layer — Bambuddy waiting on state.temperatures between FTP upload and start_print — is the right architectural place; the slicer side is a dead end. Where it fires. print_scheduler._preheat_and_soak(), called from _start_print() immediately before the FTP upload section (print_scheduler.py:2306-ish). Best-effort: any failure (printer drops, gcode refused, no bed temp in metadata) logs and returns rather than failing the queue item — the normal upload + start path runs straight after. Hardware-tier behaviour (three branches; the OP collapsed two of them and we kept them distinct): (1) Active chamber heater — H2C / H2D / H2D Pro / H2S / X2D / X1E (supports_chamber_heater() true) — dispatches M141 Sx for the configured target then polls state.temperatures["chamber"] against it. (2) Chamber sensor only — X1C / P2S (supports_chamber_temp() true but supports_chamber_heater() false) — no M141, polls the chamber sensor and considers the chamber phase satisfied when bed radiation has driven the sensor to target. Radiant warm-up to ABS-friendly temps on a cold X1C is 20-30 min — the max_wait_seconds cap (default 900 s, range 60-3600) is a hard ceiling so a cold room can't stall the queue indefinitely; falls through to the soak phase if the chamber never converges. (3) No chamber sensor — P1S / P1P / A1 / A1 Mini — no chamber wait possible (the chamber_temper value these models report is meaningless per printer_manager.supports_chamber_temp), so only the bed phase + soak timer apply. Bed target is read from the archive's parsed bed_temperature metadata (the same bed_temperature_initial_layer / bed_temperature field archive.py:438 already extracts from the 3MF); if missing the preheat stage skips and logs rather than guessing a default that might wreck a non-PLA print. Settings — Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card. Master toggle (preheat_enabled, default off — disabled installs see no behavioural change), preheat_chamber_target (°C, 0-60, default 0 = chamber phase disabled; PA: 50, ABS: 45, PETG-CF: 40), preheat_max_wait_seconds (60-3600, default 900), preheat_soak_seconds (0-1800, default 300). The numeric fields auto-disable in the UI when the master toggle is off so they read as "config that's not currently doing anything." A static helper line under the inputs spells out the three hardware tiers so users don't have to consult the wiki to know what their printer will do. Tests. Eight new cases in backend/tests/unit/test_scheduler_preheat.py: disabled-setting skip (no M140 / M141 dispatched), no-bed-temp-in-archive skip, H2D dispatches both M140 and M141, X1C dispatches only M140 (the explicit chamber-sensor-but-no-heater regression guard — wiring this to supports_chamber_temp() alone would have falsely fired M141 on the entire X1 family), P1S ignores its meaningless chamber reading and lets only the soak timer run, preheat_chamber_target=0 keeps the bed phase but skips chamber even on a heater-capable printer, lost-client mid-flow returns silently, lost-state mid-wait exits the poll loop gracefully and still soaks. asyncio.sleep patched to AsyncMock so the soak phase doesn't actually wait — assertions are on what was scheduled, not wall-clock. 8/8 green. Wiki. bambuddy-wiki/docs/features/monitoring.md gains a new "Preheat & Heat Soak" subsection under the queue/scheduling area documenting the three hardware tiers and the four-setting interface, so users with X1C or P1S know upfront what the feature can and cannot do for their printer. i18n. 13 new keys × 11 locales (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere — no English fallback. Scope. Backend (scheduler + schema + settings route) + frontend (settings card + AppSettings type) + tests + wiki. No DB migration (uses the existing key/value Settings table). No new permission (Settings → Workflow already gates on the same admin scope). The OP's "select option per print" UX is not part of this drop — preheat is a global default applied to every queued item that has a parseable bed temperature; per-queue-item override would need a PrintQueueItem schema migration plus print-modal and queue-modify UI work that would have widened the change beyond the scope agreed with the user. Rework on user review. First cut shipped a global-only design: one single preheat_chamber_target int in Settings → Workflow, no per-print override. User flagged two gaps on review: (1) you can't enable preheat for a single queue item, (2) different filaments need different chamber temps but the setting was a single value. Both are fair: PA needs 50, PETG-CF wants 40, PLA wants 0, but the global single-int forced one number across all of them. Reworked the data shape: replaced the single preheat_chamber_target int with preheat_filament_targets (JSON map of normalised filament type → °C, user-editable in the same card via the new PreheatFilamentTargetsEditor component) and added two columns to PrintQueueItempreheat_override (inherit / on / off, default inherit) and preheat_chamber_target_override (nullable int, beats the filament-map derivation). The scheduler's resolution order: item.preheat_override == 'off' skips entirely; 'inherit' falls back to the global preheat_enabled master toggle; 'on' forces the stage even when the global is off. Chamber target: item.preheat_chamber_target_override (explicit °C) > max of preheat_filament_targets[normalize(t.tray_type)] across loaded AMS slots > 0. Mixed PA+PLA load picks PA's 50 (max-across-slots, NOT lowest-common-denominator — PA's chamber requirement is the binding constraint, PLA doesn't suffer from being warm). PLA-only prints derive 0 and skip the chamber phase automatically without the user touching anything. The per-print UI lives in PrintModal's "Print Options" panel — tri-state segmented control (Inherit / On / Off) plus an optional chamber-target override input (shown only when override ≠ Off, blank = use filament map). Same control in edit-queue-item mode so you can flip preheat on an already-queued print. Tests grew from 8 cases to 15 across three categories — override resolution (3), chamber-target derivation (5), hardware-tier branching (5), plus 2 helper-fn tests — all green. DB migration added: preheat_override VARCHAR(10) DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL on print_queue, idempotent via _safe_execute. Existing rows behave exactly as before (inherit + null = use global). i18n grew from 13 keys to 19 × 11 locales — real translations for the new override radio + per-filament editor strings, no English fallback. Frontend PrintQueueItem TS interface and PrintQueueItemCreate / PrintQueueItemUpdate shapes updated to carry the new fields end-to-end. PrintOptions.tsx now uses options[key as 'bed_levelling'] for the boolean rows since options[key] would type-error against the new non-boolean preheat keys — minor TS-only adjustment, no behavioural change. Airduct flap follows the resolved chamber target — bidirectional, idempotent. Reported by user mid-test on H2D: preheat ran M141 for ABS but the cooling/heating airduct flap stayed in cooling, the open exhaust vent actively fought the heater and the chamber crawled toward target. The H-series (H2C/H2D/H2D Pro/H2S), X2D, and P2S all have a motorised flap with two modes: cooling (modeId=0, open exhaust, vents heat — right for PLA/PETG/TPU) and heating (modeId=1, closed exhaust, recirculates warm air — right for ABS/ASA/PC/PA). Bambu's firmware does not auto-switch the flap based on M141; whatever mode the user last left it in persists. So a PLA→ABS workflow inherits PLA's cooling-mode flap and the heater never wins; conversely an ABS→PLA workflow inherits ABS's heating-mode recirculation and runs PLA's chamber hot. Fix. New supports_airduct(model) helper in printer_manager.py mirrors the frontend whitelist (P2S, X2D, H2C, H2D, H2D Pro, H2S plus their internal codes). In _preheat_and_soak, after the bed dispatch and BEFORE the chamber M141: read the printer's current state.airduct_mode, derive the desired mode from the resolved chamber target (chamber_target > 0 → heating, chamber_target == 0 → cooling), and only fire set_airduct_mode when current ≠ desired. The bidirectional switch is the load-bearing part per Martin: when the resolved chamber target is 0 (PLA-only print, or per-item override disables chamber), the flap MUST switch to cooling even on a heater-capable printer that was previously running ABS, otherwise the closed-flap recirculation cooks PLA. The idempotency check (read state.airduct_mode, compare against desired before sending) keeps the flap motor from cycling needlessly when it's already where we want it. Gating is on supports_airduct(model) only — distinct from supports_chamber_heater(model): X1E has a chamber heater but no flap (skipped), P2S has a flap but no active heater (still flipped, because even a passive-sensor printer benefits from the right airflow for its filament); the intersection that needs both is H2C/H2D/H2D Pro/H2S/X2D and they all work. No post-print restoration per Martin's preference — once a preheat sets the flap, it stays there for the print's duration (the print itself wants the same mode the preheat picked) and for any subsequent prints until the next preheat decision flips it. Best-effort: any set_airduct_mode failure logs and continues; M141 still fires regardless so a stuck flap doesn't kill the print. Tests. Four new cases in test_scheduler_preheat.py: test_h2d_chamber_heat_switches_airduct_to_heating (the OP scenario — cooling→heating before M141 for ABS), test_h2d_chamber_zero_switches_airduct_to_cooling (heating→cooling for a PLA print on a previously-warm flap), test_h2d_airduct_already_correct_idempotent (no command sent when current mode matches desired), test_x1c_no_airduct_flap_never_fires_set_airduct (gate regression guard — X1C has chamber sensor + chamber heater logic adjacent but no flap, must not leak the command). 21/21 in the file now.

Fixed

  • Cancel during queue dispatch actually cancels — no more "pressed cancel and the print started" (#1853, reporter guy-blotnick) — Symptom: user queued a batch of 10 print jobs of the same item across two P2S printers (Windows installation, v0.2.4.8), clicked Cancel on a pending row, and the print started anyway. Repeated consecutively. Support-bundle log scan reported 15× sqlite3.OperationalError: database is locked from the printer sensor history recorder in the same 8-minute window — a tell-tale that something was holding the SQLite WAL writer lock for the full 15 s busy_timeout. Root cause (the race). _start_print() in backend/app/services/print_scheduler.py carried a check-then-act window of several seconds between "scheduler's check_queue snapshotted this row as pending" and "scheduler sent the MQTT start_print command". The scheduler reads item via its session, then does FTP delete + FTP upload of the 3MF (5-30 s on a typical archive) before the unconditional item.status = "printing"; await db.commit() at line 2792. If the user pressed Cancel in that window, /cancel (a separate session) saw status == 'pending', flipped to cancelled, and returned 200 — but the scheduler's stale in-memory write overwrote the cancellation in the very next commit. Then printer_manager.start_print shipped to the printer and the print commenced. The cancel_queue_item route at print_queue.py:1245 correctly guards against late cancels (status not in ("pending",) → 400) but is useless if the scheduler races back to pending → printing AFTER the cancel commit. Root cause (the lock contention amplifier). _start_print() did await db.flush() at line 2555 immediately after writing item.archive_id = archive.id and await db.delete(library_file) (the library-file-to-archive promotion path) — flush() opens the SQLite write transaction but doesn't commit, holding the WAL writer lock through the FTP upload below. Every other writer in the process (sensor history task every 60 s, runtime tracking every 30 s, MQTT state UPDATEs from the live H2D/P2S, the user's own concurrent /cancel commits) blocks behind that lock for the full upload duration. With 15 s busy_timeout and FTP uploads regularly exceeding 15 s on larger 3MFs, the sensor history task hit its first lock error → logged + slept 60 s → next tick still blocked → repeat. The lock contention also widened the cancel-race window (the user's cancel commit was queued behind the scheduler's held write), making the race that much easier to lose. Fix is three guards layered in defence-in-depth. (1) Atomic CAS at the pending→printing transition. Replaced item.status = "printing"; await db.commit() with UPDATE print_queue SET status='printing', started_at=NOW() WHERE id=:id AND status='pending'. If rowcount == 0 the user already won the race; log the abort, best-effort delete_file_async the file we just FTP'd up to the printer's SD card (no leftover that would surface in BambuStudio's file picker), send a queue_item_failed{reason: "cancelled_mid_dispatch"} WebSocket event so the user sees their cancel actually took effect, and return WITHOUT calling printer_manager.start_print. The in-memory item.status / item.started_at are synced from the CAS values so the rest of _start_print reads consistent state for notifications. (2) Early refresh + bail before FTP I/O. Right after the printer-connectivity check (~line 2487) the scheduler now await db.refresh(item) and returns early if item.status != "pending". Saves the wasted 5-30 s FTP upload when the user cancelled BEFORE the scheduler tick reached this row (the snapshot is taken at the top of check_queue but iterated through serially; with 10 items in the batch the last item can be picked up minutes after the snapshot, by which time it may already be cancelled). Not load-bearing for correctness — guard (1) catches the same case at the CAS point — but cuts wasted FTP bandwidth, printer SD writes, and downstream cleanup work. (3) flush()commit() before FTP. Replaced the await db.flush() at line 2555 with await db.commit() so the library-file-to-archive promotion's writes (item.archive_id set, library_file deleted) commit cleanly before the FTP block. SQLite WAL writer lock releases immediately; concurrent writers — the sensor history task, the user's /cancel commit, the MQTT UPDATE path — stop queueing behind the scheduler's session. The flush-not-commit pattern existed because the original code wanted to roll back the archive promotion if a later step failed, but archive_service.archive_print() (called four lines above) had already committed the PrintArchive row in its own session, so the rollback was only ever rolling back the item.archive_id pointer back to NULL — which doesn't actually undo the archive creation. The new behaviour matches reality: archive is created (committed), pointer is set (committed), FTP runs without writer-lock contention. Subsequent failure paths still mark the item as failed correctly; they don't try to un-create the archive. Tests. Three new cases in backend/tests/unit/test_scheduler_cancel_race.py: test_cancel_during_ftp_upload_aborts_before_mqtt simulates the headline scenario (cancel commits in a separate session inside upload_file_async's mock side-effect, then the CAS sees rowcount==0 → start_print mock asserted not-called → row stays cancelledstarted_at stays None → two delete_file_async calls, one pre-upload sweep and one post-CAS cleanup); test_cancel_before_ftp_upload_skips_dispatch mirrors the early-bail path (row pre-cancelled before _start_print runs → upload never awaited → start_print never called); test_happy_path_still_dispatches is the regression guard so the CAS doesn't accidentally block normal dispatch on a row that was always pending. 3/3 green + 140/140 in the wider scheduler suite (test_scheduler_cleanup_library.py, test_scheduler_preheat.py, test_scheduler_dispatch_hold.py, test_scheduler_watchdog.py, test_scheduler_ams_mapping.py) regression-clean. Scope. Backend-only. No DB migration. No schema change. No new permission. No new i18n key. No frontend change — the existing queue_item_failed WebSocket toast already renders for the new cancelled_mid_dispatch reason via its generic display path. The database is locked errors are addressed structurally by guard (3); a more invasive session-lifetime restructure inside _start_print was considered and deferred — flush→commit catches the dominant offender (library-file-promoted dispatches, which the OP's 10-item batch hits per copy) without widening the change beyond what #1853 needs.
  • Inject auto-print G-code checkbox can be ticked in PrintModal create mode (#1852, reporter Lamcois) — Symptom: in v0.2.4.8 the user opens the print dialog for a single archive, sees the Inject auto-print G-code toggle next to the gcode-snippet section, clicks it, and the checkbox visually flips back to unchecked instantly. Submitting and then editing the queued item lets the same toggle be ticked normally — so the bug was only in the create-mode flow. Root cause. PrintModal/index.tsx:947-955 carried a useEffect that reset scheduleOptions.gcodeInjection to false whenever mode === 'create' AND (effectiveQuantity <= 1 || !settings?.gcode_snippets). The reset's stale code-comment claimed "the checkbox only renders for create + snippets configured + quantity > 1" — but the actual render gate in ScheduleOptions.tsx:277 is just {hasGcodeSnippets && (...)} with no quantity check. So with snippets configured + quantity = 1 (the OP scenario): user clicks the checkbox → React updates state to true → the parent's useEffect immediately sees the gate's effectiveQuantity <= 1 condition and resets it to false → the checkbox appears un-clickable. Edit-queue-item mode worked because mode !== 'create' short-circuited the reset before it could fire. Fix. Drop the effectiveQuantity <= 1 clause from the reset. The legitimate cleanup that survives — the !settings?.gcode_snippets half — handles the actual edge case the effect was guarding against: an admin removing every snippet while the modal is open, in which case the checkbox's render gate hides the control but the boolean would otherwise still be true on submit. The scheduler's _start_print (print_scheduler.py:2306) reads item.gcode_injection per queue item regardless of batch size, so there's no underlying reason to block injection on single prints — that gate was inserted in error and never matched the render condition. Tests. New regression case in frontend/src/__tests__/components/PrintModal.test.tsx: quantity 1 + snippets configured: checkbox toggles cleanly (#1852) opens the modal in create mode at the default quantity = 1, asserts the checkbox starts unchecked, clicks it, waitFor confirms the displayed checked state stays true after re-render (the pre-fix reset would have flipped the displayed checked back to false), and confirms the gcode_injection: true flag actually reaches the queue API on submit. 61/61 in PrintModal.test.tsx (was 60 + 1 new). Existing batch-mode case (injection ON queues all copies and dispatches none immediately) still passes — my fix doesn't affect the quantity > 1 multi-copy fan-out path. Scope. Frontend-only, one-line behavioural change inside an existing effect. No backend change, no i18n key, no permission.
  • Ignore and Resume now actually ignores the fault — wrong-plate HMS no longer re-pauses 1-2 s after click — Symptom (continuation of #1869): clicking Ignore and Resume on a wrong-plate 0500_8051 HMS cleared the modal, the printer left PAUSE, then ~1-2 s later re-detected the wrong plate and re-paused with the identical HMS code — the modal popped back open and the user could not actually print on the "wrong" plate (the whole point of the Ignore button). Root cause: Bambuddy's execute_hms_action (backend/app/services/bambu_mqtt.py:5496-5523) redirected IGNORE_RESUME on state == "PAUSE" to a plain resume command, with a code comment claiming BambuStudio's "err-bearing shape" was "silently rejected by Bambu firmware" (verified during #1830). That diagnosis was wrong on two counts. (1) IGNORE_RESUME doesn't map to resume at all in BambuStudio. Source-of-truth from BambuStudio src/slic3r/GUI/DeviceErrorDialog.cpp:600-602 and src/slic3r/GUI/DeviceManager.cpp:1450-1462 (commit 4019d2e): the button dispatches command_hms_ignore, whose wire shape is {"print": {"command": "ignore", "err": "<decimal>", "param": "reserve", "job_id": "<job_id>", "sequence_id": "..."}}. The firmware treats command: "ignore" as "suppress this check on the next attempt AND auto-resume the paused print" — semantically distinct from command: "resume" which means "I fixed the problem, re-check normally" and is exactly why the wrong-plate check re-fired. (2) err is a DECIMAL string of the int, not the hex shortcode. BambuStudio passes std::to_string(m_error_code) where m_error_code is the 32-bit int value of the print_error code — so for 0x05008051 the wire string is "83918929", not "05008051". The #1830 H2D test that "verified" the err-bearing shape was silently rejected almost certainly sent the hex string, which the firmware couldn't match against the active int fault → silent rejection looked like the entire shape was broken, when the actual problem was the format of one field. Fix. Replace the hms_ignore(persistent) helper with two distinct helpers matching BambuStudio's source: hms_ignore_command() publishes command_hms_ignore's shape (command: "ignore", decimal err, param: "reserve", job_id); hms_idle_ignore(persistent) publishes command_hms_idle_ignore's shape (command: "idle_ignore", decimal err, type: 0|1). Wire IGNORE_RESUME, IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME (alias) to hms_ignore_command() — BambuStudio routes all three to the same command_hms_ignore (DeviceErrorDialog.cpp:596-602), the "don't remind" half is the firmware's job. Wire NO_REMINDER_NEXT_TIME to hms_idle_ignore(persistent=False) — BambuStudio dispatches it via command_hms_idle_ignore(..., 0) (DeviceErrorDialog.cpp:588-590), distinct from the resume-bearing ignore command. The decimal-int conversion lives at the helper layer (str(int(print_error, 16)) with a defensive fallback to the raw input on parse failure so the route can surface 502 rather than raising mid-dispatch). The job_id=None case sends an empty string, matching BambuStudio's std::string empty default. Resume / stop unchanged — the user has independently confirmed plain resume and plain stop work for PROBLEM_SOLVED_RESUME and STOP_PRINTING on their printer; BambuStudio's command_hms_resume / command_hms_stop do carry the same err+param: "reserve"+job_id fields, but changing a working shape without a field test risks regressing a path the user has confirmed, so the plain shape stays. The previous stale comments about "verified silently rejected" are removed and replaced with citations to BambuStudio's exact source lines. Tests. Six cases in TestExecuteHmsActionDispatch (backend/tests/unit/services/test_hms_actions.py) rewritten to assert the BambuStudio shape: test_ignore_resume_sends_bambustudio_ignore_command_paused pins the full payload (the exact #1869 trace), test_ignore_resume_state_independent confirms no PAUSE/RUNNING branch (BambuStudio's dispatch is unconditional), test_ignore_no_reminder_uses_ignore_command_not_idle_ignore pins the DONT_REMIND_NEXT_TIMEcommand: "ignore" route, test_no_reminder_next_time_uses_idle_ignore_type_zero pins the still-correct NO_REMINDER_NEXT_TIMEidle_ignore route (decimal err now), test_ignore_accepts_16_char_full_code_as_decimal covers the 64-bit hms[]-array fault shape, test_ignore_with_no_job_id_sends_empty_string pins the empty-string sentinel. 35/35 in test_hms_actions.py, 12/12 in HMS-touching test_printers_api.py integration cases, ruff check clean. Scope. Backend-only. Same modal, same API contract, same dispatch route — just a different MQTT command shape on the wire for the ignore branch. No DB migration, no permission, no i18n key.
  • HMS error-modal action buttons look like buttons and stop falsely 502-ing on wrong-plate Ignore and Resume — Two compounding issues in the HMS error modal surfaced when a user forced an HMS error for a wrong build plate and tried to dispatch the per-fault actions. (1) Buttons read as inert badges. The action <button> in frontend/src/components/HMSErrorModal.tsx:1042 used hover:${buttonHoverColor} — a template-literal interpolation Tailwind's JIT scanner can't see as a literal string. The per-severity hover:bg-red-500/10 / hover:bg-orange-500/10 / hover:bg-blue-500/10 classes never reached the compiled CSS, so the hover treatment never fired. The button also reused the same ${bgColor} + ${color} as the severity badge two lines above (line 1020), with no border or weight differentiation, so visually it read as another label. And there was no disabled={mutation.isPending} and no Loader2 placeholder, so during the 2.5 s ack wait the click sat inert with zero feedback. (2) IGNORE_RESUME 502-rejected on wrong-plate while PROBLEM_SOLVED_RESUME succeeded — even though both dispatch the IDENTICAL resume payload. execute_hms_action in bambu_mqtt.py:5511-5513 redirects IGNORE_RESUME to hms_resume() on PAUSE state (firmware silently rejects idle_ignore for paused prints), so the wire shape is the same. The race was in the route's ack-detection at printers.py:3848: acked = client.state.state != pre_gcode or len(client.state.hms_errors) != pre_hms_count. For wrong-plate the printer briefly resumes, immediately re-detects the bad plate, and re-pauses with the same hms code within ~1-2 s. Inside the 2.5 s sample window state.state round-trips PAUSE → IDLE/RUNNING → PAUSE and len(hms_errors) lands back at the pre-publish count → both deltas read zero → false 502 even though the firmware fully ack'd. The user's two-out-of-three success pattern (abort + problem-solved-resume worked, ignore-and-resume 502'd) was the same race resolving differently across runs — sampling caught the brief non-PAUSE in two and missed it in the third. Fix (1). Drop the dead buttonHoverColor field from getSeverityInfo and the per-card destructure. Rewrite the action button with a static Tailwind class string bg-white/10 hover:bg-white/20 active:bg-white/30 text-white border border-white/20 hover:border-white/30 so the JIT actually picks up the hover / active / border-hover utilities; the contrast against any severity-tinted container reads as a clear affordance. Wire disabled={!hasPermission('printers:control') || activateActionMutation.isPending} so all action buttons in the modal lock during a single in-flight command (prevents racing concurrent dispatches). Add a <Loader2 className="w-4 h-4 animate-spin" /> that renders only on the button whose (action, print_error) matches mutation.variables, so the user sees exactly which button is pending. Fix (2). Swap the ack probe from "fault-state diff" to "did the printer push anything back". client._last_message_time (bumped in the MQTT on_message handler at bambu_mqtt.py:939 on every inbound message, regardless of payload) is the robust signal — execute_hms_action always publishes a pushall after the command, so an accepting printer always responds with at least one status push inside the 2.5 s window. The wrong-plate re-pause case still ack's because the printer DID respond; only a genuinely-offline / firmware-silently-dropped command leaves _last_message_time untouched, which is exactly the 502 path #1830 wanted to surface. Tests. Updated three existing cases in TestExecuteHMSAction (backend/tests/integration/test_printers_api.py) — happy-path success, 16-char-full-code, and the 502 no-ack — to mock _last_message_time advance instead of (state, hms_errors) mutation. New case test_execute_hms_action_ignore_resume_repauses_within_window_still_acks pins the wrong-plate IGNORE_RESUME shape: dispatcher returns True, state.state round-trips PAUSE→PAUSE, hms_errors length round-trips to the same N, _last_message_time advances → route returns 200, not 502. 8/8 in TestExecuteHMSAction green. Frontend npm run build clean, npm run test:run 173 files / 2288 tests green, i18n parity clean across all 11 locales. ESLint clean on the modal file. Scope. No DB migration. No new permission. No new i18n key. No new backend route. The action handlers, MQTT publish shapes, and execute_hms_action dispatcher are unchanged.
  • Slice failure UI now surfaces the slicer's real diagnostic, not Bambu Studio's input preset file is invalid placeholder (#1851, reporter srausser) — When the BambuStudio CLI rejects a slice for a content reason (preset-vs-printer compat failure, missing fields, range validation), it exits -5 and writes Bambu Studio's catch-all error_string The input preset file is invalid and can not be parsed. to result.json. The actual per-incident diagnostic — e.g. filament preset Generic PLA BBL H2C (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle. — only lives in the stdout dump as [error] run NNNN: <reason>. The sidecar packed both into the response (message carried the placeholder, details carried the stdout dump), _format_sidecar_error joined them, but _slicer_rejection_message in backend/app/api/routes/library.py:3295 trimmed at the first \nstdout: / \nstderr: cut point — discarding the real CLI error before it reached the SliceJob's error_detail. The reporter only knew an H2C-bound preset had slipped into slot 1 because they checked the container logs by hand; the UI's AlertModal showed the unhelpful placeholder verbatim. Fix. _slicer_rejection_message now mines the response body with _CLI_ERROR_LINE_RE (\[error\]\s*(?:run\s+\d+:\s*)?(.+?)$, MULTILINE) BEFORE the stdout/stderr trim removes it. When the headline reason matches the bundled The input preset file is invalid and can not be parsed. placeholder — or when the headline is empty — the mined [error] line is substituted in its place; when the headline carries a useful reason already (Some objects are located over the boundary of the heated bed., The temperature difference of the filaments used is too large., etc.) the headline is kept and the [error] line is ignored to avoid duplicating the same text. The regex tolerates both [error] run NNNN: <msg> and the bare [error] <msg> shape the CLI uses on different code paths, and matches against the FULL pre-trim response so anything in stderr is also covered. Tests. Three new cases in TestSlicerRejectionMessage (backend/tests/integration/test_library_slice_api.py): test_replaces_input_preset_invalid_placeholder_with_cli_error_line pins the exact #1851 H2C/A1 trace from the report; test_keeps_meaningful_reason_even_when_cli_error_line_present ensures bed-boundary and other already-specific reasons aren't clobbered by an unrelated stdout [error]; test_cli_error_line_without_run_prefix covers the bare [error] <msg> shape. Existing four cases stay green. 7/7 in the class. Scope. Backend-only. The frontend already reads state.error_detail verbatim into the AlertModal (SliceJobTrackerContext.tsx:188), so the better message flows through with no UI change. No DB migration, no new permission, no new i18n key.
  • Slice modal's filament auto-pick hard-skips printer-mismatched presets when any compatible alternative exists (#1851 root cause, reporter srausser)pickFilamentForSlot in frontend/src/components/SliceModal.tsx:123 scored every filament against the plate slot's (type, colour) requirement plus a -100 penalty when the preset's compatible_printers list / BBL <token> name resolved to a different printer than the user picked (#1325). The soft penalty was dominant in nominal-data scenarios but the contract — "never auto-fill a slot with a printer-incompatible preset while a compatible one exists" — was implicit, not enforced; any future scoring change (a higher type-match weight, an extra metadata bump, a Bambu Cloud filament shape change that erases compatible_printers) would silently flip the picker back into the soft regime. The propagation amplifier: when the picked plate doesn't use every project slot (here: H2C source plate uses slot 4 only; slots 1-3 are unused), substitute_unused_plate_filaments in backend/app/services/slicer_3mf_convert.py:238 rewrites every unused slot to slot 1's content — so any printer-mismatch in slot 1 silently propagates across the whole filament array, and one bad auto-pick poisons the entire slice. Fix. The scorer now partitions candidates into two buckets — compatible/unknown vs mismatch — and returns the best-scoring compatible/unknown candidate whenever the compatible bucket is non-empty; the mismatch bucket is only consulted when zero compatible alternatives exist (preserves graceful-degrade for preset registries that genuinely have nothing for the selected printer). Identical to the existing pickProcessDefault two-pass 'match''unknown' shape, applied to filaments. No metadata-scoring change. The four shared picker helpers (pickFilamentForSlot, pickProcessDefault, pickDefault, findPreset / findPresetByName plus the SLICE_MODAL_TIER_ORDER constant) moved out of SliceModal.tsx into a new frontend/src/utils/slicePresetPicker.ts so the contract is unit-testable and the modal file only exports React components (the react-refresh/only-export-components lint rule fails fast-refresh on non-component exports from a .tsx file). Tests. Three new cases in a dedicated pickFilamentForSlot — printer-compat contract (#1851) describe block in frontend/src/__tests__/components/SliceModal.test.tsx: the OP trace (A1 printer + Generic PLA BBL H2C perfect colour vs Bambu PLA Basic BBL A1 colour mismatch → A1 wins), the all-mismatch graceful degrade (only H2C preset present → H2C still returned, dropdown not empty), and the no-printer-context transient (printerName === null during first render → no compat filter, plain score-best wins). 35/35 SliceModal tests + 25/25 utils/slicerPrinterMatch tests green. Scope. Frontend-only, single helper, one new export for testability. No new i18n key. No backend change. The unused-slot substitution stays as-is — it's only correct when slot 1 is correct, which the picker now enforces.
  • Uncataloged-but-actionable HMS faults now render in the UI (#1840, reporter Boa-Thomas) — H2C printers (firmware 01.02.00.00) emit HMS faults whose short codes aren't in the bundled ERROR_DESCRIPTIONS map — e.g. 0500_809C, which pauses the print and carries IGNORE_RESUME / PROBLEM_SOLVED_RESUME actions the user needs to dispatch. filterKnownHMSErrors in frontend/src/components/HMSErrorModal.tsx:900 (and the inline modal-local copy at line 929) gated visibility purely on catalog membership (ERROR_DESCRIPTIONS[shortCode] !== undefined), so the entire error never rendered: no problem pip, no per-card count, no errors-panel entry, no action buttons. Backend correctly captured + dispatched the fault (verified via REST and WebSocket); frontend silently dropped it. The catalog gate isn't dead code — it's also a noise filter for transient post-cancel echoes like 0C00_001B (see PrintersPageBucketing.test.ts) — so deleting it would re-introduce the FAILED-after-cancel "1 problem forever" regression. Fix. filterKnownHMSErrors now keeps an error if EITHER it's in ERROR_DESCRIPTIONS (existing behaviour, preserves bucketing for noise) OR it carries actions.length > 0 (actionable fault from any source — surface so the buttons can render). The modal's inline filter is replaced with a call to the shared helper so badge counts and modal contents agree by construction. For uncataloged errors, the description falls back to t('hmsErrors.unknownCode') ("Unknown HMS code — see the Bambu Lab wiki for details."); the existing [XXXX-YYYY] short-code header, severity badge, action buttons, and wiki link all work without a catalog entry. The action-dispatch path is unchanged — full_code already flows through correctly from #1830, so IGNORE_RESUME etc. land on the firmware the moment the user clicks. The reporter's secondary observation about severity === "error" is a false positive — that comparison lives in SystemHealthPanel.tsx (log-health findings, string-typed severity), not the HMS path which correctly switches on the numeric 1–4 scale. Severity 6 falls into the default Info branch — acceptable for an unrecognized level and out of scope here. Tests. New case in PrintersPageBucketing.test.ts: 'classifies PAUSE + uncataloged HMS WITH actions as "error"' pins the H2C scenario (0500_809C + IGNORE_RESUME/PROBLEM_SOLVED_RESUME → bucket error). Existing case 'classifies FAILED + only unknown HMS as "finished"' (uncataloged WITHOUT actions = noise) stays green — the gate distinguishes the two by action presence. 18/18 frontend tests in PrintersPageBucketing.test.ts + HMSErrorModal.test.tsx green. i18n. One new key hmsErrors.unknownCode, real translation in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), parity check clean. Scope. Frontend-only. No backend change. No DB migration. No new permission. The fix is data-shape agnostic — any future printer whose HMS dictionary diverges from the bundled catalog will now surface actionable faults without a Bambuddy release.
  • First-layer notification photo no longer shows pre-print calibration state (#1837, reporter MartinNYHC) — On P1S (and any Bambu printer with a long pre-print calibration sequence) the "First Layer Complete" notification fired during PREPARE, not after layer 1 was actually printed — the attached photo showed a lowered bed + parked toolhead + clean plate, because the firmware ticks layer_num during homing / auto-bed-leveling / bed-surface scan / nozzle clean before the first real extrusion. Reporter's log timeline made it explicit: print start at 13:54:27, notification fired at 14:10:13 with [SNAPSHOT] Capturing fresh frame, gcode_state: RUNNING not seen until 14:44:28 — i.e. the notification went out ~30 minutes before the print actually started. The trigger in main.py:6044 only gated on 2 <= layer_num <= 5 with no check that the printer was actually printing. Fix. The trigger now requires state.state == "RUNNING" AND state.mc_print_sub_stage in (None, 0)0 is the "Printing" stage in the canonical Bambu STAGE_NAMES map (bambu_mqtt.py:376), so the non-zero pre-print sub-stages (1 Auto bed leveling, 9 Scanning bed surface, 10 Inspecting first layer, 13 Homing toolhead, 14 Cleaning nozzle tip, …) all skip. None is preserved as a no-opinion fall-through for any firmware that doesn't push mc_print_sub_stage so unknown-firmware installs keep their existing behaviour. _first_layer_notified is only set once the gate passes, so calibration-phase layer_num ticks are non-consuming — the next on_layer_change edge after the printer enters real printing fires the notification. The trigger window widens from [2, 5] to [2, 10] so that if calibration consumes several layer_num slots before RUNNING, the deferred edge still falls inside. Tests. Manual verification via the issue reporter's installation; no new unit tests added (the on_layer_change closure is wired inside an event-handler factory and isn't a unit-testable pure function — would require a substantial fixture rewrite for a one-condition guard that's already covered by integration of the printer-state machine). Scope. Backend-only, single-file change. No DB migration. No new permission. No frontend change. No new i18n key. The window widening doesn't risk firing a stale notification on prints whose layer_num advances past 10 during PREPARE — the RUNNING + sub-stage gate ensures the notification only fires when the printer is actually printing, regardless of how many ticks PREPARE consumed.
  • Multi-nozzle prints no longer collapse all filaments onto one nozzle (#1825, reporter needo37) — The single-active-extruder shortcut added in #851 (for #827) at threemf_tools.py:354 runs before the per-filament group_id mapping, and fires whenever extruder_nozzle_stats reports 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) == 1 triggered → every filament was force-assigned to physical_extruder_map[active_idx], the authoritative per-filament group_id was 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 verbatim nozzle_mapping from 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 on len(distinct_group_ids) <= 1 from slice_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 existing group_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 in TestExtractNozzleMappingFrom3MF: test_single_active_under_report_with_multi_group_falls_through pins 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_shortcut preserves the #851 behaviour (same stats + only group_id=0 → shortcut still fires → {1:1, 2:1}). Existing test_single_active_extruder_maps_all_slots and test_two_active_extruders_falls_through stay 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.py 272/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 in AssignSpoolModal.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 the truthy && guard so empty notes don't add a blank row. The existing title={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 extra text-[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. Frontend npm run build clean. npx vitest run AssignSpoolModal.test.tsx AssignToAmsModal.test.tsx 23/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_permission gates API keys on all_perm only (line 1668) — the comment block at line 1659 says OWN and ALL "both map to the same scope flag" for queue / archives / etc., so checking all_perm is the correct gate. Library deliberately broke that invariant by putting LIBRARY_UPDATE_ALL / LIBRARY_DELETE_ALL in _APIKEY_DENIED_PERMISSIONS while only the OWN variants were allowlisted under can_manage_library. Net effect: every library curation route (DELETE /library/files/{id}, PUT /library/files/{id} rename, POST /library/files/move) returned 403 "API keys cannot be used for administrative operations" for keys with can_manage_library=True, contradicting the wiki docs that explicitly list "rename and delete your own library entries" under that scope. Only POST /library/files/{id}/slice worked (it doesn't go through require_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's file.created_by_id != user.id ownership check would AttributeError on a key acting under OWN anyway — the only path that ever worked was can_modify_all=True, which all_perm denial blocked outright. Fix. Fold LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into _APIKEY_SCOPE_BY_PERMISSION mapping to can_manage_library (matching the can_queue precedent — both QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL map to can_queue for the same per-key-identity reason). Remove both from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE deliberately 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 in TestLibraryPermissions pinning 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), and test_apikey_with_manage_library_still_cannot_purge (LIBRARY_PURGE stays admin-only). The matrix drift-detection in test_auth_apikey_rbac.py updated to include LIBRARY_UPDATE_OWN, LIBRARY_UPDATE_ALL, LIBRARY_DELETE_ALL under can_manage_library and removes LIBRARY_DELETE_ALL from _ADMIN_CASES. pytest -n 30 backend/tests/unit backend/tests/integration green (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-permissions now matches actual behaviour.
  • Administrators system group self-heals to include every current permission on upgrade — covers printer_sensor_history:read and every future new permission — Fresh installs bootstrap the Administrators group with ALL_PERMISSIONS (every value in the Permission enum), so a fresh install always has the full set. On upgraded installs, seed_default_groups() in backend/app/core/database.py previously only backfilled the specific permissions explicitly listed in one-off migration blocks (library:purge, archives:purge, the OWN/ALL read-flag split, orca_cloud:auth, pipelines:*, …). Any permission added to the enum without a matching block silently stayed missing on existing admin rows, leaving admins gated out of the feature it controlled. The most recent gap was printer_sensor_history:read (Read Printer Sensor History was never granted to upgraded admin groups, so the Sensor History charts read as 403 for admins on installs seeded before that permission existed). Fix. Replaced the per-permission admin backfills with a single sync block: for the Administrators system group, append every value in ALL_PERMISSIONS that isn't already on the row. Additive only — custom permissions added by hand (e.g. plugin permissions, hand-edited rows) are preserved. The legacy admin-only backfills (library:purge/archives:purge block, the OWN/ALL read-flag block including orca_cloud:auth and the legacy archives:read/library:read/queue:read UI gates, and the Administrators branch of the pipeline backfill) are retired since they're subsumed by the sync. Non-admin backfills (Operators / Viewers OWN-tier read flags, Operators orca_cloud:auth, pipelines for non-admin groups, MakerWorld + printers:clear_plate cross-group adders) are untouched. Tests. Three new cases in test_read_permission_backfill_migration.py: test_administrators_printer_sensor_history_read_backfilled (the exact regression reported), test_administrators_sync_covers_every_current_permission (generic invariant — every ALL_PERMISSIONS value lands on Administrators after the sync, catches any future new permission without a one-off test), and test_administrators_sync_is_additive_only (hand-added custom permissions are preserved). 12/12 backfill-migration tests + 102/102 broader permission tests green; ruff clean.
  • Slicer Pipelines runs dashboard — native browser <select> filters replaced with themed dropdowns — The Pipeline / Status / Target filter row on the Print Queue → Pipelines tab now uses a bambu-themed FilterDropdown (button trigger styled like the existing filter chips, floating menu, optgroup-style headers for the Target picker's Specific printer / Printer class sections, hover + selected states with a check mark, closes on outside click and Escape) instead of the browser's native selects, which rendered as washed-out grey strips that fought the rest of the page palette. Same value/onChange contract — no behaviour change, just visuals. Also fixes a react-hooks/exhaustive-deps warning in SlicerPipelinesPanel.tsx: the inline list?.pipelines ?? [] was returning a fresh empty array on every render, invalidating both downstream useMemo caches (target-options computation and filtered pipeline list) on every re-render. Wrapped in its own useMemo keyed on list?.pipelines so the reference is stable when the data is stable.
  • HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification) — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned 200 OK. Three independent bugs combined into one user-facing failure. (1) Wrong command shape for resume / stop. hms_resume() and hms_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 on device/<sn>/request against a live H2D paused on a wrong-plate HMS: the err-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 plain resume transitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, no err, no job_id, no param:"reserve". (2) IGNORE_RESUME mapped to the wrong command for paused prints. The original mapping dispatched idle_ignore for both IGNORE_RESUME and NO_REMINDER_NEXT_TIME. idle_ignore is 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 of err. hms_ignore() now branches on self.state.gcode_state == "PAUSE": paused → dispatch plain resume (which is what the button actually means on a paused print), running/idle → keep idle_ignore with the type=0/1 persistence flag. DONT_REMIND_NEXT_TIME on 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-bit hms[]-array faults truncated to a non-matching err (#1830 §(1)). The hms[] parser at line 2740 built the short code as f"{(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 truncated 0C00000C doesn't match what the firmware compares against in idle_ignore. New HMSError.full_code field carries the canonical hex identifier — 16 chars f"{attr:08X}{code:08X}" for hms[]-sourced faults, 8 chars f"{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 echoes error.full_code back as HmsActionBody.print_error instead 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_action returned True the moment the publish succeeded, so any of the three bugs above produced 200 OK while the printer ignored the command and the modal kept popping. The /hms/execute-action route now snapshots (gcode_state, print_error, hms_errors count) before dispatch, awaits HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests override), and returns 502 "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 on device/0948BB540200427/request confirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raises print_error=0x05008051 ("Detected build plate is not the same as the Gcode file"), the printer enters gcode_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.py shape 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. New TestHMSFullCode class in test_bambu_mqtt.py pins 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 in test_printers_api.pytest_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.py green (509 + 181). ruff check clean. Frontend npm run build clean. Scope. No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existing hmsErrors.actionFailed string, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". The HMSError.full_code field 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 (Pydantic ge=1, le=1000) replaces the implicit 1 from PR B; the orchestration loop creates one PipelineJob row per copy. SlicerPipelineUpdate accepts target_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), and fanout_strategy (max_parallel / round_robin / fill_one_first). A new pipeline_max_copies setting (default 50, Pydantic ge=1, le=1000) gates the copies input in the Run-with-pipeline modal and is enforced again at POST /run time so an API caller can't bypass the cap. PR C also adds PipelineRun.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 in services/pipeline_eligibility.py now branches on pipeline.target_kind: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates every Printer whose model matches pipeline.target_model_class, runs the per-printer slot-by-slot check for each via a status_lookup closure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-level printer_reports: list[PerPrinterReport] with ok derived as any across the candidates. New issue kinds: no_class_matches (the install has zero printers in the chosen model class) and class_not_set (target_kind is printer_class but no model was picked). The lenient-policy story is the same — operators can Run anyway past blocking issues, and PipelineRun.eligibility_overridden is 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_parallel sets target_model=pipeline.target_model_class on every queue item and leaves printer_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_robin enumerates eligible printers (is_active=True, model matches) ordered by id and assigns copy i to eligible[i % len(eligible)] — each item gets a fixed printer_id, the wear distributes evenly. fill_one_first pins every copy to eligible[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 through slice_dispatch.enqueue exactly 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&status is the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status. POST /pipeline-runs/{id}/retry-failed counts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a fresh PipelineRunCreateRequest with copies=that count and force=True (operator already accepted eligibility on the parent), routes it through the existing run_pipeline handler, and stamps parent_run_id on 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}/cancel extends PR B's cancel to cascade across N queue entries — only the ones still in pending / queued are touched so in-flight prints continue on the printer (operator must Stop on the machine). WebSocket. New pipeline_run_updated event type carries the full materialised PipelineRunResponse and fires on every state transition (queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled). Per-user routing via ws_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's useWebSocket switch 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_status function 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 new partial_failure status (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_progress counts 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 for target_kind (Specific printer / Printer class), a model-class picker filtered to the models present on at least one installed Printer row (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. RunWithPipelineModal grows a number input for copies bounded by settings.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 on pipelines: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_failure is amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing each PipelineJob'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 across nav.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), and common.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 in test_pipeline_runs_api.py covering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequential copy_index, class eligibility with two X1C candidates returns a 2-entry printer_reports array, class eligibility with no matching printers in install returns no_class_matches, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stamps parent_run_id. Plus the existing 16 PR A/B cases were lightly updated where class_not_set is now a valid no-target signal alongside printer_not_set. Five new frontend cases in PipelineRunsPage.test.tsx pin 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 on runPipeline (pipelineId, source, force, copies). One updated SettingsPage.test.tsx sidebar-order test reflects the new pipelineRuns nav entry between queue and projects. Suites. pytest -n 30 backend/tests/ 6539/6539 green; npx vitest run 2284/2284 green (173 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. 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. The fill_one_first strategy'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 is max_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 through asyncio.create_task directly and never registered with SliceJobTracker. Fix. (1) POST /slicer-pipelines/{id}/check-eligibility and POST /slicer-pipelines/{id}/run now accept source_archive_id as an alternative to source_library_file_id (XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via _resolve_source which reads archive.source_3mf_path with a fallback to archive.file_path. PipelineRun.source_archive_id is a new nullable FK column (Postgres + SQLite ALTER TABLE in run_migrations — idempotent via _safe_execute). PipelineRunResponse echoes the field. ArchiveCard's context menu picks up a Run with pipeline item alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated on useSlicerApi + pipelines:run. Path-safety: Path(base_dir) / archive.source_3mf_path carries a SEC-PATH-OK marker citing the upload-time validator at _resolve_source_3mf_path (same comment style as routes/archives.py:3955); the LibraryFile.file_path site gets the same treatment. (2) The pipeline orchestrator is now the run callable of a slice_dispatch.enqueue call — the same dispatcher the manual SliceModal flow uses — instead of a bare asyncio.create_task. The SliceJob's lifecycle (pending → running → completed/failed) drives the existing progress toast end to end: same persistent toast, same Generating G-code 75% weave from the sidecar's --pipe channel, same auto-replace with a transient success/error toast on terminal. PipelineRun.slice_job_id is set on the run row before the route returns 202, so the frontend can call useSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename) from RunWithPipelineModal's runMutation.onSuccess — same one-call surface that SliceModal's slice mutation already uses. (3) RunWithPipelineModal's source prop is now {kind: 'libraryFile' | 'archive', id, filename} (mirrors SliceModal.SliceSource); api.checkPipelineEligibility + api.runPipeline take a discriminated-union source argument and route to the right backend field. PipelineRun TS type grows source_archive_id. Tests. Three new backend cases in test_pipeline_runs_api.py — archive-source happy path (creates a PrintArchive row + on-disk file, posts with source_archive_id, verifies the response carries source_archive_id + slice_job_id from a stubbed slice_dispatch.enqueue), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patch backend.app.services.slice_dispatch.slice_dispatch.enqueue (the new mock target) instead of the removed _run_pipeline_orchestration helper, and the run-happy-path now asserts slice_job_id == 9001 arrives on the response. One new frontend case in RunWithPipelineModal.test.tsx pins the archive flow end to end (checkPipelineEligibility called with {kind: 'archive', id: 7}, then runPipeline with the same). The existing fast/slow path tests were updated to wrap in SliceJobTrackerProvider (the new useSliceJobTracker hook requires it) and to assert the new discriminated-union source argument. Suites. pytest -n 30 backend/tests/ 6533/6533 green; npx vitest run 2279/2279 green (172 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. 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) and PipelineJob (one row per copy; PR B always 1, PR C variable). Soft-link to slicer_pipelines via ondelete='SET NULL' so run history survives a pipeline delete; same for source_library_file. status on 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 at services/pipeline_eligibility.py — given a pipeline + the live PrinterState from printer_manager.get_status, returns a structured report with typed issues: printer_not_set, printer_not_found, printer_disabled (from Printer.is_active shipped 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 mirrors print_scheduler._canonical_filament_type so PLA Basic / PLA Matte / etc. all collapse to PLA for 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 can Run anyway (sets eligibility_overridden=True on the run row so the audit trail shows which runs bypassed pre-flight). Routes. Two new routers — pipeline_run_create_router mounted under /slicer-pipelines (POST /{id}/check-eligibility, POST /{id}/run, GET /{id}/runs?limit=N) and pipeline_run_router at /pipeline-runs (GET /{id}, POST /{id}/cancel). POST /run returns 202 with the run shape; orchestration happens in a fire-and-forget asyncio.create_task that opens its own DB session (the request's session is closed by the time it runs) and walks: status='slicing' → slice_and_persist with the pipeline's SliceRequest → on success status='dispatching' + insert PrintQueueItem with printer_id=target_printer_id, library_file_id=sliced_library_file_id. The existing scheduler picks the queue entry up on its next tick. POST /run with eligibility issues and no force returns 409 with the report inside detail so the frontend can render the same confirmation modal it would for an explicit pre-flight; force=true bypasses the 409 but a missing target_printer_id still 400s (defence in depth — the UI can't enqueue the print without a target). POST /cancel is idempotent on terminal states and cascades to the linked queue entry when its status is still pending / queued (in-flight prints continue — operator must Stop on the printer itself). SlicerPipeline.target_kind / target_printer_id become writable via PUT /slicer-pipelines/{id} — the schema accepts both fields, the route treats target_printer_id=0 as "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 from api.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 — small Last run: completed · 27/06/2026, 14:23 line driven by GET /slicer-pipelines/{id}/runs?limit=1 with a 15 s refetchInterval so the chip ticks while a run is in flight. RunStatusBadge colour-codes the seven states. New component RunWithPipelineModal at components/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 a No target printer set hint), 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 the IssueText mapper — eg. Filament slot 1: expected PLA, AMS has PETG for filament_type_mismatch, AMS slot 2 not available on this printer for ams_slot_missing — then Run anyway posts with force=true. FileManagerPage integration: FileCard's action menu picks up a Run with pipeline entry (gated on the new pipelines:run permission); 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 same setRunPipelineFile(file) state which renders the modal. The action is only offered on slice-eligible files (3MF / STL / STEP) and only when use_slicer_api is 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 grows PipelineEligibilityReport, PipelineRun, PipelineJob, PipelineRunListResponse, plus six new api.* methods (checkPipelineEligibility, runPipeline, listPipelineRuns, getPipelineRun, cancelPipelineRun, and the updated updateSlicerPipeline which now accepts target_kind + target_printer_id). The Permission union also gets pipelines: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 rejected hasPermission('pipelines:run')). i18n. ~36 new keys across library.runWithPipeline.* (modal title / confirm / source-hint / pipeline-hint / target-hint / Run-anyway / 8 issue-kind strings / 2 toast / empty-state / no-target hint) and settings.pipelines.field.targetPrinter / field.noTarget / noTargetHint / noTargetWarning / runs.lastRun + seven runs.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 string slicing was added to IT_COGNATES (genuine cognate — same word in Italian). Tests. 13 new backend integration cases in test_pipeline_runs_api.py covering 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 via patch(..._run_pipeline_orchestration) so CI runs without a live sidecar. 4 new vitest cases in RunWithPipelineModal.test.tsx pin the modal's two-step flow: empty state, disabled pipeline-without-target, fast-path (issues empty → modal closes immediately after runPipeline(..., false)), slow-path (issues shown → Run anyway posts with force=true). Suites. pytest -n 30 backend/tests/ 6530/6530 green; npx vitest run 2278/2278 green (172 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. 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 to status='failed' with the slicer's error string in error_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_pipelines materialises 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. Model SlicerPipeline (models/slicer_pipeline.py), Pydantic schemas SlicerPipelineCreate / Update / Response reusing the existing PresetRef shape from schemas/slicer.py, CRUD routes at /api/v1/slicer-pipelines/ (GET list, POST create, GET/PUT/DELETE by id). Soft-delete via is_deleted so PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first by id DESC (more reliable than created_at under back-to-back inserts whose DateTime precision can tie). Routes use explicit await db.commit() after the mutation (matches the routes/library.py pattern) so the response shape returns the committed row. Permissions. Three new Permission values: PIPELINES_READ, PIPELINES_WRITE, PIPELINES_RUN. PR A only consumes the first two; RUN is defined now so PR C doesn't need to backfill. Administrators and Operators get all three; Viewers get PIPELINES_READ. A backfill block in seed_default_groups() adds them to existing groups on upgrade (mirrors the library:purge / archives:purge pattern from earlier). All three are added to _APIKEY_DENIED_PERMISSIONS so they fail closed for any API-key surface — PR B / PR C may move PIPELINES_RUN onto can_queue once 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. an orca_cloud preset 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 an Apply pipeline ▾ dropdown plus a Save as pipeline button 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 across settings.pipelines.* and slice.pipelines.* plus settings.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 to IDENTICAL_TO_EN_ALLOWED for the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). Tests. Backend: 11 integration cases in test_slicer_pipelines_api.py covering 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 a presetSelects() helper that filters out the new Apply-pipeline combobox so historical selects[0] indexing into printer/process/filament remains stable. Suites. pytest -n 30 backend/tests/ 6517/6517 green; npx vitest run 2274/2274 green (171 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. Scope. No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (the target_kind='specific_printer' path), PR C adds multi-copy batch with capability matching + the three fanout strategies (max_parallel / fill_one_first / round_robin). The Run pipeline action 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 with progress events — 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 s project_file digestion window before the printer actually started extruding. Fix. The legacy bg-dispatch toast rendering from 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510–650 is ported back in place verbatim — same DOM tree, same Tailwind classes, same formatFileSize bytes line, same uppercase status chip, same collapse chevron, same awaitingPrinter derivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacy background-dispatch aggregate event. Materialization only on actual upload start. The toast appears when the FTP push to the printer starts (queue_item_uploading), NOT on POST /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, carries printer_name + total_bytes from file_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 at bytes_transferred >= total_bytes; this matches the legacy background_dispatch.py:614-615 gates 1:1 so the bar feels identical on small AND large files; a single shared _UploadProgressBridge instance bridges from the FTP executor thread back to the asyncio loop via run_coroutine_threadsafe), queue_item_acked (watchdog confirmed printer transitioned out of pre_state), queue_item_failed (any error, with a reason key the toast looks up as dispatchToast.failed.{reason} for upload-vs-start-command differentiation, generic fallback). No queue_item_dispatched event — the legacy bg-dispatch path kept status='processing' from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely from upload_progress_pct >= 99.9 (the legacy uploadDoneAwaitingPrinter trick at line 568-572). An explicit dispatched event would push the status chip out of PROCESSING prematurely — which is exactly what the first screenshot-iteration showed. Per-user routing. New ws_manager.broadcast_to_user(user_id, msg) filters connections by websocket.state.bambuddy_principal_user_id — resolved once at WS connect time via a select(User.id).where(User.username == principal) lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs route user_id=None to all connections, matching the legacy single-user toast behaviour. The watchdog success path receives created_by_id via a new kwarg so the static _watchdog_print_start method can still emit the acked event without re-fetching the queue item. Backend. ~110 LOC across 3 files: core/websocket.py (broadcast_to_user + four event helpers, bambuddy_principal_user_id filter on each connection), api/routes/websocket.py (principal username → User.id resolve at connect, stashed on websocket.state.bambuddy_principal_user_id), services/print_scheduler.py (_UploadProgressBridge thread-safe throttle class, queue_item_uploading emitted before FTP with printer.name, progress_callback= plumbed into both the with_ftp_retry and direct upload_file_async branches via **kwargs, queue_item_failed at the FTP-fail spot, watchdog success path emits acked on both Phase A and Phase B exits). Frontend. Rendering ported in place to contexts/ToastContext.tsx (dispatchData field on Toast, ingest useEffect mapping the four bambuddy:dispatch-toast event types to legacy DispatchToastJob shape, terminal-state auto-dismiss useEffect; 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.ts forwards the four queue_item_* cases via window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail })), matching the existing plate-not-empty / unknown-tag patterns. i18n. 11 keys × 11 locales under dispatchToast (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW): untitled / startingPrints / progressSummary (header {{complete}}/{{total}} complete • Processing: {{processing}}Dispatched: X from 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. Backend test_ws_broadcast_to_user.py pins the routing contract (filter by user_id, fan-out on None, payload shape with printer_name for uploading, server-side pct compute including divide-by-zero); test_upload_progress_bridge.py pins 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.tsx pins the materialization-on-uploading invariant (stray progress / acked event before any uploading does NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip staying PROCESSING through 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.py green; vitest run src/__tests__/contexts/ 49/49 green; ruff check backend/ clean; npm run build clean. Scope. No DB migration. No new permission. The bambuddy:dispatch-toast window 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 useSponsorPrompt was calibrated for power users: the lowest print milestone was 100, the lowest archive milestone was 50, the lowest filament-cost milestone was 100. 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_MILESTONES now (10, 25, 100, 500, 1000, 2500, 5000), ARCHIVE_MILESTONES now (5, 10, 50, 250, 1000), COST_MILESTONES now (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_cost is unchanged — a user already at 200 prints still gets prints-100 first (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_count and test_fires_when_cost_sum_crosses_100 rebalanced (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. New test_fires_at_lowest_threshold pins prints-10 as the new minimum trigger. pytest -n 30 backend/tests/unit/test_sponsor_prompt_service.py backend/tests/integration/test_sponsor_prompt_api.py green (25/25). ruff check clean. 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/login rejects 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-password rejects 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 path BAMBUDDY_LOCAL_LOGIN=true (also accepts 1 / yes, case-insensitive) bypasses the gate on both routes and flips the reported local_login_enabled flag on /auth/advanced-auth/status back 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 own ldap_enabled switch 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-provider is_autologin flag — 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 races getOIDCAuthorizeUrl against 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=local query param always skips the autologin redirect — paired with the BAMBUDDY_LOCAL_LOGIN=true env-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 no UserOIDCLink row. Either failure mode would otherwise lock everyone out of the install. Backend. local_login_enabled: bool = True added to AppSettings + AppSettingsUpdate schemas and to the _BOOL_KEYS allowlist in routes/settings.py. OIDCProvider.is_autologin: bool column via _safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...) — SQLite DEFAULT 0, Postgres DEFAULT false per the project's existing boolean-migration pattern. New OIDCProviderResponse.is_autologin field threaded through from_attributes=True. _local_login_env_bypass() reads at call time (not import time) so tests can monkeypatch the env between cases. /auth/advanced-auth/status extended with local_login_enabled and autologin_provider_id so the LoginPage decides UI in one query — autologin_provider_id filters on is_enabled=True AND is_autologin=True so disabling a provider stops the autologin redirect even if the flag stays set. Frontend. LoginPage.tsx adds the autologin useEffect (skips redirect when ?fallback=local is in the URL, when an OIDC token is already in the fragment, or when an oidc_error query 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.tsx exposes the local_login_enabled toggle in the OIDC tab card above the existing provider list; OIDCProviderSettings.tsx adds the per-provider Autologin toggle in the form's flags row. AppSettings, AdvancedAuthStatus, OIDCProvider, and OIDCProviderCreate TypeScript 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 in test_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.py 44/44, test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py 159/159. Backend ruff check clean. Frontend npm run build clean. Scope. No new permission — the existing SETTINGS_UPDATE permission gates the toggle. The migration is a single ADD COLUMN per backend; the local_login_enabled setting 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-R caption 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 reads L, the right reads R), and the bottom caption is removed. Single-nozzle externals — a single tray with no left/right distinction — keep the 1 index. The FilamentSlotCircle slotNumber prop is widened from number to number | string to carry the L/R label; the two regular-AMS callsites that pass a numeric index keep working unchanged. The Ext-L / Ext-R strings 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. Frontend npm run build clean. Existing 10 FilamentSlotCircle tests 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 assigned live mode by CameraWall.modeByPrinter — it consumed a Max live streams budget slot AND rendered the red LIVE chip on top of the WifiOff placeholder. The allocator now treats !connected like off-screen — assigns paused, leaves the live budget intact. The existing CameraTile rendering (WifiOff icon, dark Off chip) 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/stop is the unmount cleanup for every camera consumer (CameraTile, EmbeddedCameraViewer, popup CameraPage). It used to unconditionally shutdown_broadcaster(f"printer-{id}") + kill every ffmpeg in _active_streams whose 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 showed No signal until the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown calls unsubscribe(queue), and when the count reaches 0 the broadcaster's own _grace_then_stop waits _GRACE_SECONDS (5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown. /camera/stop was just a fast-cleanup shortcut for the single-viewer case. Fix. New get_subscriber_count(key) accessor in camera_fanout.py exposes the broadcaster's subscriber_count (the private list-len already used internally). The /camera/stop route now reads get_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 natural iter_subscriber.finally → unsubscribe path, 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/stop POST 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. New test_stop_camera_stream_skips_shutdown_when_subscribers_remain in test_camera_api.py patches get_subscriber_count to return 2 and asserts /camera/stop returns {stopped: 0, skipped: true}, does NOT call shutdown_broadcaster, and does NOT terminate any _active_streams ffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers — get_subscriber_count returns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Full test_camera_api.py 43/43 green. ruff check backend/ clean. Frontend npm run build clean. 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 overlay switches between Off, Compact, and Full (default Full). Compact paints a colour-coded state chip in the top-left corner — Printing / Paused / Finished / Error — bucketed using the same classifyPrinterStatus rules that drive the printer-card badges, with Idle deliberately suppressed so a wall of cold printers stays visually quiet. Full adds a bottom info strip on tiles whose state is Printing or Paused: the active file's subtask_name ?? gcode_file, the rounded progress percent, Layer N/M when both are known, and the remaining time formatted by the existing formatDuration(remaining_time * 60) helper from utils/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 existing filterKnownHMSErrors from HMSErrorModal), the chip flips to the red Error colour with a lucide-react AlertTriangle icon inline. The whole overlay layer is gated by connected — disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. Zero new network cost. CameraWall.tsx already ran useQueries({ queryKey: ['printerStatus', id], ... }) against every printer for the connected flag; the patch widens the useMemo to expose the full PrinterStatus payload and threads state, progress, remaining_time, layer_num, total_layers, subtask_name, gcode_file, and the filtered HMS error count into each CameraTile — same shared React Query cache the PrinterCard flow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. Settings. Per-user, persisted in localStorage under camWallStatusMode alongside the existing camWallMaxLive and camWallSnapshotSec keys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelled Off / Compact / Full. Default Full because 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 in CameraTile.test.tsx continue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located in CameraTile.tsx (mirrors PrintersPage.classifyPrinterStatus for RUNNING/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 under printers.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 existing printers.status.{printing,paused,finished,error,idle} keys so no new translation work was needed for the bucket vocabulary. Parity script check-i18n-parity.mjs adds two legitimate-cognate exceptions: Compact for French (same word) and Off for 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 to Full, so installs see the overlay the first time they open Cam Wall — flipping to Off reverts 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 (CardsCam 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 to Max live streams (default 4) at any moment — everything else falls back to per-tile snapshot polling against /api/v1/printers/{id}/camera/snapshot at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. Architecture. frontend/src/components/CameraTile.tsx is the leaf — three modes (live / snapshot / paused), a single <img> element with loading="lazy", an onError no-signal fallback, and a useEffect cleanup that POSTs /camera/stop (with keepalive: true) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same /camera/stop discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. frontend/src/components/CameraWall.tsx is the scheduler — an IntersectionObserver (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a useMemo walks the printer list in sort order and assigns the first N visible tiles to live, the rest of the visible set to snapshot, and off-screen tiles to paused. 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 each PrinterCard already 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 existing Settings → camera_view_mode preference — opens the floating EmbeddedCameraViewer when set to embedded, otherwise pops the /camera/:id window with the saved size/position from cameraWindowState. Settings. Both knobs are per-user, persisted in localStorage (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 on camera: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 in frontend/src/__tests__/components/CameraTile.test.tsx cover live URL emission with fps=8, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the /camera/stop POST firing when the tile transitions out of live. Scope. No backend change. No DB migration. No new permission. The existing EmbeddedCameraViewer is untouched — Cam Wall is purely additive. The printerPageView toggle defaults to cards, 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 bare Drying · 11h 35m left. Bambu's per-tick AMS push only carries the dry_time countdown — 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 on mode=0 and on the per-AMS dry_time falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). PrinterManager.get_drying_targets(printer_id) exposes it, printer_state_to_dict and routes/printers.py::get_printer_status thread it onto each AMS dict as dry_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's tray_type + RFID-recommended drying_temp when 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 key printers.drying.targetSummary = {{filament}} {{temp}}°C, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in TestSupportsDryingCommand (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in TestDryingTargetExposure (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: 65 ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the filament field 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) in printer_manager.py is 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 via dry_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's model field can carry either, the existing supports_drying precedent uses both. _check_auto_drying in print_scheduler.py now resolves model + firmware up front for every printer and computes mid_print = busy AND toggle_on AND supports_drying_while_printing; when mid_print is 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 at max(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 when print_drying_enabled is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: routes/printers.py::start_drying has no Bambuddy-side is_idle gate; the "printer busy" rejection comes from firmware dry_sf_reason=[0], which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on PrinterStatus.supports_drying_while_printing so the frontend can light up the AMS card affordances correctly. Settings. New print_drying_enabled: bool = False in schemas/settings.py, added to the boolean allowlist in routes/settings.py (_BOOL_KEYS), and threaded through the existing dirty-detection / save call in SettingsPage.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 in TestSupportsDryingWhilePrinting cover every supported display name + internal code, below-min firmware, excluded models (P1*, A1, A1 MINI, X1, X1E), missing firmware, None model, case-insensitivity, and the strict unknown-model default (False — unlike supports_drying which leniently allows unknowns). 4 new scheduler integration cases in TestMidPrintDrying cover: 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. Full pytest -n 30 green (4251/4251 in 49 s). Backend ruff clean. Frontend npm run build clean. 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 via dry_sf_reason so 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 /

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

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.