mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -377,6 +377,7 @@ class TestPrinterManager:
|
||||
vibration_cali=True,
|
||||
layer_inspect=False,
|
||||
use_ams=True,
|
||||
nozzle_offset_cali=False,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@@ -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<BackgroundDispatchResponse>(
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Print Options</span>
|
||||
<span>{t('queue.bulkEdit.printOptions')}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 ml-auto" />
|
||||
) : (
|
||||
@@ -42,7 +58,7 @@ export function PrintOptionsPanel({
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
|
||||
{PRINT_OPTIONS_CONFIG.map(({ key, label, desc }) => (
|
||||
{visibleOptions.map(({ key, label, desc }) => (
|
||||
<label key={key} className="flex items-center justify-between cursor-pointer group">
|
||||
<div>
|
||||
<span className="text-sm text-white">{label}</span>
|
||||
|
||||
@@ -100,6 +100,7 @@ export function PrintModal({
|
||||
vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
|
||||
layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
|
||||
timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
|
||||
nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
|
||||
};
|
||||
}
|
||||
return DEFAULT_PRINT_OPTIONS;
|
||||
@@ -237,6 +238,7 @@ export function PrintModal({
|
||||
vibration_cali: settings.default_vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
|
||||
layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
|
||||
timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
|
||||
nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
|
||||
});
|
||||
}, [settings, mode]);
|
||||
|
||||
@@ -917,6 +919,23 @@ export function PrintModal({
|
||||
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
|
||||
);
|
||||
|
||||
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
|
||||
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
|
||||
// the toggle without a specific printer selected. For printer-mode we rely
|
||||
// on the canonical `nozzle_count` field auto-detected from MQTT.
|
||||
const DUAL_NOZZLE_MODELS = useMemo(
|
||||
() => new Set(['H2D', 'H2DPRO', 'H2C', 'X2D']),
|
||||
[],
|
||||
);
|
||||
const showDualNozzleOptions = useMemo(() => {
|
||||
if (assignmentMode === 'model') {
|
||||
if (!targetModel) return false;
|
||||
return DUAL_NOZZLE_MODELS.has(targetModel.toUpperCase().replace(/[\s-]/g, ''));
|
||||
}
|
||||
if (!printers || selectedPrinters.length === 0) return false;
|
||||
return selectedPrinters.some(id => printers.find(p => p.id === id)?.nozzle_count === 2);
|
||||
}, [assignmentMode, targetModel, printers, selectedPrinters, DUAL_NOZZLE_MODELS]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
@@ -1067,7 +1086,12 @@ export function PrintModal({
|
||||
|
||||
{/* Print options */}
|
||||
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
|
||||
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
|
||||
<PrintOptionsPanel
|
||||
options={printOptions}
|
||||
onChange={setPrintOptions}
|
||||
defaultExpanded={!!initialSelectedPrinterIds?.length}
|
||||
showDualNozzleOptions={showDualNozzleOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface PrintOptions {
|
||||
vibration_cali: boolean;
|
||||
layer_inspect: boolean;
|
||||
timelapse: boolean;
|
||||
nozzle_offset_cali: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +60,7 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
|
||||
vibration_cali: true,
|
||||
layer_inspect: false,
|
||||
timelapse: false,
|
||||
nozzle_offset_cali: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -203,6 +205,9 @@ export interface PrintOptionsProps {
|
||||
options: PrintOptions;
|
||||
onChange: (options: PrintOptions) => void;
|
||||
defaultExpanded?: boolean;
|
||||
/** Show the dual-nozzle-only options (nozzle offset calibration). Default false.
|
||||
* Pass true when at least one selected printer is dual-nozzle. */
|
||||
showDualNozzleOptions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'Erste-Schicht-Prüfung',
|
||||
timelapse: 'Zeitraffer',
|
||||
useAms: 'AMS verwenden',
|
||||
nozzleOffsetCali: 'Düsenversatz-Kalibrierung',
|
||||
applyChanges: 'Änderungen übernehmen',
|
||||
selectAll: 'Alle auswählen',
|
||||
deselectAll: 'Auswahl aufheben',
|
||||
@@ -1846,6 +1847,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'KI-Inspektion der ersten Schicht',
|
||||
defaultTimelapse: 'Zeitraffer',
|
||||
defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',
|
||||
defaultNozzleOffsetCali: 'Düsenversatz-Kalibrierung',
|
||||
defaultNozzleOffsetCaliDesc: 'Düsenversatz zwischen Extrudern kalibrieren',
|
||||
staggeredStart: 'Versetzter Start',
|
||||
staggeredStartDescription: 'Standard-Gruppengröße und -Intervall beim Staffeln von Mehrdrucker-Batchstarts. Pro Batch im Druck-Dialog überschreibbar.',
|
||||
plateClear: 'Druckplatte-Bestätigung',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'First layer inspection',
|
||||
timelapse: 'Timelapse',
|
||||
useAms: 'Use AMS',
|
||||
nozzleOffsetCali: 'Nozzle offset calibration',
|
||||
applyChanges: 'Apply Changes',
|
||||
selectAll: 'Select All',
|
||||
deselectAll: 'Deselect All',
|
||||
@@ -1849,6 +1850,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'AI inspection of first layer',
|
||||
defaultTimelapse: 'Timelapse',
|
||||
defaultTimelapseDesc: 'Record timelapse video',
|
||||
defaultNozzleOffsetCali: 'Nozzle Offset Calibration',
|
||||
defaultNozzleOffsetCaliDesc: 'Calibrate nozzle offsets between extruders',
|
||||
staggeredStart: 'Staggered Start',
|
||||
staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
|
||||
plateClear: 'Plate-Clear Confirmation',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'Inspección de la primera capa',
|
||||
timelapse: 'Time-lapse',
|
||||
useAms: 'Usar AMS',
|
||||
nozzleOffsetCali: 'Calibración del offset de boquillas',
|
||||
applyChanges: 'Aplicar cambios',
|
||||
selectAll: 'Seleccionar todo',
|
||||
deselectAll: 'Deseleccionar todo',
|
||||
@@ -1849,6 +1850,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'Inspección de la primera capa por IA',
|
||||
defaultTimelapse: 'Time-lapse',
|
||||
defaultTimelapseDesc: 'Grabar vídeo time-lapse',
|
||||
defaultNozzleOffsetCali: 'Calibración del offset de boquillas',
|
||||
defaultNozzleOffsetCaliDesc: 'Calibrar los desplazamientos entre boquillas',
|
||||
staggeredStart: 'Inicio escalonado',
|
||||
staggeredStartDescription: 'Tamaño de grupo e intervalo predeterminados al escalonar los inicios de lotes en varias impresoras. Se pueden anular por lote en la ventana de impresión.',
|
||||
plateClear: 'Confirmación de cama despejada',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'Inspection 1ère couche',
|
||||
timelapse: 'Time-lapse',
|
||||
useAms: 'Utiliser AMS',
|
||||
nozzleOffsetCali: 'Calibration du décalage des buses',
|
||||
applyChanges: 'Appliquer',
|
||||
selectAll: 'Tout sélectionner',
|
||||
deselectAll: 'Tout désélectionner',
|
||||
@@ -1802,6 +1803,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'Inspection IA de la première couche',
|
||||
defaultTimelapse: 'Time-lapse',
|
||||
defaultTimelapseDesc: 'Enregistrer une vidéo timelapse',
|
||||
defaultNozzleOffsetCali: 'Calibration du décalage des buses',
|
||||
defaultNozzleOffsetCaliDesc: 'Calibrer les décalages entre les buses',
|
||||
staggeredStart: 'Démarrage échelonné',
|
||||
staggeredStartDescription: 'Taille de groupe et intervalle par défaut lors de l\'échelonnement des démarrages de lots multi-imprimantes. Modifiable par lot dans la fenêtre d\'impression.',
|
||||
plateClear: 'Confirmation de plateau libre',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'Controllo primo layer',
|
||||
timelapse: 'Timelapse',
|
||||
useAms: 'Usa AMS',
|
||||
nozzleOffsetCali: 'Calibrazione offset ugelli',
|
||||
applyChanges: 'Applica modifiche',
|
||||
selectAll: 'Seleziona tutto',
|
||||
deselectAll: 'Deseleziona tutto',
|
||||
@@ -1802,6 +1803,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'Ispezione IA del primo strato',
|
||||
defaultTimelapse: 'Timelapse',
|
||||
defaultTimelapseDesc: 'Registra un video timelapse',
|
||||
defaultNozzleOffsetCali: 'Calibrazione offset ugelli',
|
||||
defaultNozzleOffsetCaliDesc: 'Calibra gli offset tra gli ugelli',
|
||||
staggeredStart: 'Avvio scaglionato',
|
||||
staggeredStartDescription: 'Dimensione gruppo e intervallo predefiniti per scaglionare avvii di batch multi-stampante. Sovrascrivibili per batch nella finestra di stampa.',
|
||||
plateClear: 'Conferma piatto libero',
|
||||
|
||||
@@ -1080,6 +1080,7 @@ export default {
|
||||
layerInspection: '第一層検査',
|
||||
timelapse: 'タイムラプス',
|
||||
useAms: 'AMS使用',
|
||||
nozzleOffsetCali: 'ノズルオフセットキャリブレーション',
|
||||
applyChanges: '変更を適用',
|
||||
selectAll: 'すべて選択',
|
||||
deselectAll: 'すべて選択解除',
|
||||
@@ -1845,6 +1846,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'AIによる第1層の検査',
|
||||
defaultTimelapse: 'タイムラプス',
|
||||
defaultTimelapseDesc: 'タイムラプス動画を記録',
|
||||
defaultNozzleOffsetCali: 'ノズルオフセットキャリブレーション',
|
||||
defaultNozzleOffsetCaliDesc: 'エクストルーダー間のノズルオフセットを校正',
|
||||
staggeredStart: '段階的開始',
|
||||
staggeredStartDescription: '複数プリンターのバッチ開始を段階的に行う際のデフォルトのグループサイズと間隔。プリントモーダルでバッチごとに上書き可能。',
|
||||
plateClear: 'プレートクリア確認',
|
||||
|
||||
@@ -1015,6 +1015,7 @@ export default {
|
||||
layerInspection: '첫 번째 층 검사',
|
||||
timelapse: '타임랩스',
|
||||
useAms: 'AMS 사용',
|
||||
nozzleOffsetCali: '노즐 오프셋 보정',
|
||||
applyChanges: '변경사항 적용',
|
||||
selectAll: '전체 선택',
|
||||
deselectAll: '전체 해제',
|
||||
@@ -1728,6 +1729,8 @@ export default {
|
||||
defaultLayerInspectDesc: '첫 번째 층 AI 검사',
|
||||
defaultTimelapse: '타임랩스',
|
||||
defaultTimelapseDesc: '타임랩스 영상 녹화',
|
||||
defaultNozzleOffsetCali: '노즐 오프셋 보정',
|
||||
defaultNozzleOffsetCaliDesc: '익스트루더 간 노즐 오프셋 보정',
|
||||
staggeredStart: '엇갈린 시작',
|
||||
staggeredStartDescription: '다중 프린터 일괄 시작 시 기본 그룹 크기 및 간격. 인쇄 모달에서 배치별로 재정의할 수 있습니다.',
|
||||
plateClear: '플레이트 비움 확인',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'Inspeção da Primeira Camada',
|
||||
timelapse: 'Timelapse',
|
||||
useAms: 'Usar AMS',
|
||||
nozzleOffsetCali: 'Calibração de offset dos bicos',
|
||||
applyChanges: 'Aplicar Alterações',
|
||||
selectAll: 'Selecionar Todos',
|
||||
deselectAll: 'Desmarcar Todos',
|
||||
@@ -1802,6 +1803,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'Inspeção IA da primeira camada',
|
||||
defaultTimelapse: 'Timelapse',
|
||||
defaultTimelapseDesc: 'Gravar vídeo timelapse',
|
||||
defaultNozzleOffsetCali: 'Calibração de offset dos bicos',
|
||||
defaultNozzleOffsetCaliDesc: 'Calibrar offsets entre extrusores',
|
||||
staggeredStart: 'Início escalonado',
|
||||
staggeredStartDescription: 'Tamanho de grupo e intervalo padrão ao escalonar inícios de lotes multi-impressora. Pode ser sobrescrito por lote no modal de impressão.',
|
||||
plateClear: 'Confirmação de placa livre',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: 'İlk katman denetimi',
|
||||
timelapse: 'Zaman atlamalı video',
|
||||
useAms: "AMS Kullan",
|
||||
nozzleOffsetCali: 'Nozül ofset kalibrasyonu',
|
||||
applyChanges: 'Değişiklikleri Uygula',
|
||||
selectAll: 'Tümünü Seç',
|
||||
deselectAll: 'Seçimi Kaldır',
|
||||
@@ -1849,6 +1850,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'İlk katmanın AI denetimi',
|
||||
defaultTimelapse: 'Zaman Atlamalı Video',
|
||||
defaultTimelapseDesc: 'Zaman atlamalı video kaydet',
|
||||
defaultNozzleOffsetCali: 'Nozül Ofset Kalibrasyonu',
|
||||
defaultNozzleOffsetCaliDesc: 'Ekstrüderler arasındaki nozül ofsetlerini kalibre et',
|
||||
staggeredStart: 'Kademeli Başlatma',
|
||||
staggeredStartDescription: 'Çoklu yazıcı toplu başlatmaları kademelendirilirken varsayılan grup boyutu ve aralığı. Baskı modalinde yığın başına geçersiz kılınabilir.',
|
||||
plateClear: 'Plaka Temizleme Onayı',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: '首层检查',
|
||||
timelapse: '延时摄影',
|
||||
useAms: '使用 AMS',
|
||||
nozzleOffsetCali: '喷嘴偏移校准',
|
||||
applyChanges: '应用更改',
|
||||
selectAll: '全选',
|
||||
deselectAll: '取消全选',
|
||||
@@ -1847,6 +1848,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'AI首层检测',
|
||||
defaultTimelapse: '延时摄影',
|
||||
defaultTimelapseDesc: '录制延时摄影视频',
|
||||
defaultNozzleOffsetCali: '喷嘴偏移校准',
|
||||
defaultNozzleOffsetCaliDesc: '校准两个挤出机之间的喷嘴偏移',
|
||||
staggeredStart: '错峰启动',
|
||||
staggeredStartDescription: '错峰启动多台打印机批次时的默认组大小和间隔。可在打印对话框中按批次覆盖。',
|
||||
plateClear: '热床清空确认',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ export default {
|
||||
layerInspection: '首層檢查',
|
||||
timelapse: '縮時攝影',
|
||||
useAms: '使用 AMS',
|
||||
nozzleOffsetCali: '噴嘴偏移校準',
|
||||
applyChanges: '套用更改',
|
||||
selectAll: '全選',
|
||||
deselectAll: '取消全選',
|
||||
@@ -1847,6 +1848,8 @@ export default {
|
||||
defaultLayerInspectDesc: 'AI首層檢測',
|
||||
defaultTimelapse: '縮時攝影',
|
||||
defaultTimelapseDesc: '錄製縮時攝影影片',
|
||||
defaultNozzleOffsetCali: '噴嘴偏移校準',
|
||||
defaultNozzleOffsetCaliDesc: '校準兩個擠出機之間的噴嘴偏移',
|
||||
staggeredStart: '錯開啟動',
|
||||
staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
|
||||
plateClear: '熱床清空確認',
|
||||
|
||||
@@ -124,7 +124,7 @@ function BulkEditModal({
|
||||
t,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
printers: { id: number; name: string }[];
|
||||
printers: { id: number; name: string; nozzle_count?: number }[];
|
||||
onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
|
||||
onClose: () => void;
|
||||
isSaving: boolean;
|
||||
@@ -141,6 +141,12 @@ function BulkEditModal({
|
||||
const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
|
||||
const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
|
||||
const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
|
||||
const [nozzleOffsetCali, setNozzleOffsetCali] = useState<boolean | 'unchanged'>('unchanged');
|
||||
|
||||
// Show the dual-nozzle-only toggle when the user has at least one
|
||||
// dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle
|
||||
// queues never see it — the MQTT layer ignores the field anyway.
|
||||
const hasDualNozzlePrinter = printers.some(p => p.nozzle_count === 2);
|
||||
|
||||
const handleSave = () => {
|
||||
const data: Partial<PrintQueueBulkUpdate> = {};
|
||||
@@ -154,12 +160,14 @@ function BulkEditModal({
|
||||
if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
|
||||
if (timelapse !== 'unchanged') data.timelapse = timelapse;
|
||||
if (useAms !== 'unchanged') data.use_ams = useAms;
|
||||
if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali;
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
|
||||
requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
|
||||
vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged';
|
||||
vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
|
||||
nozzleOffsetCali !== 'unchanged';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
@@ -219,6 +227,9 @@ function BulkEditModal({
|
||||
<TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
|
||||
{hasDualNozzlePrinter && (
|
||||
<TriStateToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1583,7 +1594,7 @@ export function QueuePage() {
|
||||
{showBulkEditModal && (
|
||||
<BulkEditModal
|
||||
selectedCount={selectedItems.length}
|
||||
printers={printers?.map(p => ({ id: p.id, name: p.name })) || []}
|
||||
printers={printers?.map(p => ({ id: p.id, name: p.name, nozzle_count: p.nozzle_count })) || []}
|
||||
onSave={(data) => {
|
||||
if (Object.keys(data).length > 0) {
|
||||
bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
|
||||
|
||||
@@ -1008,6 +1008,7 @@ export function SettingsPage() {
|
||||
(settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
|
||||
(settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
|
||||
(settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
|
||||
(settings.default_nozzle_offset_cali ?? true) !== (localSettings.default_nozzle_offset_cali ?? true) ||
|
||||
(settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
|
||||
(settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
|
||||
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false);
|
||||
@@ -1092,6 +1093,7 @@ export function SettingsPage() {
|
||||
default_vibration_cali: localSettings.default_vibration_cali,
|
||||
default_layer_inspect: localSettings.default_layer_inspect,
|
||||
default_timelapse: localSettings.default_timelapse,
|
||||
default_nozzle_offset_cali: localSettings.default_nozzle_offset_cali,
|
||||
stagger_group_size: localSettings.stagger_group_size,
|
||||
stagger_interval_minutes: localSettings.stagger_interval_minutes,
|
||||
require_plate_clear: localSettings.require_plate_clear,
|
||||
@@ -4057,12 +4059,15 @@ export function SettingsPage() {
|
||||
{t('settings.defaultPrintOptionsDescription', 'Set default values for print options when starting new prints. These can be overridden per print in the print dialog.')}
|
||||
</p>
|
||||
{[
|
||||
{ key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true },
|
||||
{ key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false },
|
||||
{ key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true },
|
||||
{ key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false },
|
||||
{ key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false },
|
||||
].map(({ key, label, desc, fallback }) => (
|
||||
{ key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true, dualNozzleOnly: false },
|
||||
{ key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false, dualNozzleOnly: false },
|
||||
{ key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true, dualNozzleOnly: false },
|
||||
{ key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false, dualNozzleOnly: false },
|
||||
{ key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false, dualNozzleOnly: false },
|
||||
{ key: 'default_nozzle_offset_cali' as const, label: t('settings.defaultNozzleOffsetCali', 'Nozzle Offset Calibration'), desc: t('settings.defaultNozzleOffsetCaliDesc', 'Calibrate nozzle offsets between extruders'), fallback: true, dualNozzleOnly: true },
|
||||
]
|
||||
.filter(({ dualNozzleOnly }) => !dualNozzleOnly || (printers || []).some(p => p.nozzle_count === 2))
|
||||
.map(({ key, label, desc, fallback }) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<p className="text-sm text-white">{label}</p>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CqscW2CN.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BhDnAnn6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user