mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user