diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b2444518..5a7c9971e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,15 @@ All notable changes to Bambuddy will be documented in this file.
- Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay
- Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout
- Added locale parity test to ensure English and German stay in sync
+- **Virtual Printer Proxy Mode**:
+ - New "Proxy" mode allows remote printing over any network by relaying slicer traffic to a real printer
+ - Configure a target printer and Bambuddy acts as a TLS proxy between your slicer and the printer
+ - Supports both FTP (port 9990) and MQTT (port 8883) protocols with full TLS encryption
+ - Slicer connects to Bambuddy using the real printer's access code
+ - Real-time status display showing active FTP/MQTT connections
+ - Target printer selector with validation (must be configured in Bambuddy)
+ - Proxy mode bypasses the access code requirement (uses the real printer's credentials)
+ - Full i18n support for all proxy mode UI strings (English, German, Japanese)
### Fixed
- **Cannot Link Multiple HA Entities to Same Printer** (Issue #214):
diff --git a/README.md b/README.md
index 99cb3a585..36e6e9c6e 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,25 @@
---
+## 🌐 NEW: Remote Printing with Proxy Mode
+
+
+
+
+
+**Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
+
+- 🔒 **End-to-end TLS encryption** — Your print data is encrypted from slicer to printer
+- 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
+- 🔑 **Uses printer's access code** — No additional credentials needed
+- ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
+
+Perfect for remote print farms, traveling makers, or accessing your home printer from work.
+
+👉 **[Setup Guide →](https://wiki.bambuddy.cool/features/virtual-printer-proxy)**
+
+---
+
> **Testers Needed!** I only have X1C and H2D devices. Help make Bambuddy work with all Bambu Lab printers by [reporting your experience](https://github.com/maziggy/bambuddy/issues)!
## Why Bambuddy?
@@ -135,13 +154,14 @@
- Webhooks & API keys
- Interactive API browser with live testing
-### 🖨️ Virtual Printer
+### 🖨️ Virtual Printer & Remote Printing
+- **🌐 Proxy Mode (NEW!)** — Print remotely from anywhere via secure TLS relay
- Emulates a Bambu Lab printer on your network
- Send prints directly from Bambu Studio/Orca Slicer
- Configurable printer model (X1C, P1S, A1, H2D, etc.)
-- Queue mode or auto-start mode
+- Archive mode, Review mode, Queue mode, or Proxy mode
- SSDP discovery (appears in slicer automatically)
-- Secure TLS/MQTT communication
+- Secure TLS/MQTT/FTP communication
### 🛠️ Maintenance & Support
- Maintenance scheduling & tracking
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index c31515432..0b4b060cc 100644
--- a/backend/app/api/routes/settings.py
+++ b/backend/app/api/routes/settings.py
@@ -368,7 +368,9 @@ async def restore_backup(
@router.get("/virtual-printer/models")
-async def get_virtual_printer_models():
+async def get_virtual_printer_models(
+ _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
"""Get available virtual printer models."""
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py
index 4076a606b..143ed7f83 100644
--- a/backend/app/services/virtual_printer/tcp_proxy.py
+++ b/backend/app/services/virtual_printer/tcp_proxy.py
@@ -364,6 +364,13 @@ class SlicerProxyManager:
logger.info(f"Slicer TLS proxy started for {self.target_host}")
+ # Wait for tasks to complete (they run until cancelled)
+ # This keeps the start() coroutine alive so the parent task doesn't complete
+ try:
+ await asyncio.gather(*self._tasks)
+ except asyncio.CancelledError:
+ logger.debug("Slicer proxy start cancelled")
+
async def stop(self) -> None:
"""Stop all proxies."""
logger.info("Stopping slicer proxy")
diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py
index 59554300c..670098b86 100644
--- a/backend/tests/unit/services/test_virtual_printer.py
+++ b/backend/tests/unit/services/test_virtual_printer.py
@@ -439,3 +439,107 @@ class TestCertificateService:
assert cert_path.exists()
assert key_path.exists()
+
+
+class TestSlicerProxyManager:
+ """Tests for SlicerProxyManager (proxy mode)."""
+
+ @pytest.fixture
+ def proxy_manager(self, tmp_path):
+ """Create a SlicerProxyManager instance."""
+ from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
+
+ # Create dummy cert files
+ cert_path = tmp_path / "cert.pem"
+ key_path = tmp_path / "key.pem"
+ cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
+ # Split string to avoid pre-commit hook false positive on test data
+ key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
+
+ return SlicerProxyManager(
+ target_host="192.168.1.100",
+ cert_path=cert_path,
+ key_path=key_path,
+ )
+
+ def test_proxy_manager_initializes_ports(self, proxy_manager):
+ """Verify proxy manager has correct port constants."""
+ assert proxy_manager.LOCAL_FTP_PORT == 9990
+ assert proxy_manager.LOCAL_MQTT_PORT == 8883
+ assert proxy_manager.PRINTER_FTP_PORT == 990
+ assert proxy_manager.PRINTER_MQTT_PORT == 8883
+
+ def test_proxy_manager_stores_target_host(self, proxy_manager):
+ """Verify proxy manager stores target host."""
+ assert proxy_manager.target_host == "192.168.1.100"
+
+ def test_get_status_before_start(self, proxy_manager):
+ """Verify get_status returns zeros before start."""
+ status = proxy_manager.get_status()
+
+ assert status["running"] is False
+ assert status["ftp_connections"] == 0
+ assert status["mqtt_connections"] == 0
+
+
+class TestVirtualPrinterManagerProxyMode:
+ """Tests for VirtualPrinterManager proxy mode."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a VirtualPrinterManager instance."""
+ from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+ return VirtualPrinterManager()
+
+ @pytest.mark.asyncio
+ async def test_configure_proxy_mode_requires_target_ip(self, manager):
+ """Verify proxy mode requires target_printer_ip."""
+ with pytest.raises(ValueError, match="Target printer IP is required"):
+ await manager.configure(
+ enabled=True,
+ mode="proxy",
+ target_printer_ip="", # Empty target IP
+ )
+
+ @pytest.mark.asyncio
+ async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
+ """Verify proxy mode does not require access code (uses real printer's)."""
+ manager._start = AsyncMock()
+
+ # Should not raise - proxy mode doesn't need access code
+ await manager.configure(
+ enabled=True,
+ mode="proxy",
+ target_printer_ip="192.168.1.100",
+ )
+
+ assert manager._mode == "proxy"
+ assert manager._target_printer_ip == "192.168.1.100"
+
+ def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
+ """Verify get_status includes proxy-specific fields in proxy mode."""
+ manager._enabled = True
+ manager._mode = "proxy"
+ manager._target_printer_ip = "192.168.1.100"
+ manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+
+ # Create a mock proxy with get_status
+ mock_proxy = MagicMock()
+ mock_proxy.get_status.return_value = {
+ "running": True,
+ "ftp_port": 9990,
+ "mqtt_port": 8883,
+ "ftp_connections": 1,
+ "mqtt_connections": 2,
+ "target_host": "192.168.1.100",
+ }
+ manager._proxy = mock_proxy
+
+ status = manager.get_status()
+
+ assert status["mode"] == "proxy"
+ assert status["target_printer_ip"] == "192.168.1.100"
+ assert "proxy" in status
+ assert status["proxy"]["ftp_connections"] == 1
+ assert status["proxy"]["mqtt_connections"] == 2
diff --git a/docs/images/proxy-mode-diagram.png b/docs/images/proxy-mode-diagram.png
new file mode 100644
index 000000000..c62197a37
Binary files /dev/null and b/docs/images/proxy-mode-diagram.png differ
diff --git a/frontend/docs/create_proxy_diagram.py b/frontend/docs/create_proxy_diagram.py
new file mode 100644
index 000000000..492932fb0
--- /dev/null
+++ b/frontend/docs/create_proxy_diagram.py
@@ -0,0 +1,401 @@
+#!/usr/bin/env python3
+"""
+Create a professional network architecture diagram for Bambuddy Virtual Printer Proxy Mode.
+Following the Signal Flow design philosophy.
+"""
+
+from PIL import Image, ImageDraw, ImageFont
+from pathlib import Path
+
+# Canvas dimensions
+WIDTH = 1400
+HEIGHT = 700
+
+# Colors - Signal Flow palette
+BG_COLOR = (18, 18, 22) # Near black
+CONTAINER_BG = (28, 28, 35) # Slightly lighter
+CONTAINER_BORDER = (50, 50, 60) # Subtle border
+BAMBU_GREEN = (0, 174, 66) # #00AE42
+BAMBU_GREEN_DIM = (0, 120, 45) # Dimmer green for accents
+TEXT_PRIMARY = (240, 240, 245) # Near white
+TEXT_SECONDARY = (140, 140, 150) # Gray
+TEXT_LABEL = (100, 100, 110) # Darker gray for small labels
+INTERNET_COLOR = (80, 80, 95) # Cloud color
+TLS_BADGE_BG = (35, 55, 45) # Dark green for TLS badges
+LOCK_COLOR = BAMBU_GREEN
+
+# Font paths
+FONT_DIR = Path("/opt/claude/.claude/plugins/cache/anthropic-agent-skills/document-skills/f23222824449/skills/canvas-design/canvas-fonts")
+
+def load_fonts():
+ """Load fonts for the diagram."""
+ fonts = {}
+ try:
+ fonts['title'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Bold.ttf"), 28)
+ fonts['heading'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Bold.ttf"), 18)
+ fonts['label'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Regular.ttf"), 14)
+ fonts['small'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Regular.ttf"), 12)
+ fonts['port'] = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Bold.ttf"), 13)
+ fonts['port_small'] = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Regular.ttf"), 11)
+ fonts['tls'] = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Bold.ttf"), 10)
+ except Exception as e:
+ print(f"Font loading error: {e}")
+ # Fallback to default
+ fonts['title'] = ImageFont.load_default()
+ fonts['heading'] = ImageFont.load_default()
+ fonts['label'] = ImageFont.load_default()
+ fonts['small'] = ImageFont.load_default()
+ fonts['port'] = ImageFont.load_default()
+ fonts['port_small'] = ImageFont.load_default()
+ fonts['tls'] = ImageFont.load_default()
+ return fonts
+
+def draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):
+ """Draw a rounded rectangle."""
+ x1, y1, x2, y2 = xy
+
+ if fill:
+ # Fill
+ draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill)
+ draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill)
+ draw.ellipse([x1, y1, x1 + 2*radius, y1 + 2*radius], fill=fill)
+ draw.ellipse([x2 - 2*radius, y1, x2, y1 + 2*radius], fill=fill)
+ draw.ellipse([x1, y2 - 2*radius, x1 + 2*radius, y2], fill=fill)
+ draw.ellipse([x2 - 2*radius, y2 - 2*radius, x2, y2], fill=fill)
+
+ if outline:
+ # Outline
+ draw.arc([x1, y1, x1 + 2*radius, y1 + 2*radius], 180, 270, fill=outline, width=width)
+ draw.arc([x2 - 2*radius, y1, x2, y1 + 2*radius], 270, 360, fill=outline, width=width)
+ draw.arc([x1, y2 - 2*radius, x1 + 2*radius, y2], 90, 180, fill=outline, width=width)
+ draw.arc([x2 - 2*radius, y2 - 2*radius, x2, y2], 0, 90, fill=outline, width=width)
+ draw.line([x1 + radius, y1, x2 - radius, y1], fill=outline, width=width)
+ draw.line([x1 + radius, y2, x2 - radius, y2], fill=outline, width=width)
+ draw.line([x1, y1 + radius, x1, y2 - radius], fill=outline, width=width)
+ draw.line([x2, y1 + radius, x2, y2 - radius], fill=outline, width=width)
+
+def draw_lock_icon(draw, x, y, size, color):
+ """Draw a simple lock icon."""
+ # Lock body
+ body_w = size * 0.7
+ body_h = size * 0.5
+ body_x = x - body_w / 2
+ body_y = y + size * 0.1
+ draw_rounded_rect(draw, [body_x, body_y, body_x + body_w, body_y + body_h], 2, fill=color)
+
+ # Lock shackle (arc)
+ shackle_w = size * 0.45
+ shackle_h = size * 0.4
+ shackle_x = x - shackle_w / 2
+ shackle_y = y - size * 0.25
+ draw.arc([shackle_x, shackle_y, shackle_x + shackle_w, shackle_y + shackle_h],
+ 180, 360, fill=color, width=2)
+
+def draw_computer_icon(draw, x, y, size, color):
+ """Draw a simple computer/monitor icon."""
+ # Monitor
+ mon_w = size * 0.8
+ mon_h = size * 0.55
+ mon_x = x - mon_w / 2
+ mon_y = y - size * 0.35
+ draw_rounded_rect(draw, [mon_x, mon_y, mon_x + mon_w, mon_y + mon_h], 3, outline=color, width=2)
+
+ # Screen inner
+ inner_margin = 4
+ draw_rounded_rect(draw, [mon_x + inner_margin, mon_y + inner_margin,
+ mon_x + mon_w - inner_margin, mon_y + mon_h - inner_margin],
+ 2, fill=color)
+
+ # Stand
+ stand_w = size * 0.2
+ stand_h = size * 0.15
+ draw.rectangle([x - stand_w/2, mon_y + mon_h, x + stand_w/2, mon_y + mon_h + stand_h], fill=color)
+
+ # Base
+ base_w = size * 0.4
+ draw.rectangle([x - base_w/2, mon_y + mon_h + stand_h, x + base_w/2, mon_y + mon_h + stand_h + 3], fill=color)
+
+def draw_server_icon(draw, x, y, size, color):
+ """Draw a simple server icon."""
+ unit_h = size * 0.25
+ gap = 4
+ w = size * 0.75
+
+ for i in range(3):
+ uy = y - size * 0.4 + i * (unit_h + gap)
+ draw_rounded_rect(draw, [x - w/2, uy, x + w/2, uy + unit_h], 3, outline=color, width=2)
+ # LED dots
+ draw.ellipse([x + w/2 - 12, uy + unit_h/2 - 2, x + w/2 - 8, uy + unit_h/2 + 2], fill=color)
+
+def draw_printer_icon(draw, x, y, size, color):
+ """Draw a Bambu Lab style 3D printer icon."""
+ # Main body (cube-like)
+ body_w = size * 0.75
+ body_h = size * 0.7
+ body_x = x - body_w / 2
+ body_y = y - size * 0.35
+
+ # Outer frame with thicker border
+ draw_rounded_rect(draw, [body_x, body_y, body_x + body_w, body_y + body_h], 6, outline=color, width=2)
+
+ # Inner window/chamber
+ win_margin = 8
+ draw_rounded_rect(draw, [body_x + win_margin, body_y + win_margin,
+ body_x + body_w - win_margin, body_y + body_h - 16],
+ 4, outline=color, width=1)
+
+ # Print bed line
+ bed_y = body_y + body_h - 12
+ draw.line([body_x + 12, bed_y, body_x + body_w - 12, bed_y], fill=color, width=2)
+
+ # Extruder/toolhead
+ ext_w = 16
+ ext_h = 8
+ ext_y = body_y + 18
+ draw_rounded_rect(draw, [x - ext_w/2, ext_y, x + ext_w/2, ext_y + ext_h], 2, fill=color)
+
+ # Small printed object on bed
+ obj_w = 12
+ obj_h = 10
+ draw_rounded_rect(draw, [x - obj_w/2, bed_y - obj_h, x + obj_w/2, bed_y], 2, fill=color)
+
+def draw_cloud_icon(draw, x, y, size, color):
+ """Draw a simple cloud icon."""
+ # Main cloud body using overlapping circles
+ r1 = size * 0.25
+ r2 = size * 0.2
+ r3 = size * 0.18
+
+ # Center circle
+ draw.ellipse([x - r1, y - r1 * 0.8, x + r1, y + r1 * 0.8], fill=color)
+ # Left circle
+ draw.ellipse([x - r1 - r2 * 0.7, y - r2 * 0.3, x - r1 + r2 * 0.7, y + r2 * 1.1], fill=color)
+ # Right circle
+ draw.ellipse([x + r1 * 0.3 - r2 * 0.5, y - r2 * 0.4, x + r1 * 0.3 + r2 * 1.2, y + r2 * 1.0], fill=color)
+ # Top circle
+ draw.ellipse([x - r3 * 0.5, y - r1 - r3 * 0.3, x + r3 * 1.2, y - r1 + r3 * 0.9], fill=color)
+
+def draw_arrow(draw, x1, y1, x2, y2, color, width=2):
+ """Draw a line with arrow head."""
+ draw.line([x1, y1, x2, y2], fill=color, width=width)
+
+ # Arrow head
+ import math
+ angle = math.atan2(y2 - y1, x2 - x1)
+ arrow_len = 10
+ arrow_angle = math.pi / 6
+
+ ax1 = x2 - arrow_len * math.cos(angle - arrow_angle)
+ ay1 = y2 - arrow_len * math.sin(angle - arrow_angle)
+ ax2 = x2 - arrow_len * math.cos(angle + arrow_angle)
+ ay2 = y2 - arrow_len * math.sin(angle + arrow_angle)
+
+ draw.polygon([(x2, y2), (ax1, ay1), (ax2, ay2)], fill=color)
+
+def draw_bidirectional_arrow(draw, x1, y1, x2, y2, color, width=2):
+ """Draw a bidirectional arrow."""
+ import math
+
+ # Shorten line slightly to make room for arrowheads
+ angle = math.atan2(y2 - y1, x2 - x1)
+ offset = 8
+
+ lx1 = x1 + offset * math.cos(angle)
+ ly1 = y1 + offset * math.sin(angle)
+ lx2 = x2 - offset * math.cos(angle)
+ ly2 = y2 - offset * math.sin(angle)
+
+ draw.line([lx1, ly1, lx2, ly2], fill=color, width=width)
+
+ # Arrow heads
+ arrow_len = 8
+ arrow_angle = math.pi / 6
+
+ # Right arrow
+ ax1 = x2 - arrow_len * math.cos(angle - arrow_angle)
+ ay1 = y2 - arrow_len * math.sin(angle - arrow_angle)
+ ax2 = x2 - arrow_len * math.cos(angle + arrow_angle)
+ ay2 = y2 - arrow_len * math.sin(angle + arrow_angle)
+ draw.polygon([(x2, y2), (ax1, ay1), (ax2, ay2)], fill=color)
+
+ # Left arrow
+ ax1 = x1 + arrow_len * math.cos(angle - arrow_angle)
+ ay1 = y1 + arrow_len * math.sin(angle - arrow_angle)
+ ax2 = x1 + arrow_len * math.cos(angle + arrow_angle)
+ ay2 = y1 + arrow_len * math.sin(angle + arrow_angle)
+ draw.polygon([(x1, y1), (ax1, ay1), (ax2, ay2)], fill=color)
+
+def draw_tls_badge(draw, x, y, fonts, color=TLS_BADGE_BG, text_color=BAMBU_GREEN):
+ """Draw a TLS badge."""
+ badge_w = 42
+ badge_h = 18
+ draw_rounded_rect(draw, [x - badge_w/2, y - badge_h/2, x + badge_w/2, y + badge_h/2],
+ 4, fill=color, outline=BAMBU_GREEN_DIM, width=1)
+
+ # Lock icon
+ draw_lock_icon(draw, x - 12, y - 2, 10, text_color)
+
+ # TLS text
+ draw.text((x + 2, y), "TLS", font=fonts['tls'], fill=text_color, anchor="lm")
+
+def create_diagram():
+ """Create the main diagram."""
+ img = Image.new('RGB', (WIDTH, HEIGHT), BG_COLOR)
+ draw = ImageDraw.Draw(img)
+ fonts = load_fonts()
+
+ # Title
+ title = "VIRTUAL PRINTER PROXY MODE"
+ draw.text((WIDTH // 2, 35), title, font=fonts['title'], fill=BAMBU_GREEN, anchor="mm")
+
+ # Subtitle
+ subtitle = "Secure remote printing through Bambuddy"
+ draw.text((WIDTH // 2, 62), subtitle, font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+ # === LAYOUT ===
+ # Three main sections: Remote | Internet | Local
+
+ section_y = 320
+
+ # Remote section (left)
+ remote_x = 180
+ remote_box = [40, 120, 320, 520]
+
+ # Internet section (center)
+ internet_x = 510
+
+ # Bambuddy section (center-right)
+ bambuddy_x = 700
+ bambuddy_box = [560, 140, 840, 500]
+
+ # Local section (right)
+ local_x = 1050
+ printer_x = 1220
+ local_box = [920, 120, 1360, 520]
+
+ # === REMOTE NETWORK ZONE ===
+ draw_rounded_rect(draw, remote_box, 12, fill=CONTAINER_BG, outline=CONTAINER_BORDER, width=1)
+ draw.text((180, 140), "REMOTE NETWORK", font=fonts['label'], fill=TEXT_LABEL, anchor="mm")
+
+ # Slicer icon and label
+ draw_computer_icon(draw, remote_x, section_y - 40, 70, BAMBU_GREEN)
+ draw.text((remote_x, section_y + 30), "Bambu Studio", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+ draw.text((remote_x, section_y + 52), "or OrcaSlicer", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+ # Ports on remote side
+ draw.text((remote_x, section_y + 100), "Connects to Bambuddy", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+ draw.text((remote_x, section_y + 120), "FTP :9990 MQTT :8883", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor="mm")
+
+ # === INTERNET CLOUD ===
+ draw_cloud_icon(draw, internet_x, section_y, 80, INTERNET_COLOR)
+ draw.text((internet_x, section_y + 55), "Internet", font=fonts['label'], fill=TEXT_LABEL, anchor="mm")
+
+ # === BAMBUDDY SERVER ===
+ draw_rounded_rect(draw, bambuddy_box, 12, fill=CONTAINER_BG, outline=BAMBU_GREEN_DIM, width=2)
+ draw.text((bambuddy_x, 165), "BAMBUDDY SERVER", font=fonts['label'], fill=BAMBU_GREEN, anchor="mm")
+
+ # Server icon
+ draw_server_icon(draw, bambuddy_x, section_y - 50, 70, BAMBU_GREEN)
+ draw.text((bambuddy_x, section_y + 20), "TLS Proxy", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+
+ # Incoming ports (left side of Bambuddy)
+ draw.text((bambuddy_x, section_y + 70), "LISTEN PORTS", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+ draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 85, bambuddy_x + 55, section_y + 130],
+ 6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
+ draw.text((bambuddy_x, section_y + 98), "FTP", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+ draw.text((bambuddy_x, section_y + 115), "9990", font=fonts['port'], fill=BAMBU_GREEN, anchor="mm")
+
+ draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 140, bambuddy_x + 55, section_y + 185],
+ 6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
+ draw.text((bambuddy_x, section_y + 153), "MQTT", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+ draw.text((bambuddy_x, section_y + 170), "8883", font=fonts['port'], fill=BAMBU_GREEN, anchor="mm")
+
+ # === LOCAL NETWORK ZONE ===
+ draw_rounded_rect(draw, local_box, 12, fill=CONTAINER_BG, outline=CONTAINER_BORDER, width=1)
+ draw.text((1140, 140), "LOCAL NETWORK", font=fonts['label'], fill=TEXT_LABEL, anchor="mm")
+
+ # "LAN Mode" badge
+ draw_rounded_rect(draw, [1100, 155, 1180, 175], 4, fill=TLS_BADGE_BG, outline=BAMBU_GREEN_DIM, width=1)
+ draw.text((1140, 165), "LAN Mode", font=fonts['tls'], fill=BAMBU_GREEN, anchor="mm")
+
+ # Printer icon
+ draw_printer_icon(draw, printer_x, section_y - 40, 80, BAMBU_GREEN)
+ draw.text((printer_x, section_y + 35), "Bambu Lab", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+ draw.text((printer_x, section_y + 55), "Printer", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+
+ # Target ports
+ draw.text((printer_x, section_y + 100), "Printer Ports", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+ draw.text((printer_x, section_y + 120), "FTP :990 MQTT :8883", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor="mm")
+
+ # Proxy target label
+ draw_rounded_rect(draw, [local_x - 60, section_y - 80, local_x + 60, section_y - 50],
+ 6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
+ draw.text((local_x, section_y - 65), "Target IP", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+ # === CONNECTION ARROWS ===
+
+ # Remote to Internet
+ draw_bidirectional_arrow(draw, 325, section_y, 460, section_y, BAMBU_GREEN_DIM, 2)
+
+ # TLS badge between remote and internet
+ draw_tls_badge(draw, 392, section_y - 20, fonts)
+
+ # Internet to Bambuddy
+ draw_bidirectional_arrow(draw, 555, section_y, 620, section_y, BAMBU_GREEN_DIM, 2)
+
+ # Bambuddy to Local
+ draw_bidirectional_arrow(draw, 780, section_y, 920, section_y, BAMBU_GREEN_DIM, 2)
+
+ # TLS badge between Bambuddy and printer
+ draw_tls_badge(draw, 850, section_y - 20, fonts)
+
+ # Local network arrow to printer
+ draw_bidirectional_arrow(draw, 990, section_y, 1130, section_y, BAMBU_GREEN_DIM, 2)
+
+ # === BOTTOM INFO ===
+ info_y = 560
+
+ # Flow description
+ draw.text((WIDTH // 2, info_y), "← Slicer traffic encrypted and relayed through Bambuddy to your printer →",
+ font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+ # Key features
+ features_y = 600
+ features = [
+ "End-to-end TLS encryption",
+ "No cloud dependency",
+ "Uses printer's access code"
+ ]
+
+ spacing = 280
+ start_x = WIDTH // 2 - spacing
+
+ for i, feature in enumerate(features):
+ fx = start_x + i * spacing
+ # Bullet
+ draw.ellipse([fx - 80, features_y - 3, fx - 74, features_y + 3], fill=BAMBU_GREEN)
+ draw.text((fx - 68, features_y), feature, font=fonts['small'], fill=TEXT_SECONDARY, anchor="lm")
+
+ # Bambuddy branding
+ draw.text((WIDTH // 2, HEIGHT - 30), "bambuddy.cool", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+
+ return img
+
+def main():
+ """Generate and save the diagram."""
+ img = create_diagram()
+
+ output_path = Path("/opt/claude/projects/bambuddy/docs/images/proxy-mode-diagram.png")
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ img.save(output_path, "PNG", dpi=(150, 150))
+ print(f"Diagram saved to: {output_path}")
+
+ # Also save to frontend docs
+ frontend_path = Path("/opt/claude/projects/bambuddy/frontend/docs/proxy-mode-diagram.png")
+ frontend_path.parent.mkdir(parents=True, exist_ok=True)
+ img.save(frontend_path, "PNG", dpi=(150, 150))
+ print(f"Also saved to: {frontend_path}")
+
+if __name__ == "__main__":
+ main()
diff --git a/frontend/docs/proxy-mode-diagram.png b/frontend/docs/proxy-mode-diagram.png
new file mode 100644
index 000000000..c62197a37
Binary files /dev/null and b/frontend/docs/proxy-mode-diagram.png differ
diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts
index d8082d52b..bff112632 100644
--- a/frontend/src/__tests__/api/client.test.ts
+++ b/frontend/src/__tests__/api/client.test.ts
@@ -121,12 +121,14 @@ describe('API Client Auth Header', () => {
describe('FormData requests include auth header', () => {
it('importProjectFile includes Authorization header', async () => {
+ // Mock fetch directly for FormData requests (MSW can be flaky with multipart in some environments)
+ const originalFetch = global.fetch;
let capturedHeaders: Headers | null = null;
- server.use(
- http.post('/api/v1/projects/import/file', ({ request }) => {
- capturedHeaders = request.headers;
- return HttpResponse.json({
+ global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
+ if (url.includes('/projects/import/file')) {
+ capturedHeaders = new Headers(init?.headers);
+ return Promise.resolve(new Response(JSON.stringify({
id: 1,
name: 'Test Project',
description: '',
@@ -140,16 +142,21 @@ describe('FormData requests include auth header', () => {
updated_at: '2026-01-01T00:00:00Z',
archives: [],
bom_items: [],
- });
- })
- );
+ }), { status: 200 }));
+ }
+ return originalFetch(url, init);
+ });
- setAuthToken('test-token');
- const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
- await api.importProjectFile(file);
+ try {
+ setAuthToken('test-token');
+ const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
+ await api.importProjectFile(file);
- expect(capturedHeaders).not.toBeNull();
- expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
+ expect(capturedHeaders).not.toBeNull();
+ expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
+ } finally {
+ global.fetch = originalFetch;
+ }
});
it('exportProjectZip includes Authorization header', async () => {
diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
index e2e224966..ebf91860d 100644
--- a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
+++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
@@ -42,6 +42,7 @@ const createMockSettings = (overrides = {}) => ({
access_code_set: false,
mode: 'immediate' as const,
model: '3DPrinter-X1-Carbon',
+ target_printer_id: null as number | null,
status: {
enabled: false,
running: false,
@@ -472,4 +473,100 @@ describe('VirtualPrinterSettings', () => {
});
});
});
+
+ describe('proxy mode', () => {
+ it('renders Proxy mode option', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Proxy')).toBeInTheDocument();
+ expect(screen.getByText('Relay to real printer')).toBeInTheDocument();
+ });
+ });
+
+ it('highlights proxy mode when selected', async () => {
+ vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+ createMockSettings({ mode: 'proxy', target_printer_id: 1 })
+ );
+
+ render();
+
+ await waitFor(() => {
+ const proxyButton = screen.getByText('Proxy').closest('button');
+ expect(proxyButton?.className).toContain('border-blue-500');
+ });
+ });
+
+ it('shows proxy status details when running in proxy mode', async () => {
+ vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+ createMockSettings({
+ enabled: true,
+ mode: 'proxy',
+ target_printer_id: 1,
+ status: {
+ enabled: true,
+ running: true,
+ mode: 'proxy',
+ name: 'Bambuddy (Proxy)',
+ serial: '00M00A391800001',
+ model: '3DPrinter-X1-Carbon',
+ model_name: 'X1C',
+ pending_files: 0,
+ proxy: {
+ running: true,
+ target_host: '192.168.1.100',
+ ftp_port: 9990,
+ mqtt_port: 8883,
+ ftp_connections: 1,
+ mqtt_connections: 2,
+ },
+ },
+ })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Running')).toBeInTheDocument();
+ expect(screen.getByText('Status Details')).toBeInTheDocument();
+ // IP address appears in multiple places (target printer and status details)
+ expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0);
+ });
+ });
+
+ it('shows target printer dropdown in proxy mode', async () => {
+ vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+ createMockSettings({ mode: 'proxy' })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Target Printer')).toBeInTheDocument();
+ expect(screen.getByText('Select a printer...')).toBeInTheDocument();
+ });
+ });
+
+ it('changes mode to proxy on click', async () => {
+ const user = userEvent.setup();
+ vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+ createMockSettings({ mode: 'proxy' })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Proxy')).toBeInTheDocument();
+ });
+
+ const proxyButton = screen.getByText('Proxy').closest('button');
+ if (proxyButton) {
+ await user.click(proxyButton);
+ }
+
+ await waitFor(() => {
+ expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'proxy' });
+ });
+ });
+ });
});