# 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. ### Added - **API keys can log maintenance — new "Manage Maintenance" scope for HA-style automations (#1832 follow-up, reporter @MorganMLGman)** — Follow-up on the closed #1832 thread: reporter noted that maintenance had no checkbox in the API-key permissions UI and wasn't clearly denied in the wiki, unlike the other admin-only surfaces. Root cause was that `MAINTENANCE_CREATE / _UPDATE / _DELETE` sat on `_APIKEY_DENIED_PERMISSIONS` in `backend/app/core/auth.py`, so every API-key call to `POST /maintenance/items/{id}/perform` (mark maintenance as done — the useful endpoint for a "cleaned nozzle every N hours" Home Assistant automation) returned `403 "API keys cannot be used for administrative operations."` **Fix.** New `can_manage_maintenance` scope flag on `api_keys`, following the same shape as `can_manage_library` and `can_manage_inventory`. Wires the three maintenance write permissions into `_APIKEY_SCOPE_BY_PERMISSION` under the new flag; drops them from the denylist. Covers the per-printer maintenance CRUD (assign/remove items, edit intervals, log completion), the type-catalog CRUD (system types + custom types), and — via `MAINTENANCE_UPDATE` on the perform endpoint — the load-bearing "reset counter" call. `MAINTENANCE_READ` stays under `can_read_status`. **Backfill choice.** Distinct from the `can_manage_library` / `can_manage_inventory` migrations, which mirrored `can_queue` to preserve existing capability from the prior denylist-model. Maintenance writes were EXPLICITLY denied for every API key pre-migration, so no existing integration relies on them — backfilling to `can_queue` would silently widen scope on upgrade for every queue-capable key without unlocking any real use case. Existing rows backfill to **FALSE**; new keys created via the Settings UI default to **TRUE** (matches the safe-on-by-default pattern for the two prior scopes). Users opt in per key from Settings → API Keys → Edit. **CLI.** Bundled SpoolBuddy kiosk key gets `can_manage_maintenance=False` — kiosks don't need it, keep them minimally scoped. **Tests.** RBAC matrix in `backend/tests/integration/test_auth_apikey_rbac.py` extended: `valid_flags` set + `_each_scope_flag_has_at_least_one_permission` parametrize decorator + `_FakeApiKey.__init__` all pick up the new flag; three new `_SCOPE_CASES` for `MAINTENANCE_CREATE / _UPDATE / _DELETE`; the cross-scope leakage guard (`other_flags` set at line 355) widened from four flags to six so a `can_manage_library` toggle can't accidentally allow a `MAINTENANCE_UPDATE` call (and vice versa). 78/78 in `test_auth_apikey_rbac.py` + adjacent auth suites green. **Frontend.** New checkbox in the Settings → API Keys create form (between Manage Inventory and Allow Cloud Access), new teal badge in the key list, TS types extended (`APIKey`, `APIKeyCreate`, `APIKeyUpdate`). **i18n.** Three new keys (`settings.manageMaintenance`, `settings.manageMaintenanceDescription`, `settings.maintenanceBadge`) × 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere per convention. **Wiki.** `bambuddy-wiki/docs/features/api-keys.md` — new row in the permissions table, new bullet in the Principle-of-Least-Privilege list ("Home Assistant maintenance-log automation: Read Status + Manage Maintenance"), and a paragraph in Upgrade Notes explaining the FALSE backfill so existing users don't see a silent scope widening. **Migration.** Idempotent `_safe_execute` `ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE` + one-shot `UPDATE api_keys SET can_manage_maintenance = FALSE` guarded by column-existed-before check, mirroring the two prior scope migrations. Works on both SQLite and Postgres — no dialect-specific column type needed since it's just a BOOLEAN. ### Fixed - **P1S / P1P printer card no longer shows a bogus "Door Closed" badge (#1866, reporter @MartinNYHC)** — The enclosure-door badge in `frontend/src/pages/PrintersPage.tsx` gated visibility on a model whitelist that included `P1S` and `P1P`, but neither has a door sensor: P1S has an enclosure door with no hall sensor behind it, P1P has no enclosure at all. Backend `bambu_mqtt.py:2876-2907` unconditionally parses bit 23 of the `stat` field for every non-X1 model, and the bit stays 0 on P1S/P1P firmware — so the card rendered a permanent green "Door Closed" chip that couldn't reflect real state. **Fix.** Drop `P1S` and `P1P` from the frontend whitelist. Whitelist now reads `['X1C', 'X1', 'X1E', 'X2D', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S']` — the models that ship with a hall sensor for the door (and, on X1 family, top glass). Corrected the stale `X1/P1S/P2S/H2*` comment in `frontend/src/api/client.ts:473` (TypeScript `PrinterStatus` interface) and `backend/app/services/bambu_mqtt.py:295` (`PrinterState` dataclass) to match reality — those comments were what led the whitelist astray in the first place. Backend parsing left as-is: the bit read is cheap and if Bambu ever ships firmware that wires the P-series enclosure into a door sensor, the state gets picked up for free without a Bambuddy change. **Scope.** Two-line frontend whitelist change plus two comment corrections. No test change (no unit tests existed for the frontend gate). No i18n key change (`printers.door.open`/`printers.door.closed` still used for the models that keep the badge). No DB migration. No new permission. No backend behavioural change. - **Bambu Cloud custom filament preset lookup restored — SpoolBuddy "Assign to AMS" now surfaces the real custom profile in BambuStudio (#1815, reporter @Bgabor997)** — Symptom: SpoolBuddy scans a tag for a spool whose slicer_filament is a Bambu Cloud user preset (PFUS-prefix, "PFUS12f68b29a18aa4" etc.), Bambuddy applies the assignment, and BambuStudio's AMS panel shows "Generic PETG" / "Generic PLA" instead of the user's custom profile. Manual AMS-card configure with the same profile worked. **Root cause.** `BambuCloudService.get_setting_detail` at `backend/app/services/bambu_cloud.py:393` hits `GET /v1/iot-service/api/slicer/setting/{setting_id}` without the `?version=XX.YY.ZZ.WW` query parameter — Bambu's API answers `HTTP 400 "field 'version' is not set"` for every call. The plural GET five methods above (`get_slicer_settings`, line 362) *does* send `params={"version": _SLICER_API_VERSION}` and the source comment at line 69-77 documents precisely this contract for the endpoint subtree; the singular GET and the sibling DELETE (`delete_setting`, line 545) were left uncovered when the `_SLICER_API_VERSION` placeholder landed in #1013's compliance rework 2026-05-12. `slicer_filament_resolver.resolve_slicer_filament` swallowed the 400 as a warning and fell through to `normalize_slicer_filament`; the caller in `inventory.py:146-165` then generic-material-fell-back tray_info_idx to `GFL99`/`GFG99`, and BambuStudio's AMS panel reads the printer's `tray_info_idx` echo → Generic. **Why nobody caught this in 50 days.** Two rescue paths mask the bug in 99% of real assigns: (1) if the target slot already carries a P-prefix filament_id from any prior configure, `current_tray_info_idx` reuse at `inventory.py:147-156` reuses it; (2) if the spool has a stored `spool_k_profile` matching a live `state.kprofiles` entry on the printer, `printer_kp.filament_id` realigns tray_info_idx at line 216-230 (`Spool assign: realigning tray_info_idx 'GFL99' → 'P…' (source=printer)`). Reporter's spool 54 → tray 2 assign at 2026-06-30 09:21:18 was the rare case with neither rescue — fresh spool, no prior K-profile calibration, slot didn't hold a valid P-prefix — and fell all the way to generic. Every other PFUS assign in his same log shipped a valid P-prefix. Owner reproduced the WARNING line in his own log on the H2D after a Reset-Slot + rescan cycle; his display was rescued by the K-profile realign path, not by cloud. **Fix.** `get_setting_detail` and `delete_setting` now both send `params={"version": _SLICER_API_VERSION}` — same neutral `"1.0.0.0"` placeholder the plural GET has always used (Bambu accepts any XX.YY.ZZ.WW value, doesn't validate against a release manifest, so no impersonation). Once cloud returns 200, the resolver reads `detail["filament_id"]` at line 108-113 and the P-prefix lands on tray_info_idx directly — no reliance on slot reuse or K-profile realign. The endpoint-subtree comment at line 69-77 updated to name the singular GET, DELETE, and POST variants explicitly so the next sibling method added to this class doesn't silently regress. `get_setting_detail` also includes the truncated response body in the raised `BambuCloudError` message, so a future contract change is self-diagnostic from support-bundle logs (this bug cost 50 days precisely because the error was an opaque `"400"`). **Adjacent surfaces that were also silently broken and are now fixed.** `preset_resolver.resolve_preset`'s cloud branch, `cloud.py`'s three UI-facing routes (`get_setting_detail` at `:534`, `import_setting` chain at `:784`, forecast at `:1120`), `cloud.py`'s delete-cloud-preset route (`:1016`), and internally `BambuCloudService.update_setting` at `:483` (which calls `get_setting_detail` then `delete_setting` then POSTs a replacement — every UI-driven edit of a cloud preset was 400ing at step 1). **Tests.** Three new cases in `TestSlicerSettingVersionParam` (`backend/tests/unit/services/test_bambu_cloud.py`) pinning the contract as a unit-testable invariant so the next sibling method can't silently omit the param: `test_get_setting_detail_sends_version_param` asserts `params.get("version")` is truthy on the GET call; `test_get_setting_detail_error_includes_response_body` pins the reporter's exact 400 body shape (`"field 'version' is not set"`) making it into the raised exception message; `test_delete_setting_sends_version_param` mirrors the invariant for DELETE. 26/26 in `test_bambu_cloud.py` (was 23 + 3 new). `ruff check backend/app/services/bambu_cloud.py backend/tests/unit/services/test_bambu_cloud.py` clean. **Scope.** Backend-only. Two API-call edits, one comment edit, three tests. No DB migration. No new permission. No frontend change. No new i18n key. Users on 0.2.5b1 and earlier who hit this: the fix takes effect on the next Bambuddy restart with no data migration required — reassigning the affected spool via SpoolBuddy after upgrade lands the correct tray_info_idx and the AMS panel in BambuStudio updates to the actual custom profile name. - **Cancel during queue dispatch actually cancels — no more "pressed cancel and the print started" (#1853, reporter @guy-blotnick)** — Symptom: user queued a batch of 10 print jobs of the same item across two P2S printers (Windows installation, v0.2.4.8), clicked Cancel on a pending row, and the print started anyway. Repeated consecutively. Support-bundle log scan reported 15× `sqlite3.OperationalError: database is 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 `cancelled` → `started_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": "", "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 `