From e33ab07a937bf9f25c874f058288dfaa0d858efd Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 1 Jul 2026 08:37:31 +0200 Subject: [PATCH] fix(cloud): send required ?version= param on singular GET/DELETE of slicer setting endpoint (#1815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_setting_detail and delete_setting were hitting /v1/iot-service/api/slicer/setting/{id} without the version query parameter Bambu Cloud requires — every call returned HTTP 400 "field 'version' is not set". The sibling plural GET (get_slicer_settings) has always sent it; the comment above _SLICER_API_VERSION documents the contract for the endpoint subtree. Missed when the placeholder landed in the 2026-05-12 compliance rework. Downstream effect: slicer_filament_resolver.resolve_slicer_filament's PFUS branch swallowed the 400, fell through to normalize_slicer_filament, and caller inventory.py generic-material-fell-back tray_info_idx to GFL99/GFG99. BambuStudio's AMS panel reads the printer's tray_info_idx echo, so the user saw "Generic PLA" instead of the custom cloud preset. Masked for 50 days by two rescue paths in the caller: prior-slot tray_info_idx reuse, and stored spool_k_profile → live state.kprofiles realign. Reporter's spool 54 → tray 2 assign had neither. Adjacent surfaces also fixed by the same two-line change: the delete cloud preset UI route, the whole update_setting flow (get_setting_detail → delete_setting → POST), preset_resolver's cloud branch, and three UI-facing cloud.py routes that fetch setting detail. get_setting_detail also includes the truncated response body in the raised BambuCloudError so the next contract change is self-diagnostic from support-bundle logs. --- CHANGELOG.md | 2 + backend/app/services/bambu_cloud.py | 29 +++--- .../tests/unit/services/test_bambu_cloud.py | 88 +++++++++++++++++++ 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c94203fa8..78f88b160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to Bambuddy will be documented in this file. - **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 +- **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. diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index b280f0eb4..7ea43701b 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -66,14 +66,15 @@ def _detect_cloudflare_challenge(response) -> str | None: return None -# The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query -# parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the -# API returns HTTP 400 "field 'version' is not set"; non-matching formats like -# "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's -# server accepts ANY value within that format — it doesn't validate against a -# release manifest. We therefore use a neutral "1.0.0.0" placeholder that does -# not impersonate any real Bambu Studio release. Our client identity is in the -# User-Agent header. +# The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET +# for the list, the singular GET/DELETE for a specific preset by setting_id, and +# the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW +# format Bambu Studio releases use. Without it the API returns HTTP 400 +# "field 'version' is not set"; non-matching formats like "bambuddy-1.0" return +# HTTP 422 "Invalid input parameters". However, Bambu's server accepts ANY value +# within that format — it doesn't validate against a release manifest. We +# therefore use a neutral "1.0.0.0" placeholder that does not impersonate any +# real Bambu Studio release. Our client identity is in the User-Agent header. _SLICER_API_VERSION = "1.0.0.0" @@ -397,13 +398,17 @@ class BambuCloudService: try: response = await self._client.get( - f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers() + f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", + headers=self._get_headers(), + params={"version": _SLICER_API_VERSION}, ) if response.status_code == 200: return response.json() - raise BambuCloudError(f"Failed to get setting detail: {response.status_code}") + # Include body so a future contract change is self-diagnostic from logs. + body = (response.text or "")[:200] + raise BambuCloudError(f"Failed to get setting detail: {response.status_code} {body}") except httpx.RequestError as e: raise BambuCloudError(f"Request failed: {e}") @@ -557,7 +562,9 @@ class BambuCloudService: try: response = await self._client.delete( - f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers() + f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", + headers=self._get_headers(), + params={"version": _SLICER_API_VERSION}, ) if response.status_code in (200, 204): diff --git a/backend/tests/unit/services/test_bambu_cloud.py b/backend/tests/unit/services/test_bambu_cloud.py index 23eb6b013..bfe110a7d 100644 --- a/backend/tests/unit/services/test_bambu_cloud.py +++ b/backend/tests/unit/services/test_bambu_cloud.py @@ -437,3 +437,91 @@ class TestCloudflareChallengeDetection: assert result["success"] is False assert result["needs_verification"] is False assert "Cloudflare" in result["message"] + + +# =========================================================================== +# Issue #1815: PFUS cloud user preset lookup silently 400s in resolver +# =========================================================================== + + +class TestSlicerSettingVersionParam: + """`/v1/iot-service/api/slicer/setting` endpoints require ?version=XX.YY.ZZ.WW. + + The plural GET (`get_slicer_settings`) has always sent it. The singular + GET (`get_setting_detail`) and DELETE (`delete_setting`) hit the same + subtree and were silently omitting it since #1013's compliance rework + (2026-05-12), which surfaced as #1815: every PFUS-prefix cloud user preset + lookup in the slicer_filament_resolver 400'd, so BambuStudio saw the + generic-material fallback instead of the user's actual custom profile + (rescued in most cases by slot-tray_info_idx reuse or K-profile realign, + Bgabor997's spool 54 had neither). + """ + + def _auth(self) -> BambuCloudService: + cloud = BambuCloudService() + cloud.access_token = "test-token" + return cloud + + @pytest.mark.asyncio + async def test_get_setting_detail_sends_version_param(self): + """`get_setting_detail` must include the version query param — without + it Bambu Cloud returns HTTP 400 'field version is not set'.""" + cloud = self._auth() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"filament_id": "P4d64437", "name": "Overture Matte PLA"} + + with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = mock_response + + result = await cloud.get_setting_detail("PFUS992454068158eb") + + assert result["filament_id"] == "P4d64437" + url = mock_get.call_args[0][0] + assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb") + params = mock_get.call_args.kwargs.get("params") or {} + assert params.get("version"), "get_setting_detail must send ?version=… to avoid 400" + + @pytest.mark.asyncio + async def test_get_setting_detail_error_includes_response_body(self): + """The 400 body identifies the exact contract violation. Callers include + it in log warnings so a next contract change is self-diagnostic instead + of surfacing an opaque status code (which cost 50 days on #1815).""" + cloud = self._auth() + + from backend.app.services.bambu_cloud import BambuCloudError + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "field 'version' is not set" + + with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = mock_response + + with pytest.raises(BambuCloudError) as exc: + await cloud.get_setting_detail("PFUS992454068158eb") + + assert "400" in str(exc.value) + assert "field 'version'" in str(exc.value) + + @pytest.mark.asyncio + async def test_delete_setting_sends_version_param(self): + """`delete_setting` hits the same subtree; same requirement applies.""" + cloud = self._auth() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"{}" + mock_response.json.return_value = {} + + with patch.object(cloud._client, "delete", new_callable=AsyncMock) as mock_delete: + mock_delete.return_value = mock_response + + result = await cloud.delete_setting("PFUS992454068158eb") + + assert result["success"] is True + url = mock_delete.call_args[0][0] + assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb") + params = mock_delete.call_args.kwargs.get("params") or {} + assert params.get("version"), "delete_setting must send ?version=… to avoid 400"