mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
parent
ca20342949
commit
d196cfc500
12 changed files with 519 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue