From 166e9f9ef25fc2232e7d2577dd8454cdd6669dbe Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 21 Jun 2026 14:09:05 +0200 Subject: [PATCH] =?UTF-8?q?=20=20fix(vp):=20correct=20#1780=20root=20cause?= =?UTF-8?q?=20=E2=80=94=20VP=20intake=20key=20mismatch=20dropped=20every?= =?UTF-8?q?=20slicer=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-attempt fix (d196cfc5) was wrong about the cause. Real root, traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21 support bundle: mqtt_server.py:1296 was passing the slicer's bare subtask_name (e.g. "Model_Name") into on_print_command, which stashed under that key. _add_to_print_queue looked up under file_path.name (the FTP filename WITH extension, "Model_Name.gcode.3mf"). The two strings never matched. pop returned None, the 2s wait fired against a key the stash side never signaled, every captured slicer field silently fell back to settings defaults. Affected EVERY Bambu Studio "Send" upload across EVERY model — not just H2C nozzle_mapping. bed_leveling / flow_cali / vibration_cali / layer_inspect / timelapse from the original #1403 capture have been silently ignored since BambuStudio started splitting subtask_name (bare) from file (with extension). Unit tests passed because fixtures called on_print_command with file_path.name directly, bypassing the broken caller. Fix in manager.py::on_print_command: derive stash_key = data.get("file") or filename and use it for both _slicer_print_options and the event lookup. filename (subtask_name) still flows unchanged to _schedule_finish_release — push_status echoes it back as gcode_file / subtask_name and the slicer matches against its own subtask_name there, so re-routing that path was a separate regression I caught and reverted mid-audit. Also: nozzles_info field was a wrong guess in d196cfc5 — BambuStudio never sends it (confirmed via wire capture). Drop the capture, dispatch, schema, kwarg, and route paths. DB column stays nullable so old rows still load; nothing reads or writes it. Diagnostic: DEBUG log when _add_to_print_queue finds no slicer options after the 2s wait, including the looked-up key and the actual cache keys present. Future stash/lookup mismatches will be obvious from a log line instead of needing a wire capture. Behaviour change worth flagging: users on Bambu Studio whose slicer-side bed-leveling / flow-cali / vibration-cali / layer-inspect / timelapse differ from Bambuddy's default-workflow settings will see their slicer choices honored now instead of silently overridden. Restores #1403's original intent. --- CHANGELOG.md | 2 +- backend/app/api/routes/print_queue.py | 17 +- backend/app/core/database.py | 13 +- backend/app/models/print_queue.py | 13 +- backend/app/schemas/print_queue.py | 10 +- backend/app/services/bambu_mqtt.py | 48 ++---- backend/app/services/print_scheduler.py | 9 +- backend/app/services/printer_manager.py | 11 +- .../app/services/virtual_printer/manager.py | 92 +++++++---- .../services/virtual_printer/mqtt_server.py | 8 + .../tests/unit/services/test_bambu_mqtt.py | 68 +++----- .../unit/services/test_printer_manager.py | 1 - .../unit/services/test_virtual_printer.py | 155 ++++++++++++++---- 13 files changed, 261 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a586453f7..b2df3f7ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ All notable changes to Bambuddy will be documented in this file. - **"Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by @biduleman)** — Reporter (P1S) had `prefer_lowest_filament=true` with two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. **(1) The frontend sort never saw inventory grams.** The backend has a two-tier sort (`_prefer_lowest_sort_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_mapping`. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (`useFilamentMapping.ts::computeAmsMapping` + `useFilamentMapping`, `useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides` + `computeMatchDetails`, `amsHelpers.ts::autoMatchFilament`) only sorted by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=100` (freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. **(2) The sort had no notion of whether the printer could actually USE a near-empty spool.** Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. **Fix — dispatch-time backend gate.** `_compute_ams_mapping_for_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.ams_filament_backup is False`, logs `[prefer-lowest] skipped (AMS Backup OFF on printer %s)` so the decision is visible in support bundles without enabling DEBUG. Tri-state default: `None` (unknown / A1 family) applies the sort, preserving today's behaviour. **Fix — banding-equivalent two-tier frontend sort.** New exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map` param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft used `amsId * 4 + trayId` for all slots, which gives `ams_id = -1` (external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. **Fix — frontend backup gate.** New exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. **Settings → Filaments tooltip.** Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). **i18n.** New key `settings.preferLowestFilamentBackupNote` translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. **Tests.** 7 new backend cases — `test_scheduler_backup_gate.py` (tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) and `test_inventory_remain_endpoint.py` (× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases — `useFilamentMapping.test.ts` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green. -- **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — printer auto-picked the last matching nozzle instead (#1780, reported by @mkoreen)** — The reporter (H2C with the rack-swap "dual nozzle variant" model code O1C2; 7 nozzles registered across two extruders: R1/R2 high-flow 0.4, R3/R4 standard 0.4, plus three other diameters) noticed that picking a specific nozzle in Bambu Studio (e.g. "use R1") had no effect — the H2C would consistently load R2 for HF prints and R4 for standard prints. Root-cause traced from the printer's reported `device.nozzle.info[]` state + the Bambu Studio source: BambuStudio's `project_file` MQTT command for O1C2 carries two extra fields — `nozzle_mapping` (a `list[int]` of per-filament physical nozzle position IDs, populated from a prior `get_auto_nozzle_mapping` round-trip with the firmware) and `nozzles_info` (a `list[dict]` of per-extruder rack metadata: `id`/`type`/`flowSize`/`diameter`). The VP intake at `virtual_printer/manager.py:564-574` only captured five fields out of the slicer's project_file dict (bed_leveling / flow_cali / vibration_cali / layer_inspect / timelapse) and dropped everything else, including these two. Without `nozzle_mapping` on the dispatched project_file, the H2C firmware fell back to its auto-pick rule ("any nozzle matching diameter + flow class") and deterministically landed on the last matching slot in the rack — which is why R1 selections always became R2 and R3 selections always became R4. **Fix:** carry both fields through the full intake → queue → dispatch path. `_add_to_print_queue` reads `nozzle_mapping` + `nozzles_info` out of the captured slicer opts, normalises a stringified-JSON or already-parsed shape to the same canonical JSON-string representation, and stamps both on the PrintQueueItem inside the multi-plate loop (so a multi-plate "Send All" preserves the nozzle pick across plates, mirroring `gcode_injection` / `filament_overrides` per-plate stamping from #1697 / #1188). New nullable TEXT columns `nozzle_mapping` / `nozzles_info` on `print_queue` — non-branched ALTER (same as `ams_mapping` / `filament_overrides` precedent at `database.py:944/955`). The dispatcher reads the JSON strings off the queue item, parses them back to list/dict, and includes them on the published `project_file` command as parsed JSON values (not strings — the wire shape matches BS's, same convention as `ams_mapping` / `ams_mapping2`). Dual-nozzle gate at `bambu_mqtt.py::start_print()` keeps the fields off single-nozzle dispatches as defense-in-depth (`is_dual_nozzle` runtime flag already established by `device.extruder.info[]` len ≥ 2). **Fail-open on malformed JSON:** an unparseable column value logs a WARNING and omits the field — firmware then runs the same auto-pick path that was the pre-fix behaviour, never a worse one. **No model gate elsewhere:** every other model omits these fields from its project_file, so the pass-through is a transparent no-op on X1C / P1S / A1 / H2D / X2D. **API surface:** `PrintQueueItemResponse` parses both fields back to `list[int]` / `list[dict]` so any future "edit print → nozzle" UI can read+round-trip them; `PrintQueueItemUpdate` accepts them and the route handler serialises to JSON for storage (same shape as `ams_mapping`). **Tests:** 3 new cases in `test_virtual_printer.py::TestVirtualPrinterInstance` (capture round-trip, NULL-on-omitted-fields, per-plate stamping on multi-plate) and 6 new cases in `test_bambu_mqtt.py::TestStartPrintNozzleMappingDispatch` (dual-nozzle injection both fields, single-nozzle no-emit even when set, dual-nozzle no-fields no-op, partial-only mapping passthrough, malformed JSON logs + dispatch continues, empty-string treated as absent). 1 line update in `test_printer_manager.py::test_start_print_calls_client` to add the two new kwargs to the `assert_called_once_with` matcher. Full backend `pytest -n 30` 6176/6176 in 86.67s; ruff clean; `npm run build` clean; vitest 2158/2158; i18n parity green. **Scope:** O1C2 (the H2C dual-nozzle-rack variant) is the only Bambu model with a rack-swap mechanism where the firmware can choose between multiple physical nozzles per side, so the observable fix lands there. H2D / X2D dual-extruder routing was never affected — those carry filament-to-extruder mapping through `ams_mapping2` (ams_id 254/255), which Bambuddy already forwards correctly. No DB migration on Postgres-only side; no permission change, no i18n keys, no frontend changes (a "pick a different nozzle from queue/archives" UI is reasonable follow-up scope but isn't required to close this bug — the slicer's pick now rides through, which is the reporter's primary expected behaviour). +- **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — plus a much wider silent-fallback bug across every Bambu Studio "Send" upload (#1780, reported by @mkoreen)** — The reporter's H2C consistently loaded R2 for HF prints and R4 for standard prints regardless of the nozzle they picked in Bambu Studio. **First-attempt fix (commit d196cfc5) was wrong about the root cause** and didn't take. The real root cause, traced through @mkoreen's `BAMBUDDY_VP_DUMP_WIRE=1` capture + the 0.2.5b1 support bundle on 2026-06-21, is a filename-key mismatch in the VP intake cache that affected EVERY Bambu Studio "Send" upload across EVERY model, not just H2C. **What was actually broken.** `mqtt_server.py:1296` was passing the slicer's bare `subtask_name` (e.g. `Filament_Track_Switch_Holder`, no extension) into the `on_print_command(filename, data)` callback. `on_print_command` then stashed the slicer's print options under that bare name. But `_add_to_print_queue` looked up the cache under `file_path.name` — the FTP filename WITH extension (e.g. `Filament_Track_Switch_Holder.gcode.3mf`). The two strings never matched. The initial `pop` returned None, the 2-second event wait fired against a key that the stash-side never signaled, the wait timed out, and every captured slicer field silently fell back to settings defaults. The user-visible blast radius: not just H2C's `nozzle_mapping` (which was added speculatively in d196cfc5 — see below), but ALSO `bed_leveling` / `flow_cali` / `vibration_cali` / `layer_inspect` / `timelapse` from the original #1403 slicer-opts capture have been silently ignored since BambuStudio started putting the bare model name in `subtask_name` and the extended filename in a separate `file` field. The mismatch survived unit tests because the existing fixtures called `on_print_command(file_path.name, {...})` directly with the FTP filename — bypassing the broken `mqtt_server.py` caller. **Fix.** `on_print_command` derives `stash_key = data.get("file") or filename` and uses THAT for both `_slicer_print_options[...]` and the event-signal lookup. `filename` (subtask_name) still flows to `_schedule_finish_release` unchanged — push_status echoes it back to the slicer as `gcode_file` / `subtask_name` and the slicer matches against its own local subtask_name there, so changing what that path receives would have introduced its own regression (caught and reverted mid-audit). The new derivation falls back to `filename` when `data["file"]` is absent (legacy slicers / non-3MF uploads), so the change is strictly an improvement or a no-op — never a regression. **H2C nozzle_mapping rides through correctly now.** The `nozzle_mapping` array BambuStudio writes into `project_file.print` at the top level (verified via wire capture: 32-entry `list[int]` matching the printer's `get_auto_nozzle_mapping` reply) now reaches the queue item and is replayed on the dispatched `project_file` command via `bambu_mqtt.py::start_print(nozzle_mapping=…)`, gated by the `is_dual_nozzle` flag at runtime. Nullable TEXT column `nozzle_mapping` on `print_queue` — non-branched ALTER matching the `ams_mapping` / `filament_overrides` precedent. The dispatcher parses the JSON-string column back to a `list[int]` on the wire so the shape matches BS exactly. Fail-open on malformed JSON: unparseable column logs a WARNING and omits the field — firmware then runs its auto-pick, never worse than pre-fix. **`nozzles_info` is dead code, dropped.** The original #1780 fix also captured a `nozzles_info` field that was a best-guess based on the OrcaSlicer source. The wire capture confirmed BambuStudio never actually sends it — the field doesn't exist on the project_file body for H2C. Captured-but-unused code removed from `manager.py` intake, `bambu_mqtt.py` dispatch + signature, `printer_manager.py` kwarg, `print_scheduler.py` call, `PrintQueueItemUpdate` + `PrintQueueItemResponse` schemas, and the `print_queue.py` route's parse / serialise paths. The DB column is kept nullable so old rows still load — nothing reads or writes to it anymore. **Diagnostic log added.** `_add_to_print_queue` now logs at DEBUG when slicer_opts is None at the end of intake, including the looked-up key and the actual cache keys present. A future stash/lookup mismatch will surface in 30 seconds of log reading instead of needing a wire capture to diagnose. **Behaviour change worth flagging.** Users with Bambu Studio-set values for bed-leveling / flow-cali / vibration-cali / layer-inspect / timelapse that differ from the Bambuddy default-workflow settings will see those slicer choices honored now instead of silently overridden. This restores the original #1403 intent — slicer values take precedence, settings defaults are the fallback. **Tests.** 3 new regression cases in `test_virtual_printer.py::TestVPProjectFileStashKey` pin the contract: the FTP filename (from `data["file"]`) MUST be the stash key, the bare subtask_name fallback when `file` is absent, and the wait-event signal must fire under the FTP-filename key even when the callback was called with the bare subtask_name. All 3 would fail under the pre-fix code. Existing nozzle / dispatch tests (`TestStartPrintNozzleMappingDispatch` × 5, `test_virtual_printer.py::TestVirtualPrinterInstance` × 3) updated to drop the now-removed `nozzles_info` assertions. `test_printer_manager.py::test_start_print_calls_client` updated for the dropped kwarg. Full backend `pytest -n 30` 6274/6274 in 92.65s; ruff clean; `npm run build` clean; vitest 2156/2156 + QueuePage 26/26 + FileManagerPage 51/51 green; i18n parity green. **Scope.** The stash-key fix affects every queue-mode Virtual Printer regardless of model. The H2C-specific nozzle_mapping pass-through is the user-visible surface that motivated finding the bug. H2D / X2D dual-extruder routing was never affected — those use `ams_mapping2` (ams_id 254/255), already forwarded correctly. No DB migration changes (the `nozzles_info` column stays on disk, just unused), no permission change, no i18n keys, no frontend changes. - **Docker installer fails on the default `/opt/bambuddy` path with "Permission denied" (#1774, reported by @jmoore-skild)** — `install/docker-install.sh::create_install_dir` (line 252) ran `mkdir -p "$INSTALL_PATH"` without sudo while `DEFAULT_INSTALL_PATH="/opt/bambuddy"` (line 32) — root-owned on every Linux distro. `set -e` at line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documented `curl … | bash` flow as a normal user hit this immediately. The native installer at `install/install.sh:361` already handles the same situation correctly with `sudo mkdir -p` + `sudo chown`; the Docker variant just never got the same treatment. **Why the fix isn't a default-path change:** the contributor's first instinct was to drop the default to `~/bambuddy` since the Docker installer only writes `docker-compose.yml` + `.env` on the host (real app data lives in named volumes), but `install/update.sh:4` and `install/update_macos.sh:4` both default `INSTALL_DIR` to `/opt/bambuddy`, and `install/README.md:274` documents `INSTALL_DIR=/opt/bambuddy sudo ./update.sh` for the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation in `create_install_dir`, not the default path. **Fix:** `create_install_dir` now tries `mkdir -p "$INSTALL_PATH" 2>/dev/null` first — the cheap no-sudo path covers `--path ~/bambuddy`, `--path /srv/bambuddy`, and any other writable target — and only falls back to `sudo mkdir -p "$INSTALL_PATH"` + `sudo chown -R "$USER:$USER" "$INSTALL_PATH"` when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to write `docker-compose.yml` and `.env` into a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the second `mkdir -p` succeeds against the now-owned dir, no second sudo prompt). `set -e` survives the redirected stderr because the `if !` construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. **Smoke-tested all three branches:** writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path → `set -e` survives intact. **What this does NOT change:** the default install path stays `/opt/bambuddy` for parity with `install.sh` / `update.sh` / the documented update flow; the Windows mirror at `install/docker-install.ps1` already uses `$env:USERPROFILE\bambuddy` (per-user convention on Windows) and is untouched. No docs change required — `install/README.md` and the wiki Docker page (`bambuddy-wiki/docs/getting-started/docker.md`) both still accurately describe the behaviour. - **MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by @Mx772)** — The reporter (working on a browser extension that drives Bambuddy via `X-API-Key`) noticed that `POST /api/v1/makerworld/import` and `POST /api/v1/makerworld/resolve` returned `{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}` even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced: `require_permission_if_auth_enabled` in `backend/app/core/auth.py:1414` deliberately returns `current_user=None` for API-keyed callers — the comment at line 1408 makes this explicit and points at `cloud.py` for the resolver. The MakerWorld routes never got that resolver wired in, so `_build_service(db, None)` → `get_stored_token(db, None)` → no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-`/cloud/*` routes is already in the codebase as `resolve_api_key_cloud_owner` (cloud.py:128-160) — used by `slicer_presets.py:491` and `library.py:3871`. The MakerWorld routes were missing the wire-up. **Fix:** Three routes get the extra `api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)` parameter — `get_status`, `resolve_url`, `import_instance` — and each resolves `cloud_token_user = current_user or api_key_cloud_owner` before calling `get_stored_token` / `_build_service`. `import_instance` additionally uses `cloud_token_user.id` for the `owner_id` argument to `save_3mf_bytes_to_library` (which translates to `LibraryFile.created_by_id`), so library rows imported via API key are now attributed to the key's owner instead of staying NULL. `/recent-imports` is unchanged — it only uses `current_user` as a permission gate (`_ = current_user`) and never touches the cloud token. The fix preserves fail-closed semantics for keys *without* the `can_access_cloud` flag: `resolve_api_key_cloud_owner` already fences on `api_key.user_id is not None and api_key.can_access_cloud` (cloud.py:158), so a key with only the per-route scope (`can_read_status` / `can_manage_library`) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. **Two scope fields the API key needs:** the per-route scope (`MAKERWORLD_VIEW` → `can_read_status`, `MAKERWORLD_IMPORT` → `can_manage_library` per `_APIKEY_SCOPE_BY_PERMISSION` in `core/auth.py`) AND the orthogonal `can_access_cloud` flag (separate column on the `api_keys` table). The fix doesn't change that surface — it just stops dropping valid `can_access_cloud=True` keys on the floor. **Tests:** 6 new cases in `backend/tests/integration/test_makerworld_apikey_auth.py` pinning the full surface — API key with `can_access_cloud=True` + owner-has-token → `/status` reports `has_cloud_token=True`, `/resolve` builds the service with the owner User (asserted on the `_build_service` mock's call args), `/import` succeeds end-to-end and the resulting `LibraryFile.created_by_id` matches the API-key owner; API key with `can_access_cloud=False` → status still reports `has_cloud_token=False` (no widening) and import-row's `created_by_id` stays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the added `Depends`. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy. diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index b58e79488..f2c9534a4 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -142,22 +142,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: except json.JSONDecodeError: filament_overrides_parsed = None - # Parse nozzle_mapping + nozzles_info from JSON string (#1780 — H2C rack - # slicer-pick preservation). Both are nullable opaque JSON blobs stored - # verbatim from BambuStudio's project_file; surface them parsed for the - # response model and any future "edit print → nozzle" UI. + # Parse nozzle_mapping from JSON string (#1780 — H2C rack slicer-pick + # preservation). Nullable opaque JSON blob stored verbatim from + # BambuStudio's project_file; surface it parsed for the response model + # and any future "edit print → nozzle" UI. nozzle_mapping_parsed = None if item.nozzle_mapping: try: nozzle_mapping_parsed = json.loads(item.nozzle_mapping) except json.JSONDecodeError: nozzle_mapping_parsed = None - nozzles_info_parsed = None - if item.nozzles_info: - try: - nozzles_info_parsed = json.loads(item.nozzles_info) - except json.JSONDecodeError: - nozzles_info_parsed = None # Create response with parsed ams_mapping item_dict = { @@ -203,7 +197,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "gcode_injection": item.gcode_injection, # H2C rack-swap nozzle pick (#1780) "nozzle_mapping": nozzle_mapping_parsed, - "nozzles_info": nozzles_info_parsed, } response = PrintQueueItemResponse(**item_dict) if item.archive: @@ -1035,8 +1028,6 @@ async def update_queue_item( update_data["nozzle_mapping"] = ( json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None ) - if "nozzles_info" in update_data: - update_data["nozzles_info"] = json.dumps(update_data["nozzles_info"]) if update_data["nozzles_info"] else None for field, value in update_data.items(): setattr(item, field, value) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index d846d29bd..b9f307229 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -969,11 +969,14 @@ async def run_migrations(conn): await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE") # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap - # slicer-pick preservation (#1780). Opaque JSON-string columns carrying - # BambuStudio's per-filament physical nozzle position IDs and the - # per-extruder rack metadata, forwarded straight from the VP intake to - # the dispatcher's project_file MQTT command. NULL on every other model. - # Nullable TEXT — no Postgres / SQLite divergence here. + # slicer-pick preservation (#1780). Opaque JSON-string column carrying + # BambuStudio's per-filament physical nozzle position IDs, forwarded + # straight from the VP intake to the dispatcher's project_file MQTT + # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite + # divergence here. `nozzles_info` shipped in the original #1780 attempt + # but BambuStudio never actually sends it (verified via wire capture on + # H2C, see CHANGELOG 0.2.5b1) — the column stays nullable so old rows + # still load; nothing reads or writes to it anymore. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT") await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT") diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 9c7f8ee66..bc4a7b771 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -67,12 +67,13 @@ class PrintQueueItem(Base): # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's # project_file MQTT command for rack-swap-capable models (O1C2 today) - # carries per-filament physical nozzle position IDs in `nozzle_mapping` - # and per-extruder rack metadata in `nozzles_info`. Both are forwarded - # verbatim through the queue and replayed by the dispatcher so the - # firmware honours the user's pick instead of falling back to - # "last matching nozzle type" auto-pick. Stored as opaque JSON strings - # (list[int] and list[dict] respectively); NULL on every other model. + # carries per-filament physical nozzle position IDs in `nozzle_mapping`, + # forwarded verbatim through the queue and replayed by the dispatcher so + # the firmware honours the user's pick instead of falling back to + # "last matching nozzle type" auto-pick. Stored as opaque JSON string + # (list[int]); NULL on every other model. `nozzles_info` is a deprecated + # column from the original #1780 attempt — kept nullable so old rows still + # load; never written to or read from. nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True) nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 4427da9bf..b37136306 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -82,13 +82,10 @@ class PrintQueueItemUpdate(BaseModel): nozzle_offset_cali: bool | None = None # Auto-print G-code injection gcode_injection: bool | None = None - # H2C dual-nozzle-rack slicer pick (#1780). Both fields are opaque - # JSON-encoded structures BambuStudio sends in its project_file MQTT - # body; sent back to the printer verbatim on dispatch. list[int] for - # nozzle_mapping (per-filament physical nozzle position IDs), list[dict] - # for nozzles_info (per-extruder rack metadata). + # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament + # physical nozzle position IDs from BambuStudio's project_file MQTT + # body; sent back to the printer verbatim on dispatch. nozzle_mapping: list[int] | None = None - nozzles_info: list[dict] | None = None class PrintQueueItemResponse(BaseModel): @@ -174,7 +171,6 @@ class PrintQueueItemResponse(BaseModel): # "edit print → choose nozzle" UI; null on every model except O1C2 # uploads from BambuStudio. nozzle_mapping: list[int] | None = None - nozzles_info: list[dict] | None = None class Config: from_attributes = True diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index a6a4b6d69..b5d8a05a9 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -3502,7 +3502,6 @@ class BambuMQTTClient: use_ams: bool = True, nozzle_offset_cali: bool = False, nozzle_mapping: str | None = None, - nozzles_info: str | None = None, ): """Start a print job on the printer. @@ -3528,9 +3527,6 @@ class BambuMQTTClient: firmware honours the user's slicer pick instead of falling back to "last matching nozzle" auto-pick. Silently ignored on single-nozzle printers. - nozzles_info: Opaque JSON string for the per-extruder rack - metadata BambuStudio's project_file carries alongside - `nozzle_mapping` (#1780). Same dual-nozzle gating. """ if self._client and self.state.connected: # Bambu print command format — matches Bambu Studio's format. @@ -3690,32 +3686,24 @@ class BambuMQTTClient: # H2C dual-nozzle-rack slicer-pick preservation (#1780). # `nozzle_mapping` carries per-filament physical nozzle position - # IDs (`list[int]`), `nozzles_info` carries per-extruder rack - # metadata (`list[dict]`). Both are JSON-string-encoded when - # they leave the queue item; parse here so the wire ships - # arrays/objects, matching BambuStudio's project_file shape. - # Gate by `is_dual_nozzle` defensively — single-nozzle firmwares - # would ignore them but we err on the side of not emitting - # unrecognised fields. A parse failure is logged but never - # blocks the dispatch — the firmware will fall back to its - # auto-pick path, which is the pre-fix behaviour. - if is_dual_nozzle: - for src_str, json_key in ( - (nozzle_mapping, "nozzle_mapping"), - (nozzles_info, "nozzles_info"), - ): - if not src_str: - continue - try: - command["print"][json_key] = json.loads(src_str) - except json.JSONDecodeError: - logger.warning( - "[%s] Invalid %s JSON on dispatch, omitting from " - "project_file (firmware will auto-pick): %r", - self.serial_number, - json_key, - src_str, - ) + # IDs (`list[int]`), JSON-string-encoded when it leaves the queue + # item; parse here so the wire ships an array, matching + # BambuStudio's project_file shape. Gate by `is_dual_nozzle` + # defensively — single-nozzle firmwares would ignore the field + # but we err on the side of not emitting unrecognised fields. A + # parse failure is logged but never blocks the dispatch — the + # firmware will fall back to its auto-pick path, which is the + # pre-fix behaviour. + if is_dual_nozzle and nozzle_mapping: + try: + command["print"]["nozzle_mapping"] = json.loads(nozzle_mapping) + except json.JSONDecodeError: + logger.warning( + "[%s] Invalid nozzle_mapping JSON on dispatch, omitting from " + "project_file (firmware will auto-pick): %r", + self.serial_number, + nozzle_mapping, + ) logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command)) self._client.publish(self.topic_publish, json.dumps(command), qos=1) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 17dcfed8f..e237b55ee 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -2325,10 +2325,10 @@ class PrintScheduler: effective_timelapse = bool(item.timelapse) # Start the print with AMS mapping, plate_id and print options. - # nozzle_mapping / nozzles_info ride through verbatim — JSON strings - # captured from Bambu Studio's project_file on VP intake (#1780); the - # MQTT layer parses + injects them only for dual-nozzle models so a - # null on every other model is a transparent pass-through. + # nozzle_mapping rides through verbatim — JSON string captured from + # Bambu Studio's project_file on VP intake (#1780); the MQTT layer + # parses + injects it only for dual-nozzle models so a null on every + # other model is a transparent pass-through. started = printer_manager.start_print( item.printer_id, remote_filename, @@ -2342,7 +2342,6 @@ class PrintScheduler: use_ams=item.use_ams, nozzle_offset_cali=item.nozzle_offset_cali, nozzle_mapping=item.nozzle_mapping, - nozzles_info=item.nozzles_info, ) if started: diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 8049dbb48..7b3db90e7 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -566,15 +566,13 @@ class PrinterManager: use_ams: bool = True, nozzle_offset_cali: bool = False, nozzle_mapping: str | None = None, - nozzles_info: str | None = None, ) -> bool: """Start a print on a connected printer. - ``nozzle_mapping`` and ``nozzles_info`` are opaque JSON strings - captured from BambuStudio's project_file MQTT command (H2C rack-swap - slicer pick preservation, #1780). They ride through to the MQTT - client untouched; the dispatch builder there parses + injects them - only on dual-nozzle models. + ``nozzle_mapping`` is an opaque JSON string captured from BambuStudio's + project_file MQTT command (H2C rack-swap slicer pick preservation, + #1780). It rides through to the MQTT client untouched; the dispatch + builder there parses + injects it only on dual-nozzle models. """ caller = traceback.extract_stack(limit=3)[0] logger.info( @@ -598,7 +596,6 @@ class PrinterManager: use_ams=use_ams, nozzle_offset_cali=nozzle_offset_cali, nozzle_mapping=nozzle_mapping, - nozzles_info=nozzles_info, ) return False diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index a12b17567..e3f040532 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -289,9 +289,10 @@ class VirtualPrinterInstance: """Handle print command from MQTT. Captures the slicer's project_file options (`timelapse`, `bed_leveling`, - `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the - VP-queue path can inherit them when adding the item to the queue, - rather than falling back to the global default settings (#1403). + `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`, plus the + H2C rack-pick `nozzle_mapping`) so the VP-queue path can inherit them + when adding the item to the queue, rather than falling back to the + global default settings (#1403, #1780). Only queue mode consumes the capture; archive / review / proxy modes ignore the print command, so we skip the stash there to keep the dict from accumulating one entry per print over the VP's @@ -301,6 +302,16 @@ class VirtualPrinterInstance: moment after the synthetic project_file ack — for every non-proxy mode — so the slicer's "Downloading" UI releases on the slicer's FTP-first-then-MQTT send order. + + ``filename`` is the slicer's ``subtask_name`` (bare model name, no + extension) — used verbatim for `_schedule_finish_release` because + push_status echoes it back to the slicer as gcode_file / subtask_name. + The queue-side stash key is derived from ``data["file"]`` (the FTP + filename with extension) so `_add_to_print_queue`'s + ``file_path.name`` lookup matches; falls back to ``filename`` when + ``data["file"]`` is absent (legacy slicers / non-3MF uploads). + Stash/lookup mismatch was the #1780 root cause — every captured field + silently fell back to settings defaults on every Bambu Studio "Send". """ logger.info("[VP %s] Print command for: %s", self.name, filename) mode = normalize_vp_mode(self.mode) @@ -308,6 +319,12 @@ class VirtualPrinterInstance: self._schedule_finish_release(filename) if mode != VP_MODE_QUEUE: return + # Stash key must match `_add_to_print_queue`'s lookup, which uses + # `file_path.name` (FTP filename WITH extension). The slicer's + # `subtask_name` (== this method's `filename` arg) is the bare model + # name, no extension — using it as the stash key was the #1780 root + # cause. + stash_key = data.get("file") or filename # Drop the oldest stash if the cache is growing — happens when the # slicer sends project_file for a filename whose FTP upload was # rejected / cancelled / non-3MF, so _add_to_print_queue's pop @@ -321,8 +338,8 @@ class VirtualPrinterInstance: logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key) except StopIteration: pass - self._slicer_print_options[filename] = dict(data) - event = self._slicer_print_options_events.get(filename) + self._slicer_print_options[stash_key] = dict(data) + event = self._slicer_print_options_events.get(stash_key) if event: event.set() @@ -525,6 +542,18 @@ class VirtualPrinterInstance: slicer_opts = None finally: self._slicer_print_options_events.pop(file_path.name, None) + # If the cache still misses, queued workflow flags / nozzle pick will + # silently fall back to settings defaults. Surface the missed key so a + # future stash/lookup mismatch (the #1780 root cause) is obvious in + # the log instead of needing a wire capture to diagnose. + if slicer_opts is None: + logger.debug( + "[VP %s] No slicer options cached for %r (cache keys: %s); " + "workflow flags + nozzle pick will fall back to settings defaults.", + self.name, + file_path.name, + sorted(self._slicer_print_options.keys()), + ) try: import json @@ -575,46 +604,38 @@ class VirtualPrinterInstance: # H2C dual-nozzle-rack slicer-pick preservation (#1780). # BambuStudio's project_file MQTT command for rack-swap models - # (O1C2 today) carries: - # `nozzle_mapping` — per-filament array of physical nozzle - # position IDs (`list[int]`). - # `nozzles_info` — per-extruder rack metadata - # (`list[dict]`, fields: id / type / flowSize / diameter). - # Forward both verbatim onto the queue item so the dispatcher - # can replay them in its own project_file command. Without - # this the H2C firmware falls back to "last matching nozzle" - # auto-pick and ignores the user's Bambu Studio choice. Every - # other model has these absent from slicer_opts, so the - # capture is a transparent no-op there. + # (O1C2 today) carries `nozzle_mapping` — a per-filament array + # of physical nozzle position IDs (`list[int]`). Forward it + # verbatim onto the queue item so the dispatcher can replay it + # in its own project_file command. Without this the H2C + # firmware falls back to "last matching nozzle" auto-pick and + # ignores the user's Bambu Studio choice. Every other model + # has it absent from slicer_opts, so the capture is a + # transparent no-op there. (`nozzles_info` was also captured + # in the original fix but BambuStudio never actually sends it + # — verified via wire capture on H2C — so only `nozzle_mapping` + # is forwarded now.) nozzle_mapping_json: str | None = None - nozzles_info_json: str | None = None if slicer_opts is not None: - for src_key in ("nozzle_mapping", "nozzles_info"): - raw = slicer_opts.get(src_key) - if raw is None: - continue - # BambuStudio's NetworkAgent should embed these as - # parsed JSON in the project_file body (matching the - # ams_mapping / ams_mapping2 shape Bambuddy already - # consumes as list[int] / list[dict]). Accept a - # JSON-encoded string defensively in case any path - # arrives stringified. + raw = slicer_opts.get("nozzle_mapping") + if raw is not None: + # BambuStudio's NetworkAgent embeds this as parsed + # JSON in the project_file body (matching the + # ams_mapping shape Bambuddy already consumes as + # list[int]). Accept a JSON-encoded string defensively + # in case any path arrives stringified. if isinstance(raw, str): try: raw = json.loads(raw) except json.JSONDecodeError: logger.warning( - "[VP %s] Slicer %s is unparseable JSON, dropping: %r", + "[VP %s] Slicer nozzle_mapping is unparseable JSON, dropping: %r", self.name, - src_key, raw, ) - continue - encoded = json.dumps(raw) - if src_key == "nozzle_mapping": - nozzle_mapping_json = encoded - else: - nozzles_info_json = encoded + raw = None + if raw is not None: + nozzle_mapping_json = json.dumps(raw) service = ArchiveService(db) archive = await service.archive_print( @@ -723,7 +744,6 @@ class VirtualPrinterInstance: # the same nozzle pick across plates rather than only the # first one (mirrors the #1697 / #1188 per-plate loop fix). nozzle_mapping=nozzle_mapping_json, - nozzles_info=nozzles_info_json, ) db.add(queue_item) await db.flush() # populate queue_item.id before logging diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 1a2290148..4f93e569c 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -1293,6 +1293,14 @@ class SimpleMQTTServer: file_3mf = print_data.get("file", filename) await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial) if self.on_print_command: + # `filename` is the slicer's `subtask_name` (bare model + # name, no extension). Pass it through verbatim — the + # `_schedule_finish_release` chain echoes it back as + # gcode_file + subtask_name in push_status, and the + # slicer matches against its own subtask_name there. + # The FTP filename (with extension) is in print_data + # under "file" for the queue-stash side to use as its + # own key matching `_add_to_print_queue`'s lookup. await self._notify_print_command(filename, print_data) handled_locally = True diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 34f5eecb1..68d808025 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -5082,14 +5082,17 @@ class TestStartPrintRecordsDispatchedPlate: class TestStartPrintNozzleMappingDispatch: - """H2C dual-nozzle-rack (#1780) — nozzle_mapping + nozzles_info on dispatch. + """H2C dual-nozzle-rack (#1780) — nozzle_mapping on dispatch. BambuStudio's project_file MQTT command for O1C2 carries a per-filament - physical nozzle position ID array (`nozzle_mapping`) and a per-extruder - rack metadata array (`nozzles_info`). Without forwarding both, the H2C - firmware falls back to "last matching nozzle type" auto-pick and ignores - the user's slicer choice. Tests pin the gate, the parse, the no-op cases, - and the malformed-JSON safety net. + physical nozzle position ID array (`nozzle_mapping`). Without forwarding + it, the H2C firmware falls back to "last matching nozzle type" auto-pick + and ignores the user's slicer choice. Tests pin the gate, the parse, the + no-op cases, and the malformed-JSON safety net. + + The original #1780 attempt also captured `nozzles_info` but a wire capture + on H2C confirmed BambuStudio never sends that field — the capture/dispatch + paths for it were dropped in the same release. """ @pytest.fixture @@ -5111,29 +5114,23 @@ class TestStartPrintNozzleMappingDispatch: call_args = mqtt_client._client.publish.call_args return json.loads(call_args[0][1])["print"] - def test_dual_nozzle_includes_nozzle_mapping_and_nozzles_info(self, mqtt_client): - """Dual-nozzle + both fields present → parsed JSON arrays injected + def test_dual_nozzle_includes_nozzle_mapping(self, mqtt_client): + """Dual-nozzle + nozzle_mapping present → parsed JSON array injected verbatim onto the dispatched project_file command.""" mqtt_client._is_dual_nozzle = True - nozzles_info = [ - {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4}, - {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4}, - ] mqtt_client.start_print( "test.3mf", - nozzle_mapping=json.dumps([16, 0, 19]), - nozzles_info=json.dumps(nozzles_info), + nozzle_mapping=json.dumps([16, -1, -1, 1, -1, -1, -1, -1]), ) cmd = self._published_print_cmd(mqtt_client) - # Lists, not strings — the wire shape must match BambuStudio's. - assert cmd["nozzle_mapping"] == [16, 0, 19] - assert cmd["nozzles_info"] == nozzles_info + # List, not string — the wire shape must match BambuStudio's. + assert cmd["nozzle_mapping"] == [16, -1, -1, 1, -1, -1, -1, -1] def test_single_nozzle_omits_nozzle_mapping_even_if_set(self, mqtt_client): - """A single-nozzle printer must NOT emit the rack fields even if the - caller passes them (defense-in-depth — the queue item could legitimately + """A single-nozzle printer must NOT emit the rack field even if the + caller passes it (defense-in-depth — the queue item could legitimately carry a stale capture from before a model change).""" mqtt_client._is_dual_nozzle = False mqtt_client.model = "P1S" # single-nozzle @@ -5141,41 +5138,22 @@ class TestStartPrintNozzleMappingDispatch: mqtt_client.start_print( "test.3mf", nozzle_mapping=json.dumps([16, 0, 19]), - nozzles_info=json.dumps([{"id": 1}]), ) cmd = self._published_print_cmd(mqtt_client) assert "nozzle_mapping" not in cmd - assert "nozzles_info" not in cmd - def test_dual_nozzle_no_fields_no_injection(self, mqtt_client): + def test_dual_nozzle_no_field_no_injection(self, mqtt_client): """Dual-nozzle printer + no slicer pick (NULL on queue item) → command - carries no nozzle_mapping / nozzles_info. The firmware then runs its - normal auto-pick, which is the pre-fix behaviour for any non-O1C2 dual- + carries no nozzle_mapping. The firmware then runs its normal + auto-pick, which is the pre-fix behaviour for any non-O1C2 dual- nozzle model that has no rack to disambiguate against anyway.""" mqtt_client._is_dual_nozzle = True - mqtt_client.start_print("test.3mf", nozzle_mapping=None, nozzles_info=None) + mqtt_client.start_print("test.3mf", nozzle_mapping=None) cmd = self._published_print_cmd(mqtt_client) assert "nozzle_mapping" not in cmd - assert "nozzles_info" not in cmd - - def test_dual_nozzle_partial_only_mapping(self, mqtt_client): - """Half-populated case: nozzle_mapping carried but nozzles_info NULL. - Forward what we have; firmware tolerates a missing rack metadata - field and resolves against its own state.""" - mqtt_client._is_dual_nozzle = True - - mqtt_client.start_print( - "test.3mf", - nozzle_mapping=json.dumps([16]), - nozzles_info=None, - ) - - cmd = self._published_print_cmd(mqtt_client) - assert cmd["nozzle_mapping"] == [16] - assert "nozzles_info" not in cmd def test_malformed_nozzle_mapping_is_logged_and_omitted(self, mqtt_client, caplog): """Invalid JSON on the queue item must NOT block the dispatch. Log a @@ -5189,7 +5167,6 @@ class TestStartPrintNozzleMappingDispatch: result = mqtt_client.start_print( "test.3mf", nozzle_mapping="not valid json {", - nozzles_info=None, ) assert result is True # dispatch still proceeded @@ -5197,17 +5174,16 @@ class TestStartPrintNozzleMappingDispatch: assert "nozzle_mapping" not in cmd assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records) - def test_empty_string_fields_are_treated_as_absent(self, mqtt_client): + def test_empty_string_field_is_treated_as_absent(self, mqtt_client): """An empty-string column value (legacy data, or a NOT NULL DB recovery shim) must behave the same as NULL — no injection, no parse error log.""" mqtt_client._is_dual_nozzle = True - mqtt_client.start_print("test.3mf", nozzle_mapping="", nozzles_info="") + mqtt_client.start_print("test.3mf", nozzle_mapping="") cmd = self._published_print_cmd(mqtt_client) assert "nozzle_mapping" not in cmd - assert "nozzles_info" not in cmd class TestFilamentTrackSwitchDetection: diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 75761cadd..742377e2e 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -379,7 +379,6 @@ class TestPrinterManager: use_ams=True, nozzle_offset_cali=False, nozzle_mapping=None, - nozzles_info=None, ) assert result is True diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index bde71d29e..2b0f5e79f 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -1580,13 +1580,13 @@ class TestVirtualPrinterInstance: assert all(q.manual_start for q in added_items) @pytest.mark.asyncio - async def test_add_to_print_queue_captures_nozzle_mapping_and_nozzles_info(self, tmp_path): + async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path): """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends - per-filament physical nozzle position IDs in `nozzle_mapping` and - per-extruder rack metadata in `nozzles_info`. VP intake must store - both as JSON strings on the queue item so the dispatcher can replay - them. Without this the H2C firmware falls back to "last matching - nozzle" auto-pick and ignores the user's slicer choice. + per-filament physical nozzle position IDs in `nozzle_mapping`. VP + intake must store it as a JSON string on the queue item so the + dispatcher can replay it. Without this the H2C firmware falls back + to "last matching nozzle" auto-pick and ignores the user's slicer + choice. """ import json as _json @@ -1617,18 +1617,16 @@ class TestVirtualPrinterInstance: file_path.write_bytes(b"fake3mf") # Pre-populate as if BS's project_file arrived. Wire shape matches - # BambuStudio's PrintJob params: nozzle_mapping = array of per- - # filament physical nozzle position IDs, nozzles_info = array of - # per-extruder rack-side metadata. + # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of + # per-filament physical nozzle position IDs (verified via H2C wire + # capture). The slicer-side `nozzles_info` field that the original + # #1780 attempt also looked for was never actually sent — it has + # been dropped from the capture path entirely. await inst.on_print_command( file_path.name, { "command": "project_file", - "nozzle_mapping": [16, 0, 19], - "nozzles_info": [ - {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4}, - {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4}, - ], + "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1], }, ) @@ -1653,18 +1651,14 @@ class TestVirtualPrinterInstance: assert len(added_items) == 1 item = added_items[0] assert item.nozzle_mapping is not None - assert _json.loads(item.nozzle_mapping) == [16, 0, 19] - assert item.nozzles_info is not None - parsed_info = _json.loads(item.nozzles_info) - assert parsed_info[0]["flowSize"] == "High Flow" - assert parsed_info[1]["flowSize"] == "Standard" + assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1] @pytest.mark.asyncio - async def test_add_to_print_queue_no_nozzle_fields_when_slicer_omits(self, tmp_path): - """#1780: every model other than O1C2 sends no nozzle_mapping / - nozzles_info — the queue item must carry NULL on both, not an empty - list. NULL is what the dispatch layer keys off of to skip the - injection entirely on non-rack-swap printers. + async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path): + """#1780: every model other than O1C2 sends no nozzle_mapping — the + queue item must carry NULL, not an empty list. NULL is what the + dispatch layer keys off of to skip the injection entirely on non- + rack-swap printers. """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance @@ -1719,13 +1713,12 @@ class TestVirtualPrinterInstance: assert len(added_items) == 1 item = added_items[0] assert item.nozzle_mapping is None - assert item.nozzles_info is None @pytest.mark.asyncio async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch): """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the - same nozzle_mapping / nozzles_info on every plate's queue item, not - only the first. Mirrors the per-plate stamping for gcode_injection, + same nozzle_mapping on every plate's queue item, not only the first. + Mirrors the per-plate stamping for gcode_injection, filament_overrides, etc. """ import json as _json @@ -1767,7 +1760,6 @@ class TestVirtualPrinterInstance: { "command": "project_file", "nozzle_mapping": [16, 0], - "nozzles_info": [{"id": 1, "flowSize": "High Flow", "diameter": 0.4}], }, ) @@ -1792,7 +1784,6 @@ class TestVirtualPrinterInstance: assert len(added_items) == 3 for item in added_items: assert _json.loads(item.nozzle_mapping) == [16, 0] - assert _json.loads(item.nozzles_info)[0]["flowSize"] == "High Flow" class TestVirtualPrinterManager: @@ -3491,3 +3482,109 @@ class TestSSDPProxyName: rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet) assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten + + +class TestVPProjectFileStashKey: + """Regression: `on_print_command` MUST stash slicer options under the + FTP filename (`data["file"]`, with extension), NOT under `filename` + (the slicer's `subtask_name`, bare). + + #1780 root cause (real bundle, 2026-06-21): BambuStudio sends + `subtask_name = "Model_Name"` (bare) and `file = "Model_Name.gcode.3mf"` + (with extension). `_add_to_print_queue` looks up the stash under + `file_path.name` from the FTP receive side, which always has the + extension. If the stash uses `subtask_name`, lookup misses → every + captured slicer field (bed_leveling, flow_cali, vibration_cali, + layer_inspect, timelapse, nozzle_mapping) silently falls back to + settings defaults on every Bambu Studio "Send" upload. + + `filename` (subtask_name) must still flow to `_schedule_finish_release` + untouched — push_status echoes it back as gcode_file / subtask_name and + the slicer matches against its own local subtask_name there. So + `on_print_command` keeps `filename` for state-feedback but derives the + stash key from `data["file"]`. + """ + + @pytest.fixture + def instance(self, tmp_path): + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + return VirtualPrinterInstance( + vp_id=99, + name="StashKeyTest", + mode="queue", + model="O1C2", + access_code="12345678", + serial_suffix="999999999", + base_dir=tmp_path, + ) + + @pytest.mark.asyncio + async def test_stash_key_uses_file_field_not_subtask_name(self, instance): + """BambuStudio's real wire shape: `subtask_name` ≠ `file`. + on_print_command must stash under `data["file"]` so the FTP-side + `_add_to_print_queue` lookup matches. + """ + # mqtt_server.py:_handle_publish hands the bare subtask_name as + # `filename` and the full print_data body as `data`. The FTP filename + # lives in `data["file"]`. + await instance.on_print_command( + "Filament_Track_Switch_Holder", # subtask_name (bare) + { + "command": "project_file", + "subtask_name": "Filament_Track_Switch_Holder", + "file": "Filament_Track_Switch_Holder.gcode.3mf", + "nozzle_mapping": [16, -1, -1, 1], + }, + ) + + # Stash MUST be under the FTP filename, not the bare subtask_name. + # `_add_to_print_queue` does `_slicer_print_options.pop(file_path.name, None)` + # where file_path.name == "Filament_Track_Switch_Holder.gcode.3mf". + assert "Filament_Track_Switch_Holder.gcode.3mf" in instance._slicer_print_options + assert "Filament_Track_Switch_Holder" not in instance._slicer_print_options + # Body must carry nozzle_mapping verbatim. + stashed = instance._slicer_print_options["Filament_Track_Switch_Holder.gcode.3mf"] + assert stashed["nozzle_mapping"] == [16, -1, -1, 1] + + @pytest.mark.asyncio + async def test_stash_key_falls_back_to_filename_when_file_absent(self, instance): + """Defensive fallback: a slicer that omits the `file` field entirely + (legacy / non-3MF) must fall back to `filename` (subtask_name), not + leave the stash unkeyed.""" + await instance.on_print_command( + "BareName", + { + "command": "project_file", + "subtask_name": "BareName", + # no "file" field + }, + ) + + assert "BareName" in instance._slicer_print_options + + @pytest.mark.asyncio + async def test_stash_key_signals_event_under_file_key(self, instance): + """`_add_to_print_queue` registers a wait-event under `file_path.name` + when the slicer's project_file arrives late. on_print_command must + signal THAT event (keyed by the FTP filename), not one keyed by + subtask_name — else the waiter times out even though the stash is + present and addressable.""" + import asyncio + + ftp_filename = "Filament_Track_Switch_Holder.gcode.3mf" + event = asyncio.Event() + instance._slicer_print_options_events[ftp_filename] = event + + await instance.on_print_command( + "Filament_Track_Switch_Holder", # bare subtask_name + { + "command": "project_file", + "subtask_name": "Filament_Track_Switch_Holder", + "file": ftp_filename, + }, + ) + + # Event keyed by FTP filename must fire even though on_print_command + # was called with the bare subtask_name. + assert event.is_set()