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 + +

+ Proxy Mode Architecture +

+ +**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' }); + }); + }); + }); });