fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)

BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
  nozzle-rack variant) carries nozzle_mapping (per-filament physical
  nozzle position IDs) and nozzles_info (per-extruder rack metadata).
  The VP intake was dropping both, so the H2C firmware fell back to
  "last matching nozzle type" auto-pick and ignored the user's
  slicer choice — every HF print landed on R2, every standard print
  landed on R4.

  Carry both fields through the VP intake → queue item → MQTT
  dispatch path. New nullable TEXT columns on print_queue, non-
  branched ALTER (matches ams_mapping / filament_overrides
  precedent). Dual-nozzle gate at start_print() keeps the fields
  off single-nozzle dispatches. Fail-open on malformed JSON —
  firmware auto-picks, never worse than pre-fix.

  Stamps both fields on every plate in the multi-plate Send All
  loop (#1697 / #1188 precedent).

  ams_mapping2 still handles H2D/X2D dual-extruder routing
  unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
This commit is contained in:
maziggy 2026-06-19 13:19:31 +02:00
parent ca20342949
commit d196cfc500
12 changed files with 519 additions and 2 deletions

View file

@ -22,6 +22,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by @phieb)** — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. **Default off so upgraders don't silently start injecting**: existing `gcode_snippets` installs keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unless `gcode_snippets` are configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." **DB column:** new `virtual_printers.gcode_injection BOOLEAN DEFAULT FALSE` with a branched `is_sqlite()` migration (SQLite `DEFAULT 0` / Postgres `DEFAULT FALSE`) matching the `queue_force_color_match` / `tailscale_disabled` precedent. **Multi-plate stamping:** the flag is set on every plate's `PrintQueueItem` inside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. **Live-toggle correctness:** the `_sync_from_db_locked` change detector now compares `instance.gcode_injection != vp.gcode_injection`, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicated `test_sync_from_db_restarts_on_gcode_injection_toggle`. **UI:** new toggle on `VirtualPrinterCard.tsx` (queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standard `updateMutation` save-on-click + toast on success, plus the `pendingAction='gcodeInjection'` opacity dim during the round-trip. **PrintModal hardening:** when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A new `useEffect` clears the stale `gcodeInjection` state if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. **Diagnostics:** the resolved start/end snippets (with `{placeholder}` substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. **i18n:** new `virtualPrinter.gcodeInjection.title` + `description` keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. **Tests:** 2 new unit cases in `test_virtual_printer.py` (queue items opt in / out based on the VP flag), 2 new integration cases in `test_virtual_printer_api.py` (create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to `_make_db_vp` so the change-detector test fixture carries an explicit `False` rather than relying on `MagicMock` truthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean; `npm run build` clean.
### Fixed
- **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).
- **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.
- **Archive thumbnails missing for prints sliced via the docker sidecar (#1759, reported by @VID-PRO)** — The reporter (P2S) noticed every print sliced through Bambuddy's BS docker sidecar landed in the archive with no thumbnail, while the same model sliced from desktop Bambu Studio on their laptop showed the cover image. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 (`Store sent files on external storage`) which is unrelated — that flag is set on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy entirely: **neither the BambuStudio CLI nor the OrcaSlicer CLI renders `Metadata/plate_N.png` when invoked headlessly with `--slice --export-3mf`.** That render is a separate code path triggered by the `--export-png` flag, which is mutually exclusive with `--export-3mf` and additionally requires a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — even `XDG_SESSION_TYPE=x11` + `GDK_BACKEND=x11` + `QT_QPA_PLATFORM=xcb` don't switch it back to X11, so an Xvfb display in the sidecar wouldn't help even if we wired a second-pass call). Confirmed empirically by feeding a thumbnail-stripped `Cube-MegaS.3mf` through both sidecars: both produced `.gcode.3mf` with zero PNG entries. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since it launched; nobody noticed until VID-PRO filed this against BS specifically. **Fix:** New `backend/app/services/plate_thumbnail.py` renders the missing thumbnails server-side after the slice returns. `inject_plate_thumbnails_if_missing(threemf_bytes)` parses the sliced zip, finds every `Metadata/plate_N.gcode` entry that doesn't have a matching `plate_N.png`, loads `3D/3dmodel.model` via trimesh, renders an isometric Bambu-green-on-dark view at 512×512 (`plate_N.png`) + 128×128 (`plate_N_small.png`) using the same matplotlib Agg pipeline as `stl_thumbnail.py`, and re-packs the zip with the PNGs injected. Visual style deliberately matches Bambuddy's existing library thumbnails — archive cards stay consistent inside Bambuddy rather than chasing parity with desktop Studio's plate render. Best-effort: input bytes are returned unchanged on any failure (no model file, trimesh can't parse, matplotlib render fails) so the slice flow itself can't fail because of a missing thumbnail. Idempotent: re-running on a previously-injected 3MF hits the no-op fast path and returns the input verbatim. Wired into both `backend/app/api/routes/library.py` slice paths (library-file slice at line 3593 + archive re-slice at line 3718) via `result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))` immediately before `out_path.write_bytes(...)` — covers the cross-class merged-multi-plate path (`slicer_3mf_convert.merge_plate_3mfs`) automatically since merged bytes flow into the same write site. **Dependencies:** trimesh's 3MF loader imports `networkx` (scene-graph traversal) and `lxml` (model.xml parse) lazily inside the 3MF code path — both added to `requirements.txt` because they aren't strict trimesh transitives but the loader fails at runtime without them (`ModuleNotFoundError`). **Tests:** 7 new cases in `backend/tests/unit/services/test_plate_thumbnail.py`: input bytes returned unchanged (identity) when every plate already has a thumbnail (desktop-Studio fast path); both PNG sizes injected when missing; injected PNGs decode as 512x512 + 128x128 RGBA; multi-plate 3MF with one pre-existing thumbnail only renders the missing slots (pre-existing bytes preserved verbatim); 3MF with no `3D/3dmodel.model` returns input unchanged; non-zip input returns input unchanged; idempotent on second pass. **Verified end-to-end:** running `inject_plate_thumbnails_if_missing` against the actual BS sidecar and Orca sidecar outputs (`/tmp/bs-no-thumb-out.3mf` / `/tmp/orca-no-thumb-out.3mf` — both 25932/25992 bytes with zero PNG entries) produces 3MFs with valid `Metadata/plate_1.png` + `Metadata/plate_1_small.png` containing the rendered cube model (38.5% Bambu-green pixel coverage confirms the model is actually drawn, not a blank canvas). 6151/6151 backend tests still green; ruff clean. No sidecar Dockerfile change required — earlier experiments with Xvfb + `xvfb-run` in `Dockerfile.bambu-studio` were a false start (the BS GLFW Wayland lock means no X display can help) and have been reverted from the sidecar repo. No frontend change required — the archive UI already extracts `plate_1.png` from the sliced 3MF, the cards just had nothing to show.

View file

@ -184,6 +184,23 @@ 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.
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 = {
"id": item.id,
@ -226,6 +243,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"been_jumped": item.been_jumped,
# Auto-print G-code injection
"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:
@ -1051,6 +1071,15 @@ async def update_queue_item(
json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
)
# Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
# storage; same Text-as-opaque-blob convention as ams_mapping above.
if "nozzle_mapping" in update_data:
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)

View file

@ -967,6 +967,15 @@ async def run_migrations(conn):
else:
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.
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")
# Migration: Add target_parts_count column to projects for tracking total parts needed
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")

View file

@ -65,6 +65,17 @@ class PrintQueueItem(Base):
# Auto-print G-code injection (#422)
gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
# 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.
nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
# Print options
bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)

View file

@ -82,6 +82,13 @@ 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).
nozzle_mapping: list[int] | None = None
nozzles_info: list[dict] | None = None
class PrintQueueItemResponse(BaseModel):
@ -163,6 +170,12 @@ class PrintQueueItemResponse(BaseModel):
# Auto-print G-code injection
gcode_injection: bool = False
# H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
# "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

View file

@ -3439,6 +3439,8 @@ class BambuMQTTClient:
timelapse: bool = False,
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.
@ -3457,6 +3459,16 @@ class BambuMQTTClient:
use_ams: Use AMS for automatic filament changes
nozzle_offset_cali: Run nozzle offset calibration before print
(dual-nozzle printers only silently ignored on single-nozzle).
nozzle_mapping: Opaque JSON string captured from BambuStudio's
project_file for H2C rack-swap (O1C2) (#1780). When non-null
AND the printer is dual-nozzle, parsed and injected as the
`nozzle_mapping` array on the dispatched project_file so the
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.
@ -3614,6 +3626,35 @@ class BambuMQTTClient:
command["print"]["ams_mapping"] = flat_ams_mapping
command["print"]["ams_mapping2"] = ams_mapping2
# 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,
)
logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
# Record what we dispatched so /cover can pick the right plate

View file

@ -2253,7 +2253,11 @@ class PrintScheduler:
# FINISH-state fallback — no need to force a video.
effective_timelapse = bool(item.timelapse)
# Start the print with AMS mapping, plate_id and print options
# 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.
started = printer_manager.start_print(
item.printer_id,
remote_filename,
@ -2266,6 +2270,8 @@ class PrintScheduler:
timelapse=effective_timelapse,
use_ams=item.use_ams,
nozzle_offset_cali=item.nozzle_offset_cali,
nozzle_mapping=item.nozzle_mapping,
nozzles_info=item.nozzles_info,
)
if started:

View file

@ -565,8 +565,17 @@ class PrinterManager:
timelapse: bool = False,
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."""
"""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.
"""
caller = traceback.extract_stack(limit=3)[0]
logger.info(
"PRINT COMMAND: printer=%s, file=%s, caller=%s:%s:%s",
@ -588,6 +597,8 @@ class PrinterManager:
layer_inspect=layer_inspect,
use_ams=use_ams,
nozzle_offset_cali=nozzle_offset_cali,
nozzle_mapping=nozzle_mapping,
nozzles_info=nozzles_info,
)
return False

View file

@ -573,6 +573,49 @@ class VirtualPrinterInstance:
)
timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
# 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.
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.
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
logger.warning(
"[VP %s] Slicer %s 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
service = ArchiveService(db)
archive = await service.archive_print(
printer_id=None,
@ -675,6 +718,12 @@ class VirtualPrinterInstance:
# gcode_snippets are configured for the target model, so it's
# effectively "inject when enabled AND snippets exist".
gcode_injection=self.gcode_injection,
# H2C rack-swap slicer pick (#1780). Captured above;
# stamped on every plate so a multi-plate Send All keeps
# 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

View file

@ -5081,6 +5081,135 @@ class TestStartPrintRecordsDispatchedPlate:
assert mqtt_client.state.dispatched_subtask is None
class TestStartPrintNozzleMappingDispatch:
"""H2C dual-nozzle-rack (#1780) — nozzle_mapping + nozzles_info 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.
"""
@pytest.fixture
def mqtt_client(self):
from unittest.mock import MagicMock
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST_O1C2",
access_code="12345678",
)
client._client = MagicMock()
client.state.connected = True
return client
def _published_print_cmd(self, mqtt_client):
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
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),
)
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
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
carry a stale capture from before a model change)."""
mqtt_client._is_dual_nozzle = False
mqtt_client.model = "P1S" # single-nozzle
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):
"""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-
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)
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
warning and let the firmware auto-pick the failure mode is just
the pre-fix behaviour, not a worse one. Fail-open is correct here
because the alternative would silently brick every dispatch on a
single bad row."""
mqtt_client._is_dual_nozzle = True
with caplog.at_level("WARNING"):
result = mqtt_client.start_print(
"test.3mf",
nozzle_mapping="not valid json {",
nozzles_info=None,
)
assert result is True # dispatch still proceeded
cmd = self._published_print_cmd(mqtt_client)
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):
"""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="")
cmd = self._published_print_cmd(mqtt_client)
assert "nozzle_mapping" not in cmd
assert "nozzles_info" not in cmd
class TestFilamentTrackSwitchDetection:
"""Tests for Filament Track Switch (FTS) accessory detection (#1162).

View file

@ -378,6 +378,8 @@ class TestPrinterManager:
layer_inspect=False,
use_ams=True,
nozzle_offset_cali=False,
nozzle_mapping=None,
nozzles_info=None,
)
assert result is True

View file

@ -1579,6 +1579,221 @@ class TestVirtualPrinterInstance:
# auto_dispatch=False on the VP → every item is manual_start.
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):
"""#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.
"""
import json as _json
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=42,
name="H2CRack",
mode="queue",
model="O1C2",
access_code="12345678",
serial_suffix="391800042",
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
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.
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},
],
},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
),
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
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"
@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.
"""
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=43,
name="NotH2C",
mode="queue",
model="C11",
access_code="12345678",
serial_suffix="391800043",
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
# X1C-style slicer command — no nozzle fields.
await inst.on_print_command(
file_path.name,
{"command": "project_file", "timelapse": False, "bed_leveling": True},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
),
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
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,
filament_overrides, etc.
"""
import json as _json
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.flush = AsyncMock()
mock_db.commit = AsyncMock()
mock_db.execute = AsyncMock()
mock_db.execute.return_value.scalar.return_value = None
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=44,
name="H2CMultiPlate",
mode="queue",
model="O1C2",
access_code="12345678",
serial_suffix="391800044",
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
# Force 3 plates so the queue loop runs three times.
monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2, 3])
await inst.on_print_command(
file_path.name,
{
"command": "project_file",
"nozzle_mapping": [16, 0],
"nozzles_info": [{"id": 1, "flowSize": "High Flow", "diameter": 0.4}],
},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
),
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
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:
"""Tests for VirtualPrinterManager orchestrator."""