From 2c2725cb533f89327c87093c5da9ef875583adc2 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 8 Jun 2026 09:20:19 +0200 Subject: [PATCH] fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682) Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip), giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle BambuStudio exposes. Critical for diamond-nozzle setups that must keep the calibration off. start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as 1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines always send 2 even if a stale flag arrives. The kwarg threads through printer_manager, both background_dispatch sites, and print_scheduler so every dispatch path respects the per-item setting. print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite() branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali defaults to TRUE to match BambuStudio. Schemas updated across print_queue, library FilePrintRequest, archive ReprintRequest, settings. PrintModal renders the new toggle only when the selected printer is dual- nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS). SettingsPage default-print-options row + QueuePage bulk-edit tri-state both hide unless any registered printer is dual-nozzle. Labels reuse the existing settings.default* keys so the only new i18n strings are settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali - real translations in all 11 locales. --- CHANGELOG.md | 2 + backend/app/api/routes/print_queue.py | 2 + backend/app/api/routes/settings.py | 1 + backend/app/core/database.py | 6 + backend/app/models/print_queue.py | 2 + backend/app/schemas/archive.py | 1 + backend/app/schemas/library.py | 1 + backend/app/schemas/print_queue.py | 7 + backend/app/schemas/settings.py | 5 + backend/app/services/background_dispatch.py | 2 + backend/app/services/bambu_mqtt.py | 9 +- backend/app/services/print_scheduler.py | 1 + backend/app/services/printer_manager.py | 2 + .../tests/unit/services/test_bambu_mqtt.py | 55 ++++ .../unit/services/test_printer_manager.py | 1 + frontend/src/api/client.ts | 7 + .../components/PrintModal/PrintOptions.tsx | 36 ++- frontend/src/components/PrintModal/index.tsx | 26 +- frontend/src/components/PrintModal/types.ts | 5 + frontend/src/i18n/locales/de.ts | 3 + frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/es.ts | 3 + frontend/src/i18n/locales/fr.ts | 3 + frontend/src/i18n/locales/it.ts | 3 + frontend/src/i18n/locales/ja.ts | 3 + frontend/src/i18n/locales/ko.ts | 3 + frontend/src/i18n/locales/pt-BR.ts | 3 + frontend/src/i18n/locales/tr.ts | 3 + frontend/src/i18n/locales/zh-CN.ts | 3 + frontend/src/i18n/locales/zh-TW.ts | 3 + frontend/src/pages/QueuePage.tsx | 17 +- frontend/src/pages/SettingsPage.tsx | 17 +- .../{index-CqscW2CN.js => index-BhDnAnn6.js} | 234 +++++++++--------- static/index.html | 2 +- 34 files changed, 335 insertions(+), 139 deletions(-) rename static/assets/{index-CqscW2CN.js => index-BhDnAnn6.js} (73%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ace2e22b..230ca91c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Fixed +- **Print modal now exposes a "Nozzle Offset Calibration" toggle for dual-nozzle printers (#1682, reported by @louiskleiman)** — Reporter on H2D running diamond nozzles: BambuStudio exposes a per-print "Nozzle Offset Calibration" option that is incompatible with diamond hot ends, but Bambuddy had no way to control the same flag, so every dispatch silently set it to the firmware default. **Root cause: the field was hardcoded.** `bambu_mqtt.py:3445` always wrote `"nozzle_offset_cali": 2` (skip) into the MQTT `project_file` payload, regardless of model, regardless of any user choice. The wire format is tri-state — `1`=run, `2`=skip — and matches BambuStudio's encoding; the manual-calibration route (`/printers/{id}/calibration`) already wired the corresponding `cali_idx=2` MQTT command, but the **dispatch-time** toggle was simply absent. For most users this was invisible (BambuStudio's default is "run" on H2D / H2D Pro / H2C / X2D, Bambuddy's default was effectively "skip"), but a diamond-nozzle setup that needs the calibration explicitly off had no way to confirm Bambuddy's behaviour or override it the other way once we add a toggle that follows the slicer's default. **Fix: end-to-end plumbing of `nozzle_offset_cali` with a hard MQTT-layer gate on dual-nozzle.** `start_print()` (`bambu_mqtt.py:3300`) gains a `nozzle_offset_cali: bool = False` kwarg and the project_file payload line becomes `"nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2`. The dual-nozzle check reuses `is_dual_nozzle_model()` and the runtime `_is_dual_nozzle` flag (set when `device.extruder.info` has ≥ 2 entries) — same canonical signal the rest of bambu_mqtt.py uses for routing decisions. **Even if a stale queue item from when the printer was misidentified carries the flag, the MQTT layer downgrades it to `2`** so firmware never tries to calibrate a head it doesn't have. The kwarg threads through `printer_manager.start_print()`, both `background_dispatch` call sites, and `print_scheduler._start_print` so every dispatch path — direct reprint, library file, queue-dispatched, watchdog-recover — respects the per-item setting. **Persistence:** `print_queue.nozzle_offset_cali` column (BOOLEAN DEFAULT TRUE, branched on `is_sqlite()` because Postgres rejects `DEFAULT 1` for BOOLEAN, caught by my Postgres test environment before this shipped) — default TRUE matches BambuStudio's behaviour on dual-nozzle, the MQTT gate makes the value a no-op on single-nozzle. New `default_nozzle_offset_cali` setting (default TRUE) plumbed through `schemas/settings.py`, the settings PUT allowlist, and the SettingsPage card — the row in **Settings → Default Print Options** only renders when `printers.some(p => p.nozzle_count === 2)`, so single-nozzle-only users never see a control they can't act on. ReprintRequest + FilePrintRequest schemas (`schemas/archive.py`, `schemas/library.py`) carry the field too so the API surface is consistent across the three "send 3MF to printer" routes. **Frontend:** `PrintOptionsPanel` (`components/PrintModal/PrintOptions.tsx`) accepts a `showDualNozzleOptions` prop and filters the option list; `PrintModal/index.tsx` computes it from `selectedPrinters.some(p => p.nozzle_count === 2)` in printer-mode or from a small inline `DUAL_NOZZLE_MODELS` set in model-mode (mirrors the backend `DUAL_NOZZLE_MODELS` frozenset: `H2D`, `H2DPRO`, `H2C`, `X2D`). The same gate flows through `QueuePage` bulk-edit — the new tri-state toggle only renders if any registered printer has `nozzle_count === 2`. Labels reuse the existing `settings.defaultBedLevelling` / `settings.defaultFlowCali` / etc. translation keys (identical strings, already translated) to keep i18n churn proportional to the actual new copy. **i18n:** 3 new keys per locale × 11 locales = 33 entries — `settings.defaultNozzleOffsetCali`, `settings.defaultNozzleOffsetCaliDesc`, `queue.bulkEdit.nozzleOffsetCali` — real translations in every locale (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallbacks. i18n parity check confirms 5069 leaves × 11 locales. **Tests:** 4 new in `test_bambu_mqtt.py` pin the four-quadrant gate: default value (P1S, no kwarg → `2`), single-nozzle ignore (P1S, kwarg `True` → still `2` — the safety net), dual-nozzle honour (H2D, `True` → `1`), dual-nozzle false (H2D Pro, `False` → `2` — the diamond-nozzle case). `test_printer_manager.py` updated for the new kwarg in `assert_called_once_with`. Frontend tests: existing PrintModal / QueuePage / SettingsPage suites pass with the new field threaded through (117 / 117). Full backend suite: 3840 / 3840 pass. ruff clean; frontend build clean; ESLint clean; i18n parity green. + - **"Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by @kleinwareio)** — Reporter clicked Assign Spool from the printer card for AMS-B slot 4 while that slot was empty. The toast said "Spool assigned and AMS slot configured" but the AMS card kept showing slot 4 as Empty. **Root cause: misleading toast on the empty-slot deferred-config path.** The backend (`inventory.py:1385-1405`) deliberately skips the MQTT `ams_filament_setting` publish when the AMS reports an empty tray state (state ∈ {9, 10}) because Bambu firmware silently drops the push for empty slots — there's no point sending a command the printer will discard. The assignment row is persisted with `pending_config=true`, and `on_ams_change` (`main.py:1031-1054`) re-fires the full configuration the moment the AMS reports a non-empty fingerprint in that slot. The flow is correct; the success log line `Pre-configured assignment: spool 16 → printer 1 AMS1-T3 (slot empty, will configure on insert)` confirms the backend did exactly that. **But the frontend ignored the response flag.** `AssignSpoolModal.tsx:153` always called `showToast(t('inventory.assignSuccess'), 'success')` — the wording "Spool assigned and AMS slot configured" — regardless of whether the backend actually configured the slot or deferred. The sibling SpoolBuddy modal (`spoolbuddy/AssignToAmsModal.tsx:212-226`) already branched on `pending_config` and showed a distinct "Slot will configure when you insert the spool" message; the printer-card modal was just never updated to match. **Fix:** `AssignSpoolModal.tsx` now reads `newAssignment.pending_config` and picks between `'inventory.assignSuccess'` (slot configured immediately) and the new `'inventory.assignPendingInsert'` ("Assigned. Slot will configure when you insert the spool.") key. Spoolman-mode branch unchanged — the Spoolman backend route always sends the MQTT push (no pending_config flag is exposed) and the SpoolBuddy modal's existing comment documents that. **i18n:** new `inventory.assignPendingInsert` key in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), translations copied verbatim from the existing parallel `spoolbuddy.modal.assignPendingInsert` entries so the message reads identically across the app. No English fallbacks per the project's hard rule; i18n parity check confirms 5066 leaves × 11 locales. **Tests:** 2 new in `AssignSpoolModal.test.tsx` — `shows the pending-insert toast when backend returns pending_config=true (#1680)` pins the new branch (slot-was-empty case the reporter hit), and `shows the configured toast when backend returns pending_config=false (#1680)` is the counterpart regression guard so a future refactor can't silently mark every assign as pending. Both also assert the WRONG toast is NOT also called (defense against accidental double-toast). 16/16 AssignSpoolModal tests pass; frontend build clean; ESLint clean. - **Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by @IndividualGhost1905)** — Reporter on X1C, daily build `v0.2.5b1-daily.20260607`: a print was running, the host was restarted (planned reboot / power outage / watchtower image update), and Bambuddy's printer card showed the print as **cancelled** while the printer continued printing happily. Print log showed `aborted` for that row, filament usage was deducted at the cancellation moment (48.6 g / 5 % in the supplied screenshots), and when the print actually finished a *second* archive was created and filament was deducted *again*. Net effect: filament inventory off by the entire print weight, statistics showing one "user-cancelled" entry alongside one "completed" entry for the same physical print. Second confirmed hit from the same reporter, plus a corroborating comment from @Arn0uDz on watchtower-driven restarts. **Root cause: connected-edge reconciliation fired on a bare MQTT-connected state that had no real data yet.** On Bambuddy startup, a fresh `BambuMQTTClient` is constructed with `PrinterState` defaults — most importantly `state.state = "unknown"` and `state.subtask_name = ""`. The MQTT `_on_connect` callback (`bambu_mqtt.py:668-669`) broadcasts `on_state_change(self.state)` *immediately* after the broker accepts the connection — BEFORE the `_request_push_all` round-trips with the printer's real status. `on_printer_status_change` (`main.py:825`) sees `state.connected=True` flip on the connected-edge, spawns `reconcile_stale_active_prints` for that printer. The reconcile walks every archive in `status="printing"`, calls `_is_active_archive_stale` (`main.py:3352`) — which sees `state.state="UNKNOWN"` (skips the IDLE/FINISH/FAILED branch), then `state.subtask_name=""` (matches trigger 3, "printer subtask_name empty") and **returns stale**. A synthesised `aborted` PRINT COMPLETE fires for every in-flight archive on every printer, clears `_active_prints`, and when the real PRINT COMPLETE finally arrives at print end, `_active_prints` doesn't have the entry, so a brand-new archive row is created instead of overwriting the synthesised one. The pre-existing comment at `_is_active_archive_stale` ("the next real PRINT COMPLETE would have overwritten the status anyway") was wrong: the reactive completion handler uses `_active_prints` for lookup, not a join on filename/subtask_id, so the original row stays cancelled and a duplicate is born. Timing-dependent in practice — on hosts where the printer's first `push_status` response wins the race against the reconcile background task, state is real and reconcile doesn't false-positive; on slower hosts or busy MQTT brokers, the bare-connect-edge fires first and the bug hits. The reporter is on a slower-race host and saw it twice. **Fix: two-layer guard.** (1) Primary: `on_printer_status_change` now gates the reconcile spawn on `state.state` being a real value — `state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")` — so reconcile doesn't fire until the first `push_status` updates `state.state` to a real Bambu firmware value (RUNNING / IDLE / FINISH / PREPARE / SLICING / PAUSE / FAILED). When that real push arrives, `on_printer_status_change` fires again, the connected-edge flag is still `False` (we never set it), and reconcile runs against actual evidence. The existing #1542 mechanism — synthesising a missed PRINT COMPLETE for prints that finished during a disconnect window — keeps working: if the printer reports `IDLE` on its first real push after reconnect, reconcile catches it the way it always did. (2) Belt-and-braces: `_is_active_archive_stale` now returns `(False, "")` when `state.state` is empty / `"unknown"` / `None`, regardless of the subtask fields. Strictly more conservative than the previous behaviour; only suppresses the degenerate-input false positive. Any future caller that bypasses the primary gate still can't synthesise an aborted completion from defaults. **Tests:** `test_reconcile_stale_active_prints.py` 26 cases (up from 21) — new parametrize `test_pre_push_state_returns_not_stale_even_with_empty_subtask` pins all five degenerate forms (`"unknown"`, `"UNKNOWN"`, `"Unknown"`, `""`, `None`) and asserts none triggers stale even with empty `subtask_id` + empty `subtask_name`. The existing #1542 regression coverage stays green — terminal-state, subtask-id-mismatch, and empty-subtask-name-under-RUNNING all still report stale on real state pushes. Full backend suite: 3836 / 3836 pass. ruff clean. diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index d8a72debe..ffae1bbb9 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -207,6 +207,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "layer_inspect": item.layer_inspect, "timelapse": item.timelapse, "use_ams": item.use_ams, + "nozzle_offset_cali": item.nozzle_offset_cali, "status": item.status, "started_at": item.started_at, "completed_at": item.completed_at, @@ -538,6 +539,7 @@ async def add_to_queue( layer_inspect=data.layer_inspect, timelapse=data.timelapse, use_ams=data.use_ams, + nozzle_offset_cali=data.nozzle_offset_cali, gcode_injection=data.gcode_injection, project_id=data.project_id, position=max_pos + 1 + i, diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 04aa325bf..994ff89d3 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -134,6 +134,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) - "default_vibration_cali", "default_layer_inspect", "default_timelapse", + "default_nozzle_offset_cali", "ldap_enabled", "ldap_auto_provision", ]: diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f0acbb37a..4bcee6688 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1109,6 +1109,12 @@ async def run_migrations(conn): await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0") await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0") await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1") + # Migration: Add nozzle offset calibration option (dual-nozzle printers, #1682). + # Postgres rejects `DEFAULT 1` on a BOOLEAN column — use TRUE / 1 per dialect. + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT 1") + else: + await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE") # Migration: Add library_file_id column to print_queue and make archive_id nullable # This allows queue items to reference library files directly (archive created at print start) diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index e0628ef24..07806234c 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -72,6 +72,8 @@ class PrintQueueItem(Base): layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False) timelapse: Mapped[bool] = mapped_column(Boolean, default=False) use_ams: Mapped[bool] = mapped_column(Boolean, default=True) + # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682) + nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True) # Status: pending, printing, completed, failed, skipped, cancelled status: Mapped[str] = mapped_column(String(20), default="pending") diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 126dfad2d..f1661faa0 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -240,3 +240,4 @@ class ReprintRequest(BaseModel): layer_inspect: bool = False timelapse: bool = False use_ams: bool = True # Not exposed in UI, but needed for API + nozzle_offset_cali: bool = True # Dual-nozzle printers only — MQTT-gated (#1682) diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index 3c7a2e85d..bc5a7b35c 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -208,6 +208,7 @@ class FilePrintRequest(BaseModel): layer_inspect: bool = False timelapse: bool = False use_ams: bool = True + nozzle_offset_cali: bool = True # Dual-nozzle printers only — MQTT-gated (#1682) # Project to associate the resulting archive with project_id: int | None = None # When true, delete the LibraryFile row + disk file after the archive has diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 715099253..cd04aee08 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -40,6 +40,10 @@ class PrintQueueItemCreate(BaseModel): layer_inspect: bool = False timelapse: bool = False use_ams: bool = True + # Nozzle offset calibration — dual-nozzle printers only (#1682). Default True + # matches BambuStudio's default; the MQTT layer ignores the flag on + # single-nozzle printers so the wire value stays "skip" there. + nozzle_offset_cali: bool = True # Auto-print G-code injection gcode_injection: bool = False # Batch: create multiple copies (creates a batch if > 1) @@ -67,6 +71,7 @@ class PrintQueueItemUpdate(BaseModel): layer_inspect: bool | None = None timelapse: bool | None = None use_ams: bool | None = None + nozzle_offset_cali: bool | None = None # Auto-print G-code injection gcode_injection: bool | None = None @@ -100,6 +105,7 @@ class PrintQueueItemResponse(BaseModel): layer_inspect: bool = False timelapse: bool = False use_ams: bool = True + nozzle_offset_cali: bool = True status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"] started_at: UTCDatetime completed_at: UTCDatetime @@ -171,6 +177,7 @@ class PrintQueueBulkUpdate(BaseModel): layer_inspect: bool | None = None timelapse: bool | None = None use_ams: bool | None = None + nozzle_offset_cali: bool | None = None # Auto-print G-code injection gcode_injection: bool | None = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 798288730..2057fff26 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -245,6 +245,10 @@ class AppSettings(BaseModel): default=False, description="Default first layer inspection option for new prints" ) default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints") + default_nozzle_offset_cali: bool = Field( + default=True, + description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)", + ) # Staggered batch start for multi-printer jobs stagger_group_size: int = Field( @@ -404,6 +408,7 @@ class AppSettingsUpdate(BaseModel): default_vibration_cali: bool | None = None default_layer_inspect: bool | None = None default_timelapse: bool | None = None + default_nozzle_offset_cali: bool | None = None stagger_group_size: int | None = Field(default=None, ge=1, le=50) stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60) require_plate_clear: bool | None = None diff --git a/backend/app/services/background_dispatch.py b/backend/app/services/background_dispatch.py index cb9a140ad..489ee655b 100644 --- a/backend/app/services/background_dispatch.py +++ b/backend/app/services/background_dispatch.py @@ -695,6 +695,7 @@ class BackgroundDispatchService: vibration_cali=job.options.get("vibration_cali", True), layer_inspect=job.options.get("layer_inspect", False), use_ams=job.options.get("use_ams", True), + nozzle_offset_cali=job.options.get("nozzle_offset_cali", False), ) if not started: @@ -898,6 +899,7 @@ class BackgroundDispatchService: vibration_cali=job.options.get("vibration_cali", True), layer_inspect=job.options.get("layer_inspect", False), use_ams=job.options.get("use_ams", True), + nozzle_offset_cali=job.options.get("nozzle_offset_cali", False), ) if not started: diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 1b5302f28..ba500b724 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -3300,6 +3300,7 @@ class BambuMQTTClient: layer_inspect: bool = False, timelapse: bool = False, use_ams: bool = True, + nozzle_offset_cali: bool = False, ): """Start a print job on the printer. @@ -3316,6 +3317,8 @@ class BambuMQTTClient: vibration_cali: Vibration compensation calibration layer_inspect: First layer AI inspection 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). """ if self._client and self.state.connected: # Bambu print command format — matches Bambu Studio's format. @@ -3442,7 +3445,11 @@ class BambuMQTTClient: # regardless of the flow_cali toggle (#1478). "extrude_cali_flag": 1 if flow_cali else 2, "extrude_cali_manual_mode": 0, - "nozzle_offset_cali": 2, + # 1 = run, 2 = skip. BambuStudio exposes the toggle only for + # dual-nozzle machines (H2D/H2D Pro/H2C/X2D); on single-nozzle + # printers we always send 2 so firmware never wastes cycles + # on a calibration their head doesn't support (#1682). + "nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2, "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""), "profile_id": "0", "project_id": submission_id, diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index c29601542..4458d9298 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -2206,6 +2206,7 @@ class PrintScheduler: layer_inspect=item.layer_inspect, timelapse=effective_timelapse, use_ams=item.use_ams, + nozzle_offset_cali=item.nozzle_offset_cali, ) if started: diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index edc731862..2f09bcc59 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -508,6 +508,7 @@ class PrinterManager: layer_inspect: bool = False, timelapse: bool = False, use_ams: bool = True, + nozzle_offset_cali: bool = False, ) -> bool: """Start a print on a connected printer.""" caller = traceback.extract_stack(limit=3)[0] @@ -530,6 +531,7 @@ class PrinterManager: vibration_cali=vibration_cali, layer_inspect=layer_inspect, use_ams=use_ams, + nozzle_offset_cali=nozzle_offset_cali, ) return False diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 12cf23b5d..36e51ebd0 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -3826,6 +3826,61 @@ class TestStartPrintAmsMapping: # flow-dynamics calibration instead of reusing the stored PA value. assert cmd["extrude_cali_flag"] == 1 + def test_nozzle_offset_cali_default_is_skip(self, mqtt_client): + """Default `nozzle_offset_cali=False` → wire value `2` (skip). + + Matches the legacy behavior on every model: BambuStudio sends `2` + unless the user enabled the toggle for a dual-nozzle machine. The + legacy hardcoded value before #1682 was `2` for everyone — this + test pins that default so we don't regress. + """ + mqtt_client.model = "P1S" + mqtt_client.start_print("test.3mf") + + cmd = self._get_published_command(mqtt_client) + assert cmd["nozzle_offset_cali"] == 2 + + def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client): + """Single-nozzle printer: `nozzle_offset_cali=True` is silently dropped. + + H2S is in the H2 firmware family but single-nozzle. The toggle has + no physical meaning on single-nozzle machines and the UI gates it + behind `nozzle_count==2`. Even if a stale queue item from when the + printer was misidentified as dual carries the flag, the MQTT layer + must downgrade it so firmware never tries to calibrate a head it + doesn't have (#1682). + """ + mqtt_client.model = "P1S" + mqtt_client.start_print("test.3mf", nozzle_offset_cali=True) + + cmd = self._get_published_command(mqtt_client) + assert cmd["nozzle_offset_cali"] == 2 + + def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client): + """Dual-nozzle printer (H2D): `nozzle_offset_cali=True` → wire value `1`. + + H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the + printer runs the nozzle-offset calibration pass before the print + starts. `1`=run, `2`=skip — matches BambuStudio's encoding (#1682). + """ + mqtt_client.model = "H2D" + mqtt_client.start_print("test.3mf", nozzle_offset_cali=True) + + cmd = self._get_published_command(mqtt_client) + assert cmd["nozzle_offset_cali"] == 1 + + def test_nozzle_offset_cali_false_on_dual_nozzle(self, mqtt_client): + """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali=False` → `2` (skip). + + Same wire encoding as legacy. Critical for users like #1682 who run + diamond nozzles and need to keep the calibration off. + """ + mqtt_client.model = "H2D Pro" + mqtt_client.start_print("test.3mf", nozzle_offset_cali=False) + + cmd = self._get_published_command(mqtt_client) + assert cmd["nozzle_offset_cali"] == 2 + class TestStartPrintUniqueIdentityFields: """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011). diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 16067479d..fff2ba681 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -377,6 +377,7 @@ class TestPrinterManager: vibration_cali=True, layer_inspect=False, use_ams=True, + nozzle_offset_cali=False, ) assert result is True diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7934b0dd9..3a89bc95b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1129,6 +1129,7 @@ export interface AppSettings { default_vibration_cali: boolean; default_layer_inspect: boolean; default_timelapse: boolean; + default_nozzle_offset_cali: boolean; // Staggered batch start defaults stagger_group_size: number; stagger_interval_minutes: number; @@ -1873,6 +1874,7 @@ export interface PrintQueueItem { layer_inspect: boolean; timelapse: boolean; use_ams: boolean; + nozzle_offset_cali: boolean; status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled'; started_at: string | null; completed_at: string | null; @@ -1938,6 +1940,7 @@ export interface PrintQueueItemCreate { layer_inspect?: boolean; timelapse?: boolean; use_ams?: boolean; + nozzle_offset_cali?: boolean; // Auto-print G-code injection gcode_injection?: boolean; // Batch: create multiple copies (creates a batch if > 1) @@ -1965,6 +1968,7 @@ export interface PrintQueueItemUpdate { layer_inspect?: boolean; timelapse?: boolean; use_ams?: boolean; + nozzle_offset_cali?: boolean; // Auto-print G-code injection gcode_injection?: boolean; } @@ -1983,6 +1987,7 @@ export interface PrintQueueBulkUpdate { layer_inspect?: boolean; timelapse?: boolean; use_ams?: boolean; + nozzle_offset_cali?: boolean; // Auto-print G-code injection gcode_injection?: boolean; } @@ -4213,6 +4218,7 @@ export const api = { vibration_cali?: boolean; layer_inspect?: boolean; use_ams?: boolean; + nozzle_offset_cali?: boolean; } ) => request( @@ -5715,6 +5721,7 @@ export const api = { layer_inspect?: boolean; timelapse?: boolean; use_ams?: boolean; + nozzle_offset_cali?: boolean; project_id?: number; cleanup_library_after_dispatch?: boolean; } diff --git a/frontend/src/components/PrintModal/PrintOptions.tsx b/frontend/src/components/PrintModal/PrintOptions.tsx index 88dd48366..ba438c9c6 100644 --- a/frontend/src/components/PrintModal/PrintOptions.tsx +++ b/frontend/src/components/PrintModal/PrintOptions.tsx @@ -1,26 +1,42 @@ import { useState } from 'react'; import { Settings, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types'; -const PRINT_OPTIONS_CONFIG = [ - { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' }, - { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' }, - { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' }, - { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' }, - { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' }, -] as const; +type OptionConfig = { + key: keyof PrintOptionsType; + label: string; + desc: string; + dualNozzleOnly?: boolean; +}; /** * Print options toggle panel with collapsible UI. - * Shows bed levelling, flow/vibration calibration, layer inspection, and timelapse options. + * Shows bed levelling, flow/vibration calibration, layer inspection, timelapse, + * and (for dual-nozzle printers only) nozzle offset calibration. */ export function PrintOptionsPanel({ options, onChange, defaultExpanded = false, + showDualNozzleOptions = false, }: PrintOptionsProps) { + const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(defaultExpanded); + // Labels/descriptions reuse the settings.default* namespace — identical strings, + // already translated across all locales. Only nozzle_offset_cali is new (#1682). + const printOptionsConfig: OptionConfig[] = [ + { key: 'bed_levelling', label: t('settings.defaultBedLevelling'), desc: t('settings.defaultBedLevellingDesc') }, + { key: 'flow_cali', label: t('settings.defaultFlowCali'), desc: t('settings.defaultFlowCaliDesc') }, + { key: 'vibration_cali', label: t('settings.defaultVibrationCali'), desc: t('settings.defaultVibrationCaliDesc') }, + { key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') }, + { key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') }, + { key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true }, + ]; + + const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions); + const handleToggle = (key: keyof PrintOptionsType) => { onChange({ ...options, [key]: !options[key] }); }; @@ -33,7 +49,7 @@ export function PrintOptionsPanel({ className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full" > - Print Options + {t('queue.bulkEdit.printOptions')} {isExpanded ? ( ) : ( @@ -42,7 +58,7 @@ export function PrintOptionsPanel({ {isExpanded && (
- {PRINT_OPTIONS_CONFIG.map(({ key, label, desc }) => ( + {visibleOptions.map(({ key, label, desc }) => (