Add Virtual Printer Proxy Mode for remote printing

Introduces a new "Proxy Mode" for the Virtual Printer that enables
remote printing from anywhere in the world without VPN, port forwarding,
or Bambu Cloud dependency.

Bambuddy acts as a TLS relay between a remote slicer (Bambu Studio/
OrcaSlicer) and the local Bambu Lab printer:

  Remote Slicer → Internet → Bambuddy Server → Local Network → Printer

The slicer connects to Bambuddy using the real printer's serial number
and access code. Bambuddy authenticates and relays all FTP (file transfer)
and MQTT (commands/status) traffic with end-to-end TLS encryption.

- No port forwarding required - printer stays safely on local network
- No VPN needed - connect from coffee shops, hotels, work, anywhere
- No Bambu Cloud dependency - fully self-hosted solution
- End-to-end TLS encryption on FTP (port 9990) and MQTT (port 8883)
- Works with Bambu Studio and OrcaSlicer
- Uses real printer credentials for authentication
- Automatic printer selection from connected printers

- Add SlicerProxyManager class for TLS relay (tcp_proxy.py)
  - TLS termination with auto-generated certificates
  - Concurrent FTP and MQTT proxy servers
  - Connection lifecycle management with proper cleanup
- Extend VirtualPrinterManager with proxy mode support
  - New 'proxy' mode alongside archive/review/queue modes
  - Target printer selection and credential management
- Add proxy configuration endpoints to settings API
- Add permission checks for proxy endpoints

- Add Proxy Mode card to Virtual Printer settings
- Target printer dropdown for proxy destination
- Real-time proxy status display (ports, target, running state)
- Full i18n support (English, German)

- Add network architecture diagram
- Add proxy mode section to README
- Add comprehensive guide to wiki
- Add prominent feature section to website

- Backend unit tests for SlicerProxyManager
- Backend unit tests for proxy mode configuration
- Frontend tests for proxy mode UI components

Closes #207 #170
This commit is contained in:
maziggy 2026-02-03 11:02:13 +01:00
parent 158fa88b7a
commit 583c374f01
10 changed files with 663 additions and 16 deletions

View file

@ -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):

View file

@ -28,6 +28,25 @@
---
## 🌐 NEW: Remote Printing with Proxy Mode
<p align="center">
<img src="docs/images/proxy-mode-diagram.png" alt="Proxy Mode Architecture" width="800">
</p>
**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

View file

@ -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,

View file

@ -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")

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -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 () => {

View file

@ -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(<VirtualPrinterSettings />);
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(<VirtualPrinterSettings />);
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(<VirtualPrinterSettings />);
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(<VirtualPrinterSettings />);
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(<VirtualPrinterSettings />);
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' });
});
});
});
});