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.
This commit is contained in:
maziggy
2026-06-02 14:33:20 +02:00
parent 1e08c25a9f
commit 5d6d928b3f
27 changed files with 1065 additions and 615 deletions
+1
View File
File diff suppressed because one or more lines are too long
+20 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
@@ -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 = "",
@@ -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)
@@ -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")
assert response.status_code == 200
result = response.json()
assert result["mode"] == "print_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'."""
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"] == "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_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"] == "queue"
@pytest.mark.asyncio
@pytest.mark.integration
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"]
@@ -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",
@@ -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",
@@ -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
View File
@@ -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()
@@ -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} />);
@@ -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: {} },
@@ -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 />);
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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
View File
@@ -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;
@@ -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
View File
@@ -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">
@@ -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'
}`}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>