Explorar o código

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 hai 1 día
pai
achega
5d6d928b3f
Modificáronse 27 ficheiros con 635 adicións e 186 borrados
  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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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")
     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 {
         "enabled": enabled == "true" if enabled else False,
         "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,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
@@ -1168,7 +1172,9 @@ async def update_virtual_printer_settings(
     # Get current values
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     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_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
@@ -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_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(
             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
     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,
             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
     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):
     name: str = "Bambuddy"
     enabled: bool = False
-    mode: str = "immediate"
+    mode: str = "archive"
     model: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
@@ -133,12 +133,14 @@ async def create_virtual_printer(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """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.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"})
 
     # Validate model
@@ -357,9 +359,12 @@ async def update_virtual_printer(
     if body.name is not None:
         vp.name = body.name
     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"})
-        vp.mode = body.mode
+        vp.mode = canonical_mode
     if body.model is not None:
         if body.model not in VIRTUAL_PRINTER_MODELS:
             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'"))
                     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":
                         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'"))
                     row = result.fetchone()
@@ -1691,7 +1700,7 @@ async def run_migrations(conn):
                         {
                             "name": "Bambuddy",
                             "enabled": old_enabled,
-                            "mode": old_mode or "immediate",
+                            "mode": old_mode or "archive",
                             "model": old_model,
                             "access_code": old_access_code,
                             "target_id": old_target_id,
@@ -1806,6 +1815,24 @@ async def run_migrations(conn):
             {"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
     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)")

+ 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
 
+# 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):
     """Virtual printer configuration for multi-instance support."""
@@ -14,13 +42,11 @@ class VirtualPrinter(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     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(
         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
     # filament loaded (#1188).
     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_access_code: str = Field(default="", description="Access code for virtual printer authentication")
     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(
         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 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.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
@@ -137,7 +142,11 @@ class VirtualPrinterInstance:
     ):
         self.id = vp_id
         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.access_code = access_code
         self.serial_suffix = serial_suffix
@@ -234,9 +243,14 @@ class VirtualPrinterInstance:
 
         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)
-        elif self.mode == "print_queue":
+        elif mode == VP_MODE_QUEUE:
             await self._add_to_print_queue(file_path, source_ip)
         else:
             await self._queue_file(file_path, source_ip)
@@ -267,13 +281,13 @@ class VirtualPrinterInstance:
         `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
         VP-queue path can inherit them when adding the item to the queue,
         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
         the dict from accumulating one entry per print over the VP's
         uptime.
         """
         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
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
@@ -1085,8 +1099,12 @@ class VirtualPrinterManager:
                     ):
                         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 = (
-                instance.mode != vp.mode
+                instance.mode != db_mode
                 or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
                 or instance.access_code != (vp.access_code or "")
                 or instance.bind_ip != (vp.bind_ip or "")
@@ -1220,7 +1238,7 @@ class VirtualPrinterManager:
         return {
             "enabled": False,
             "running": False,
-            "mode": "immediate",
+            "mode": VP_MODE_ARCHIVE,
             "name": "Bambuddy",
             "serial": "",
             "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -1232,7 +1250,7 @@ class VirtualPrinterManager:
         self,
         enabled: bool,
         access_code: str = "",
-        mode: str = "immediate",
+        mode: str = VP_MODE_ARCHIVE,
         model: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",

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

@@ -282,6 +282,15 @@ class MQTTBridge:
             return
 
         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
 
         # Client identity changed — unregister from the old, register on the new.
@@ -297,20 +306,7 @@ class MQTTBridge:
 
         self._target_client = current
         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(
             "[%s] MQTT bridge bound to printer %s (serial=%s)",
@@ -348,6 +344,94 @@ class MQTTBridge:
         self._target_client = 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:
         """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).
             # 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.
-            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
             # the freshly-parsed tree and from any reader's reference.
             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.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
         result = response.json()
-        assert result["mode"] == "print_queue"
+        assert result["mode"] == "queue"
 
     @pytest.mark.asyncio
     @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
         result = response.json()
-        assert result["mode"] == "review"  # Legacy queue maps to review
+        assert result["mode"] == "queue"
 
     @pytest.mark.asyncio
     @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")
 
         assert response.status_code == 200
         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.integration
@@ -136,7 +149,7 @@ class TestVirtualPrinterSettingsAPI:
                 return_value={
                     "enabled": True,
                     "running": True,
-                    "mode": "immediate",
+                    "mode": "archive",
                     "name": "Bambuddy",
                     "serial": "00M09A391800001",
                     "pending_files": 0,
@@ -157,7 +170,7 @@ class TestVirtualPrinterSettingsAPI:
                 return_value={
                     "enabled": False,
                     "running": False,
-                    "mode": "immediate",
+                    "mode": "archive",
                     "name": "Bambuddy",
                     "serial": "00M09A391800001",
                     "pending_files": 0,
@@ -283,7 +296,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestDefaultDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
             },
         )
@@ -300,7 +313,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestManualDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
                 "auto_dispatch": False,
             },
@@ -319,7 +332,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestToggleDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
             },
         )
@@ -359,7 +372,7 @@ class TestVirtualPrinterTailscaleToggleAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestTailscaleToggle",
-                "mode": "immediate",
+                "mode": "archive",
                 "access_code": "12345678",
             },
         )
@@ -426,7 +439,7 @@ class TestVirtualPrinterDiagnosticAPI:
         """A freshly created (disabled) VP fails the 'enabled' check."""
         create_resp = await async_client.post(
             "/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
         vp_id = create_resp.json()["id"]

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

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

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

@@ -19,7 +19,7 @@ def _vp(**overrides):
     base = {
         "id": 1,
         "name": "Test VP",
-        "mode": "immediate",
+        "mode": "archive",
         "enabled": True,
         "bind_ip": "192.168.1.50",
         "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()
 
+    @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
     async def test_request_topic_message_is_ignored(self):
         server = _make_server()

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

@@ -46,7 +46,7 @@ const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): Virtu
   id: 1,
   name: 'Test VP',
   enabled: false,
-  mode: 'immediate',
+  mode: 'archive',
   model: 'BL-P001',
   model_name: 'X1C',
   access_code_set: false,
@@ -68,7 +68,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
   });
 
   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} />);
 
     await waitFor(() => {
@@ -77,7 +77,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
   });
 
   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} />);
 
     // 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 () => {
-    const printer = createMockPrinter({ mode: 'print_queue', auto_dispatch: true });
+    const printer = createMockPrinter({ mode: 'queue', auto_dispatch: true });
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
     await waitFor(() => {
@@ -118,9 +118,9 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
 
   it('clicking auto-dispatch toggle calls update API', async () => {
     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(
-      createMockPrinter({ mode: 'print_queue', auto_dispatch: false })
+      createMockPrinter({ mode: 'queue', auto_dispatch: false })
     );
 
     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 () => {
-    const printer = createMockPrinter({ mode: 'print_queue' });
+    const printer = createMockPrinter({ mode: 'queue' });
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
     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 () => {
-    const printer = createMockPrinter({ mode: 'immediate' });
+    const printer = createMockPrinter({ mode: 'archive' });
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
     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 () => {
-    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} />);
 
     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 () => {
-    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} />);
 
     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 () => {
     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(
-      createMockPrinter({ mode: 'print_queue', queue_force_color_match: true })
+      createMockPrinter({ mode: 'queue', queue_force_color_match: true })
     );
 
     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 = {
   vp_id: 3,
   vp_name: 'Garage VP',
-  mode: 'immediate',
+  mode: 'archive',
   overall: 'problems',
   checks: [
     { 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 = {}) => ({
   enabled: false,
   access_code_set: false,
-  mode: 'immediate' as const,
+  mode: 'archive' as const,
   model: 'BL-P001',
   target_printer_id: null as number | null,
   remote_interface_ip: null as string | null,
   status: {
     enabled: false,
     running: false,
-    mode: 'immediate',
+    mode: 'archive',
     name: 'Bambuddy',
     serial: '00M00A391800001',
     model: 'BL-P001',
@@ -155,7 +155,7 @@ describe('VirtualPrinterSettings', () => {
           status: {
             enabled: true,
             running: true,
-            mode: 'immediate',
+            mode: 'archive',
             name: 'Bambuddy',
             serial: '00M00A391800001',
             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(
-        createMockSettings({ mode: 'queue' })
+        createMockSettings({ mode: 'immediate' })
       );
 
       render(<VirtualPrinterSettings />);
 
       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();
       vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ mode: 'print_queue' })
+        createMockSettings({ mode: 'queue' })
       );
 
       render(<VirtualPrinterSettings />);
@@ -357,7 +374,7 @@ describe('VirtualPrinterSettings', () => {
       }
 
       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', () => {
     it('shows interface dropdown when enabled in immediate mode', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'immediate' })
+        createMockSettings({ enabled: true, mode: 'archive' })
       );
 
       render(<VirtualPrinterSettings />);
@@ -600,7 +617,7 @@ describe('VirtualPrinterSettings', () => {
 
     it('shows interface dropdown when enabled in print_queue mode', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'print_queue' })
+        createMockSettings({ enabled: true, mode: 'queue' })
       );
 
       render(<VirtualPrinterSettings />);
@@ -624,7 +641,7 @@ describe('VirtualPrinterSettings', () => {
 
     it('hides interface dropdown when disabled', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: false, mode: 'immediate' })
+        createMockSettings({ enabled: false, mode: 'archive' })
       );
 
       render(<VirtualPrinterSettings />);
@@ -638,7 +655,7 @@ describe('VirtualPrinterSettings', () => {
 
     it('shows configured status when interface is set', async () => {
       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 />);
@@ -650,7 +667,7 @@ describe('VirtualPrinterSettings', () => {
 
     it('shows optional hint when no interface is set', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
-        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '' })
+        createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '' })
       );
 
       render(<VirtualPrinterSettings />);

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

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

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

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

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

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

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

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

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

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

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

@@ -6299,7 +6299,11 @@ export const discoveryApi = {
 };
 
 // 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 {
   running: boolean;
@@ -6375,7 +6379,7 @@ export const virtualPrinterApi = {
   updateSettings: (data: {
     enabled?: boolean;
     access_code?: string;
-    mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
+    mode?: 'archive' | 'review' | 'queue' | 'proxy';
     model?: string;
     target_printer_id?: number;
     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 { useToast } from '../contexts/ToastContext';
 
-type Mode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+type Mode = 'archive' | 'review' | 'queue' | 'proxy';
 
 const MODE_LABELS: Record<string, string> = {
-  immediate: 'archive',
+  archive: 'archive',
   review: 'review',
-  print_queue: 'queue',
+  queue: 'queue',
   proxy: 'proxy',
 };
 
@@ -26,7 +26,7 @@ export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProp
   const { showToast } = useToast();
 
   const [name, setName] = useState('');
-  const [mode, setMode] = useState<Mode>('immediate');
+  const [mode, setMode] = useState<Mode>('archive');
   const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);
 
   const { data: printers } = useQuery({
@@ -80,7 +80,7 @@ export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProp
           <div>
             <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.mode.title')}</label>
             <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
                   key={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 { copyTextToClipboard } from '../utils/clipboard';
 
-type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+type LocalMode = 'archive' | 'review' | 'queue' | 'proxy';
 
 const MODE_LABELS: Record<string, string> = {
-  immediate: 'archive',
+  archive: 'archive',
   review: 'review',
-  print_queue: 'queue',
+  queue: 'queue',
   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 {
   printer: VirtualPrinterConfig;
   models: Record<string, string>;
@@ -37,9 +48,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localEnabled, setLocalEnabled] = useState(printer.enabled);
   const [localName, setLocalName] = useState(printer.name);
   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 [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
@@ -83,7 +92,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   useEffect(() => {
     if (!pendingAction) {
       setLocalEnabled(printer.enabled);
-      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
+      setLocalMode(normalizeMode(printer.mode));
       setLocalName(printer.name);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
@@ -118,7 +127,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     onError: (error: Error) => {
       showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
       setLocalEnabled(printer.enabled);
-      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
+      setLocalMode(normalizeMode(printer.mode));
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
@@ -321,7 +330,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
             <div>
               <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
               <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
                     key={mode}
                     onClick={() => handleModeChange(mode)}
@@ -346,8 +355,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </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="flex items-center justify-between gap-3">
                   <div className="min-w-0">
@@ -376,8 +385,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </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="flex items-center justify-between gap-3">
                   <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 { 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() {
   const { t } = useTranslation();
@@ -16,7 +27,7 @@ export function VirtualPrinterSettings() {
 
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<LocalMode>('immediate');
+  const [localMode, setLocalMode] = useState<LocalMode>('archive');
   const [localModel, setLocalModel] = useState('BL-P001');
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
@@ -53,12 +64,7 @@ export function VirtualPrinterSettings() {
   useEffect(() => {
     if (settings) {
       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);
       setLocalTargetPrinterId(settings.target_printer_id);
       setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
@@ -79,9 +85,7 @@ export function VirtualPrinterSettings() {
       // Revert local state on error
       if (settings) {
         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);
         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="grid grid-cols-2 gap-3">
               <button
-                onClick={() => handleModeChange('immediate')}
+                onClick={() => handleModeChange('archive')}
                 disabled={pendingAction === 'mode'}
                 className={`p-3 rounded-lg border text-left transition-colors ${
-                  localMode === 'immediate'
+                  localMode === 'archive'
                     ? 'border-bambu-green bg-bambu-green/10'
                     : '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>
               </button>
               <button
-                onClick={() => handleModeChange('print_queue')}
+                onClick={() => handleModeChange('queue')}
                 disabled={pendingAction === 'mode'}
                 className={`p-3 rounded-lg border text-left transition-colors ${
-                  localMode === 'print_queue'
+                  localMode === 'queue'
                     ? 'border-bambu-green bg-bambu-green/10'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BuuCTvNJ.js


+ 1 - 1
static/index.html

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

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio