# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.4.8] - 2026-06-28 ### Added - **Lower sponsor-prompt thresholds so the toast fires for typical new installs** (`257b9e2c`) - **SSO autologin + disable local username/password login (#1589, requested by @einstux)** (`549d3216`) - **"Auto-add unknown RFID spools" toggle + global confirmation modal (#1764)** (`9f9c1775`) - **Backup-aware filament deficit check, colour-strict (#1762)** (`29a5abd9`) - **Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows** (`7b06ebd7`) - **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** (`7e5eff14`) - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** (`ee270922`) - **In-app sponsor-toast at earned milestones** (`e761e092`) - **Prominent sponsor banner on Settings → General** (`5c16ef3e`) - **Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal** (`d1d16659`) - **AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman)** (`99c6949b`) - **Updated printer card UI for structure and readability (#1661)** (`6fa74be4`) ## [0.2.5b2] - Unreleased ### 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](https://makerworld.com/de/models/1200262-g-code-for-x1c-chamber-heating-and-heat-soak) 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 `PrintQueueItem` — `preheat_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 - **`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": "", "param": "reserve", "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_TIME` → `command: "ignore"` route, `test_no_reminder_next_time_uses_idle_ignore_type_zero` pins the still-correct `NO_REMINDER_NEXT_TIME` → `idle_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 `