[Fix] X1C Virtual Printer not accepting sends (#735)

X1C and X1 virtual printers used legacy SSDP model codes
  (3DPrinter-X1-Carbon, 3DPrinter-X1) that BambuStudio doesn't
  recognize, causing "incompatible printer preset" errors when
  sending prints. Changed to the correct codes (BL-P001, BL-P002)
  that real printers report via SSDP.

  Also fixed proxy mode auto-inherit storing printer display names
  (e.g. "X1C") instead of SSDP codes, by adding a resolution layer
  that maps display names to model codes.

  DB migration auto-converts existing VPs on startup.
This commit is contained in:
maziggy
2026-03-17 16:35:16 +01:00
parent d6bf494c0b
commit c518fd15ac
9 changed files with 112 additions and 1424 deletions
-1396
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -40,6 +40,23 @@ class VirtualPrinterUpdate(BaseModel):
remote_interface_ip: str | None = None
def _resolve_printer_model(printer_model: str | None) -> str | None:
"""Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
"""
if not printer_model:
return None
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
# Already a valid SSDP model code
if printer_model in VIRTUAL_PRINTER_MODELS:
return printer_model
# Map display name to SSDP code
return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
def _vp_to_dict(vp, status: dict | None = None) -> dict:
"""Convert VirtualPrinter model to response dict."""
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
@@ -172,7 +189,7 @@ async def create_virtual_printer(
enabled=body.enabled,
mode=body.mode,
model=body.model
or (target_printer.model if target_printer and target_printer.model and body.mode == "proxy" else None)
or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
or DEFAULT_VIRTUAL_PRINTER_MODEL,
access_code=body.access_code,
target_printer_id=body.target_printer_id,
@@ -276,7 +293,7 @@ async def update_virtual_printer(
vp.target_printer_id = body.target_printer_id
# Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
if body.model is None and vp.mode == "proxy" and target_printer.model:
vp.model = target_printer.model
vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
if body.auto_dispatch is not None:
vp.auto_dispatch = body.auto_dispatch
if body.bind_ip is not None:
@@ -291,7 +308,7 @@ async def update_virtual_printer(
result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
existing_target = result.scalar_one_or_none()
if existing_target and existing_target.model:
vp.model = existing_target.model
vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
# Determine final enabled state
explicitly_enabling = body.enabled is True
+28 -1
View File
@@ -1321,7 +1321,7 @@ async def run_migrations(conn):
result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
row = result.fetchone()
old_model = row[0] if row else "3DPrinter-X1-Carbon"
old_model = row[0] if row else "BL-P001"
result = await conn.execute(
text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
@@ -1440,6 +1440,33 @@ async def run_migrations(conn):
except OperationalError:
pass # Already applied
# Migration: Fix VP model codes — convert legacy SSDP codes and display names to correct SSDP codes
# Legacy codes (from multi-VP refactor) and display names (from proxy auto-inherit)
vp_model_fixes = {
"3DPrinter-X1-Carbon": "BL-P001",
"3DPrinter-X1": "BL-P002",
"X1C": "BL-P001",
"X1": "BL-P002",
"X1E": "C13",
"P1P": "C11",
"P1S": "C12",
"P2S": "N7",
"A1": "N2S",
"A1 Mini": "N1",
"H2D": "O1D",
"H2C": "O1C",
"H2S": "O1S",
}
for old_val, new_val in vp_model_fixes.items():
await conn.execute(
text("UPDATE virtual_printers SET model = :new WHERE model = :old"),
{"old": old_val, "new": new_val},
)
await conn.execute(
text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_model' AND value = :old"),
{"old": old_val, "new": new_val},
)
# Migration: Add per-user Bambu Cloud credential columns
try:
await conn.execute(text("ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)"))
@@ -28,8 +28,8 @@ logger = logging.getLogger(__name__)
# - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
VIRTUAL_PRINTER_MODELS = {
# X1 Series
"3DPrinter-X1-Carbon": "X1C", # X1 Carbon
"3DPrinter-X1": "X1", # X1
"BL-P001": "X1C", # X1 Carbon
"BL-P002": "X1", # X1
"C13": "X1E", # X1E
# P Series
"C11": "P1P", # P1P
@@ -56,8 +56,8 @@ VIRTUAL_PRINTER_MODELS = {
# UUUUU = Unit number (5 chars)
MODEL_SERIAL_PREFIXES = {
# X1 Series
"3DPrinter-X1-Carbon": "00M00A", # X1C
"3DPrinter-X1": "00M00A", # X1
"BL-P001": "00M00A", # X1C
"BL-P002": "00M00A", # X1
"C13": "03W00A", # X1E
# P Series
"C11": "01S00A", # P1P
@@ -73,8 +73,11 @@ MODEL_SERIAL_PREFIXES = {
"O1S": "09400A", # H2S
}
# Reverse mapping: display name → SSDP model code (for auto-inheriting from printer model)
DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}
# Default model
DEFAULT_VIRTUAL_PRINTER_MODEL = "3DPrinter-X1-Carbon" # X1C
DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
@@ -18,8 +18,8 @@ MQTT_PORT = 8883
# Model code → product_name for version response (must match what slicer expects)
MODEL_PRODUCT_NAMES = {
"3DPrinter-X1-Carbon": "X1 Carbon",
"3DPrinter-X1": "X1",
"BL-P001": "X1 Carbon",
"BL-P002": "X1",
"C13": "X1E",
"C11": "P1P",
"C12": "P1S",
@@ -54,7 +54,7 @@ class TestVirtualPrinterInstance:
vp_id=2,
name="X1C",
mode="immediate",
model="3DPrinter-X1-Carbon",
model="BL-P001",
access_code="12345678",
serial_suffix="391800002",
base_dir=tmp_path,
@@ -1305,7 +1305,7 @@ class TestVirtualPrinterInstanceIPOverride:
vp_id=20,
name="IPTest",
mode="immediate",
model="3DPrinter-X1-Carbon",
model="BL-P001",
access_code="12345678",
serial_suffix="391800020",
bind_ip="192.168.1.50",
@@ -1342,7 +1342,7 @@ class TestVirtualPrinterInstanceIPOverride:
vp_id=21,
name="NoRemote",
mode="immediate",
model="3DPrinter-X1-Carbon",
model="BL-P001",
access_code="12345678",
serial_suffix="391800021",
bind_ip="192.168.1.50",
@@ -1368,7 +1368,7 @@ class TestVirtualPrinterInstanceIPOverride:
vp_id=22,
name="NoIPs",
mode="immediate",
model="3DPrinter-X1-Carbon",
model="BL-P001",
access_code="12345678",
serial_suffix="391800022",
base_dir=tmp_path,
@@ -1396,7 +1396,7 @@ class TestBindServer:
return BindServer(
serial="01S00C000000001",
model="3DPrinter-X1-Carbon",
model="BL-P001",
name="Bambuddy",
)
@@ -1464,7 +1464,7 @@ class TestBindServer:
def test_bind_server_stores_config(self, bind_server):
"""Verify config is stored correctly."""
assert bind_server.serial == "01S00C000000001"
assert bind_server.model == "3DPrinter-X1-Carbon"
assert bind_server.model == "BL-P001"
assert bind_server.name == "Bambuddy"
assert bind_server.version == "01.00.00.00"
@@ -1474,7 +1474,7 @@ class TestBindServer:
server = BindServer(
serial="01S00C000000001",
model="3DPrinter-X1-Carbon",
model="BL-P001",
name="Bambuddy",
version="01.09.00.10",
)
@@ -1501,7 +1501,7 @@ class TestBindServer:
vp_id=99,
name="Bambuddy",
mode="immediate",
model="3DPrinter-X1-Carbon",
model="BL-P001",
access_code="12345678",
serial_suffix="391800099",
bind_ip="192.168.1.50",
@@ -1524,9 +1524,46 @@ class TestBindServer:
mock_bind_cls.assert_called_once_with(
serial=inst.serial,
model="3DPrinter-X1-Carbon",
model="BL-P001",
name="Bambuddy",
bind_address="192.168.1.50",
cert_path=Path("/tmp/cert.pem"), # nosec B108
key_path=Path("/tmp/key.pem"), # nosec B108
)
class TestResolveModelCodes:
"""Tests for model code resolution (display name → SSDP code)."""
def test_display_name_to_model_code_maps_all_models(self):
"""Verify reverse mapping covers all VIRTUAL_PRINTER_MODELS entries."""
from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE, VIRTUAL_PRINTER_MODELS
for _code, display_name in VIRTUAL_PRINTER_MODELS.items():
assert display_name in DISPLAY_NAME_TO_MODEL_CODE
# For non-duplicate display names, should map back to a valid code
assert DISPLAY_NAME_TO_MODEL_CODE[display_name] in VIRTUAL_PRINTER_MODELS
def test_resolve_printer_model_with_ssdp_code(self):
"""SSDP codes pass through unchanged."""
from backend.app.api.routes.virtual_printers import _resolve_printer_model
assert _resolve_printer_model("BL-P001") == "BL-P001"
assert _resolve_printer_model("O1D") == "O1D"
assert _resolve_printer_model("N2S") == "N2S"
def test_resolve_printer_model_with_display_name(self):
"""Display names resolve to SSDP codes."""
from backend.app.api.routes.virtual_printers import _resolve_printer_model
assert _resolve_printer_model("X1C") == "BL-P001"
assert _resolve_printer_model("H2D") == "O1D"
assert _resolve_printer_model("A1") == "N2S"
assert _resolve_printer_model("P1S") == "C12"
def test_resolve_printer_model_with_none_or_unknown(self):
"""None and unknown values return None."""
from backend.app.api.routes.virtual_printers import _resolve_printer_model
assert _resolve_printer_model(None) is None
assert _resolve_printer_model("UnknownModel") is None
@@ -30,7 +30,7 @@ vi.mock('../../api/client', () => ({
import { multiVirtualPrinterApi } from '../../api/client';
const models: Record<string, string> = {
'3DPrinter-X1-Carbon': 'X1C',
'BL-P001': 'X1C',
'C12': 'P1S',
};
@@ -39,7 +39,7 @@ const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): Virtu
name: 'Test VP',
enabled: false,
mode: 'immediate',
model: '3DPrinter-X1-Carbon',
model: 'BL-P001',
model_name: 'X1C',
access_code_set: false,
serial: '00M00A391800001',
@@ -27,7 +27,7 @@ vi.mock('../../api/client', () => ({
updateSettings: vi.fn(),
getModels: vi.fn().mockResolvedValue({
models: {
'3DPrinter-X1-Carbon': 'X1C',
'BL-P001': 'X1C',
'C12': 'P1S',
'N7': 'P2S',
},
@@ -43,7 +43,7 @@ const createMockSettings = (overrides = {}) => ({
enabled: false,
access_code_set: false,
mode: 'immediate' as const,
model: '3DPrinter-X1-Carbon',
model: 'BL-P001',
target_printer_id: null as number | null,
remote_interface_ip: null as string | null,
status: {
@@ -52,7 +52,7 @@ const createMockSettings = (overrides = {}) => ({
mode: 'immediate',
name: 'Bambuddy',
serial: '00M00A391800001',
model: '3DPrinter-X1-Carbon',
model: 'BL-P001',
model_name: 'X1C',
pending_files: 0,
},
@@ -158,7 +158,7 @@ describe('VirtualPrinterSettings', () => {
mode: 'immediate',
name: 'Bambuddy',
serial: '00M00A391800001',
model: '3DPrinter-X1-Carbon',
model: 'BL-P001',
model_name: 'X1C',
pending_files: 0,
},
@@ -512,7 +512,7 @@ describe('VirtualPrinterSettings', () => {
mode: 'proxy',
name: 'Bambuddy (Proxy)',
serial: '00M00A391800001',
model: '3DPrinter-X1-Carbon',
model: 'BL-P001',
model_name: 'X1C',
pending_files: 0,
proxy: {
@@ -17,7 +17,7 @@ export function VirtualPrinterSettings() {
const [localEnabled, setLocalEnabled] = useState(false);
const [localAccessCode, setLocalAccessCode] = useState('');
const [localMode, setLocalMode] = useState<LocalMode>('immediate');
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
const [localModel, setLocalModel] = useState('BL-P001');
const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
const [showAccessCode, setShowAccessCode] = useState(false);