Prechádzať zdrojové kódy

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.
maziggy 3 mesiacov pred
rodič
commit
2c2725cb53
34 zmenil súbory, kde vykonal 216 pridanie a 22 odobranie
  1. 0 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/print_queue.py
  3. 1 0
      backend/app/api/routes/settings.py
  4. 6 0
      backend/app/core/database.py
  5. 2 0
      backend/app/models/print_queue.py
  6. 1 0
      backend/app/schemas/archive.py
  7. 1 0
      backend/app/schemas/library.py
  8. 7 0
      backend/app/schemas/print_queue.py
  9. 5 0
      backend/app/schemas/settings.py
  10. 2 0
      backend/app/services/background_dispatch.py
  11. 8 1
      backend/app/services/bambu_mqtt.py
  12. 1 0
      backend/app/services/print_scheduler.py
  13. 2 0
      backend/app/services/printer_manager.py
  14. 55 0
      backend/tests/unit/services/test_bambu_mqtt.py
  15. 1 0
      backend/tests/unit/services/test_printer_manager.py
  16. 7 0
      frontend/src/api/client.ts
  17. 26 10
      frontend/src/components/PrintModal/PrintOptions.tsx
  18. 25 1
      frontend/src/components/PrintModal/index.tsx
  19. 5 0
      frontend/src/components/PrintModal/types.ts
  20. 3 0
      frontend/src/i18n/locales/de.ts
  21. 3 0
      frontend/src/i18n/locales/en.ts
  22. 3 0
      frontend/src/i18n/locales/es.ts
  23. 3 0
      frontend/src/i18n/locales/fr.ts
  24. 3 0
      frontend/src/i18n/locales/it.ts
  25. 3 0
      frontend/src/i18n/locales/ja.ts
  26. 3 0
      frontend/src/i18n/locales/ko.ts
  27. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 3 0
      frontend/src/i18n/locales/tr.ts
  29. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 14 3
      frontend/src/pages/QueuePage.tsx
  32. 11 6
      frontend/src/pages/SettingsPage.tsx
  33. 0 0
      static/assets/index-BhDnAnn6.js
  34. 1 1
      static/index.html

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
CHANGELOG.md


+ 2 - 0
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,

+ 1 - 0
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",
         ]:

+ 6 - 0
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)

+ 2 - 0
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")

+ 1 - 0
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)

+ 1 - 0
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

+ 7 - 0
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
 

+ 5 - 0
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

+ 2 - 0
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:

+ 8 - 1
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,

+ 1 - 0
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:

+ 2 - 0
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
 

+ 55 - 0
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).

+ 1 - 0
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
 

+ 7 - 0
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<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;
     }

+ 26 - 10
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"
       >
         <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>

+ 25 - 1
frontend/src/components/PrintModal/index.tsx

@@ -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. */}

+ 5 - 0
frontend/src/components/PrintModal/types.ts

@@ -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;
 }
 
 /**

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -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',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -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',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -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',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -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',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -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',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -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: 'プレートクリア確認',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -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: '플레이트 비움 확인',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -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ı',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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: '热床清空确认',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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: '熱床清空確認',

+ 14 - 3
frontend/src/pages/QueuePage.tsx

@@ -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 });

+ 11 - 6
frontend/src/pages/SettingsPage.tsx

@@ -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>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/index-BhDnAnn6.js


+ 1 - 1
static/index.html

@@ -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>

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov