Просмотр исходного кода

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

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

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

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

      ams_mapping2 still handles H2D/X2D dual-extruder routing
      unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
maziggy 2 месяцев назад
Родитель
Сommit
b691605509

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 29 - 0
backend/app/api/routes/print_queue.py

@@ -184,6 +184,23 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             filament_overrides_parsed = None
 
+    # Parse nozzle_mapping + nozzles_info from JSON string (#1780 — H2C rack
+    # slicer-pick preservation). Both are nullable opaque JSON blobs stored
+    # verbatim from BambuStudio's project_file; surface them parsed for the
+    # response model and any future "edit print → nozzle" UI.
+    nozzle_mapping_parsed = None
+    if item.nozzle_mapping:
+        try:
+            nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
+        except json.JSONDecodeError:
+            nozzle_mapping_parsed = None
+    nozzles_info_parsed = None
+    if item.nozzles_info:
+        try:
+            nozzles_info_parsed = json.loads(item.nozzles_info)
+        except json.JSONDecodeError:
+            nozzles_info_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
@@ -226,6 +243,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "been_jumped": item.been_jumped,
         # Auto-print G-code injection
         "gcode_injection": item.gcode_injection,
+        # H2C rack-swap nozzle pick (#1780)
+        "nozzle_mapping": nozzle_mapping_parsed,
+        "nozzles_info": nozzles_info_parsed,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -1051,6 +1071,15 @@ async def update_queue_item(
             json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
         )
 
+    # Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
+    # storage; same Text-as-opaque-blob convention as ams_mapping above.
+    if "nozzle_mapping" in update_data:
+        update_data["nozzle_mapping"] = (
+            json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
+        )
+    if "nozzles_info" in update_data:
+        update_data["nozzles_info"] = json.dumps(update_data["nozzles_info"]) if update_data["nozzles_info"] else None
+
     for field, value in update_data.items():
         setattr(item, field, value)
 

+ 9 - 0
backend/app/core/database.py

@@ -967,6 +967,15 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
 
+    # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
+    # slicer-pick preservation (#1780). Opaque JSON-string columns carrying
+    # BambuStudio's per-filament physical nozzle position IDs and the
+    # per-extruder rack metadata, forwarded straight from the VP intake to
+    # the dispatcher's project_file MQTT command. NULL on every other model.
+    # Nullable TEXT — no Postgres / SQLite divergence here.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
+
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 

+ 11 - 0
backend/app/models/print_queue.py

@@ -65,6 +65,17 @@ class PrintQueueItem(Base):
     # Auto-print G-code injection (#422)
     gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
+    # project_file MQTT command for rack-swap-capable models (O1C2 today)
+    # carries per-filament physical nozzle position IDs in `nozzle_mapping`
+    # and per-extruder rack metadata in `nozzles_info`. Both are forwarded
+    # verbatim through the queue and replayed by the dispatcher so the
+    # firmware honours the user's pick instead of falling back to
+    # "last matching nozzle type" auto-pick. Stored as opaque JSON strings
+    # (list[int] and list[dict] respectively); NULL on every other model.
+    nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Print options
     bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
     flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)

+ 13 - 0
backend/app/schemas/print_queue.py

@@ -82,6 +82,13 @@ class PrintQueueItemUpdate(BaseModel):
     nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    # H2C dual-nozzle-rack slicer pick (#1780). Both fields are opaque
+    # JSON-encoded structures BambuStudio sends in its project_file MQTT
+    # body; sent back to the printer verbatim on dispatch. list[int] for
+    # nozzle_mapping (per-filament physical nozzle position IDs), list[dict]
+    # for nozzles_info (per-extruder rack metadata).
+    nozzle_mapping: list[int] | None = None
+    nozzles_info: list[dict] | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -163,6 +170,12 @@ class PrintQueueItemResponse(BaseModel):
     # Auto-print G-code injection
     gcode_injection: bool = False
 
+    # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
+    # "edit print → choose nozzle" UI; null on every model except O1C2
+    # uploads from BambuStudio.
+    nozzle_mapping: list[int] | None = None
+    nozzles_info: list[dict] | None = None
+
     class Config:
         from_attributes = True
 

+ 41 - 0
backend/app/services/bambu_mqtt.py

@@ -3439,6 +3439,8 @@ class BambuMQTTClient:
         timelapse: bool = False,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
+        nozzle_mapping: str | None = None,
+        nozzles_info: str | None = None,
     ):
         """Start a print job on the printer.
 
@@ -3457,6 +3459,16 @@ class BambuMQTTClient:
             use_ams: Use AMS for automatic filament changes
             nozzle_offset_cali: Run nozzle offset calibration before print
                 (dual-nozzle printers only — silently ignored on single-nozzle).
+            nozzle_mapping: Opaque JSON string captured from BambuStudio's
+                project_file for H2C rack-swap (O1C2) (#1780). When non-null
+                AND the printer is dual-nozzle, parsed and injected as the
+                `nozzle_mapping` array on the dispatched project_file so the
+                firmware honours the user's slicer pick instead of falling
+                back to "last matching nozzle" auto-pick. Silently ignored
+                on single-nozzle printers.
+            nozzles_info: Opaque JSON string for the per-extruder rack
+                metadata BambuStudio's project_file carries alongside
+                `nozzle_mapping` (#1780). Same dual-nozzle gating.
         """
         if self._client and self.state.connected:
             # Bambu print command format — matches Bambu Studio's format.
@@ -3614,6 +3626,35 @@ class BambuMQTTClient:
                 command["print"]["ams_mapping"] = flat_ams_mapping
                 command["print"]["ams_mapping2"] = ams_mapping2
 
+            # H2C dual-nozzle-rack slicer-pick preservation (#1780).
+            # `nozzle_mapping` carries per-filament physical nozzle position
+            # IDs (`list[int]`), `nozzles_info` carries per-extruder rack
+            # metadata (`list[dict]`). Both are JSON-string-encoded when
+            # they leave the queue item; parse here so the wire ships
+            # arrays/objects, matching BambuStudio's project_file shape.
+            # Gate by `is_dual_nozzle` defensively — single-nozzle firmwares
+            # would ignore them but we err on the side of not emitting
+            # unrecognised fields. A parse failure is logged but never
+            # blocks the dispatch — the firmware will fall back to its
+            # auto-pick path, which is the pre-fix behaviour.
+            if is_dual_nozzle:
+                for src_str, json_key in (
+                    (nozzle_mapping, "nozzle_mapping"),
+                    (nozzles_info, "nozzles_info"),
+                ):
+                    if not src_str:
+                        continue
+                    try:
+                        command["print"][json_key] = json.loads(src_str)
+                    except json.JSONDecodeError:
+                        logger.warning(
+                            "[%s] Invalid %s JSON on dispatch, omitting from "
+                            "project_file (firmware will auto-pick): %r",
+                            self.serial_number,
+                            json_key,
+                            src_str,
+                        )
+
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             # Record what we dispatched so /cover can pick the right plate

+ 7 - 1
backend/app/services/print_scheduler.py

@@ -2253,7 +2253,11 @@ class PrintScheduler:
         # FINISH-state fallback — no need to force a video.
         effective_timelapse = bool(item.timelapse)
 
-        # Start the print with AMS mapping, plate_id and print options
+        # Start the print with AMS mapping, plate_id and print options.
+        # nozzle_mapping / nozzles_info ride through verbatim — JSON strings
+        # captured from Bambu Studio's project_file on VP intake (#1780); the
+        # MQTT layer parses + injects them only for dual-nozzle models so a
+        # null on every other model is a transparent pass-through.
         started = printer_manager.start_print(
             item.printer_id,
             remote_filename,
@@ -2266,6 +2270,8 @@ class PrintScheduler:
             timelapse=effective_timelapse,
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
+            nozzle_mapping=item.nozzle_mapping,
+            nozzles_info=item.nozzles_info,
         )
 
         if started:

+ 12 - 1
backend/app/services/printer_manager.py

@@ -565,8 +565,17 @@ class PrinterManager:
         timelapse: bool = False,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
+        nozzle_mapping: str | None = None,
+        nozzles_info: str | None = None,
     ) -> bool:
-        """Start a print on a connected printer."""
+        """Start a print on a connected printer.
+
+        ``nozzle_mapping`` and ``nozzles_info`` are opaque JSON strings
+        captured from BambuStudio's project_file MQTT command (H2C rack-swap
+        slicer pick preservation, #1780). They ride through to the MQTT
+        client untouched; the dispatch builder there parses + injects them
+        only on dual-nozzle models.
+        """
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
             "PRINT COMMAND: printer=%s, file=%s, caller=%s:%s:%s",
@@ -588,6 +597,8 @@ class PrinterManager:
                 layer_inspect=layer_inspect,
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
+                nozzle_mapping=nozzle_mapping,
+                nozzles_info=nozzles_info,
             )
         return False
 

+ 49 - 0
backend/app/services/virtual_printer/manager.py

@@ -573,6 +573,49 @@ class VirtualPrinterInstance:
                 )
                 timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
 
+                # H2C dual-nozzle-rack slicer-pick preservation (#1780).
+                # BambuStudio's project_file MQTT command for rack-swap models
+                # (O1C2 today) carries:
+                #   `nozzle_mapping` — per-filament array of physical nozzle
+                #     position IDs (`list[int]`).
+                #   `nozzles_info`   — per-extruder rack metadata
+                #     (`list[dict]`, fields: id / type / flowSize / diameter).
+                # Forward both verbatim onto the queue item so the dispatcher
+                # can replay them in its own project_file command. Without
+                # this the H2C firmware falls back to "last matching nozzle"
+                # auto-pick and ignores the user's Bambu Studio choice. Every
+                # other model has these absent from slicer_opts, so the
+                # capture is a transparent no-op there.
+                nozzle_mapping_json: str | None = None
+                nozzles_info_json: str | None = None
+                if slicer_opts is not None:
+                    for src_key in ("nozzle_mapping", "nozzles_info"):
+                        raw = slicer_opts.get(src_key)
+                        if raw is None:
+                            continue
+                        # BambuStudio's NetworkAgent should embed these as
+                        # parsed JSON in the project_file body (matching the
+                        # ams_mapping / ams_mapping2 shape Bambuddy already
+                        # consumes as list[int] / list[dict]). Accept a
+                        # JSON-encoded string defensively in case any path
+                        # arrives stringified.
+                        if isinstance(raw, str):
+                            try:
+                                raw = json.loads(raw)
+                            except json.JSONDecodeError:
+                                logger.warning(
+                                    "[VP %s] Slicer %s is unparseable JSON, dropping: %r",
+                                    self.name,
+                                    src_key,
+                                    raw,
+                                )
+                                continue
+                        encoded = json.dumps(raw)
+                        if src_key == "nozzle_mapping":
+                            nozzle_mapping_json = encoded
+                        else:
+                            nozzles_info_json = encoded
+
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -675,6 +718,12 @@ class VirtualPrinterInstance:
                             # gcode_snippets are configured for the target model, so it's
                             # effectively "inject when enabled AND snippets exist".
                             gcode_injection=self.gcode_injection,
+                            # H2C rack-swap slicer pick (#1780). Captured above;
+                            # stamped on every plate so a multi-plate Send All keeps
+                            # the same nozzle pick across plates rather than only the
+                            # first one (mirrors the #1697 / #1188 per-plate loop fix).
+                            nozzle_mapping=nozzle_mapping_json,
+                            nozzles_info=nozzles_info_json,
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging

+ 129 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5081,6 +5081,135 @@ class TestStartPrintRecordsDispatchedPlate:
         assert mqtt_client.state.dispatched_subtask is None
 
 
+class TestStartPrintNozzleMappingDispatch:
+    """H2C dual-nozzle-rack (#1780) — nozzle_mapping + nozzles_info on dispatch.
+
+    BambuStudio's project_file MQTT command for O1C2 carries a per-filament
+    physical nozzle position ID array (`nozzle_mapping`) and a per-extruder
+    rack metadata array (`nozzles_info`). Without forwarding both, the H2C
+    firmware falls back to "last matching nozzle type" auto-pick and ignores
+    the user's slicer choice. Tests pin the gate, the parse, the no-op cases,
+    and the malformed-JSON safety net.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_O1C2",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _published_print_cmd(self, mqtt_client):
+        call_args = mqtt_client._client.publish.call_args
+        return json.loads(call_args[0][1])["print"]
+
+    def test_dual_nozzle_includes_nozzle_mapping_and_nozzles_info(self, mqtt_client):
+        """Dual-nozzle + both fields present → parsed JSON arrays injected
+        verbatim onto the dispatched project_file command."""
+        mqtt_client._is_dual_nozzle = True
+        nozzles_info = [
+            {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
+            {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
+        ]
+
+        mqtt_client.start_print(
+            "test.3mf",
+            nozzle_mapping=json.dumps([16, 0, 19]),
+            nozzles_info=json.dumps(nozzles_info),
+        )
+
+        cmd = self._published_print_cmd(mqtt_client)
+        # Lists, not strings — the wire shape must match BambuStudio's.
+        assert cmd["nozzle_mapping"] == [16, 0, 19]
+        assert cmd["nozzles_info"] == nozzles_info
+
+    def test_single_nozzle_omits_nozzle_mapping_even_if_set(self, mqtt_client):
+        """A single-nozzle printer must NOT emit the rack fields even if the
+        caller passes them (defense-in-depth — the queue item could legitimately
+        carry a stale capture from before a model change)."""
+        mqtt_client._is_dual_nozzle = False
+        mqtt_client.model = "P1S"  # single-nozzle
+
+        mqtt_client.start_print(
+            "test.3mf",
+            nozzle_mapping=json.dumps([16, 0, 19]),
+            nozzles_info=json.dumps([{"id": 1}]),
+        )
+
+        cmd = self._published_print_cmd(mqtt_client)
+        assert "nozzle_mapping" not in cmd
+        assert "nozzles_info" not in cmd
+
+    def test_dual_nozzle_no_fields_no_injection(self, mqtt_client):
+        """Dual-nozzle printer + no slicer pick (NULL on queue item) → command
+        carries no nozzle_mapping / nozzles_info. The firmware then runs its
+        normal auto-pick, which is the pre-fix behaviour for any non-O1C2 dual-
+        nozzle model that has no rack to disambiguate against anyway."""
+        mqtt_client._is_dual_nozzle = True
+
+        mqtt_client.start_print("test.3mf", nozzle_mapping=None, nozzles_info=None)
+
+        cmd = self._published_print_cmd(mqtt_client)
+        assert "nozzle_mapping" not in cmd
+        assert "nozzles_info" not in cmd
+
+    def test_dual_nozzle_partial_only_mapping(self, mqtt_client):
+        """Half-populated case: nozzle_mapping carried but nozzles_info NULL.
+        Forward what we have; firmware tolerates a missing rack metadata
+        field and resolves against its own state."""
+        mqtt_client._is_dual_nozzle = True
+
+        mqtt_client.start_print(
+            "test.3mf",
+            nozzle_mapping=json.dumps([16]),
+            nozzles_info=None,
+        )
+
+        cmd = self._published_print_cmd(mqtt_client)
+        assert cmd["nozzle_mapping"] == [16]
+        assert "nozzles_info" not in cmd
+
+    def test_malformed_nozzle_mapping_is_logged_and_omitted(self, mqtt_client, caplog):
+        """Invalid JSON on the queue item must NOT block the dispatch. Log a
+        warning and let the firmware auto-pick — the failure mode is just
+        the pre-fix behaviour, not a worse one. Fail-open is correct here
+        because the alternative would silently brick every dispatch on a
+        single bad row."""
+        mqtt_client._is_dual_nozzle = True
+
+        with caplog.at_level("WARNING"):
+            result = mqtt_client.start_print(
+                "test.3mf",
+                nozzle_mapping="not valid json {",
+                nozzles_info=None,
+            )
+
+        assert result is True  # dispatch still proceeded
+        cmd = self._published_print_cmd(mqtt_client)
+        assert "nozzle_mapping" not in cmd
+        assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records)
+
+    def test_empty_string_fields_are_treated_as_absent(self, mqtt_client):
+        """An empty-string column value (legacy data, or a NOT NULL DB
+        recovery shim) must behave the same as NULL — no injection, no
+        parse error log."""
+        mqtt_client._is_dual_nozzle = True
+
+        mqtt_client.start_print("test.3mf", nozzle_mapping="", nozzles_info="")
+
+        cmd = self._published_print_cmd(mqtt_client)
+        assert "nozzle_mapping" not in cmd
+        assert "nozzles_info" not in cmd
+
+
 class TestFilamentTrackSwitchDetection:
     """Tests for Filament Track Switch (FTS) accessory detection (#1162).
 

+ 2 - 0
backend/tests/unit/services/test_printer_manager.py

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

+ 215 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1579,6 +1579,221 @@ class TestVirtualPrinterInstance:
         # auto_dispatch=False on the VP → every item is manual_start.
         assert all(q.manual_start for q in added_items)
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_captures_nozzle_mapping_and_nozzles_info(self, tmp_path):
+        """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
+        per-filament physical nozzle position IDs in `nozzle_mapping` and
+        per-extruder rack metadata in `nozzles_info`. VP intake must store
+        both as JSON strings on the queue item so the dispatcher can replay
+        them. Without this the H2C firmware falls back to "last matching
+        nozzle" auto-pick and ignores the user's slicer choice.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=42,
+            name="H2CRack",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="391800042",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        # Pre-populate as if BS's project_file arrived. Wire shape matches
+        # BambuStudio's PrintJob params: nozzle_mapping = array of per-
+        # filament physical nozzle position IDs, nozzles_info = array of
+        # per-extruder rack-side metadata.
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "nozzle_mapping": [16, 0, 19],
+                "nozzles_info": [
+                    {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
+                    {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
+                ],
+            },
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        item = added_items[0]
+        assert item.nozzle_mapping is not None
+        assert _json.loads(item.nozzle_mapping) == [16, 0, 19]
+        assert item.nozzles_info is not None
+        parsed_info = _json.loads(item.nozzles_info)
+        assert parsed_info[0]["flowSize"] == "High Flow"
+        assert parsed_info[1]["flowSize"] == "Standard"
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_no_nozzle_fields_when_slicer_omits(self, tmp_path):
+        """#1780: every model other than O1C2 sends no nozzle_mapping /
+        nozzles_info — the queue item must carry NULL on both, not an empty
+        list. NULL is what the dispatch layer keys off of to skip the
+        injection entirely on non-rack-swap printers.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=43,
+            name="NotH2C",
+            mode="queue",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800043",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        # X1C-style slicer command — no nozzle fields.
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "timelapse": False, "bed_leveling": True},
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        item = added_items[0]
+        assert item.nozzle_mapping is None
+        assert item.nozzles_info is None
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
+        """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
+        same nozzle_mapping / nozzles_info on every plate's queue item, not
+        only the first. Mirrors the per-plate stamping for gcode_injection,
+        filament_overrides, etc.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+        mock_db.execute = AsyncMock()
+        mock_db.execute.return_value.scalar.return_value = None
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=44,
+            name="H2CMultiPlate",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="391800044",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        # Force 3 plates so the queue loop runs three times.
+        monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2, 3])
+
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "nozzle_mapping": [16, 0],
+                "nozzles_info": [{"id": 1, "flowSize": "High Flow", "diameter": 0.4}],
+            },
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 3
+        for item in added_items:
+            assert _json.loads(item.nozzle_mapping) == [16, 0]
+            assert _json.loads(item.nozzles_info)[0]["flowSize"] == "High Flow"
+
 
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""

+ 10 - 0
frontend/package-lock.json

@@ -28,6 +28,7 @@
         "i18next-browser-languagedetector": "^8.2.0",
         "jszip": "^3.10.1",
         "lucide-react": "^0.555.0",
+        "qrcode.react": "^4.2.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-i18next": "^16.3.5",
@@ -6396,6 +6397,15 @@
         "node": ">=6"
       }
     },
+    "node_modules/qrcode.react": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
+      "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/react": {
       "version": "19.2.4",
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",

+ 1 - 0
frontend/package.json

@@ -35,6 +35,7 @@
     "i18next-browser-languagedetector": "^8.2.0",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
+    "qrcode.react": "^4.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",

+ 41 - 0
frontend/src/__tests__/utils/apiKeyQr.test.ts

@@ -0,0 +1,41 @@
+/**
+ * Tests for the API-key QR payload builder.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { buildApiKeyQrPayload, API_KEY_QR_VERSION } from '../../utils/apiKeyQr';
+
+describe('buildApiKeyQrPayload', () => {
+  it('uses the bambuddy://config scheme with v first', () => {
+    const payload = buildApiKeyQrPayload('https://printer.local', 'bb_abc123');
+    expect(payload.startsWith(`bambuddy://config?v=${API_KEY_QR_VERSION}`)).toBe(true);
+  });
+
+  it('encodes the url and key parameters', () => {
+    const payload = buildApiKeyQrPayload('https://printer.local', 'bb_abc123');
+    expect(payload).toBe('bambuddy://config?v=1&url=https%3A%2F%2Fprinter.local&key=bb_abc123');
+  });
+
+  it('URL-encodes special characters in both values', () => {
+    const baseUrl = 'http://host:5173/sub path';
+    const key = 'bb_a+b/c=d&e';
+    const payload = buildApiKeyQrPayload(baseUrl, key);
+
+    expect(payload).toContain(`url=${encodeURIComponent(baseUrl)}`);
+    expect(payload).toContain(`key=${encodeURIComponent(key)}`);
+    // The raw, unencoded key must never leak into the payload.
+    expect(payload).not.toContain(key);
+  });
+
+  it('round-trips the values back out of the query string', () => {
+    const baseUrl = 'https://my.bambuddy.example:8443';
+    const key = 'bb_ZZ/99+aa==';
+    const payload = buildApiKeyQrPayload(baseUrl, key);
+
+    const query = payload.slice(payload.indexOf('?') + 1);
+    const params = new URLSearchParams(query);
+    expect(params.get('v')).toBe(String(API_KEY_QR_VERSION));
+    expect(params.get('url')).toBe(baseUrl);
+    expect(params.get('key')).toBe(key);
+  });
+});

+ 70 - 0
frontend/src/components/ApiKeyQRCodeModal.tsx

@@ -0,0 +1,70 @@
+import { useEffect } from 'react';
+import { QRCodeSVG } from 'qrcode.react';
+import { X, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from './Button';
+import { buildApiKeyQrPayload } from '../utils/apiKeyQr';
+
+interface ApiKeyQRCodeModalProps {
+  /** Raw API key string (only available in-memory right after creation). */
+  apiKey: string;
+  /** Base URL a client uses to reach Bambuddy. Defaults to the current origin. */
+  baseUrl?: string;
+  onClose: () => void;
+}
+
+export function ApiKeyQRCodeModal({ apiKey, baseUrl, onClose }: ApiKeyQRCodeModalProps) {
+  const { t } = useTranslation();
+  const origin = baseUrl ?? window.location.origin;
+  const payload = buildApiKeyQrPayload(origin, apiKey);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">{t('settings.apiKeyQrTitle')}</h2>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-6 flex flex-col items-center">
+          <p className="text-sm text-bambu-gray mb-4 text-center">
+            {t('settings.apiKeyQrCaption')}
+          </p>
+          <div className="bg-white p-4 rounded-lg mb-4">
+            <QRCodeSVG value={payload} size={256} />
+          </div>
+          <div className="flex items-start gap-2 text-xs text-amber-400 mb-4">
+            <AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
+            <span>{t('settings.apiKeyQrWarning')}</span>
+          </div>
+          <Button onClick={onClose} className="w-full">
+            {t('common.close')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -1843,6 +1843,10 @@ export default {
     apiKeyCreated: 'API-Schlüssel erfolgreich erstellt',
     apiKeyCopyWarning: 'Kopieren Sie diesen Schlüssel jetzt - er wird nicht mehr angezeigt!',
     useInApiBrowser: 'Im API-Browser verwenden',
+    apiKeyQrButton: 'QR-Code',
+    apiKeyQrTitle: 'Zum Einrichten scannen',
+    apiKeyQrCaption: 'Mit deiner mobilen App scannen, um diesen Server und API-Schlüssel hinzuzufügen.',
+    apiKeyQrWarning: 'Enthält deinen geheimen API-Schlüssel – nicht teilen oder dort abfotografieren, wo andere ihn sehen können.',
     createNewApiKey: 'Neuen API-Schlüssel erstellen',
     keyName: 'Schlüsselname',
     keyNamePlaceholder: 'z.B. Home Assistant, OctoPrint',

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

@@ -1853,6 +1853,10 @@ export default {
     apiKeyCreated: 'API Key Created Successfully',
     apiKeyCopyWarning: "Copy this key now - it won't be shown again!",
     useInApiBrowser: 'Use in API Browser',
+    apiKeyQrButton: 'QR code',
+    apiKeyQrTitle: 'Scan to configure',
+    apiKeyQrCaption: 'Scan with your mobile app to add this server and API key.',
+    apiKeyQrWarning: "Contains your secret API key — don't share or screenshot it where others can see.",
     createNewApiKey: 'Create New API Key',
     keyName: 'Key Name',
     keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',

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

@@ -1846,6 +1846,10 @@ export default {
     apiKeyCreated: 'Clave API creada correctamente',
     apiKeyCopyWarning: '¡Copie esta clave ahora; no se volverá a mostrar!',
     useInApiBrowser: 'Usar en el explorador de API',
+    apiKeyQrButton: 'Código QR',
+    apiKeyQrTitle: 'Escanea para configurar',
+    apiKeyQrCaption: 'Escanea con tu app móvil para añadir este servidor y la clave API.',
+    apiKeyQrWarning: 'Contiene tu clave API secreta: no la compartas ni hagas capturas donde otros puedan verla.',
     createNewApiKey: 'Crear nueva clave API',
     keyName: 'Nombre de la clave',
     keyNamePlaceholder: 'p. ej., Home Assistant, OctoPrint',

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

@@ -1799,6 +1799,10 @@ export default {
     apiKeyCreated: 'Clé API créée avec succès',
     apiKeyCopyWarning: 'Copiez cette clé maintenant - elle ne sera plus affichée !',
     useInApiBrowser: 'Utiliser dans l\'explorateur API',
+    apiKeyQrButton: 'Code QR',
+    apiKeyQrTitle: 'Scanner pour configurer',
+    apiKeyQrCaption: 'Scannez avec votre application mobile pour ajouter ce serveur et cette clé API.',
+    apiKeyQrWarning: 'Contient votre clé API secrète — ne la partagez pas et ne la capturez pas là où d\'autres peuvent la voir.',
     createNewApiKey: 'Nouvelle clé API',
     keyName: 'Nom de la clé',
     keyNamePlaceholder: 'ex: Home Assistant, OctoPrint',

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

@@ -1799,6 +1799,10 @@ export default {
     apiKeyCreated: 'Chiave API creata con successo',
     apiKeyCopyWarning: 'Copia questa chiave ora - non verra mostrata di nuovo!',
     useInApiBrowser: 'Usa nel Browser API',
+    apiKeyQrButton: 'Codice QR',
+    apiKeyQrTitle: 'Scansiona per configurare',
+    apiKeyQrCaption: 'Scansiona con la tua app mobile per aggiungere questo server e la chiave API.',
+    apiKeyQrWarning: 'Contiene la tua chiave API segreta: non condividerla né farne screenshot dove altri possono vederla.',
     createNewApiKey: 'Crea nuova chiave API',
     keyName: 'Nome chiave',
     keyNamePlaceholder: 'es., Home Assistant, OctoPrint',

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

@@ -1842,6 +1842,10 @@ export default {
     apiKeyCreated: 'APIキーを作成しました',
     apiKeyCopyWarning: '今すぐこのキーをコピーしてください - 再表示されません!',
     useInApiBrowser: 'APIブラウザーで使用',
+    apiKeyQrButton: 'QRコード',
+    apiKeyQrTitle: 'スキャンして設定',
+    apiKeyQrCaption: 'モバイルアプリでスキャンして、このサーバーとAPIキーを追加します。',
+    apiKeyQrWarning: '秘密のAPIキーが含まれています。他人に見られる場所で共有・スクリーンショットしないでください。',
     createNewApiKey: '新しいAPIキーを作成',
     keyName: 'キー名',
     keyNamePlaceholder: '例: Home Assistant, OctoPrint',

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

@@ -1729,6 +1729,10 @@ export default {
     apiKeyCreated: 'API 키가 성공적으로 생성되었습니다',
     apiKeyCopyWarning: '지금 이 키를 복사하세요 - 다시 표시되지 않습니다!',
     useInApiBrowser: 'API 브라우저에서 사용',
+    apiKeyQrButton: 'QR 코드',
+    apiKeyQrTitle: '스캔하여 설정',
+    apiKeyQrCaption: '모바일 앱으로 스캔하여 이 서버와 API 키를 추가하세요.',
+    apiKeyQrWarning: '비밀 API 키가 포함되어 있습니다. 다른 사람이 볼 수 있는 곳에서 공유하거나 스크린샷하지 마세요.',
     createNewApiKey: '새 API 키 만들기',
     keyName: '키 이름',
     keyNamePlaceholder: '예: Home Assistant, OctoPrint',

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

@@ -1799,6 +1799,10 @@ export default {
     apiKeyCreated: 'Chave API criada com sucesso',
     apiKeyCopyWarning: 'Copie esta chave agora - ela não será exibida novamente!',
     useInApiBrowser: 'Usar no Navegador API',
+    apiKeyQrButton: 'Código QR',
+    apiKeyQrTitle: 'Escaneie para configurar',
+    apiKeyQrCaption: 'Escaneie com seu app móvel para adicionar este servidor e a chave de API.',
+    apiKeyQrWarning: 'Contém sua chave de API secreta — não compartilhe nem faça captura de tela onde outros possam ver.',
     createNewApiKey: 'Criar Nova Chave API',
     keyName: 'Nome da Chave',
     keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',

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

@@ -1846,6 +1846,10 @@ export default {
     apiKeyCreated: 'API Anahtarı Başarıyla Oluşturuldu',
     apiKeyCopyWarning: 'Bu anahtarı şimdi kopyalayın - bir daha gösterilmeyecek!',
     useInApiBrowser: 'API Tarayıcısında Kullan',
+    apiKeyQrButton: 'QR kodu',
+    apiKeyQrTitle: 'Yapılandırmak için tarayın',
+    apiKeyQrCaption: 'Bu sunucuyu ve API anahtarını eklemek için mobil uygulamanızla tarayın.',
+    apiKeyQrWarning: 'Gizli API anahtarınızı içerir — başkalarının görebileceği yerlerde paylaşmayın veya ekran görüntüsü almayın.',
     createNewApiKey: 'Yeni API Anahtarı Oluştur',
     keyName: 'Anahtar Adı',
     keyNamePlaceholder: 'örn., Home Assistant, OctoPrint',

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

@@ -1844,6 +1844,10 @@ export default {
     apiKeyCreated: 'API 密钥创建成功',
     apiKeyCopyWarning: '请立即复制此密钥 - 它不会再次显示!',
     useInApiBrowser: '在 API 浏览器中使用',
+    apiKeyQrButton: '二维码',
+    apiKeyQrTitle: '扫码配置',
+    apiKeyQrCaption: '使用手机应用扫描以添加此服务器和 API 密钥。',
+    apiKeyQrWarning: '包含您的机密 API 密钥——请勿在他人可见的地方分享或截图。',
     createNewApiKey: '创建新 API 密钥',
     keyName: '密钥名称',
     keyNamePlaceholder: '例如:Home Assistant、OctoPrint',

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

@@ -1844,6 +1844,10 @@ export default {
     apiKeyCreated: 'API 金鑰建立成功',
     apiKeyCopyWarning: '請立即複製此金鑰 - 它不會再次顯示!',
     useInApiBrowser: '在 API 瀏覽器中使用',
+    apiKeyQrButton: '二維碼',
+    apiKeyQrTitle: '掃碼設定',
+    apiKeyQrCaption: '使用手機應用程式掃描以新增此伺服器和 API 金鑰。',
+    apiKeyQrWarning: '包含您的機密 API 金鑰——請勿在他人可見的地方分享或截圖。',
     createNewApiKey: '建立新 API 金鑰',
     keyName: '金鑰名稱',
     keyNamePlaceholder: '例如:Home Assistant、OctoPrint',

+ 25 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -21,6 +21,7 @@ import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { ApiKeyQRCodeModal } from '../components/ApiKeyQRCodeModal';
 import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
 import { LdapUserPicker } from '../components/LdapUserPicker';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
@@ -210,6 +211,7 @@ export function SettingsPage() {
     can_update_energy_cost: false,
   });
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
+  const [showApiKeyQR, setShowApiKeyQR] = useState(false);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
   const [testApiKey, setTestApiKey] = useState('');
 
@@ -3657,7 +3659,18 @@ export function SettingsPage() {
                         <Button
                           variant="secondary"
                           size="sm"
-                          onClick={() => setCreatedAPIKey(null)}
+                          onClick={() => setShowApiKeyQR(true)}
+                        >
+                          <QrCode className="w-4 h-4" />
+                          {t('settings.apiKeyQrButton')}
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={() => {
+                            setShowApiKeyQR(false);
+                            setCreatedAPIKey(null);
+                          }}
                         >
                           {t('common.dismiss')}
                         </Button>
@@ -3668,6 +3681,16 @@ export function SettingsPage() {
               </Card>
             )}
 
+            {/* QR code with base URL + key for mobile clients. Prefer the
+                configured External URL; fall back to the current origin. */}
+            {showApiKeyQR && createdAPIKey && (
+              <ApiKeyQRCodeModal
+                apiKey={createdAPIKey}
+                baseUrl={localSettings?.external_url || undefined}
+                onClose={() => setShowApiKeyQR(false)}
+              />
+            )}
+
             {/* Create Key Form */}
             {showCreateAPIKey && (
               <Card className="mb-6">

+ 26 - 0
frontend/src/utils/apiKeyQr.ts

@@ -0,0 +1,26 @@
+/**
+ * Helpers for the API-key QR code.
+ *
+ * The QR encodes the Bambuddy base URL and the freshly-created API key together
+ * so a mobile client can scan one code to configure both.
+ *
+ * Payload contract (fixed — bump `v` if it changes):
+ *   bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>
+ */
+
+/** Current payload schema version. */
+export const API_KEY_QR_VERSION = 1;
+
+/**
+ * Build the QR payload string encoding the base URL + API key.
+ *
+ * @param baseUrl Origin a client uses to reach Bambuddy (origin only, no path).
+ * @param apiKey  Raw API key string.
+ */
+export function buildApiKeyQrPayload(baseUrl: string, apiKey: string): string {
+  return (
+    `bambuddy://config?v=${API_KEY_QR_VERSION}` +
+    `&url=${encodeURIComponent(baseUrl)}` +
+    `&key=${encodeURIComponent(apiKey)}`
+  );
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов