Przeglądaj źródła

fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename

  #1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
  that rules out subnet / mDNS-reflector theories): with the physical printer
  off the slicer's "Send" landed in Bambuddy's archive; once the printer
  powered on every subsequent "Send" went straight to the printer's SD card
  and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
  receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
  once the printer was online.

  Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
  _vp_ip_uint32_le ONLY on client-identity change and early-returned on
  every refresh tick when the same client object was still bound. If
  target_client.ip_address was empty at first bind (DB row stale, or client
  constructed before SSDP refresh filled it in), the encoding stayed None,
  the net.info[*].ip rewrite block was skipped, the cache filled with the
  real printer IP, sticky-key preservation kept the poisoned net value
  alive across every subsequent incremental push, and the slicer followed
  the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
  workaround both reporters independently arrived at. Same shape on
  multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
  whose ip equalled _target_ip_uint32_le, so a secondary interface IP
  Bambuddy never saw would leak through unchanged.

  Bridge fix:
  - _resolve_client calls a new _refresh_ip_encoding() on every refresh
    tick, even when client identity is unchanged; self-heals once
    ip_address becomes valid.
  - _refresh_ip_encoding() sweeps the existing _latest_print_state when
    encoding becomes valid for the first time. Without the sweep,
    sticky-key preservation keeps the pre-arm poisoned cache alive
    forever — incremental pushes that don't include net carry the bad
    value forward.
  - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
    that doesn't already equal the VP IP, not just entries matching
    _target_ip_uint32_le. Multi-NIC printers stop leaking secondary
    interfaces. Zero-IP placeholders are left alone so "active interface"
    detection still works.
  - INFO logging on encoding arm/update and on cache sweep so future
    bundles directly answer "did the rewrite fire?".

  Mode wire-value rename (#1429 follow-up, separate confusion source):
  - Both reporters' support bundles showed mode: immediate while the UI
    said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
    immediate in the support-info.json file. In the webui the printer is
    set to archive". UI button "Archive" had always saved immediate, and
    "Queue" had always saved print_queue. Canonical wire values are now
    archive / review / queue / proxy, matching the button labels 1:1.
  - New normalize_vp_mode() + VP_MODE_* constants in
    models/virtual_printer.py; manager.py normalises on construction so
    a legacy row read pre-migration still dispatches correctly.
  - core/database.py::run_migrations rewrites existing virtual_printers
    and settings rows; idempotent (re-runs are no-ops); identical SQL
    under SQLite and Postgres.
  - API routes accept both legacy and canonical on input, normalise
    before storage. GET /settings/virtual-printer normalises on read so
    the frontend's mode-button highlight works for stale legacy values.
  - Three frontend VP components (VirtualPrinterSettings,
    VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
    and type aliases to canonical; each got its own normalizeMode()
    helper so a stale-cached settings payload still highlights the right
    button. Two pre-existing `printer.mode === 'queue' ? 'review'`
    legacy mappings in VirtualPrinterCard were the source of a test
    failure caught mid-implementation where the new canonical 'queue'
    was being mis-aliased back to 'review' and hiding the auto-dispatch
    + force-color-match toggles.

  mode handler is NOT the dispatch bug: manager.py::_archive_file (the
  handler for archive mode) doesn't dispatch to the physical printer.
  The "files end up on the printer's SD card" symptom was the IP-leak
  from the bridge cache. The mode rename is purely clarity / support-
  bundle accuracy.
maziggy 1 dzień temu
rodzic
commit
5d6d928b3f
27 zmienionych plików z 635 dodań i 186 usunięć
  1. 0 0
      CHANGELOG.md
  2. 20 11
      backend/app/api/routes/settings.py
  3. 11 6
      backend/app/api/routes/virtual_printers.py
  4. 29 2
      backend/app/core/database.py
  5. 31 5
      backend/app/models/virtual_printer.py
  6. 2 2
      backend/app/schemas/settings.py
  7. 26 8
      backend/app/services/virtual_printer/manager.py
  8. 99 22
      backend/app/services/virtual_printer/mqtt_bridge.py
  9. 31 18
      backend/tests/integration/test_virtual_printer_api.py
  10. 42 42
      backend/tests/unit/services/test_virtual_printer.py
  11. 1 1
      backend/tests/unit/services/test_vp_diagnostic.py
  12. 151 0
      backend/tests/unit/test_vp_mode_rename_migration.py
  13. 89 0
      backend/tests/unit/test_vp_mqtt_bridge.py
  14. 12 12
      frontend/src/__tests__/components/VirtualPrinterCard.test.tsx
  15. 1 1
      frontend/src/__tests__/components/VirtualPrinterDiagnosticModal.test.tsx
  16. 32 15
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  17. 1 1
      frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx
  18. 1 1
      frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx
  19. 1 1
      frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx
  20. 1 1
      frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx
  21. 1 1
      frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx
  22. 6 2
      frontend/src/api/client.ts
  23. 5 5
      frontend/src/components/VirtualPrinterAddDialog.tsx
  24. 22 13
      frontend/src/components/VirtualPrinterCard.tsx
  25. 19 15
      frontend/src/components/VirtualPrinterSettings.tsx
  26. 0 0
      static/assets/index-BuuCTvNJ.js
  27. 1 1
      static/index.html

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 20 - 11
backend/app/api/routes/settings.py

@@ -1124,10 +1124,14 @@ async def get_virtual_printer_settings(
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
     archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
 
 
+    from backend.app.models.virtual_printer import VP_MODE_ARCHIVE, normalize_vp_mode
+
     return {
     return {
         "enabled": enabled == "true" if enabled else False,
         "enabled": enabled == "true" if enabled else False,
         "access_code_set": bool(access_code),
         "access_code_set": bool(access_code),
-        "mode": mode or "immediate",
+        # Normalize on read so older settings rows (with `immediate` /
+        # `print_queue`) come out as `archive` / `queue` for the frontend.
+        "mode": normalize_vp_mode(mode) or VP_MODE_ARCHIVE,
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
         "remote_interface_ip": remote_interface_ip or "",
@@ -1168,7 +1172,9 @@ async def update_virtual_printer_settings(
     # Get current values
     # Get current values
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
-    current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
+    # Default to `archive` (the canonical name) but tolerate legacy `immediate`
+    # in the stored value — normalized later before validation.
+    current_mode = await get_setting(db, "virtual_printer_mode") or "archive"
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
     current_target_id = int(current_target_id_str) if current_target_id_str else None
@@ -1186,15 +1192,21 @@ async def update_virtual_printer_settings(
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
     new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
     new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
 
 
-    # Validate mode
-    # "review" is the new name for "queue" (pending review before archiving)
-    # "print_queue" archives and adds to print queue (unassigned)
-    # "proxy" is transparent TCP proxy to a real printer
-    if new_mode not in ("immediate", "queue", "review", "print_queue", "proxy"):
+    # Validate mode. Canonical wire values are `archive` / `review` / `queue`
+    # / `proxy`; legacy `immediate` and `print_queue` are accepted as aliases
+    # and translated before storage so support bundles stop showing the old
+    # confusing pair (#1429 mode-label discrepancy).
+    from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
+
+    canonical_mode = normalize_vp_mode(new_mode)
+    if canonical_mode not in VP_MODE_VALUES:
         return JSONResponse(
         return JSONResponse(
             status_code=400,
             status_code=400,
-            content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
+            content={
+                "detail": f"Mode must be one of: {', '.join(VP_MODE_VALUES)}",
+            },
         )
         )
+    new_mode = canonical_mode
 
 
     # Validate archive_name_source
     # Validate archive_name_source
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
@@ -1202,9 +1214,6 @@ async def update_virtual_printer_settings(
             status_code=400,
             status_code=400,
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
         )
         )
-    # Normalize legacy "queue" to "review" for storage
-    if new_mode == "queue":
-        new_mode = "review"
 
 
     # Validate model
     # Validate model
     if model is not None and model not in VIRTUAL_PRINTER_MODELS:
     if model is not None and model not in VIRTUAL_PRINTER_MODELS:

+ 11 - 6
backend/app/api/routes/virtual_printers.py

@@ -33,7 +33,7 @@ class TailscaleStatusResponse(BaseModel):
 class VirtualPrinterCreate(BaseModel):
 class VirtualPrinterCreate(BaseModel):
     name: str = "Bambuddy"
     name: str = "Bambuddy"
     enabled: bool = False
     enabled: bool = False
-    mode: str = "immediate"
+    mode: str = "archive"
     model: str | None = None
     model: str | None = None
     access_code: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
     target_printer_id: int | None = None
@@ -133,12 +133,14 @@ async def create_virtual_printer(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Create a new virtual printer."""
     """Create a new virtual printer."""
-    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
     from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
     from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
     from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
     from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
 
 
-    # Validate mode
-    if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+    # Accept both canonical and legacy wire values so older clients (forks /
+    # mobile shortcuts / scripted setups) still work; normalize before write.
+    body.mode = normalize_vp_mode(body.mode) or body.mode
+    if body.mode not in VP_MODE_VALUES:
         return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
         return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
 
 
     # Validate model
     # Validate model
@@ -357,9 +359,12 @@ async def update_virtual_printer(
     if body.name is not None:
     if body.name is not None:
         vp.name = body.name
         vp.name = body.name
     if body.mode is not None:
     if body.mode is not None:
-        if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+        from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
+
+        canonical_mode = normalize_vp_mode(body.mode) or body.mode
+        if canonical_mode not in VP_MODE_VALUES:
             return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
             return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
-        vp.mode = body.mode
+        vp.mode = canonical_mode
     if body.model is not None:
     if body.model is not None:
         if body.model not in VIRTUAL_PRINTER_MODELS:
         if body.model not in VIRTUAL_PRINTER_MODELS:
             return JSONResponse(
             return JSONResponse(

+ 29 - 2
backend/app/core/database.py

@@ -1659,9 +1659,18 @@ async def run_migrations(conn):
 
 
                     result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
                     result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
                     row = result.fetchone()
                     row = result.fetchone()
-                    old_mode = row[0] if row else "immediate"
+                    old_mode = row[0] if row else "archive"
+                    # Translate to canonical wire values (#1429 mode-label
+                    # discrepancy): legacy `immediate` → `archive`, legacy
+                    # `print_queue` → `queue`. The historical `queue` alias
+                    # for `review` predates the canonical rename and is
+                    # preserved (existing user intent was "pending review").
                     if old_mode == "queue":
                     if old_mode == "queue":
                         old_mode = "review"
                         old_mode = "review"
+                    elif old_mode == "immediate":
+                        old_mode = "archive"
+                    elif old_mode == "print_queue":
+                        old_mode = "queue"
 
 
                     result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
                     result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
                     row = result.fetchone()
                     row = result.fetchone()
@@ -1691,7 +1700,7 @@ async def run_migrations(conn):
                         {
                         {
                             "name": "Bambuddy",
                             "name": "Bambuddy",
                             "enabled": old_enabled,
                             "enabled": old_enabled,
-                            "mode": old_mode or "immediate",
+                            "mode": old_mode or "archive",
                             "model": old_model,
                             "model": old_model,
                             "access_code": old_access_code,
                             "access_code": old_access_code,
                             "target_id": old_target_id,
                             "target_id": old_target_id,
@@ -1806,6 +1815,24 @@ async def run_migrations(conn):
             {"old": old_val, "new": new_val},
             {"old": old_val, "new": new_val},
         )
         )
 
 
+    # Migration: Rename VP mode wire values to match the user-facing labels
+    # (#1429 follow-up). The UI button "Archive" had always saved `immediate`
+    # and "Queue" had always saved `print_queue` — a mismatch that showed up
+    # confusingly in every support bundle. The button labels stay; the wire
+    # value is what changes. Idempotent: re-running the UPDATE on canonical
+    # values is a no-op. SQLite and Postgres both accept this statement
+    # unchanged (string literal comparison, no driver-specific syntax).
+    vp_mode_renames = [("immediate", "archive"), ("print_queue", "queue")]
+    for old_val, new_val in vp_mode_renames:
+        await conn.execute(
+            text("UPDATE virtual_printers SET mode = :new WHERE mode = :old"),
+            {"old": old_val, "new": new_val},
+        )
+        await conn.execute(
+            text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_mode' AND value = :old"),
+            {"old": old_val, "new": new_val},
+        )
+
     # Migration: Add per-user Bambu Cloud credential columns
     # Migration: Add per-user Bambu Cloud credential columns
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")

+ 31 - 5
backend/app/models/virtual_printer.py

@@ -5,6 +5,34 @@ from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
 
 
+# Canonical VP mode values. The legacy values `immediate` (→ archive) and
+# `print_queue` (→ queue) shipped before the UI labels were aligned with the
+# wire format. `normalize_vp_mode()` translates input from either form and
+# the DB migration in `core/database.py` rewrites existing rows once at boot.
+VP_MODE_ARCHIVE = "archive"
+VP_MODE_REVIEW = "review"
+VP_MODE_QUEUE = "queue"
+VP_MODE_PROXY = "proxy"
+VP_MODE_VALUES = (VP_MODE_ARCHIVE, VP_MODE_REVIEW, VP_MODE_QUEUE, VP_MODE_PROXY)
+
+# Legacy → canonical map. Kept narrow on purpose so unrelated typos surface
+# instead of getting silently re-pointed at a default.
+_VP_MODE_ALIASES = {
+    "immediate": VP_MODE_ARCHIVE,
+    "print_queue": VP_MODE_QUEUE,
+}
+
+
+def normalize_vp_mode(value: str | None) -> str | None:
+    """Map legacy wire values (`immediate`, `print_queue`) to canonical names.
+
+    Returns `None` unchanged so callers can decide whether to apply a default.
+    Returns unknown values unchanged so validators still see them and reject.
+    """
+    if value is None:
+        return None
+    return _VP_MODE_ALIASES.get(value, value)
+
 
 
 class VirtualPrinter(Base):
 class VirtualPrinter(Base):
     """Virtual printer configuration for multi-instance support."""
     """Virtual printer configuration for multi-instance support."""
@@ -14,13 +42,11 @@ class VirtualPrinter(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     enabled: Mapped[bool] = mapped_column(Boolean, default=False)
-    mode: Mapped[str] = mapped_column(String(20), default="immediate")  # immediate|review|print_queue|proxy
-    auto_dispatch: Mapped[bool] = mapped_column(
-        Boolean, server_default="true"
-    )  # print_queue mode: auto-start or manual
+    mode: Mapped[str] = mapped_column(String(20), default=VP_MODE_ARCHIVE)  # archive|review|queue|proxy
+    auto_dispatch: Mapped[bool] = mapped_column(Boolean, server_default="true")  # queue mode: auto-start or manual
     queue_force_color_match: Mapped[bool] = mapped_column(
     queue_force_color_match: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
         Boolean, server_default="false"
-    )  # print_queue mode: pin per-slot type+color from the 3MF onto the queue
+    )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
     # filament loaded (#1188).
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)

+ 2 - 2
backend/app/schemas/settings.py

@@ -113,8 +113,8 @@ class AppSettings(BaseModel):
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
     virtual_printer_mode: str = Field(
     virtual_printer_mode: str = Field(
-        default="immediate",
-        description="Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)",
+        default="archive",
+        description="Mode: 'archive' (archive now), 'review' (pending review), 'queue' (add to print queue), or 'proxy' (relay to real printer)",
     )
     )
     virtual_printer_archive_name_source: str = Field(
     virtual_printer_archive_name_source: str = Field(
         default="metadata",
         default="metadata",

+ 26 - 8
backend/app/services/virtual_printer/manager.py

@@ -12,6 +12,11 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
+from backend.app.models.virtual_printer import (
+    VP_MODE_ARCHIVE,
+    VP_MODE_QUEUE,
+    normalize_vp_mode,
+)
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
@@ -137,7 +142,11 @@ class VirtualPrinterInstance:
     ):
     ):
         self.id = vp_id
         self.id = vp_id
         self.name = name
         self.name = name
-        self.mode = mode
+        # Normalize on construction so the rest of the code only compares
+        # canonical values, even when a legacy DB row hasn't been migrated
+        # yet (e.g. fresh-from-disk during the boot window before the
+        # one-shot migration in `core/database.py` has executed).
+        self.mode = normalize_vp_mode(mode) or VP_MODE_ARCHIVE
         self.model = model
         self.model = model
         self.access_code = access_code
         self.access_code = access_code
         self.serial_suffix = serial_suffix
         self.serial_suffix = serial_suffix
@@ -234,9 +243,14 @@ class VirtualPrinterInstance:
 
 
         self._pending_files[file_path.name] = file_path
         self._pending_files[file_path.name] = file_path
 
 
-        if self.mode == "immediate":
+        # Accept both canonical (`archive`/`queue`) and legacy
+        # (`immediate`/`print_queue`) wire values so a stale row that hasn't
+        # been migrated yet still dispatches correctly. Migration in
+        # `core/database.py` rewrites existing rows once at boot.
+        mode = normalize_vp_mode(self.mode)
+        if mode == VP_MODE_ARCHIVE:
             await self._archive_file(file_path, source_ip)
             await self._archive_file(file_path, source_ip)
-        elif self.mode == "print_queue":
+        elif mode == VP_MODE_QUEUE:
             await self._add_to_print_queue(file_path, source_ip)
             await self._add_to_print_queue(file_path, source_ip)
         else:
         else:
             await self._queue_file(file_path, source_ip)
             await self._queue_file(file_path, source_ip)
@@ -267,13 +281,13 @@ class VirtualPrinterInstance:
         `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
         `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
         VP-queue path can inherit them when adding the item to the queue,
         VP-queue path can inherit them when adding the item to the queue,
         rather than falling back to the global default settings (#1403).
         rather than falling back to the global default settings (#1403).
-        Only queue mode consumes the capture; immediate / review / proxy
+        Only queue mode consumes the capture; archive / review / proxy
         modes ignore the print command, so we skip the stash there to keep
         modes ignore the print command, so we skip the stash there to keep
         the dict from accumulating one entry per print over the VP's
         the dict from accumulating one entry per print over the VP's
         uptime.
         uptime.
         """
         """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         logger.info("[VP %s] Print command for: %s", self.name, filename)
-        if self.mode != "print_queue":
+        if normalize_vp_mode(self.mode) != VP_MODE_QUEUE:
             return
             return
         # Drop the oldest stash if the cache is growing — happens when the
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
         # slicer sends project_file for a filename whose FTP upload was
@@ -1085,8 +1099,12 @@ class VirtualPrinterManager:
                     ):
                     ):
                         proxy_target_changed = True
                         proxy_target_changed = True
 
 
+            # Normalize the DB value before comparing — a legacy `immediate`
+            # row read before the migration window finishes would otherwise
+            # trip the "changed" branch and bounce every VP at boot.
+            db_mode = normalize_vp_mode(vp.mode)
             changed = (
             changed = (
-                instance.mode != vp.mode
+                instance.mode != db_mode
                 or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
                 or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
                 or instance.access_code != (vp.access_code or "")
                 or instance.access_code != (vp.access_code or "")
                 or instance.bind_ip != (vp.bind_ip or "")
                 or instance.bind_ip != (vp.bind_ip or "")
@@ -1220,7 +1238,7 @@ class VirtualPrinterManager:
         return {
         return {
             "enabled": False,
             "enabled": False,
             "running": False,
             "running": False,
-            "mode": "immediate",
+            "mode": VP_MODE_ARCHIVE,
             "name": "Bambuddy",
             "name": "Bambuddy",
             "serial": "",
             "serial": "",
             "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
             "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -1232,7 +1250,7 @@ class VirtualPrinterManager:
         self,
         self,
         enabled: bool,
         enabled: bool,
         access_code: str = "",
         access_code: str = "",
-        mode: str = "immediate",
+        mode: str = VP_MODE_ARCHIVE,
         model: str = "",
         model: str = "",
         target_printer_ip: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",
         target_printer_serial: str = "",

+ 99 - 22
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -282,6 +282,15 @@ class MQTTBridge:
             return
             return
 
 
         if current is self._target_client:
         if current is self._target_client:
+            # Same client object — but `ip_address` can fill in *after* the
+            # initial bind (e.g. DB row had a stale/empty value until the
+            # client's first SSDP-driven IP refresh). The original code only
+            # encoded `_target_ip_uint32_le` on client-identity change, so
+            # that late-arriving IP was never picked up, the `net.info[*].ip`
+            # rewrite stayed disabled, and the cache filled with the real
+            # printer IP — #1429. Refresh the encoding every tick so it
+            # self-heals once `ip_address` becomes valid.
+            self._refresh_ip_encoding()
             return
             return
 
 
         # Client identity changed — unregister from the old, register on the new.
         # Client identity changed — unregister from the old, register on the new.
@@ -297,20 +306,7 @@ class MQTTBridge:
 
 
         self._target_client = current
         self._target_client = current
         self._target_serial = getattr(current, "serial_number", None)
         self._target_serial = getattr(current, "serial_number", None)
-
-        # Cache printer IP and VP bind IP encoded as little-endian uint32, so we
-        # can rewrite `net.info[*].ip` in cached push_status. BambuStudio reads
-        # that field for the FTP destination IP — without rewriting, the slicer
-        # bypasses the VP and FTPs straight to the real printer.
-        target_ip = getattr(current, "ip_address", None)
-        vp_ip = getattr(self._mqtt_server, "bind_address", None)
-        if target_ip and vp_ip and vp_ip not in ("0.0.0.0", "", None):  # nosec B104
-            try:
-                self._target_ip_uint32_le = _ip_to_uint32_le(target_ip)
-                self._vp_ip_uint32_le = _ip_to_uint32_le(vp_ip)
-            except ValueError:
-                self._target_ip_uint32_le = None
-                self._vp_ip_uint32_le = None
+        self._refresh_ip_encoding()
 
 
         logger.info(
         logger.info(
             "[%s] MQTT bridge bound to printer %s (serial=%s)",
             "[%s] MQTT bridge bound to printer %s (serial=%s)",
@@ -348,6 +344,94 @@ class MQTTBridge:
         self._target_client = None
         self._target_client = None
         self._target_serial = None
         self._target_serial = None
 
 
+    def _refresh_ip_encoding(self) -> None:
+        """(Re-)encode `_target_ip_uint32_le` / `_vp_ip_uint32_le` from current values.
+
+        Called on every refresh tick, not just on client-identity change, so
+        a late-arriving printer IP (or a bind-address change) is picked up
+        without restarting the VP. When the encoding becomes valid for the
+        first time *after* the cache already received a push with the real
+        printer IP, also sweep the existing cache so the slicer's next pull
+        sees the rewritten value (#1429). Without this sweep the sticky-key
+        preservation keeps the poisoned `net.info[].ip` alive forever.
+        """
+        client = self._target_client
+        if client is None:
+            return
+
+        target_ip = getattr(client, "ip_address", None)
+        vp_ip = getattr(self._mqtt_server, "bind_address", None)
+        if not target_ip or not vp_ip or vp_ip in ("0.0.0.0", "", None):  # nosec B104
+            return
+
+        try:
+            new_target_le = _ip_to_uint32_le(target_ip)
+            new_vp_le = _ip_to_uint32_le(vp_ip)
+        except ValueError:
+            return
+
+        if new_target_le == self._target_ip_uint32_le and new_vp_le == self._vp_ip_uint32_le:
+            return  # No change — nothing to do.
+
+        # Encoding either became valid for the first time or shifted (DHCP
+        # renewal, bind_ip reconfigured, etc.). Update + sweep the cache.
+        was_armed = self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None
+        self._target_ip_uint32_le = new_target_le
+        self._vp_ip_uint32_le = new_vp_le
+        logger.info(
+            "[%s] MQTT bridge IP encoding %s: target=%s vp=%s",
+            self.vp_name,
+            "updated" if was_armed else "armed",
+            target_ip,
+            vp_ip,
+        )
+
+        cached = self._latest_print_state
+        if isinstance(cached, dict):
+            n = self._rewrite_net_info_ips(cached)
+            if n:
+                logger.info(
+                    "[%s] MQTT bridge swept %d net.info[].ip entries in cached push",
+                    self.vp_name,
+                    n,
+                )
+
+    def _rewrite_net_info_ips(self, print_state: dict) -> int:
+        """Rewrite every non-zero `net.info[].ip` in `print_state` to the VP bind IP.
+
+        Returns the number of entries rewritten. Mutates `print_state` in place.
+
+        Strategy: rewrite ALL entries with a non-zero `ip`, not only those
+        matching `_target_ip_uint32_le`. Real printers (X1C, H2D Pro) can
+        report multiple active interfaces (WiFi + Ethernet) with different
+        IPs — only one matches the IP Bambuddy tracks, but the slicer may
+        read any of them. Leaving non-matching entries pointing at real
+        printer interfaces leaks an FTP fallback path that bypasses the VP
+        (the #1429 / #1302 symptom). Entries with `ip == 0` are placeholders
+        for unpopulated interfaces — leave them alone so the slicer's
+        "active interface" detection still recognises them as absent.
+        """
+        if self._vp_ip_uint32_le is None:
+            return 0
+        net = print_state.get("net")
+        if not isinstance(net, dict):
+            return 0
+        info = net.get("info")
+        if not isinstance(info, list):
+            return 0
+        rewritten = 0
+        for entry in info:
+            if not isinstance(entry, dict):
+                continue
+            ip_value = entry.get("ip")
+            if not isinstance(ip_value, int) or ip_value == 0:
+                continue
+            if ip_value == self._vp_ip_uint32_le:
+                continue
+            entry["ip"] = self._vp_ip_uint32_le
+            rewritten += 1
+        return rewritten
+
     def _on_printer_raw(self, topic: str, payload: bytes) -> None:
     def _on_printer_raw(self, topic: str, payload: bytes) -> None:
         """Paho-thread callback — cache the latest push_status for synthetic replay.
         """Paho-thread callback — cache the latest push_status for synthetic replay.
 
 
@@ -396,14 +480,7 @@ class MQTTBridge:
             # (i.e. configure the VP with the same access code as its target).
             # (i.e. configure the VP with the same access code as its target).
             # Rewrite real printer IP → VP bind IP in `net.info[*].ip` so the
             # Rewrite real printer IP → VP bind IP in `net.info[*].ip` so the
             # slicer's FTP destination resolves to the VP, not the real printer.
             # slicer's FTP destination resolves to the VP, not the real printer.
-            if self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None:
-                net = print_data.get("net")
-                if isinstance(net, dict):
-                    info = net.get("info")
-                    if isinstance(info, list):
-                        for entry in info:
-                            if isinstance(entry, dict) and entry.get("ip") == self._target_ip_uint32_le:
-                                entry["ip"] = self._vp_ip_uint32_le
+            self._rewrite_net_info_ips(print_data)
             # Defensive deep copy on store so the cache is fully decoupled from
             # Defensive deep copy on store so the cache is fully decoupled from
             # the freshly-parsed tree and from any reader's reference.
             # the freshly-parsed tree and from any reader's reference.
             new_state = copy.deepcopy(print_data)
             new_state = copy.deepcopy(print_data)

+ 31 - 18
backend/tests/integration/test_virtual_printer_api.py

@@ -61,33 +61,46 @@ class TestVirtualPrinterSettingsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_mode_to_print_queue(self, async_client: AsyncClient):
-        """Verify mode can be set to print_queue."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
+    async def test_update_mode_to_queue(self, async_client: AsyncClient):
+        """Verify mode can be set to the canonical 'queue' value."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
-        assert result["mode"] == "print_queue"
+        assert result["mode"] == "queue"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_mode_legacy_queue_maps_to_review(self, async_client: AsyncClient):
-        """Verify legacy 'queue' mode is normalized to 'review'."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
+    async def test_update_mode_legacy_print_queue_normalises_to_queue(self, async_client: AsyncClient):
+        """Legacy `print_queue` is accepted on input and translated to `queue` on
+        storage so the UI button label and the support-bundle field agree
+        (#1429 mode-label discrepancy)."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
-        assert result["mode"] == "review"  # Legacy queue maps to review
+        assert result["mode"] == "queue"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_mode_to_immediate(self, async_client: AsyncClient):
-        """Verify mode can be set to immediate."""
+    async def test_update_mode_legacy_immediate_normalises_to_archive(self, async_client: AsyncClient):
+        """Legacy `immediate` is accepted on input and translated to `archive`
+        on storage (#1429 mode-label discrepancy)."""
         response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
         response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
-        assert result["mode"] == "immediate"
+        assert result["mode"] == "archive"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mode_to_archive(self, async_client: AsyncClient):
+        """Verify mode can be set to the canonical 'archive' value."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=archive")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mode"] == "archive"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -136,7 +149,7 @@ class TestVirtualPrinterSettingsAPI:
                 return_value={
                 return_value={
                     "enabled": True,
                     "enabled": True,
                     "running": True,
                     "running": True,
-                    "mode": "immediate",
+                    "mode": "archive",
                     "name": "Bambuddy",
                     "name": "Bambuddy",
                     "serial": "00M09A391800001",
                     "serial": "00M09A391800001",
                     "pending_files": 0,
                     "pending_files": 0,
@@ -157,7 +170,7 @@ class TestVirtualPrinterSettingsAPI:
                 return_value={
                 return_value={
                     "enabled": False,
                     "enabled": False,
                     "running": False,
                     "running": False,
-                    "mode": "immediate",
+                    "mode": "archive",
                     "name": "Bambuddy",
                     "name": "Bambuddy",
                     "serial": "00M09A391800001",
                     "serial": "00M09A391800001",
                     "pending_files": 0,
                     "pending_files": 0,
@@ -283,7 +296,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             "/api/v1/virtual-printers",
             json={
             json={
                 "name": "TestDefaultDispatch",
                 "name": "TestDefaultDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
                 "access_code": "12345678",
             },
             },
         )
         )
@@ -300,7 +313,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             "/api/v1/virtual-printers",
             json={
             json={
                 "name": "TestManualDispatch",
                 "name": "TestManualDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
                 "access_code": "12345678",
                 "auto_dispatch": False,
                 "auto_dispatch": False,
             },
             },
@@ -319,7 +332,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             "/api/v1/virtual-printers",
             json={
             json={
                 "name": "TestToggleDispatch",
                 "name": "TestToggleDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
                 "access_code": "12345678",
             },
             },
         )
         )
@@ -359,7 +372,7 @@ class TestVirtualPrinterTailscaleToggleAPI:
             "/api/v1/virtual-printers",
             "/api/v1/virtual-printers",
             json={
             json={
                 "name": "TestTailscaleToggle",
                 "name": "TestTailscaleToggle",
-                "mode": "immediate",
+                "mode": "archive",
                 "access_code": "12345678",
                 "access_code": "12345678",
             },
             },
         )
         )
@@ -426,7 +439,7 @@ class TestVirtualPrinterDiagnosticAPI:
         """A freshly created (disabled) VP fails the 'enabled' check."""
         """A freshly created (disabled) VP fails the 'enabled' check."""
         create_resp = await async_client.post(
         create_resp = await async_client.post(
             "/api/v1/virtual-printers",
             "/api/v1/virtual-printers",
-            json={"name": "TestDiagVP", "mode": "immediate", "access_code": "12345678"},
+            json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
         )
         )
         assert create_resp.status_code == 200
         assert create_resp.status_code == 200
         vp_id = create_resp.json()["id"]
         vp_id = create_resp.json()["id"]

+ 42 - 42
backend/tests/unit/services/test_virtual_printer.py

@@ -47,7 +47,7 @@ class TestVirtualPrinterInstance:
         return VirtualPrinterInstance(
         return VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestPrinter",
             name="TestPrinter",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -62,7 +62,7 @@ class TestVirtualPrinterInstance:
         """Verify constructor stores parameters correctly."""
         """Verify constructor stores parameters correctly."""
         assert instance.id == 1
         assert instance.id == 1
         assert instance.name == "TestPrinter"
         assert instance.name == "TestPrinter"
-        assert instance.mode == "immediate"
+        assert instance.mode == "archive"
         assert instance.model == "C11"
         assert instance.model == "C11"
         assert instance.access_code == "12345678"
         assert instance.access_code == "12345678"
         assert instance.serial_suffix == "391800001"
         assert instance.serial_suffix == "391800001"
@@ -79,7 +79,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=2,
             vp_id=2,
             name="X1C",
             name="X1C",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             model="BL-P001",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800002",
             serial_suffix="391800002",
@@ -182,7 +182,7 @@ class TestVirtualPrinterInstance:
         Send-flow slicers don't watch the post-upload state, so this is a
         Send-flow slicers don't watch the post-upload state, so this is a
         no-op behavior change for them.
         no-op behavior change for them.
         """
         """
-        instance.mode = "immediate"
+        instance.mode = "archive"
         instance._mqtt = MagicMock()
         instance._mqtt = MagicMock()
         instance._mqtt.set_gcode_state = MagicMock()
         instance._mqtt.set_gcode_state = MagicMock()
         file_path = Path("/tmp/test.3mf")  # nosec B108
         file_path = Path("/tmp/test.3mf")  # nosec B108
@@ -196,7 +196,7 @@ class TestVirtualPrinterInstance:
     async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
     async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
         """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
         """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
         the visible state — the slicer is only tracking the .3mf upload."""
         the visible state — the slicer is only tracking the .3mf upload."""
-        instance.mode = "immediate"
+        instance.mode = "archive"
         instance._mqtt = MagicMock()
         instance._mqtt = MagicMock()
         instance._mqtt.set_gcode_state = MagicMock()
         instance._mqtt.set_gcode_state = MagicMock()
         file_path = Path("/tmp/test.gcode")  # nosec B108
         file_path = Path("/tmp/test.gcode")  # nosec B108
@@ -236,7 +236,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=30,
             vp_id=30,
             name="ImmediateBroadcast",
             name="ImmediateBroadcast",
-            mode="immediate",
+            mode="archive",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800030",
             serial_suffix="391800030",
@@ -289,7 +289,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=10,
             vp_id=10,
             name="DefaultDispatch",
             name="DefaultDispatch",
-            mode="print_queue",
+            mode="queue",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800010",
             serial_suffix="391800010",
@@ -320,7 +320,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=11,
             vp_id=11,
             name="AutoDispatchOn",
             name="AutoDispatchOn",
-            mode="print_queue",
+            mode="queue",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800011",
             serial_suffix="391800011",
@@ -374,7 +374,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=31,
             vp_id=31,
             name="QueueBroadcast",
             name="QueueBroadcast",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800031",
             serial_suffix="391800031",
@@ -440,7 +440,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=12,
             vp_id=12,
             name="AutoDispatchOff",
             name="AutoDispatchOff",
-            mode="print_queue",
+            mode="queue",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800012",
             serial_suffix="391800012",
@@ -499,7 +499,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=22,
             vp_id=22,
             name="DefaultsTest",
             name="DefaultsTest",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800022",
             serial_suffix="391800022",
@@ -572,7 +572,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=23,
             vp_id=23,
             name="FreshInstallDefaults",
             name="FreshInstallDefaults",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800023",
             serial_suffix="391800023",
@@ -636,7 +636,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=24,
             vp_id=24,
             name="SlicerInherits",
             name="SlicerInherits",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800024",
             serial_suffix="391800024",
@@ -723,7 +723,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=25,
             vp_id=25,
             name="SlicerIntegers",
             name="SlicerIntegers",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800025",
             serial_suffix="391800025",
@@ -787,7 +787,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=21,
             vp_id=21,
             name="Reqs",
             name="Reqs",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800021",
             serial_suffix="391800021",
@@ -857,7 +857,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=22,
             vp_id=22,
             name="ForceColor",
             name="ForceColor",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800022",
             serial_suffix="391800022",
@@ -927,7 +927,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=23,
             vp_id=23,
             name="Unparseable",
             name="Unparseable",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800023",
             serial_suffix="391800023",
@@ -996,7 +996,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=20,
             vp_id=20,
             name="NameSource",
             name="NameSource",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800020",
             serial_suffix="391800020",
@@ -1058,7 +1058,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=40,
             vp_id=40,
             name="ArchiveFailCleanup",
             name="ArchiveFailCleanup",
-            mode="immediate",
+            mode="archive",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800040",
             serial_suffix="391800040",
@@ -1140,7 +1140,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=42,
             vp_id=42,
             name="DispatchFailCleanup",
             name="DispatchFailCleanup",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800042",
             serial_suffix="391800042",
@@ -1201,7 +1201,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=43,
             vp_id=43,
             name="PositionMaxPlusOne",
             name="PositionMaxPlusOne",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             model="C12",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800043",
             serial_suffix="391800043",
@@ -1265,7 +1265,7 @@ class TestVirtualPrinterManager:
         status = manager.get_status()
         status = manager.get_status()
         assert status["enabled"] is False
         assert status["enabled"] is False
         assert status["running"] is False
         assert status["running"] is False
-        assert status["mode"] == "immediate"
+        assert status["mode"] == "archive"
 
 
     def test_manager_is_enabled_with_instance(self, manager, tmp_path):
     def test_manager_is_enabled_with_instance(self, manager, tmp_path):
         """Verify is_enabled is True when instances exist."""
         """Verify is_enabled is True when instances exist."""
@@ -1274,7 +1274,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="Test",
             name="Test",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1291,7 +1291,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="Test",
             name="Test",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1335,7 +1335,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="Bambuddy",
             name="Bambuddy",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1349,7 +1349,7 @@ class TestVirtualPrinterManager:
         status = manager.get_status()
         status = manager.get_status()
         assert status["enabled"] is True
         assert status["enabled"] is True
         assert status["running"] is True
         assert status["running"] is True
-        assert status["mode"] == "immediate"
+        assert status["mode"] == "archive"
         assert status["name"] == "Bambuddy"
         assert status["name"] == "Bambuddy"
         assert status["serial"] == "01S00A391800001"
         assert status["serial"] == "01S00A391800001"
         assert status["model"] == "C11"
         assert status["model"] == "C11"
@@ -1364,7 +1364,7 @@ class TestVirtualPrinterManager:
             inst = VirtualPrinterInstance(
             inst = VirtualPrinterInstance(
                 vp_id=i,
                 vp_id=i,
                 name=f"VP{i}",
                 name=f"VP{i}",
-                mode="immediate",
+                mode="archive",
                 model="C11",
                 model="C11",
                 access_code="12345678",
                 access_code="12345678",
                 serial_suffix=f"39180000{i}",
                 serial_suffix=f"39180000{i}",
@@ -1386,7 +1386,7 @@ class TestVirtualPrinterManager:
             inst = VirtualPrinterInstance(
             inst = VirtualPrinterInstance(
                 vp_id=i,
                 vp_id=i,
                 name=f"VP{i}",
                 name=f"VP{i}",
-                mode="immediate",
+                mode="archive",
                 model="C11",
                 model="C11",
                 access_code="12345678",
                 access_code="12345678",
                 serial_suffix=f"39180000{i}",
                 serial_suffix=f"39180000{i}",
@@ -1408,7 +1408,7 @@ class TestVirtualPrinterManager:
             "id": 1,
             "id": 1,
             "name": "TestVP",
             "name": "TestVP",
             "enabled": True,
             "enabled": True,
-            "mode": "immediate",
+            "mode": "archive",
             "model": "C11",
             "model": "C11",
             "access_code": "12345678",
             "access_code": "12345678",
             "serial_suffix": "391800001",
             "serial_suffix": "391800001",
@@ -1447,7 +1447,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestVP",
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1456,8 +1456,8 @@ class TestVirtualPrinterManager:
         inst.stop_server = AsyncMock()
         inst.stop_server = AsyncMock()
         manager._instances[1] = inst
         manager._instances[1] = inst
 
 
-        # DB says mode changed to "archive"
-        db_vp = self._make_db_vp(mode="archive")
+        # DB says mode changed to "review"
+        db_vp = self._make_db_vp(mode="review")
         self._setup_sync_mocks(manager, [db_vp], tmp_path)
         self._setup_sync_mocks(manager, [db_vp], tmp_path)
 
 
         with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
         with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
@@ -1479,7 +1479,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestVP",
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1509,7 +1509,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestVP",
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1534,7 +1534,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestVP",
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1565,7 +1565,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestVP",
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -1600,7 +1600,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=1,
             vp_id=1,
             name="TestVP",
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800001",
             serial_suffix="391800001",
@@ -2312,7 +2312,7 @@ class TestVirtualPrinterManagerDirectories:
         VirtualPrinterInstance(
         VirtualPrinterInstance(
             vp_id=42,
             vp_id=42,
             name="Test",
             name="Test",
-            mode="immediate",
+            mode="archive",
             model="C11",
             model="C11",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800042",
             serial_suffix="391800042",
@@ -2402,7 +2402,7 @@ class TestVirtualPrinterInstanceIPOverride:
         return VirtualPrinterInstance(
         return VirtualPrinterInstance(
             vp_id=20,
             vp_id=20,
             name="IPTest",
             name="IPTest",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             model="BL-P001",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800020",
             serial_suffix="391800020",
@@ -2439,7 +2439,7 @@ class TestVirtualPrinterInstanceIPOverride:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=21,
             vp_id=21,
             name="NoRemote",
             name="NoRemote",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             model="BL-P001",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800021",
             serial_suffix="391800021",
@@ -2465,7 +2465,7 @@ class TestVirtualPrinterInstanceIPOverride:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=22,
             vp_id=22,
             name="NoIPs",
             name="NoIPs",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             model="BL-P001",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800022",
             serial_suffix="391800022",
@@ -2598,7 +2598,7 @@ class TestBindServer:
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
             vp_id=99,
             vp_id=99,
             name="Bambuddy",
             name="Bambuddy",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             model="BL-P001",
             access_code="12345678",
             access_code="12345678",
             serial_suffix="391800099",
             serial_suffix="391800099",

+ 1 - 1
backend/tests/unit/services/test_vp_diagnostic.py

@@ -19,7 +19,7 @@ def _vp(**overrides):
     base = {
     base = {
         "id": 1,
         "id": 1,
         "name": "Test VP",
         "name": "Test VP",
-        "mode": "immediate",
+        "mode": "archive",
         "enabled": True,
         "enabled": True,
         "bind_ip": "192.168.1.50",
         "bind_ip": "192.168.1.50",
         "access_code": "12345678",
         "access_code": "12345678",

+ 151 - 0
backend/tests/unit/test_vp_mode_rename_migration.py

@@ -0,0 +1,151 @@
+"""Regression test for the VP mode wire-value rename migration (#1429 follow-up).
+
+The UI buttons "Archive" and "Queue" had always saved the wire values
+`immediate` and `print_queue` — confusing in every support bundle. The
+rename migration in ``run_migrations`` rewrites existing rows to the
+canonical names. This test verifies it on both fresh and legacy schemas
+and confirms it's idempotent so reruns are safe (boot-on-boot).
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    """run_migrations touches multiple tables; the full schema must exist."""
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+@pytest.mark.asyncio
+async def test_legacy_mode_rows_get_canonical_names(engine):
+    """Existing rows with `immediate` / `print_queue` get rewritten to
+    `archive` / `queue` while canonical values and unrelated modes pass
+    through untouched."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers (id, name, enabled, mode, serial_suffix, position) VALUES "
+                "(1, 'A', 0, 'immediate', '391800001', 1),"
+                "(2, 'B', 0, 'print_queue', '391800002', 2),"
+                "(3, 'C', 0, 'review', '391800003', 3),"
+                "(4, 'D', 0, 'proxy', '391800004', 4),"
+                "(5, 'E', 0, 'archive', '391800005', 5),"
+                "(6, 'F', 0, 'queue', '391800006', 6)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT id, mode FROM virtual_printers ORDER BY id"))
+        rows = dict(result.fetchall())
+
+    assert rows[1] == "archive"  # immediate → archive
+    assert rows[2] == "queue"  # print_queue → queue
+    assert rows[3] == "review"  # untouched
+    assert rows[4] == "proxy"  # untouched
+    assert rows[5] == "archive"  # already canonical
+    assert rows[6] == "queue"  # already canonical
+
+
+@pytest.mark.asyncio
+async def test_legacy_settings_row_gets_canonical_name(engine):
+    """The legacy single-VP `virtual_printer_mode` setting also gets renamed
+    so the GET response (which feeds the support bundle and the settings
+    page) reads the canonical name."""
+    async with engine.begin() as conn:
+        await conn.execute(text("INSERT INTO settings (key, value) VALUES ('virtual_printer_mode', 'immediate')"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
+        value = result.scalar()
+
+    assert value == "archive"
+
+
+@pytest.mark.asyncio
+async def test_migration_is_idempotent(engine):
+    """Running the migration twice must be a no-op on canonical values —
+    every boot re-runs the migration set."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers (id, name, enabled, mode, serial_suffix, position) "
+                "VALUES (1, 'A', 0, 'immediate', '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    # Second run on already-canonical values.
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT mode FROM virtual_printers WHERE id = 1"))
+        assert result.scalar() == "archive"

+ 89 - 0
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -220,6 +220,95 @@ class TestPushStatusCache:
 
 
         await bridge.stop()
         await bridge.stop()
 
 
+    @pytest.mark.asyncio
+    async def test_net_info_ip_rewritten_for_unknown_secondary_interface(self):
+        """Regression for #1429: real printers (X1C / H2D Pro) report multiple
+        active interfaces (WiFi + Ethernet) — only ONE matches the IP Bambuddy
+        tracks. The rewrite must catch every non-zero entry, not just the one
+        whose IP equals `_target_ip_uint32_le`, or the slicer's FTP fallback
+        path leaks straight to the real printer."""
+        server = _make_server(bind_address=VP_IP)
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        h2d_le = _ip_to_uint32_le(H2D_IP)
+        # A second IP Bambuddy never saw (e.g. printer's ethernet interface
+        # while Bambuddy talks over wifi).
+        other_le = _ip_to_uint32_le("192.168.99.42")
+        vp_le = _ip_to_uint32_le(VP_IP)
+        payload = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "net": {
+                        "info": [
+                            {"ip": h2d_le, "mask": 0xFFFFFF},
+                            {"ip": other_le, "mask": 0xFFFFFF},
+                            {"ip": 0, "mask": 0},
+                        ]
+                    },
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        assert cached["net"]["info"][0]["ip"] == vp_le
+        assert cached["net"]["info"][1]["ip"] == vp_le  # secondary interface also rewritten
+        assert cached["net"]["info"][2]["ip"] == 0  # placeholder untouched
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_late_arriving_printer_ip_rewrites_existing_cache(self):
+        """Regression for #1429: if the printer's `ip_address` is empty at
+        first bind (DB row stale, or the client object exists before the
+        first SSDP refresh fills it in), the rewrite stays disabled and the
+        first cached push poisons the cache with the real-printer IP.
+        Once `ip_address` becomes valid, the next refresh tick must (a) arm
+        the encoding and (b) sweep the cached `net.info[].ip` so the slicer
+        sees the rewritten value on its next pull. Without the sweep the
+        sticky-key preservation keeps the poisoned value alive across
+        every subsequent incremental push."""
+        server = _make_server(bind_address=VP_IP)
+        # Bind to a client whose ip_address is empty at start — simulates the
+        # late-arrival path.
+        target = _make_paho_client(ip="")
+        bridge = _make_bridge(server, target)
+        await bridge.start()
+        assert bridge._target_ip_uint32_le is None  # not yet armed
+
+        h2d_le = _ip_to_uint32_le(H2D_IP)
+        vp_le = _ip_to_uint32_le(VP_IP)
+        payload = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
+        await asyncio.sleep(0.01)
+
+        # First push landed before encoding was armed → cache holds real IP.
+        cached = bridge.get_latest_print_state()
+        assert cached["net"]["info"][0]["ip"] == h2d_le
+
+        # Printer's IP becomes known. Next refresh tick must self-heal.
+        target.ip_address = H2D_IP
+        bridge._resolve_client()
+
+        cached = bridge.get_latest_print_state()
+        assert cached["net"]["info"][0]["ip"] == vp_le, (
+            "cache must be swept once encoding becomes valid; sticky-key "
+            "preservation would otherwise keep the poisoned IP forever"
+        )
+        assert bridge._target_ip_uint32_le == h2d_le
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_request_topic_message_is_ignored(self):
     async def test_request_topic_message_is_ignored(self):
         server = _make_server()
         server = _make_server()

+ 12 - 12
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -46,7 +46,7 @@ const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): Virtu
   id: 1,
   id: 1,
   name: 'Test VP',
   name: 'Test VP',
   enabled: false,
   enabled: false,
-  mode: 'immediate',
+  mode: 'archive',
   model: 'BL-P001',
   model: 'BL-P001',
   model_name: 'X1C',
   model_name: 'X1C',
   access_code_set: false,
   access_code_set: false,
@@ -68,7 +68,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
   });
   });
 
 
   it('renders auto-dispatch toggle when mode is print_queue', async () => {
   it('renders auto-dispatch toggle when mode is print_queue', async () => {
-    const printer = createMockPrinter({ mode: 'print_queue' });
+    const printer = createMockPrinter({ mode: 'queue' });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -77,7 +77,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
   });
   });
 
 
   it('does not render auto-dispatch toggle when mode is immediate', async () => {
   it('does not render auto-dispatch toggle when mode is immediate', async () => {
-    const printer = createMockPrinter({ mode: 'immediate' });
+    const printer = createMockPrinter({ mode: 'archive' });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     // Wait for the card to render fully (check for something that should be there)
     // Wait for the card to render fully (check for something that should be there)
@@ -100,7 +100,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
   });
   });
 
 
   it('auto-dispatch toggle defaults to on', async () => {
   it('auto-dispatch toggle defaults to on', async () => {
-    const printer = createMockPrinter({ mode: 'print_queue', auto_dispatch: true });
+    const printer = createMockPrinter({ mode: 'queue', auto_dispatch: true });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -118,9 +118,9 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
 
 
   it('clicking auto-dispatch toggle calls update API', async () => {
   it('clicking auto-dispatch toggle calls update API', async () => {
     const user = userEvent.setup();
     const user = userEvent.setup();
-    const printer = createMockPrinter({ mode: 'print_queue', auto_dispatch: true });
+    const printer = createMockPrinter({ mode: 'queue', auto_dispatch: true });
     vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
     vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
-      createMockPrinter({ mode: 'print_queue', auto_dispatch: false })
+      createMockPrinter({ mode: 'queue', auto_dispatch: false })
     );
     );
 
 
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
@@ -156,7 +156,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
   });
   });
 
 
   it('renders force-color-match toggle when mode is print_queue', async () => {
   it('renders force-color-match toggle when mode is print_queue', async () => {
-    const printer = createMockPrinter({ mode: 'print_queue' });
+    const printer = createMockPrinter({ mode: 'queue' });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -165,7 +165,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
   });
   });
 
 
   it('does not render force-color-match toggle when mode is immediate', async () => {
   it('does not render force-color-match toggle when mode is immediate', async () => {
-    const printer = createMockPrinter({ mode: 'immediate' });
+    const printer = createMockPrinter({ mode: 'archive' });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -185,7 +185,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
   });
   });
 
 
   it('force-color-match toggle defaults off (not green) — preserves pre-fix behaviour', async () => {
   it('force-color-match toggle defaults off (not green) — preserves pre-fix behaviour', async () => {
-    const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: false });
+    const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: false });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -201,7 +201,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
   });
   });
 
 
   it('force-color-match toggle renders enabled (green) when queue_force_color_match is true', async () => {
   it('force-color-match toggle renders enabled (green) when queue_force_color_match is true', async () => {
-    const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: true });
+    const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: true });
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -216,9 +216,9 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
 
 
   it('clicking force-color-match toggle posts queue_force_color_match in update body', async () => {
   it('clicking force-color-match toggle posts queue_force_color_match in update body', async () => {
     const user = userEvent.setup();
     const user = userEvent.setup();
-    const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: false });
+    const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: false });
     vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
     vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
-      createMockPrinter({ mode: 'print_queue', queue_force_color_match: true })
+      createMockPrinter({ mode: 'queue', queue_force_color_match: true })
     );
     );
 
 
     render(<VirtualPrinterCard printer={printer} models={models} />);
     render(<VirtualPrinterCard printer={printer} models={models} />);

+ 1 - 1
frontend/src/__tests__/components/VirtualPrinterDiagnosticModal.test.tsx

@@ -14,7 +14,7 @@ import type { VPDiagnosticResult } from '../../api/client';
 const problemResult: VPDiagnosticResult = {
 const problemResult: VPDiagnosticResult = {
   vp_id: 3,
   vp_id: 3,
   vp_name: 'Garage VP',
   vp_name: 'Garage VP',
-  mode: 'immediate',
+  mode: 'archive',
   overall: 'problems',
   overall: 'problems',
   checks: [
   checks: [
     { id: 'enabled', status: 'pass', params: {} },
     { id: 'enabled', status: 'pass', params: {} },

+ 32 - 15
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -42,14 +42,14 @@ import { virtualPrinterApi } from '../../api/client';
 const createMockSettings = (overrides = {}) => ({
 const createMockSettings = (overrides = {}) => ({
   enabled: false,
   enabled: false,
   access_code_set: false,
   access_code_set: false,
-  mode: 'immediate' as const,
+  mode: 'archive' as const,
   model: 'BL-P001',
   model: 'BL-P001',
   target_printer_id: null as number | null,
   target_printer_id: null as number | null,
   remote_interface_ip: null as string | null,
   remote_interface_ip: null as string | null,
   status: {
   status: {
     enabled: false,
     enabled: false,
     running: false,
     running: false,
-    mode: 'immediate',
+    mode: 'archive',
     name: 'Bambuddy',
     name: 'Bambuddy',
     serial: '00M00A391800001',
     serial: '00M00A391800001',
     model: 'BL-P001',
     model: 'BL-P001',
@@ -155,7 +155,7 @@ describe('VirtualPrinterSettings', () => {
           status: {
           status: {
             enabled: true,
             enabled: true,
             running: true,
             running: true,
-            mode: 'immediate',
+            mode: 'archive',
             name: 'Bambuddy',
             name: 'Bambuddy',
             serial: '00M00A391800001',
             serial: '00M00A391800001',
             model: 'BL-P001',
             model: 'BL-P001',
@@ -304,16 +304,33 @@ describe('VirtualPrinterSettings', () => {
       });
       });
     });
     });
 
 
-    it('highlights current mode (legacy queue maps to review)', async () => {
+    it('highlights current mode (legacy immediate maps to archive button)', async () => {
+      // Pre-#1429 the wire value was `immediate` but the UI button was
+      // labeled "Archive". Backend migration rewrites stored rows, but a
+      // stale-cached settings payload may still carry the legacy value —
+      // the component normalises it client-side so the right button lights up.
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ mode: 'queue' })
+        createMockSettings({ mode: 'immediate' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        const reviewButton = screen.getByText('Review').closest('button');
-        expect(reviewButton?.className).toContain('border-bambu-green');
+        const archiveButton = screen.getByText('Archive').closest('button');
+        expect(archiveButton?.className).toContain('border-bambu-green');
+      });
+    });
+
+    it('highlights current mode (legacy print_queue maps to queue button)', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ mode: 'print_queue' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        const queueButton = screen.getByText('Queue').closest('button');
+        expect(queueButton?.className).toContain('border-bambu-green');
       });
       });
     });
     });
 
 
@@ -339,10 +356,10 @@ describe('VirtualPrinterSettings', () => {
       });
       });
     });
     });
 
 
-    it('changes mode to print_queue on click', async () => {
+    it('changes mode to queue on click', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ mode: 'print_queue' })
+        createMockSettings({ mode: 'queue' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
@@ -357,7 +374,7 @@ describe('VirtualPrinterSettings', () => {
       }
       }
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'print_queue' });
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
       });
       });
     });
     });
   });
   });
@@ -576,7 +593,7 @@ describe('VirtualPrinterSettings', () => {
   describe('network interface override', () => {
   describe('network interface override', () => {
     it('shows interface dropdown when enabled in immediate mode', async () => {
     it('shows interface dropdown when enabled in immediate mode', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'immediate' })
+        createMockSettings({ enabled: true, mode: 'archive' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
@@ -600,7 +617,7 @@ describe('VirtualPrinterSettings', () => {
 
 
     it('shows interface dropdown when enabled in print_queue mode', async () => {
     it('shows interface dropdown when enabled in print_queue mode', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'print_queue' })
+        createMockSettings({ enabled: true, mode: 'queue' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
@@ -624,7 +641,7 @@ describe('VirtualPrinterSettings', () => {
 
 
     it('hides interface dropdown when disabled', async () => {
     it('hides interface dropdown when disabled', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: false, mode: 'immediate' })
+        createMockSettings({ enabled: false, mode: 'archive' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
@@ -638,7 +655,7 @@ describe('VirtualPrinterSettings', () => {
 
 
     it('shows configured status when interface is set', async () => {
     it('shows configured status when interface is set', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '10.0.0.50' })
+        createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '10.0.0.50' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
@@ -650,7 +667,7 @@ describe('VirtualPrinterSettings', () => {
 
 
     it('shows optional hint when no interface is set', async () => {
     it('shows optional hint when no interface is set', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '' })
+        createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '' })
       );
       );
 
 
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);

+ 1 - 1
frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx

@@ -53,7 +53,7 @@ const baseSettings = {
   default_printer_id: null,
   default_printer_id: null,
   virtual_printer_enabled: false,
   virtual_printer_enabled: false,
   virtual_printer_access_code: '',
   virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
+  virtual_printer_mode: 'archive',
   dark_style: 'classic',
   dark_style: 'classic',
   dark_background: 'neutral',
   dark_background: 'neutral',
   dark_accent: 'green',
   dark_accent: 'green',

+ 1 - 1
frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx

@@ -90,7 +90,7 @@ const MOCK_SETTINGS = {
   default_printer_id: null,
   default_printer_id: null,
   virtual_printer_enabled: false,
   virtual_printer_enabled: false,
   virtual_printer_access_code: '',
   virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
+  virtual_printer_mode: 'archive',
   dark_style: 'classic',
   dark_style: 'classic',
   dark_background: 'neutral',
   dark_background: 'neutral',
   dark_accent: 'green',
   dark_accent: 'green',

+ 1 - 1
frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx

@@ -78,7 +78,7 @@ const MOCK_SETTINGS = {
   default_printer_id: null,
   default_printer_id: null,
   virtual_printer_enabled: false,
   virtual_printer_enabled: false,
   virtual_printer_access_code: '',
   virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
+  virtual_printer_mode: 'archive',
   dark_style: 'classic',
   dark_style: 'classic',
   dark_background: 'neutral',
   dark_background: 'neutral',
   dark_accent: 'green',
   dark_accent: 'green',

+ 1 - 1
frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx

@@ -46,7 +46,7 @@ const mockSettings = {
   default_printer_id: null,
   default_printer_id: null,
   virtual_printer_enabled: false,
   virtual_printer_enabled: false,
   virtual_printer_access_code: '',
   virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
+  virtual_printer_mode: 'archive',
   dark_style: 'classic',
   dark_style: 'classic',
   dark_background: 'neutral',
   dark_background: 'neutral',
   dark_accent: 'green',
   dark_accent: 'green',

+ 1 - 1
frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx

@@ -45,7 +45,7 @@ const mockSettings = {
   default_printer_id: null,
   default_printer_id: null,
   virtual_printer_enabled: false,
   virtual_printer_enabled: false,
   virtual_printer_access_code: '',
   virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
+  virtual_printer_mode: 'archive',
   dark_style: 'classic',
   dark_style: 'classic',
   dark_background: 'neutral',
   dark_background: 'neutral',
   dark_accent: 'green',
   dark_accent: 'green',

+ 6 - 2
frontend/src/api/client.ts

@@ -6299,7 +6299,11 @@ export const discoveryApi = {
 };
 };
 
 
 // Virtual Printer types
 // Virtual Printer types
-export type VirtualPrinterMode = 'immediate' | 'queue' | 'review' | 'print_queue' | 'proxy';  // 'queue' is legacy, normalized to 'review'
+// Canonical wire values: `archive`, `review`, `queue`, `proxy`. The legacy
+// `immediate` (→ archive) and `print_queue` (→ queue) names are still
+// accepted by the backend so older API clients keep working, but new code
+// should send the canonical names.
+export type VirtualPrinterMode = 'archive' | 'review' | 'queue' | 'proxy' | 'immediate' | 'print_queue';
 
 
 export interface VirtualPrinterProxyStatus {
 export interface VirtualPrinterProxyStatus {
   running: boolean;
   running: boolean;
@@ -6375,7 +6379,7 @@ export const virtualPrinterApi = {
   updateSettings: (data: {
   updateSettings: (data: {
     enabled?: boolean;
     enabled?: boolean;
     access_code?: string;
     access_code?: string;
-    mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
+    mode?: 'archive' | 'review' | 'queue' | 'proxy';
     model?: string;
     model?: string;
     target_printer_id?: number;
     target_printer_id?: number;
     remote_interface_ip?: string;
     remote_interface_ip?: string;

+ 5 - 5
frontend/src/components/VirtualPrinterAddDialog.tsx

@@ -7,12 +7,12 @@ import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
-type Mode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+type Mode = 'archive' | 'review' | 'queue' | 'proxy';
 
 
 const MODE_LABELS: Record<string, string> = {
 const MODE_LABELS: Record<string, string> = {
-  immediate: 'archive',
+  archive: 'archive',
   review: 'review',
   review: 'review',
-  print_queue: 'queue',
+  queue: 'queue',
   proxy: 'proxy',
   proxy: 'proxy',
 };
 };
 
 
@@ -26,7 +26,7 @@ export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProp
   const { showToast } = useToast();
   const { showToast } = useToast();
 
 
   const [name, setName] = useState('');
   const [name, setName] = useState('');
-  const [mode, setMode] = useState<Mode>('immediate');
+  const [mode, setMode] = useState<Mode>('archive');
   const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);
   const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);
 
 
   const { data: printers } = useQuery({
   const { data: printers } = useQuery({
@@ -80,7 +80,7 @@ export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProp
           <div>
           <div>
             <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.mode.title')}</label>
             <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.mode.title')}</label>
             <div className="grid grid-cols-2 gap-2">
             <div className="grid grid-cols-2 gap-2">
-              {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((m) => (
+              {(['archive', 'review', 'queue', 'proxy'] as const).map((m) => (
                 <button
                 <button
                   key={m}
                   key={m}
                   onClick={() => setMode(m)}
                   onClick={() => setMode(m)}

+ 22 - 13
frontend/src/components/VirtualPrinterCard.tsx

@@ -14,15 +14,26 @@ import { VirtualPrinterDiagnosticModal } from './VirtualPrinterDiagnosticModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { copyTextToClipboard } from '../utils/clipboard';
 import { copyTextToClipboard } from '../utils/clipboard';
 
 
-type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+type LocalMode = 'archive' | 'review' | 'queue' | 'proxy';
 
 
 const MODE_LABELS: Record<string, string> = {
 const MODE_LABELS: Record<string, string> = {
-  immediate: 'archive',
+  archive: 'archive',
   review: 'review',
   review: 'review',
-  print_queue: 'queue',
+  queue: 'queue',
   proxy: 'proxy',
   proxy: 'proxy',
 };
 };
 
 
+// Legacy wire values (`immediate` → `archive`, `print_queue` → `queue`) shipped
+// before the UI labels were aligned with the wire format. Backend migration
+// flips existing rows but the function tolerates either form so a stale fetch
+// doesn't show an unselected mode (#1429 follow-up).
+function normalizeMode(value: string | undefined): LocalMode {
+  if (value === 'immediate') return 'archive';
+  if (value === 'print_queue' || value === 'queue') return 'queue';
+  if (value === 'archive' || value === 'review' || value === 'proxy') return value;
+  return 'archive';
+}
+
 interface VirtualPrinterCardProps {
 interface VirtualPrinterCardProps {
   printer: VirtualPrinterConfig;
   printer: VirtualPrinterConfig;
   models: Record<string, string>;
   models: Record<string, string>;
@@ -37,9 +48,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localEnabled, setLocalEnabled] = useState(printer.enabled);
   const [localEnabled, setLocalEnabled] = useState(printer.enabled);
   const [localName, setLocalName] = useState(printer.name);
   const [localName, setLocalName] = useState(printer.name);
   const [localAccessCode, setLocalAccessCode] = useState('');
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<LocalMode>(
-    (printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode
-  );
+  const [localMode, setLocalMode] = useState<LocalMode>(normalizeMode(printer.mode));
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(printer.target_printer_id);
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(printer.target_printer_id);
   const [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');
   const [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
@@ -83,7 +92,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   useEffect(() => {
   useEffect(() => {
     if (!pendingAction) {
     if (!pendingAction) {
       setLocalEnabled(printer.enabled);
       setLocalEnabled(printer.enabled);
-      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
+      setLocalMode(normalizeMode(printer.mode));
       setLocalName(printer.name);
       setLocalName(printer.name);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
       setLocalBindIp(printer.bind_ip || '');
@@ -118,7 +127,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     onError: (error: Error) => {
     onError: (error: Error) => {
       showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
       showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
       setLocalEnabled(printer.enabled);
       setLocalEnabled(printer.enabled);
-      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
+      setLocalMode(normalizeMode(printer.mode));
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
       setLocalBindIp(printer.bind_ip || '');
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
@@ -321,7 +330,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
             <div>
             <div>
               <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
               <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
               <div className="grid grid-cols-2 gap-2">
               <div className="grid grid-cols-2 gap-2">
-                {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((mode) => (
+                {(['archive', 'review', 'queue', 'proxy'] as const).map((mode) => (
                   <button
                   <button
                     key={mode}
                     key={mode}
                     onClick={() => handleModeChange(mode)}
                     onClick={() => handleModeChange(mode)}
@@ -346,8 +355,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
               </div>
             </div>
             </div>
 
 
-            {/* Auto-dispatch toggle - only for print_queue mode */}
-            {localMode === 'print_queue' && (
+            {/* Auto-dispatch toggle - only for queue mode */}
+            {localMode === 'queue' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">
               <div className="pt-2 border-t border-bambu-dark-tertiary">
                 <div className="flex items-center justify-between gap-3">
                 <div className="flex items-center justify-between gap-3">
                   <div className="min-w-0">
                   <div className="min-w-0">
@@ -376,8 +385,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
               </div>
             )}
             )}
 
 
-            {/* Force-color-match toggle - only for print_queue mode (#1188) */}
-            {localMode === 'print_queue' && (
+            {/* Force-color-match toggle - only for queue mode (#1188) */}
+            {localMode === 'queue' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">
               <div className="pt-2 border-t border-bambu-dark-tertiary">
                 <div className="flex items-center justify-between gap-3">
                 <div className="flex items-center justify-between gap-3">
                   <div className="min-w-0">
                   <div className="min-w-0">

+ 19 - 15
frontend/src/components/VirtualPrinterSettings.tsx

@@ -7,7 +7,18 @@ import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
-type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+type LocalMode = 'archive' | 'review' | 'queue' | 'proxy';
+
+// Legacy wire values shipped before the UI labels were aligned with the wire
+// format. The backend normalizes these on read but a freshly-loaded settings
+// payload can still carry an old value if it pre-dates the migration. Map
+// to canonical so the button highlight matches the saved mode (#1429).
+function normalizeMode(value: string | undefined): LocalMode {
+  if (value === 'immediate') return 'archive';
+  if (value === 'print_queue') return 'queue';
+  if (value === 'queue' || value === 'archive' || value === 'review' || value === 'proxy') return value;
+  return 'archive';
+}
 
 
 export function VirtualPrinterSettings() {
 export function VirtualPrinterSettings() {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -16,7 +27,7 @@ export function VirtualPrinterSettings() {
 
 
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localAccessCode, setLocalAccessCode] = useState('');
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<LocalMode>('immediate');
+  const [localMode, setLocalMode] = useState<LocalMode>('archive');
   const [localModel, setLocalModel] = useState('BL-P001');
   const [localModel, setLocalModel] = useState('BL-P001');
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
@@ -53,12 +64,7 @@ export function VirtualPrinterSettings() {
   useEffect(() => {
   useEffect(() => {
     if (settings) {
     if (settings) {
       setLocalEnabled(settings.enabled);
       setLocalEnabled(settings.enabled);
-      // Map legacy 'queue' mode to 'review'
-      let mode: LocalMode = settings.mode === 'queue' ? 'review' : settings.mode as LocalMode;
-      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue' && mode !== 'proxy') {
-        mode = 'immediate'; // fallback
-      }
-      setLocalMode(mode);
+      setLocalMode(normalizeMode(settings.mode));
       setLocalModel(settings.model);
       setLocalModel(settings.model);
       setLocalTargetPrinterId(settings.target_printer_id);
       setLocalTargetPrinterId(settings.target_printer_id);
       setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
       setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
@@ -79,9 +85,7 @@ export function VirtualPrinterSettings() {
       // Revert local state on error
       // Revert local state on error
       if (settings) {
       if (settings) {
         setLocalEnabled(settings.enabled);
         setLocalEnabled(settings.enabled);
-        // Map legacy 'queue' mode to 'review'
-        const mode = settings.mode === 'queue' ? 'review' : settings.mode;
-        setLocalMode(['immediate', 'review', 'print_queue', 'proxy'].includes(mode) ? mode as LocalMode : 'immediate');
+        setLocalMode(normalizeMode(settings.mode));
         setLocalModel(settings.model);
         setLocalModel(settings.model);
         setLocalTargetPrinterId(settings.target_printer_id);
         setLocalTargetPrinterId(settings.target_printer_id);
       }
       }
@@ -409,10 +413,10 @@ export function VirtualPrinterSettings() {
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
             <div className="grid grid-cols-2 gap-3">
             <div className="grid grid-cols-2 gap-3">
               <button
               <button
-                onClick={() => handleModeChange('immediate')}
+                onClick={() => handleModeChange('archive')}
                 disabled={pendingAction === 'mode'}
                 disabled={pendingAction === 'mode'}
                 className={`p-3 rounded-lg border text-left transition-colors ${
                 className={`p-3 rounded-lg border text-left transition-colors ${
-                  localMode === 'immediate'
+                  localMode === 'archive'
                     ? 'border-bambu-green bg-bambu-green/10'
                     ? 'border-bambu-green bg-bambu-green/10'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
                 }`}
@@ -433,10 +437,10 @@ export function VirtualPrinterSettings() {
                 <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.reviewDesc')}</div>
                 <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.reviewDesc')}</div>
               </button>
               </button>
               <button
               <button
-                onClick={() => handleModeChange('print_queue')}
+                onClick={() => handleModeChange('queue')}
                 disabled={pendingAction === 'mode'}
                 disabled={pendingAction === 'mode'}
                 className={`p-3 rounded-lg border text-left transition-colors ${
                 className={`p-3 rounded-lg border text-left transition-colors ${
-                  localMode === 'print_queue'
+                  localMode === 'queue'
                     ? 'border-bambu-green bg-bambu-green/10'
                     ? 'border-bambu-green bg-bambu-green/10'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
                 }`}

Plik diff jest za duży
+ 0 - 0
static/assets/index-BuuCTvNJ.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DqHz1llA.js"></script>
+    <script type="module" crossorigin src="/assets/index-BuuCTvNJ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C3FyyVE7.css">
     <link rel="stylesheet" crossorigin href="/assets/index-C3FyyVE7.css">
   </head>
   </head>
   <body>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików