Merge branch '0.1.8b' into fix/virtual_printer_proxy_error

This commit is contained in:
MartinNYHC
2026-02-04 14:38:53 +01:00
committed by GitHub
49 changed files with 12672 additions and 9077 deletions
+1
View File
@@ -0,0 +1 @@
/Users/wreaves/projects/MisterBeardy/bambuddy/.venv/bin/python
+56 -1
View File
@@ -2,7 +2,62 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.7b] - Not released
## [0.1.8b] - Not released
### Enhanced
- **Virtual Printer Proxy Mode Improvements**:
- SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
- FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990
- For systemd: requires `AmbientCapabilities=CAP_NET_BIND_SERVICE` capability
- Automatic directory permission checking at startup with clear error messages for Docker/bare metal
- Updated translations for proxy mode steps in English, German, and Japanese
### Fixed
- **Authentication Required Error After Initial Setup** (Issue #257):
- Fixed "Authentication required" error when using printer controls after fresh install with auth enabled
- Token clearing on 401 responses is now more selective - only clears on invalid token messages
- Generic "Authentication required" errors (which may be timing issues) no longer clear the token
- Also fixed smart plug discovery scan endpoints missing auth headers
- **Filament Hover Card Overlapping Navigation Bar** (Issue #259):
- Fixed filament info popup being partially covered by the navigation bar
- Hover card positioning now accounts for the fixed 56px header
- Cards near the top of the page now correctly flip to show below the slot
- **Filament Statistics Incorrectly Multiplied by Quantity** (Issue #229):
- Fixed filament totals being inflated by incorrectly multiplying by quantity
- The `filament_used_grams` field already contains the total for the entire print job
- Removed incorrect `* quantity` multiplication from archive stats, Prometheus metrics, and FilamentTrends chart
- Example: A print with 26 objects using 126g was incorrectly shown as 3,276g
- **Print Queue Status Does Not Match Printer Status** (Issue #249):
- Queue now shows "Paused" when the printer is paused instead of "Printing"
- Fetches real-time printer state for actively printing queue items
- Added translations for paused status in English, German, and Japanese
- **Queue Scheduled Time Displayed in Wrong Timezone** (Issue #233):
- Fixed scheduled time being displayed in UTC instead of local timezone when editing queue items
- The datetime picker now correctly shows and saves times in the user's local timezone
- **Mobile Layout Issues on Archives and Statistics Pages** (Issue #255):
- Fixed header buttons overflowing outside the screen on iPhone/mobile devices
- Headers now stack vertically on small screens with proper wrapping
- Applied consistent responsive pattern from PrintersPage
- **AMS Auto-Matching Selects Wrong Slot** (Issue #245):
- Fixed AMS slot mapping when multiple trays have the same `tray_info_idx` (filament type identifier)
- `tray_info_idx` (e.g., "GFA00" for generic PLA) identifies filament TYPE, not unique spools
- When multiple trays match the same type, color is now used as a tiebreaker
- Previously used `find()` which always returned the first match regardless of color
- Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
- Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
### Added
- **Windows Portable Launcher** (contributed by nmori):
- New `start_bambuddy.bat` for Windows users - double-click to run, no installation required
- Automatically downloads Python 3.13 and Node.js 22 on first run (portable, no system changes)
- Everything stored in `.portable\` folder for easy cleanup
- Commands: `start_bambuddy.bat` (launch), `start_bambuddy.bat update` (update deps), `start_bambuddy.bat reset` (clean start)
- Custom port via `set PORT=9000 & start_bambuddy.bat`
- Verifies all downloads with SHA256 checksums for security
- Supports both x64 and ARM64 Windows systems
## [0.1.7] - 2026-02-03
### Security
- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
+21 -2
View File
@@ -11,7 +11,7 @@
<p align="center">
<a href="https://github.com/maziggy/bambuddy/releases"><img src="https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue" alt="Release"></a>
<img src="https://github.com/maziggy/bambuddy/actions/workflows/github-code-scanning/codeql/badge.svg">
<img src="https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main">
<img src="https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main">
<a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square" alt="License"></a>
<a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
<a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
@@ -444,7 +444,26 @@ services:
</details>
#### Manual Installation
#### Windows (Portable Launcher)
The easiest way to run Bambuddy on Windows - no installation required:
```batch
git clone https://github.com/maziggy/bambuddy.git
cd bambuddy
start_bambuddy.bat
```
Double-click `start_bambuddy.bat` and it will:
- Download Python and Node.js automatically (portable, no system changes)
- Install dependencies and build the frontend
- Open your browser to http://localhost:8000
Everything is stored in the `.portable\` folder. Use `start_bambuddy.bat reset` to clean up.
> **Custom port:** `set PORT=9000 & start_bambuddy.bat`
#### Manual Installation (Linux/macOS)
```bash
# Clone and setup
+87 -18
View File
@@ -466,10 +466,8 @@ async def get_archive_stats(
total_seconds += print_time_seconds
total_time = total_seconds / 3600 # Convert to hours
# Multiply filament by quantity to account for multiple items printed
filament_result = await db.execute(
select(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)))
)
# Sum filament directly - filament_used_grams already contains the total for the print job
filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
total_filament = filament_result.scalar() or 0
cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
@@ -2254,6 +2252,8 @@ async def get_archive_plates(
Returns a list of plates with their index, name, thumbnail availability,
and filament requirements. For single-plate exports, returns a single plate.
"""
import json
import re
import xml.etree.ElementTree as ET
service = ArchiveService(db)
@@ -2274,19 +2274,50 @@ async def get_archive_plates(
# Find all plate gcode files to determine available plates
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
if not gcode_files:
# No sliced plates found
return {"archive_id": archive_id, "filename": archive.filename, "plates": []}
# If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
plate_indices: list[int] = []
if gcode_files:
# Extract plate indices from gcode filenames
for gf in gcode_files:
# "Metadata/plate_5.gcode" -> 5
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_indices.append(int(plate_str))
except ValueError:
pass
else:
plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
plate_png_files = [
n
for n in namelist
if n.startswith("Metadata/plate_")
and n.endswith(".png")
and "_small" not in n
and "no_light" not in n
]
plate_name_candidates = plate_json_files + plate_png_files
plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
seen_indices: set[int] = set()
for name in plate_name_candidates:
match = plate_re.match(name)
if match:
try:
index = int(match.group(1))
except ValueError:
continue
if index in seen_indices:
continue
seen_indices.add(index)
plate_indices.append(index)
# Extract plate indices from gcode filenames
plate_indices = []
for gf in gcode_files:
# "Metadata/plate_5.gcode" -> 5
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_indices.append(int(plate_str))
except ValueError:
pass
if not plate_indices:
# No plate metadata found
return {
"archive_id": archive_id,
"filename": archive.filename,
"plates": [],
"is_multi_plate": False,
}
plate_indices.sort()
@@ -2391,16 +2422,48 @@ async def get_archive_plates(
plate_metadata[plate_index] = plate_info
# Parse plate_*.json for object lists when slice_info is missing
plate_json_objects: dict[int, list[str]] = {}
for name in namelist:
match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
if not match:
continue
try:
plate_index = int(match.group(1))
except ValueError:
continue
try:
payload = json.loads(zf.read(name).decode())
bbox_objects = payload.get("bbox_objects", [])
names = []
for obj in bbox_objects:
obj_name = obj.get("name") if isinstance(obj, dict) else None
if obj_name and obj_name not in names:
names.append(obj_name)
if names:
plate_json_objects[plate_index] = names
except Exception:
continue
# Build plate list
for idx in plate_indices:
meta = plate_metadata.get(idx, {})
has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
objects = meta.get("objects", [])
if not objects:
objects = plate_json_objects.get(idx, [])
plate_name = meta.get("name")
if not plate_name:
plate_name = plate_names.get(idx)
if not plate_name and objects:
plate_name = objects[0]
plates.append(
{
"index": idx,
"name": meta.get("name"),
"objects": meta.get("objects", []),
"name": plate_name,
"objects": objects,
"has_thumbnail": has_thumbnail,
"thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
if has_thumbnail
@@ -2511,6 +2574,8 @@ async def get_filament_requirements(
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
tray_info_idx = filament_elem.get("tray_info_idx", "")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
@@ -2524,6 +2589,7 @@ async def get_filament_requirements(
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
"tray_info_idx": tray_info_idx,
}
)
break
@@ -2537,6 +2603,8 @@ async def get_filament_requirements(
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
tray_info_idx = filament_elem.get("tray_info_idx", "")
# Only include filaments that are actually used
try:
used_grams = float(used_g)
@@ -2551,6 +2619,7 @@ async def get_filament_requirements(
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
"tray_info_idx": tray_info_idx,
}
)
+79 -13
View File
@@ -1307,6 +1307,8 @@ async def get_library_file_plates(
Returns a list of plates with their index, name, thumbnail availability,
and filament requirements. For single-plate exports, returns a single plate.
"""
import json
import re
import xml.etree.ElementTree as ET
import zipfile
@@ -1334,18 +1336,44 @@ async def get_library_file_plates(
# Find all plate gcode files to determine available plates
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
if not gcode_files:
# No sliced plates found
return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
# If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
plate_indices: list[int] = []
if gcode_files:
# Extract plate indices from gcode filenames
for gf in gcode_files:
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_indices.append(int(plate_str))
except ValueError:
pass
else:
plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
plate_png_files = [
n
for n in namelist
if n.startswith("Metadata/plate_")
and n.endswith(".png")
and "_small" not in n
and "no_light" not in n
]
plate_name_candidates = plate_json_files + plate_png_files
plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
seen_indices: set[int] = set()
for name in plate_name_candidates:
match = plate_re.match(name)
if match:
try:
index = int(match.group(1))
except ValueError:
continue
if index in seen_indices:
continue
seen_indices.add(index)
plate_indices.append(index)
# Extract plate indices from gcode filenames
plate_indices = []
for gf in gcode_files:
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_indices.append(int(plate_str))
except ValueError:
pass
if not plate_indices:
# No plate metadata found
return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
plate_indices.sort()
@@ -1443,16 +1471,48 @@ async def get_library_file_plates(
plate_info["name"] = plate_info["objects"][0]
plate_metadata[plate_index] = plate_info
# Parse plate_*.json for object lists when slice_info is missing
plate_json_objects: dict[int, list[str]] = {}
for name in namelist:
match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
if not match:
continue
try:
plate_index = int(match.group(1))
except ValueError:
continue
try:
payload = json.loads(zf.read(name).decode())
bbox_objects = payload.get("bbox_objects", [])
names: list[str] = []
for obj in bbox_objects:
obj_name = obj.get("name") if isinstance(obj, dict) else None
if obj_name and obj_name not in names:
names.append(obj_name)
if names:
plate_json_objects[plate_index] = names
except Exception:
continue
# Build plate list
for idx in plate_indices:
meta = plate_metadata.get(idx, {})
has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
objects = meta.get("objects", [])
if not objects:
objects = plate_json_objects.get(idx, [])
plate_name = meta.get("name")
if not plate_name:
plate_name = plate_names.get(idx)
if not plate_name and objects:
plate_name = objects[0]
plates.append(
{
"index": idx,
"name": meta.get("name"),
"objects": meta.get("objects", []),
"name": plate_name,
"objects": objects,
"has_thumbnail": has_thumbnail,
"thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
if has_thumbnail
@@ -1574,6 +1634,8 @@ async def get_library_file_filament_requirements(
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
tray_info_idx = filament_elem.get("tray_info_idx", "")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
@@ -1587,6 +1649,7 @@ async def get_library_file_filament_requirements(
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
"tray_info_idx": tray_info_idx,
}
)
break
@@ -1599,6 +1662,8 @@ async def get_library_file_filament_requirements(
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
tray_info_idx = filament_elem.get("tray_info_idx", "")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
@@ -1612,6 +1677,7 @@ async def get_library_file_filament_requirements(
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
"tray_info_idx": tray_info_idx,
}
)
+2 -4
View File
@@ -362,13 +362,11 @@ async def get_metrics(
)
lines.append(f"bambuddy_printer_prints_total{labels} {count}")
# Total filament used (multiply by quantity to account for multiple items printed)
# Total filament used - filament_used_grams already contains the total for each print job
lines.append("")
lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
lines.append("# TYPE bambuddy_filament_used_grams counter")
result = await db.execute(
select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
)
result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
total_filament = result.scalar() or 0
lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
+316 -1
View File
@@ -543,9 +543,9 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
async def get_printer_cover(
printer_id: int,
view: str | None = None,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
# Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
"""Get the cover image for the current print job.
Args:
@@ -803,6 +803,321 @@ async def download_printer_file(
)
@router.get("/{printer_id}/files/gcode")
async def get_printer_file_gcode(
printer_id: int,
path: str,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""Get gcode for a file stored on a printer (for preview)."""
import io
# Validate printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
if data is None:
raise HTTPException(404, f"File not found: {path}")
filename = path.split("/")[-1]
lower = filename.lower()
if lower.endswith(".gcode"):
return Response(content=data, media_type="text/plain")
if lower.endswith(".3mf"):
try:
with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
if not gcode_files:
raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
gcode_content = zf.read(gcode_files[0])
return Response(content=gcode_content, media_type="text/plain")
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Invalid 3MF file")
raise HTTPException(status_code=400, detail="Unsupported file type")
@router.get("/{printer_id}/files/plates")
async def get_printer_file_plates(
printer_id: int,
path: str = Query(..., description="Full path to the 3MF file on the printer"),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""Get available plates from a multi-plate 3MF file stored on a printer."""
import io
import json
import xml.etree.ElementTree as ET
import zipfile
# Validate printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
filename = path.split("/")[-1]
if not filename.lower().endswith(".3mf"):
return {
"printer_id": printer_id,
"path": path,
"filename": filename,
"plates": [],
"is_multi_plate": False,
}
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
if data is None:
raise HTTPException(404, f"File not found: {path}")
plates = []
try:
with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
namelist = zf.namelist()
# Find all plate gcode files to determine available plates
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
# If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
plate_indices: list[int] = []
if gcode_files:
for gf in gcode_files:
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_indices.append(int(plate_str))
except ValueError:
pass
else:
plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
plate_png_files = [
n
for n in namelist
if n.startswith("Metadata/plate_")
and n.endswith(".png")
and "_small" not in n
and "no_light" not in n
]
plate_name_candidates = plate_json_files + plate_png_files
plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
seen_indices: set[int] = set()
for name in plate_name_candidates:
match = plate_re.match(name)
if match:
try:
index = int(match.group(1))
except ValueError:
continue
if index in seen_indices:
continue
seen_indices.add(index)
plate_indices.append(index)
if not plate_indices:
return {
"printer_id": printer_id,
"path": path,
"filename": filename,
"plates": [],
"is_multi_plate": False,
}
plate_indices.sort()
# Parse model_settings.config for plate names
plate_names = {}
if "Metadata/model_settings.config" in namelist:
try:
model_content = zf.read("Metadata/model_settings.config").decode()
model_root = ET.fromstring(model_content)
for plate_elem in model_root.findall(".//plate"):
plater_id = None
plater_name = None
for meta in plate_elem.findall("metadata"):
key = meta.get("key")
value = meta.get("value")
if key == "plater_id" and value:
try:
plater_id = int(value)
except ValueError:
pass
elif key == "plater_name" and value:
plater_name = value.strip()
if plater_id is not None and plater_name:
plate_names[plater_id] = plater_name
except Exception:
pass
# Parse slice_info.config for plate metadata
plate_metadata = {}
if "Metadata/slice_info.config" in namelist:
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
for plate_elem in root.findall(".//plate"):
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
plate_index = None
for meta in plate_elem.findall("metadata"):
key = meta.get("key")
value = meta.get("value")
if key == "index" and value:
try:
plate_index = int(value)
except ValueError:
pass
elif key == "prediction" and value:
try:
plate_info["prediction"] = int(value)
except ValueError:
pass
elif key == "weight" and value:
try:
plate_info["weight"] = float(value)
except ValueError:
pass
# Get filaments used in this plate
for filament_elem in plate_elem.findall("filament"):
filament_id = filament_elem.get("id")
filament_type = filament_elem.get("type", "")
filament_color = filament_elem.get("color", "")
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
used_grams = 0
if used_grams > 0 and filament_id:
plate_info["filaments"].append(
{
"slot_id": int(filament_id),
"type": filament_type,
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
}
)
plate_info["filaments"].sort(key=lambda x: x["slot_id"])
# Collect object names
for obj_elem in plate_elem.findall("object"):
obj_name = obj_elem.get("name")
if obj_name and obj_name not in plate_info["objects"]:
plate_info["objects"].append(obj_name)
# Set plate name
if plate_index is not None:
custom_name = plate_names.get(plate_index)
if custom_name:
plate_info["name"] = custom_name
elif plate_info["objects"]:
plate_info["name"] = plate_info["objects"][0]
plate_metadata[plate_index] = plate_info
# Parse plate_*.json for object lists when slice_info is missing
plate_json_objects: dict[int, list[str]] = {}
for name in namelist:
match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
if not match:
continue
try:
plate_index = int(match.group(1))
except ValueError:
continue
try:
payload = json.loads(zf.read(name).decode())
bbox_objects = payload.get("bbox_objects", [])
names: list[str] = []
for obj in bbox_objects:
obj_name = obj.get("name") if isinstance(obj, dict) else None
if obj_name and obj_name not in names:
names.append(obj_name)
if names:
plate_json_objects[plate_index] = names
except Exception:
continue
# Build plate list
for idx in plate_indices:
meta = plate_metadata.get(idx, {})
has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
objects = meta.get("objects", [])
if not objects:
objects = plate_json_objects.get(idx, [])
plate_name = meta.get("name")
if not plate_name:
plate_name = plate_names.get(idx)
if not plate_name and objects:
plate_name = objects[0]
plates.append(
{
"index": idx,
"name": plate_name,
"objects": objects,
"has_thumbnail": has_thumbnail,
"thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
"print_time_seconds": meta.get("prediction"),
"filament_used_grams": meta.get("weight"),
"filaments": meta.get("filaments", []),
}
)
except Exception as e:
logger.warning(f"Failed to parse plates from printer file {path}: {e}")
return {
"printer_id": printer_id,
"path": path,
"filename": filename,
"plates": plates,
"is_multi_plate": len(plates) > 1,
}
@router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
async def get_printer_file_plate_thumbnail(
printer_id: int,
plate_index: int,
path: str = Query(..., description="Full path to the 3MF file on the printer"),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""Get a plate thumbnail image from a printer-stored 3MF file."""
import io
import zipfile
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
if data is None:
raise HTTPException(404, f"File not found: {path}")
try:
with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
thumb_path = f"Metadata/plate_{plate_index}.png"
if thumb_path in zf.namelist():
image_data = zf.read(thumb_path)
return Response(content=image_data, media_type="image/png")
except Exception:
pass
raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
@router.post("/{printer_id}/files/download-zip")
async def download_printer_files_as_zip(
printer_id: int,
+25 -1
View File
@@ -434,6 +434,17 @@ async def restore_backup(
)
@router.get("/network-interfaces")
async def get_network_interfaces(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get available network interfaces for SSDP proxy configuration."""
from backend.app.services.network_utils import get_network_interfaces
interfaces = get_network_interfaces()
return {"interfaces": interfaces}
@router.get("/virtual-printer/models")
async def get_virtual_printer_models(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -466,6 +477,7 @@ async def get_virtual_printer_settings(
mode = await get_setting(db, "virtual_printer_mode")
model = await get_setting(db, "virtual_printer_model")
target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
return {
"enabled": enabled == "true" if enabled else False,
@@ -473,6 +485,7 @@ async def get_virtual_printer_settings(
"mode": mode or "immediate",
"model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
"target_printer_id": int(target_printer_id) if target_printer_id else None,
"remote_interface_ip": remote_interface_ip or "",
"status": virtual_printer_manager.get_status(),
}
@@ -484,10 +497,16 @@ async def update_virtual_printer_settings(
mode: str = None,
model: str = None,
target_printer_id: int = None,
remote_interface_ip: str = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update virtual printer settings and restart services if needed."""
"""Update virtual printer settings and restart services if needed.
For proxy mode with SSDP proxy (dual-homed setup):
- remote_interface_ip: IP of interface on slicer's network (LAN B)
- Local interface is auto-detected based on target printer IP
"""
from sqlalchemy import select
from backend.app.models.printer import Printer
@@ -504,6 +523,7 @@ async def update_virtual_printer_settings(
current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
current_target_id = int(current_target_id_str) if current_target_id_str else None
current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
# Apply updates
new_enabled = enabled if enabled is not None else current_enabled
@@ -511,6 +531,7 @@ async def update_virtual_printer_settings(
new_mode = mode if mode is not None else current_mode
new_model = model if model is not None else current_model
new_target_id = target_printer_id if target_printer_id is not None else current_target_id
new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
# Validate mode
# "review" is the new name for "queue" (pending review before archiving)
@@ -587,6 +608,8 @@ async def update_virtual_printer_settings(
await set_setting(db, "virtual_printer_model", model)
if target_printer_id is not None:
await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
if remote_interface_ip is not None:
await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
await db.commit()
db.expire_all()
@@ -599,6 +622,7 @@ async def update_virtual_printer_settings(
model=new_model,
target_printer_ip=target_printer_ip,
target_printer_serial=target_printer_serial,
remote_interface_ip=new_remote_iface,
)
except ValueError as e:
logger.warning(f"Virtual printer configuration validation error: {e}")
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.7"
APP_VERSION = "0.1.8b"
GITHUB_REPO = "maziggy/bambuddy"
# App directory - where the application is installed (for static files)
+6
View File
@@ -727,7 +727,11 @@ async def on_print_start(printer_id: int, data: dict):
printer = result.scalar_one_or_none()
# Plate detection check - pause if objects detected on build plate
logger.info(
f"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}"
)
if printer and printer.plate_detection_enabled:
logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}")
try:
from backend.app.services.plate_detection import check_plate_empty
@@ -2499,6 +2503,7 @@ async def lifespan(app: FastAPI):
vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
vp_model = await get_setting(db, "virtual_printer_model") or ""
vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
# Look up printer IP and serial if in proxy mode
vp_target_ip = ""
@@ -2524,6 +2529,7 @@ async def lifespan(app: FastAPI):
model=vp_model,
target_printer_ip=vp_target_ip,
target_printer_serial=vp_target_serial,
remote_interface_ip=vp_remote_iface,
)
if vp_mode == "proxy":
logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
+119
View File
@@ -0,0 +1,119 @@
"""Network utility functions for interface detection."""
import ipaddress
import logging
import socket
import struct
logger = logging.getLogger(__name__)
# Interfaces to exclude from selection
EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
def get_network_interfaces() -> list[dict]:
"""Get all network interfaces with their IPs and subnets.
Returns:
List of dicts with name, ip, netmask, subnet, broadcast
"""
interfaces = []
try:
import fcntl
for iface in socket.if_nameindex():
name = iface[1]
# Skip excluded interfaces
if any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES):
continue
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Get IP address
ip_bytes = fcntl.ioctl(
s.fileno(),
0x8915, # SIOCGIFADDR
struct.pack("256s", name[:15].encode()),
)[20:24]
ip = socket.inet_ntoa(ip_bytes)
# Get netmask
netmask_bytes = fcntl.ioctl(
s.fileno(),
0x891B, # SIOCGIFNETMASK
struct.pack("256s", name[:15].encode()),
)[20:24]
netmask = socket.inet_ntoa(netmask_bytes)
# Calculate subnet
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
interfaces.append(
{
"name": name,
"ip": ip,
"netmask": netmask,
"subnet": str(network),
}
)
s.close()
except OSError:
# Interface doesn't have an IP or other error
pass
except Exception as e:
logger.debug(f"Error getting info for interface {name}: {e}")
except ImportError:
# fcntl not available (Windows)
logger.warning("fcntl not available, interface detection limited")
except Exception as e:
logger.error(f"Error enumerating interfaces: {e}")
return interfaces
def find_interface_for_ip(target_ip: str) -> dict | None:
"""Find which interface is on the same subnet as the target IP.
Args:
target_ip: IP address to find the matching interface for
Returns:
Interface dict or None if not found
"""
try:
target = ipaddress.IPv4Address(target_ip)
except ValueError:
logger.error(f"Invalid target IP: {target_ip}")
return None
interfaces = get_network_interfaces()
for iface in interfaces:
try:
network = ipaddress.IPv4Network(iface["subnet"], strict=False)
if target in network:
logger.debug(f"Found interface {iface['name']} ({iface['ip']}) for target {target_ip}")
return iface
except ValueError:
continue
logger.warning(f"No interface found for target IP {target_ip}")
return None
def get_other_interfaces(exclude_ip: str) -> list[dict]:
"""Get all interfaces except the one with the given IP.
Args:
exclude_ip: IP address of interface to exclude
Returns:
List of interface dicts
"""
interfaces = get_network_interfaces()
return [iface for iface in interfaces if iface["ip"] != exclude_ip]
+118 -26
View File
@@ -5,7 +5,7 @@ import json
import logging
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import func, select
@@ -76,6 +76,18 @@ class PrintScheduler:
if item.scheduled_time and item.scheduled_time > datetime.utcnow():
continue
# Safety: Skip stale items (older than 24 hours) to prevent phantom reprints
# This protects against items that got stuck in "pending" status due to
# crashes/restarts after the print already started
stale_threshold = timedelta(hours=24)
if item.created_at and datetime.utcnow() - item.created_at.replace(tzinfo=None) > stale_threshold:
logger.warning(f"Queue item {item.id} is stale (created {item.created_at}), marking as expired")
item.status = "expired"
item.error_message = "Queue item expired - older than 24 hours"
item.completed_at = datetime.utcnow()
await db.commit()
continue
# Skip items that require manual start
if item.manual_start:
continue
@@ -434,6 +446,8 @@ class PrintScheduler:
filament_id = filament_elem.get("id")
filament_type = filament_elem.get("type", "")
filament_color = filament_elem.get("color", "")
# tray_info_idx identifies the specific spool selected when slicing
tray_info_idx = filament_elem.get("tray_info_idx", "")
used_g = filament_elem.get("used_g", "0")
try:
used_grams = float(used_g)
@@ -443,6 +457,7 @@ class PrintScheduler:
"slot_id": int(filament_id),
"type": filament_type,
"color": filament_color,
"tray_info_idx": tray_info_idx,
"used_grams": round(used_grams, 1),
}
)
@@ -455,6 +470,8 @@ class PrintScheduler:
filament_id = filament_elem.get("id")
filament_type = filament_elem.get("type", "")
filament_color = filament_elem.get("color", "")
# tray_info_idx identifies the specific spool selected when slicing
tray_info_idx = filament_elem.get("tray_info_idx", "")
used_g = filament_elem.get("used_g", "0")
try:
used_grams = float(used_g)
@@ -464,6 +481,7 @@ class PrintScheduler:
"slot_id": int(filament_id),
"type": filament_type,
"color": filament_color,
"tray_info_idx": tray_info_idx,
"used_grams": round(used_grams, 1),
}
)
@@ -500,6 +518,8 @@ class PrintScheduler:
if tray_type:
tray_id = tray.get("id", 0)
tray_color = tray.get("tray_color", "")
# tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
tray_info_idx = tray.get("tray_info_idx", "")
# Normalize color: remove alpha, add hash
color = self._normalize_color(tray_color)
# Calculate global tray ID
@@ -509,6 +529,7 @@ class PrintScheduler:
{
"type": tray_type,
"color": color,
"tray_info_idx": tray_info_idx,
"ams_id": ams_id,
"tray_id": tray_id,
"is_ht": is_ht,
@@ -525,6 +546,7 @@ class PrintScheduler:
{
"type": vt_tray["tray_type"],
"color": color,
"tray_info_idx": vt_tray.get("tray_info_idx", ""),
"ams_id": -1,
"tray_id": 0,
"is_ht": False,
@@ -569,11 +591,17 @@ class PrintScheduler:
def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
"""Match required filaments to loaded filaments and build AMS mapping.
Priority: exact color match > similar color match > type-only match
Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
The tray_info_idx is a filament type identifier stored in the 3MF file when the user
slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
matching among those trays.
Args:
required: List of required filaments with slot_id, type, color
loaded: List of loaded filaments with type, color, global_tray_id
required: List of required filaments with slot_id, type, color, tray_info_idx
loaded: List of loaded filaments with type, color, tray_info_idx, global_tray_id
Returns:
AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
@@ -588,31 +616,64 @@ class PrintScheduler:
for req in required:
req_type = (req.get("type") or "").upper()
req_color = req.get("color", "")
req_tray_info_idx = req.get("tray_info_idx", "")
# Find best match: exact color > similar color > type-only
# Find best match: unique tray_info_idx > exact color > similar color > type-only
idx_match = None
exact_match = None
similar_match = None
type_only_match = None
for f in loaded:
if f["global_tray_id"] in used_tray_ids:
continue
f_type = (f.get("type") or "").upper()
if f_type != req_type:
continue
# Get available trays (not already used)
available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
# Type matches - check color
f_color = f.get("color", "")
if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
exact_match = f
break # Best possible match
elif self._colors_are_similar(f_color, req_color):
if not similar_match:
similar_match = f
elif not type_only_match:
type_only_match = f
# Check if tray_info_idx is unique among available trays
if req_tray_info_idx:
idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
if len(idx_matches) == 1:
# Unique tray_info_idx - use it as definitive match
idx_match = idx_matches[0]
logger.debug(
f"Matched filament slot {req.get('slot_id')} by unique tray_info_idx={req_tray_info_idx} "
f"-> tray {idx_match['global_tray_id']}"
)
elif len(idx_matches) > 1:
# Multiple trays with same tray_info_idx - use color matching among them
logger.debug(
f"Non-unique tray_info_idx={req_tray_info_idx} found in {len(idx_matches)} trays, "
f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
)
# Use color matching within this subset
for f in idx_matches:
f_color = f.get("color", "")
if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
if not exact_match:
exact_match = f
elif self._colors_are_similar(f_color, req_color):
if not similar_match:
similar_match = f
elif not type_only_match:
type_only_match = f
match = exact_match or similar_match or type_only_match
# If no idx_match yet, do standard type/color matching on all available trays
if not idx_match and not exact_match and not similar_match and not type_only_match:
for f in available:
f_type = (f.get("type") or "").upper()
if f_type != req_type:
continue
# Type matches - check color
f_color = f.get("color", "")
if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
if not exact_match:
exact_match = f
elif self._colors_are_similar(f_color, req_color):
if not similar_match:
similar_match = f
elif not type_only_match:
type_only_match = f
match = idx_match or exact_match or similar_match or type_only_match
if match:
used_tray_ids.add(match["global_tray_id"])
comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})
@@ -809,6 +870,28 @@ class PrintScheduler:
logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
await self._power_off_if_needed(db, item)
return
# Safety: Check if this archive was printed recently (within 4 hours)
# This prevents phantom reprints if a queue item got stuck in "pending"
# after its print already started due to a crash/restart
if archive.status == "completed" and archive.completed_at:
completed_at = (
archive.completed_at.replace(tzinfo=None) if archive.completed_at.tzinfo else archive.completed_at
)
time_since_completed = datetime.utcnow() - completed_at
if time_since_completed < timedelta(hours=4):
logger.warning(
f"Queue item {item.id}: Archive {item.archive_id} was already printed "
f"{time_since_completed.total_seconds() / 3600:.1f} hours ago, skipping to prevent duplicate"
)
item.status = "skipped"
item.error_message = (
f"Archive was already printed {time_since_completed.total_seconds() / 3600:.1f} hours ago"
)
item.completed_at = datetime.utcnow()
await db.commit()
return
file_path = settings.base_dir / archive.file_path
filename = archive.filename
@@ -953,6 +1036,17 @@ class PrintScheduler:
except json.JSONDecodeError:
logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
# IMPORTANT: Set status to "printing" BEFORE sending the print command.
# This prevents phantom reprints if the backend crashes/restarts after the
# print command is sent but before the status update is committed.
# If we crash after this commit but before start_print(), the item will be
# in "printing" status without actually printing - but that's safer than
# accidentally reprinting the same file hours later.
item.status = "printing"
item.started_at = datetime.utcnow()
await db.commit()
logger.info(f"Queue item {item.id}: Status set to 'printing', sending print command...")
# Start the print with AMS mapping, plate_id and print options
started = printer_manager.start_print(
item.printer_id,
@@ -968,10 +1062,7 @@ class PrintScheduler:
)
if started:
item.status = "printing"
item.started_at = datetime.utcnow()
await db.commit()
logger.info(f"Queue item {item.id}: Print started - {filename}")
logger.info(f"Queue item {item.id}: Print started successfully - {filename}")
# Get estimated time for notification
estimated_time = None
@@ -1003,6 +1094,7 @@ class PrintScheduler:
except Exception:
pass # Don't fail if MQTT fails
else:
# Print command failed - revert status
item.status = "failed"
item.error_message = "Failed to send print command to printer"
item.completed_at = datetime.utcnow()
+116 -19
View File
@@ -17,7 +17,7 @@ from backend.app.core.config import settings as app_settings
from backend.app.services.virtual_printer.certificate import CertificateService
from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
logger = logging.getLogger(__name__)
@@ -93,9 +93,11 @@ class VirtualPrinterManager:
self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
self._target_printer_ip = "" # For proxy mode
self._target_printer_serial = "" # For proxy mode (real printer's serial)
self._remote_interface_ip = "" # For proxy mode SSDP (LAN B - slicer network)
# Service instances
self._ssdp: VirtualPrinterSSDPServer | None = None
self._ssdp_proxy: SSDPProxy | None = None
self._ftp: VirtualPrinterFTPServer | None = None
self._mqtt: SimpleMQTTServer | None = None
self._proxy: SlicerProxyManager | None = None # For proxy mode
@@ -108,12 +110,54 @@ class VirtualPrinterManager:
self._upload_dir = self._base_dir / "uploads"
self._cert_dir = self._base_dir / "certs"
# Create directories early to avoid permission issues later
# If running in Docker, these need to be on a writable volume
self._ensure_directories()
# Certificate service
self._cert_service = CertificateService(self._cert_dir)
# Track pending uploads for MQTT correlation
self._pending_files: dict[str, Path] = {}
def _ensure_directories(self) -> None:
"""Create and verify virtual printer directories are writable.
Creates all required directories at startup to catch permission
issues early rather than when the user tries to enable features.
"""
dirs_to_create = [
self._base_dir,
self._upload_dir,
self._upload_dir / "cache",
self._cert_dir,
]
logger.info(f"Checking virtual printer directories in {self._base_dir}")
for dir_path in dirs_to_create:
try:
dir_path.mkdir(parents=True, exist_ok=True)
except PermissionError:
logger.error(
f"Cannot create directory {dir_path}: Permission denied. "
f"For Docker: ensure the data volume is writable by the container user. "
f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
)
continue
# Verify directory is writable by attempting to create a test file
test_file = dir_path / ".write_test"
try:
test_file.touch()
test_file.unlink(missing_ok=True)
except PermissionError:
logger.error(
f"Directory {dir_path} exists but is not writable. "
f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
)
def _get_serial_for_model(self, model: str) -> str:
"""Get appropriate serial number for the given model.
@@ -157,6 +201,7 @@ class VirtualPrinterManager:
model: str = "",
target_printer_ip: str = "",
target_printer_serial: str = "",
remote_interface_ip: str = "",
) -> None:
"""Configure and start/stop virtual printer.
@@ -167,6 +212,7 @@ class VirtualPrinterManager:
model: SSDP model code (e.g., 'BL-P001' for X1C)
target_printer_ip: Target printer IP for proxy mode
target_printer_serial: Target printer serial for proxy mode
remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
"""
# Proxy mode has different requirements
if mode == "proxy":
@@ -183,12 +229,14 @@ class VirtualPrinterManager:
mode_changed = mode != self._mode
target_changed = target_printer_ip != self._target_printer_ip
serial_changed = target_printer_serial != self._target_printer_serial
remote_iface_changed = remote_interface_ip != self._remote_interface_ip
old_mode = self._mode
logger.debug(
f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
f"remote_interface_ip={remote_interface_ip}"
)
self._access_code = access_code
@@ -196,8 +244,13 @@ class VirtualPrinterManager:
self._model = new_model
self._target_printer_ip = target_printer_ip
self._target_printer_serial = target_printer_serial
self._remote_interface_ip = remote_interface_ip
needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
needs_restart = (
model_changed
or mode_changed
or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
)
if enabled and not self._enabled:
logger.info("Starting virtual printer (was disabled)")
@@ -247,13 +300,6 @@ class VirtualPrinterManager:
cert_path, key_path = self._cert_service.generate_certificates()
logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
# Initialize SSDP for local discovery using the real printer's serial
self._ssdp = VirtualPrinterSSDPServer(
name=f"{self.PRINTER_NAME} (Proxy)",
serial=proxy_serial,
model=self._model,
)
# Initialize TLS proxy with our certificates
self._proxy = SlicerProxyManager(
target_host=self._target_printer_ip,
@@ -269,21 +315,68 @@ class VirtualPrinterManager:
except Exception as e:
logger.error(f"Virtual printer {name} failed: {e}")
self._tasks = [
asyncio.create_task(
run_with_logging(self._ssdp.start(), "SSDP"),
name="virtual_printer_ssdp",
),
self._tasks = []
# SSDP setup: use SSDPProxy if remote interface is configured
# Local interface is auto-detected from target printer IP
if self._remote_interface_ip:
# Auto-detect local interface based on target printer IP
from backend.app.services.network_utils import find_interface_for_ip
local_iface = find_interface_for_ip(self._target_printer_ip)
if local_iface:
local_interface_ip = local_iface["ip"]
logger.info(
f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
)
self._ssdp_proxy = SSDPProxy(
local_interface_ip=local_interface_ip,
remote_interface_ip=self._remote_interface_ip,
target_printer_ip=self._target_printer_ip,
)
self._tasks.append(
asyncio.create_task(
run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
name="virtual_printer_ssdp_proxy",
)
)
else:
logger.warning(
f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
"falling back to single-interface SSDP"
)
self._start_fallback_ssdp(proxy_serial, run_with_logging)
else:
# Single interface: broadcast SSDP on same network (fallback)
self._start_fallback_ssdp(proxy_serial, run_with_logging)
# Add TLS proxy task
self._tasks.append(
asyncio.create_task(
run_with_logging(self._proxy.start(), "Proxy"),
name="virtual_printer_proxy",
),
]
)
)
logger.info(
f"Virtual printer proxy started: "
f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
)
def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
"""Start single-interface SSDP server as fallback."""
logger.info("SSDP broadcast mode (single interface)")
self._ssdp = VirtualPrinterSSDPServer(
name=f"{self.PRINTER_NAME} (Proxy)",
serial=proxy_serial,
model=self._model,
)
self._tasks.append(
asyncio.create_task(
run_with_logging(self._ssdp.start(), "SSDP"),
name="virtual_printer_ssdp",
)
)
async def _start_server_mode(self) -> None:
@@ -361,6 +454,10 @@ class VirtualPrinterManager:
await self._ssdp.stop()
self._ssdp = None
if self._ssdp_proxy:
await self._ssdp_proxy.stop()
self._ssdp_proxy = None
if self._proxy:
await self._proxy.stop()
self._proxy = None
@@ -2,18 +2,23 @@
Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
announcements so the virtual printer appears as a discoverable Bambu printer.
Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
"""
import asyncio
import logging
import re
import socket
import struct
from datetime import datetime
logger = logging.getLogger(__name__)
# SSDP multicast address - Bambu uses port 2021
SSDP_ADDR = "239.255.255.250"
# SSDP addresses - Bambu uses port 2021
# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
SSDP_MULTICAST_ADDR = "239.255.255.250"
SSDP_BROADCAST_ADDR = "255.255.255.255"
SSDP_PORT = 2021
# Bambu service target
@@ -60,44 +65,49 @@ class VirtualPrinterSSDPServer:
return "127.0.0.1"
def _build_notify_message(self) -> bytes:
"""Build SSDP NOTIFY message for periodic announcements."""
"""Build SSDP NOTIFY message for periodic announcements.
Format matches real Bambu printer SSDP broadcasts observed on the network.
Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
"""
ip = self._get_local_ip()
# Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
# Match exact format of real Bambu printers (captured via tcpdump)
# Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
message = (
"NOTIFY * HTTP/1.1\r\n"
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
"Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
"Cache-Control: max-age=1800\r\n"
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
"Server: UPnP/1.0\r\n"
f"Location: {ip}\r\n"
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
"NTS: ssdp:alive\r\n"
"EXT:\r\n"
f"USN: {self.serial}\r\n"
"Cache-Control: max-age=1800\r\n"
f"DevModel.bambu.com: {self.model}\r\n"
f"DevName.bambu.com: {self.name}\r\n"
"DevSignal.bambu.com: -44\r\n"
"DevConnect.bambu.com: lan\r\n"
"DevBind.bambu.com: free\r\n"
"Devseclink.bambu.com: secure\r\n"
"DevInf.bambu.com: eth0\r\n"
"DevVersion.bambu.com: 01.07.00.00\r\n"
"DevCap.bambu.com: 1\r\n"
"\r\n"
)
return message.encode()
def _build_response_message(self) -> bytes:
"""Build SSDP response message for M-SEARCH requests."""
"""Build SSDP response message for M-SEARCH requests.
Format matches real Bambu printer SSDP responses.
"""
ip = self._get_local_ip()
# Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
# Match format of real Bambu printers
# Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
# Added: Devseclink, DevVersion, DevCap for better compatibility
message = (
"HTTP/1.1 200 OK\r\n"
"Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
"Server: UPnP/1.0\r\n"
f"Location: {ip}\r\n"
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
"EXT:\r\n"
f"USN: {self.serial}\r\n"
"Cache-Control: max-age=1800\r\n"
f"DevModel.bambu.com: {self.model}\r\n"
@@ -106,7 +116,9 @@ class VirtualPrinterSSDPServer:
"DevConnect.bambu.com: lan\r\n"
"DevBind.bambu.com: free\r\n"
"Devseclink.bambu.com: secure\r\n"
"DevInf.bambu.com: eth0\r\n"
"DevVersion.bambu.com: 01.07.00.00\r\n"
"DevCap.bambu.com: 1\r\n"
"\r\n"
)
return message.encode()
@@ -137,7 +149,7 @@ class VirtualPrinterSSDPServer:
self._socket.bind(("", SSDP_PORT))
# Join multicast group
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
# Enable broadcast
@@ -212,13 +224,14 @@ class VirtualPrinterSSDPServer:
self._socket = None
async def _send_notify(self) -> None:
"""Send SSDP NOTIFY message."""
"""Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
if not self._socket:
return
try:
msg = self._build_notify_message()
self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
# Real Bambu printers broadcast to 255.255.255.255, not multicast
self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
logger.debug(f"Sent SSDP NOTIFY for {self.name}")
except Exception as e:
logger.debug(f"Failed to send NOTIFY: {e}")
@@ -230,7 +243,7 @@ class VirtualPrinterSSDPServer:
message = (
"NOTIFY * HTTP/1.1\r\n"
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
"NTS: ssdp:byebye\r\n"
f"USN: {self.serial}\r\n"
@@ -238,7 +251,7 @@ class VirtualPrinterSSDPServer:
)
try:
self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
logger.debug("Sent SSDP byebye")
except Exception:
pass
@@ -268,3 +281,215 @@ class VirtualPrinterSSDPServer:
logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
except Exception as e:
logger.debug(f"Failed to send SSDP response: {e}")
class SSDPProxy:
"""SSDP proxy that re-broadcasts printer discovery from one network to another.
Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
then re-broadcasts them on the remote interface (LAN B) with the Location
header changed to point to Bambuddy's IP on LAN B.
This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
"""
def __init__(
self,
local_interface_ip: str,
remote_interface_ip: str,
target_printer_ip: str,
):
"""Initialize the SSDP proxy.
Args:
local_interface_ip: IP of interface on printer's network (LAN A)
remote_interface_ip: IP of interface on slicer's network (LAN B)
target_printer_ip: IP of the real printer to proxy SSDP for
"""
self.local_interface_ip = local_interface_ip
self.remote_interface_ip = remote_interface_ip
self.target_printer_ip = target_printer_ip
self._running = False
self._local_socket: socket.socket | None = None
self._remote_socket: socket.socket | None = None
self._last_printer_ssdp: bytes | None = None
self._printer_info: dict[str, str] = {}
def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
"""Parse SSDP message into header dict."""
headers = {}
try:
text = data.decode("utf-8", errors="ignore")
for line in text.split("\r\n"):
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
except Exception:
pass
return headers
def _rewrite_ssdp_location(self, data: bytes) -> bytes:
"""Rewrite SSDP message with Bambuddy's remote IP as Location."""
try:
text = data.decode("utf-8", errors="ignore")
original = text
# Replace Location header with our remote interface IP
text = re.sub(
r"(Location:\s*)[\d.]+",
f"\\g<1>{self.remote_interface_ip}",
text,
flags=re.IGNORECASE,
)
if text != original:
logger.debug(f"Rewrote SSDP Location to {self.remote_interface_ip}")
logger.debug(f"Rewritten SSDP packet:\n{text}")
else:
logger.warning(f"SSDP Location rewrite had no effect. Packet:\n{original}")
return text.encode("utf-8")
except Exception as e:
logger.error(f"Failed to rewrite SSDP: {e}")
return data
async def start(self) -> None:
"""Start the SSDP proxy."""
if self._running:
return
logger.info(
f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
f"broadcasting on {self.remote_interface_ip} (LAN B), "
f"proxying printer {self.target_printer_ip}"
)
self._running = True
try:
# Create socket for listening on LAN A (printer network)
# Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
# We filter by source IP in the handler
self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass
self._local_socket.setblocking(False)
# Bind to all interfaces to receive broadcasts
self._local_socket.bind(("", SSDP_PORT))
# Join multicast group on local interface (for multicast SSDP if used)
mreq = struct.pack(
"4s4s",
socket.inet_aton(SSDP_MULTICAST_ADDR),
socket.inet_aton(self.local_interface_ip),
)
self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
# Create socket for broadcasting on LAN B (slicer network)
self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass
self._remote_socket.setblocking(False)
# Bind to remote interface
self._remote_socket.bind((self.remote_interface_ip, 0))
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
logger.info(f"SSDP proxy listening on 0.0.0.0:{SSDP_PORT} (filtering for printer {self.target_printer_ip})")
logger.info(f"SSDP proxy will broadcast on {self.remote_interface_ip}")
# Main loop
last_broadcast = 0.0
broadcast_interval = 30.0 # Re-broadcast every 30 seconds
while self._running:
# Listen for SSDP from printer on LAN A
try:
data, addr = self._local_socket.recvfrom(4096)
await self._handle_local_packet(data, addr)
except BlockingIOError:
pass
except Exception as e:
if self._running:
logger.debug(f"SSDP proxy receive error: {e}")
# Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
# For now, we periodically re-broadcast cached printer SSDP
now = asyncio.get_event_loop().time()
if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
await self._broadcast_to_remote()
last_broadcast = now
await asyncio.sleep(0.1)
except OSError as e:
logger.error(f"SSDP proxy error: {e}")
except asyncio.CancelledError:
logger.debug("SSDP proxy cancelled")
except Exception as e:
logger.error(f"SSDP proxy error: {e}")
finally:
await self._cleanup()
async def stop(self) -> None:
"""Stop the SSDP proxy."""
logger.info("Stopping SSDP proxy")
self._running = False
await self._cleanup()
async def _cleanup(self) -> None:
"""Clean up resources."""
for sock in [self._local_socket, self._remote_socket]:
if sock:
try:
sock.close()
except Exception:
pass
self._local_socket = None
self._remote_socket = None
async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
"""Handle SSDP packet received on local interface (LAN A)."""
sender_ip = addr[0]
# Only process packets from the target printer
if sender_ip != self.target_printer_ip:
return
# Check if it's a NOTIFY message
if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
return
# Check if it's a Bambu printer SSDP
if b"bambulab-com:device:3dprinter" not in data:
return
# Parse and store printer info
headers = self._parse_ssdp_message(data)
if headers:
self._printer_info = headers
logger.debug(f"Received SSDP from printer {sender_ip}: {headers.get('devname.bambu.com', 'unknown')}")
# Store and immediately broadcast
self._last_printer_ssdp = data
await self._broadcast_to_remote()
async def _broadcast_to_remote(self) -> None:
"""Broadcast cached printer SSDP on remote interface (LAN B)."""
if not self._remote_socket or not self._last_printer_ssdp:
return
try:
# Rewrite Location to point to Bambuddy's remote interface
rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
# Calculate broadcast address for remote network
# Use 255.255.255.255 for simplicity (works across subnets)
self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
printer_name = self._printer_info.get("devname.bambu.com", "unknown")
logger.debug(f"Broadcast SSDP for '{printer_name}' on LAN B ({self.remote_interface_ip})")
except Exception as e:
logger.debug(f"Failed to broadcast SSDP on remote: {e}")
@@ -289,8 +289,9 @@ class SlicerProxyManager:
PRINTER_FTP_PORT = 990
PRINTER_MQTT_PORT = 8883
# Local listen ports (same as virtual printer)
LOCAL_FTP_PORT = 9990
# Local listen ports - must match what Bambu Studio expects
# Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
LOCAL_FTP_PORT = 990
LOCAL_MQTT_PORT = 8883
def __init__(
@@ -464,7 +464,8 @@ class TestSlicerProxyManager:
def test_proxy_manager_initializes_ports(self, proxy_manager):
"""Verify proxy manager has correct port constants."""
assert proxy_manager.LOCAL_FTP_PORT == 9990
# FTP proxy uses privileged port 990 to match what Bambu Studio expects
assert proxy_manager.LOCAL_FTP_PORT == 990
assert proxy_manager.LOCAL_MQTT_PORT == 8883
assert proxy_manager.PRINTER_FTP_PORT == 990
assert proxy_manager.PRINTER_MQTT_PORT == 8883
@@ -482,6 +483,125 @@ class TestSlicerProxyManager:
assert status["mqtt_connections"] == 0
class TestSSDPProxy:
"""Tests for SSDPProxy (cross-network SSDP relay)."""
@pytest.fixture
def ssdp_proxy(self):
"""Create an SSDPProxy instance."""
from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
return SSDPProxy(
local_interface_ip="192.168.1.100",
remote_interface_ip="10.0.0.100",
target_printer_ip="192.168.1.50",
)
def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
"""Verify SSDPProxy stores interface IPs correctly."""
assert ssdp_proxy.local_interface_ip == "192.168.1.100"
assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
assert ssdp_proxy.target_printer_ip == "192.168.1.50"
def test_rewrite_ssdp_location(self, ssdp_proxy):
"""Verify SSDP Location header is rewritten to remote interface IP."""
original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
# Location should be changed to remote interface IP
assert b"Location: 10.0.0.100" in rewritten
assert b"Location: 192.168.1.50" not in rewritten
# Other headers should be preserved
assert b"DevName.bambu.com: TestPrinter" in rewritten
def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
"""Verify SSDP Location rewrite is case insensitive."""
original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
assert b"10.0.0.100" in rewritten
def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
"""Verify packet without Location header is returned unchanged."""
original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
# Should be unchanged (no Location header to rewrite)
assert rewritten == original_packet
def test_parse_ssdp_message(self, ssdp_proxy):
"""Verify SSDP message parsing extracts headers."""
packet = (
b"NOTIFY * HTTP/1.1\r\n"
b"Location: 192.168.1.50\r\n"
b"DevName.bambu.com: TestPrinter\r\n"
b"DevModel.bambu.com: BL-P001\r\n"
b"\r\n"
)
headers = ssdp_proxy._parse_ssdp_message(packet)
assert headers["location"] == "192.168.1.50"
assert headers["devname.bambu.com"] == "TestPrinter"
assert headers["devmodel.bambu.com"] == "BL-P001"
class TestVirtualPrinterManagerDirectories:
"""Tests for VirtualPrinterManager directory management."""
def test_ensure_directories_creates_subdirs(self, tmp_path):
"""Verify _ensure_directories creates all required subdirectories."""
from backend.app.services.virtual_printer.manager import VirtualPrinterManager
# Create a manager and manually call _ensure_directories with our tmp path
manager = VirtualPrinterManager()
# Override the paths
manager._base_dir = tmp_path / "virtual_printer"
manager._upload_dir = manager._base_dir / "uploads"
manager._cert_dir = manager._base_dir / "certs"
# Call the method
manager._ensure_directories()
# All directories should be created
assert (tmp_path / "virtual_printer").exists()
assert (tmp_path / "virtual_printer" / "uploads").exists()
assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
assert (tmp_path / "virtual_printer" / "certs").exists()
def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
"""Verify _ensure_directories logs error on permission failure."""
import logging
from unittest.mock import patch
from backend.app.services.virtual_printer.manager import VirtualPrinterManager
# Create manager and override paths
manager = VirtualPrinterManager()
vp_dir = tmp_path / "virtual_printer"
manager._base_dir = vp_dir
manager._upload_dir = vp_dir / "uploads"
manager._cert_dir = vp_dir / "certs"
# Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
original_mkdir = type(vp_dir).mkdir
def mock_mkdir(self, *args, **kwargs):
if "virtual_printer" in str(self):
raise PermissionError("Permission denied")
return original_mkdir(self, *args, **kwargs)
with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
# This should log errors but not raise
manager._ensure_directories()
# Check that error was logged
assert "Permission denied" in caplog.text
class TestVirtualPrinterManagerProxyMode:
"""Tests for VirtualPrinterManager proxy mode."""
@@ -528,7 +648,7 @@ class TestVirtualPrinterManagerProxyMode:
mock_proxy = MagicMock()
mock_proxy.get_status.return_value = {
"running": True,
"ftp_port": 9990,
"ftp_port": 990, # Privileged port for Bambu Studio compatibility
"mqtt_port": 8883,
"ftp_connections": 1,
"mqtt_connections": 2,
@@ -541,5 +661,46 @@ class TestVirtualPrinterManagerProxyMode:
assert status["mode"] == "proxy"
assert status["target_printer_ip"] == "192.168.1.100"
assert "proxy" in status
assert status["proxy"]["ftp_port"] == 990 # Privileged port for Bambu Studio compatibility
assert status["proxy"]["mqtt_port"] == 8883
assert status["proxy"]["ftp_connections"] == 1
assert status["proxy"]["mqtt_connections"] == 2
@pytest.mark.asyncio
async def test_configure_proxy_mode_with_remote_interface(self, manager):
"""Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
manager._start = AsyncMock()
await manager.configure(
enabled=True,
mode="proxy",
target_printer_ip="192.168.1.100",
remote_interface_ip="10.0.0.50",
)
assert manager._mode == "proxy"
assert manager._target_printer_ip == "192.168.1.100"
assert manager._remote_interface_ip == "10.0.0.50"
@pytest.mark.asyncio
async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
"""Verify changing remote_interface_ip restarts services."""
# Simulate running state
manager._enabled = True
manager._mode = "proxy"
manager._target_printer_ip = "192.168.1.100"
manager._remote_interface_ip = "10.0.0.50"
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
manager._stop = AsyncMock()
manager._start = AsyncMock()
await manager.configure(
enabled=True,
mode="proxy",
target_printer_ip="192.168.1.100",
remote_interface_ip="10.0.0.99", # Changed
)
# Should have stopped and started
manager._stop.assert_called_once()
manager._start.assert_called_once()
@@ -265,3 +265,205 @@ class TestMatchFilamentsToSlots:
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [254]
def test_match_by_tray_info_idx_priority(self, scheduler):
"""tray_info_idx match should have highest priority over color match."""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}]
loaded = [
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 0,
"tray_info_idx": "GFB00",
}, # Same color, different spool
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 1,
"tray_info_idx": "GFA00",
}, # Same color, exact spool
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 2,
"tray_info_idx": "GFC00",
}, # Same color, different spool
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [1] # Should pick tray 1 (exact tray_info_idx match)
def test_match_by_tray_info_idx_with_different_colors(self, scheduler):
"""tray_info_idx match should work even if colors differ slightly."""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "P4d64437"}]
loaded = [
{"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": ""}, # No idx
{
"type": "PLA",
"color": "#000010",
"global_tray_id": 3,
"tray_info_idx": "P4d64437",
}, # Exact spool (slightly different color reported)
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [3] # Should pick tray 3 (exact tray_info_idx match)
def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler):
"""Should fall back to color matching when tray_info_idx is empty."""
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
loaded = [
{"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "tray_info_idx": "GFB00"}, # Color match
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [1] # Should pick tray 1 (color match)
def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler):
"""Should fall back to color when tray_info_idx doesn't match any loaded spool."""
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "OLD_SPOOL"}]
loaded = [
{
"type": "PLA",
"color": "#FF0000",
"global_tray_id": 0,
"tray_info_idx": "NEW_SPOOL",
}, # Different idx but same color
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [0] # Should fall back to color match
def test_match_multiple_same_color_with_tray_info_idx(self, scheduler):
"""Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario)."""
# This is the exact scenario from issue #245 - 3 black PLA spools
required = [
{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA03"}, # Wants tray 3
]
loaded = [
{"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"}, # Tray 0
{"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"}, # Tray 1
{"type": "PLA", "color": "#000000", "global_tray_id": 2, "tray_info_idx": "GFA02"}, # Tray 2
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 3,
"tray_info_idx": "GFA03",
}, # Tray 3 - the one we want
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [3] # Should pick tray 3, not tray 0
def test_match_tray_info_idx_not_reused(self, scheduler):
"""tray_info_idx matched trays should not be reused for other slots."""
required = [
{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
{"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01"},
]
loaded = [
{"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [0, 1] # Each slot gets its specific tray
def test_match_non_unique_tray_info_idx_uses_color(self, scheduler):
"""Non-unique tray_info_idx should fall back to color matching.
This is the scenario where multiple trays have the same tray_info_idx
(e.g., two spools of generic PLA both have GFA00). The color should
be used as tiebreaker instead of just picking the first match.
"""
# User sliced with green PLA (tray_info_idx=GFA00)
# Two trays have GFA00: tray 3 (white) and tray 4 (green)
# Should pick tray 4 because the color matches
required = [
{"slot_id": 2, "type": "PLA", "color": "#00FF00", "tray_info_idx": "GFA00"}, # Green PLA
]
loaded = [
{"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"}, # White PLA
{"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "tray_info_idx": "GFA00"}, # Green PLA
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [-1, 4] # Should pick tray 4 (color match), not tray 3 (first match)
def test_match_non_unique_tray_info_idx_same_color(self, scheduler):
"""Non-unique tray_info_idx with identical colors picks first match.
When multiple trays have the same tray_info_idx AND same color,
there's no way to differentiate, so first match is used.
"""
required = [
{"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA00"},
]
loaded = [
{"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#FFFFFF", "global_tray_id": 4, "tray_info_idx": "GFA00"},
]
result = scheduler._match_filaments_to_slots(required, loaded)
# Both have same color, so first is used
assert result == [-1, 3]
class TestBuildLoadedFilamentsTrayInfoIdx:
"""Test tray_info_idx extraction in _build_loaded_filaments."""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler):
"""Should extract tray_info_idx from AMS trays."""
class MockStatus:
raw_data = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA00"},
{"id": 1, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA01"},
],
}
]
}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 2
assert result[0]["tray_info_idx"] == "GFA00"
assert result[1]["tray_info_idx"] == "GFA01"
def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler):
"""Missing tray_info_idx should default to empty string."""
class MockStatus:
raw_data = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}, # No tray_info_idx
],
}
]
}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1
assert result[0]["tray_info_idx"] == ""
def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler):
"""Should extract tray_info_idx from external spool."""
class MockStatus:
raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1
assert result[0]["tray_info_idx"] == "P4d64437"
assert result[0]["is_external"] is True
+25 -2
View File
@@ -95,11 +95,11 @@ describe('API Client Auth Header', () => {
expect(capturedHeaders!.get('Authorization')).toBeNull();
});
it('clears token on 401 Unauthorized response', async () => {
it('clears token on 401 with invalid token message', async () => {
server.use(
http.get('/api/v1/settings/spoolman', () => {
return HttpResponse.json(
{ detail: 'Not authenticated' },
{ detail: 'Could not validate credentials' },
{ status: 401 }
);
})
@@ -117,6 +117,29 @@ describe('API Client Auth Header', () => {
expect(getAuthToken()).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
});
it('does not clear token on 401 with generic auth error', async () => {
server.use(
http.get('/api/v1/settings/spoolman', () => {
return HttpResponse.json(
{ detail: 'Authentication required' },
{ status: 401 }
);
})
);
setAuthToken('valid-token');
expect(getAuthToken()).toBe('valid-token');
try {
await api.getSpoolmanSettings();
} catch {
// Expected to throw
}
// Token should NOT be cleared for generic auth errors (might be timing issue)
expect(getAuthToken()).toBe('valid-token');
});
});
describe('FormData requests include auth header', () => {
@@ -43,6 +43,7 @@ const createMockSettings = (overrides = {}) => ({
mode: 'immediate' as const,
model: '3DPrinter-X1-Carbon',
target_printer_id: null as number | null,
remote_interface_ip: null as string | null,
status: {
enabled: false,
running: false,
@@ -515,7 +516,7 @@ describe('VirtualPrinterSettings', () => {
proxy: {
running: true,
target_host: '192.168.1.100',
ftp_port: 9990,
ftp_port: 990, // Privileged port for Bambu Studio compatibility
mqtt_port: 8883,
ftp_connections: 1,
mqtt_connections: 2,
@@ -0,0 +1,349 @@
/**
* Tests for the useFilamentMapping hook and helper functions.
*
* Tests the tray_info_idx matching logic that ensures the exact spool
* selected during slicing is used when multiple trays have identical filament.
*/
import { describe, it, expect } from 'vitest';
import {
buildLoadedFilaments,
computeAmsMapping,
} from '../../hooks/useFilamentMapping';
import type { PrinterStatus } from '../../api/client';
// Helper to create a minimal printer status with AMS data
function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
return {
ams,
vt_tray,
} as PrinterStatus;
}
describe('buildLoadedFilaments', () => {
it('returns empty array for undefined status', () => {
const result = buildLoadedFilaments(undefined);
expect(result).toEqual([]);
});
it('extracts filaments from AMS units', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
],
},
]);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
type: 'PLA',
color: '#FF0000',
amsId: 0,
trayId: 0,
globalTrayId: 0,
trayInfoIdx: 'GFA00',
});
expect(result[1]).toMatchObject({
type: 'PETG',
color: '#00FF00',
globalTrayId: 1,
trayInfoIdx: 'GFA01',
});
});
it('includes tray_info_idx from AMS trays', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
],
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].trayInfoIdx).toBe('P4d64437');
});
it('handles missing tray_info_idx', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No tray_info_idx
],
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].trayInfoIdx).toBe('');
});
it('extracts external spool with tray_info_idx', () => {
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: 'TPU',
isExternal: true,
globalTrayId: 254,
trayInfoIdx: 'EXT001',
});
});
it('skips empty trays', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: '', tray_color: '' }, // Empty tray
{ id: 2 }, // No tray_type
],
},
]);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('PLA');
});
it('marks AMS-HT units correctly', () => {
const status = createPrinterStatus([
{
id: 128, // AMS-HT typically has high ID
tray: [
{ id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
], // Single tray = AMS-HT
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].isHt).toBe(true);
expect(result[0].globalTrayId).toBe(512); // 128 * 4 + 0
});
});
describe('computeAmsMapping', () => {
it('returns undefined for empty filament requirements', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
},
]);
expect(computeAmsMapping(undefined, status)).toBeUndefined();
expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
});
it('returns undefined when no filaments loaded', () => {
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
});
it('matches by tray_info_idx with highest priority', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([1]); // Should pick tray 1, not tray 0
});
it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
// This is the exact scenario from issue #245 - multiple black PLA spools
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
{ id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([3]); // Should pick tray 3, not tray 0
});
it('falls back to color match when tray_info_idx is empty', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([1]);
});
it('falls back to color match when tray_info_idx does not match', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0]); // Falls back to color match
});
it('matches by type only when color differs', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0]); // Type-only match
});
it('returns -1 for unmatched slots', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([-1]);
});
it('avoids duplicate tray assignment', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched
});
it('handles multi-slot mapping with tray_info_idx', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
{ slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0, 2]); // Each slot gets its specific tray
});
it('handles external spool matching', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
],
};
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([254]); // External spool global ID
});
});
+8 -2
View File
@@ -383,8 +383,14 @@ export const handlers = [
// Archives
// ========================================================================
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json([]);
http.get('/api/v1/archives/:id/plates', ({ params }) => {
const archiveId = Number(params.id);
return HttpResponse.json({
archive_id: Number.isFinite(archiveId) ? archiveId : 0,
filename: 'sample.3mf',
plates: [],
is_multi_plate: false,
});
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
@@ -84,8 +84,14 @@ describe('ArchivesPage', () => {
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(['test', 'calibration', 'functional']);
}),
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json([]);
http.get('/api/v1/archives/:id/plates', ({ params }) => {
const archiveId = Number(params.id);
return HttpResponse.json({
archive_id: Number.isFinite(archiveId) ? archiveId : 0,
filename: 'sample.3mf',
plates: [],
is_multi_plate: false,
});
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json([]);
+61 -51
View File
@@ -1,3 +1,5 @@
import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
const API_BASE = '/api/v1';
// Auth token storage
@@ -37,16 +39,27 @@ async function request<T>(
});
if (!response.ok) {
// Handle 401 Unauthorized - clear token and redirect to login
if (response.status === 401) {
setAuthToken(null);
// Don't throw here - let the auth context handle redirect
}
const error = await response.json().catch(() => ({}));
const detail = error.detail;
const message = typeof detail === 'string'
? detail
: (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
// Handle 401 Unauthorized - only clear token if it's actually invalid
// Don't clear on "Authentication required" which might be a timing issue
if (response.status === 401) {
const invalidTokenMessages = [
'Could not validate credentials',
'Token has expired',
'User not found or inactive',
'Invalid API key',
'API key has expired',
];
if (invalidTokenMessages.some(m => message.includes(m))) {
setAuthToken(null);
}
}
throw new Error(message);
}
@@ -2063,6 +2076,33 @@ export const api = {
}>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
getPrinterFileDownloadUrl: (printerId: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
getPrinterFileGcodeUrl: (printerId: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
getPrinterFilePlates: (printerId: number, path: string) =>
request<{
printer_id: number;
path: string;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) {
@@ -2557,27 +2597,7 @@ export const api = {
getArchiveForSlicer: (id: number, filename: string) =>
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
getArchivePlates: (archiveId: number) =>
request<{
archive_id: number;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/archives/${archiveId}/plates`),
request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
request<{
archive_id: number;
@@ -2713,6 +2733,8 @@ export const api = {
},
checkFfmpeg: () =>
request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
getNetworkInterfaces: () =>
request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
// Cloud
getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -2794,13 +2816,11 @@ export const api = {
// Tasmota Discovery (auto-detects network)
startTasmotaScan: () =>
fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
.then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
getTasmotaScanStatus: () =>
request<TasmotaScanStatus>('/smart-plugs/discover/status'),
stopTasmotaScan: () =>
fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
.then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
getDiscoveredTasmotaDevices: () =>
request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
@@ -3565,27 +3585,7 @@ export const api = {
}
),
getLibraryFilePlates: (fileId: number) =>
request<{
file_id: number;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/library/files/${fileId}/plates`),
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
request<{
file_id: number;
@@ -3986,9 +3986,17 @@ export interface VirtualPrinterSettings {
mode: VirtualPrinterMode;
model: string;
target_printer_id: number | null; // For proxy mode
remote_interface_ip: string | null; // For SSDP proxy across networks
status: VirtualPrinterStatus;
}
export interface NetworkInterface {
name: string;
ip: string;
netmask: string;
subnet: string;
}
export interface VirtualPrinterModels {
models: Record<string, string>; // SSDP code -> display name
default: string;
@@ -4018,6 +4026,7 @@ export const virtualPrinterApi = {
mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
model?: string;
target_printer_id?: number;
remote_interface_ip?: string;
}) => {
const params = new URLSearchParams();
if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4025,6 +4034,7 @@ export const virtualPrinterApi = {
if (data.mode !== undefined) params.set('mode', data.mode);
if (data.model !== undefined) params.set('model', data.model);
if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
method: 'PUT',
@@ -87,10 +87,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
if (isVisible && triggerRef.current && cardRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const cardHeight = cardRef.current.offsetHeight;
const spaceAbove = triggerRect.top;
// Account for fixed header (56px) - space above should exclude header area
const headerHeight = 56;
const spaceAbove = triggerRect.top - headerHeight;
const spaceBelow = window.innerHeight - triggerRect.bottom;
// Prefer top, but flip to bottom if not enough space
// Prefer top, but flip to bottom if not enough space (accounting for header)
if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
setPosition('bottom');
} else {
+2 -2
View File
@@ -143,7 +143,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
months.push({
month: monthStr,
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0)),
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
});
@@ -153,7 +153,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
}, [archives]);
const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0);
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
+236 -3
View File
@@ -20,10 +20,14 @@ import {
CheckSquare,
Square,
MinusSquare,
Box,
} from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import type { PlateMetadata } from '../types/plates';
import { useToast } from '../contexts/ToastContext';
interface FileManagerModalProps {
@@ -32,6 +36,205 @@ interface FileManagerModalProps {
onClose: () => void;
}
type PrinterViewerTab = '3d' | 'gcode';
interface PrinterFileViewerModalProps {
printerId: number;
filePath: string;
filename: string;
onClose: () => void;
}
function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) {
const [activeTab, setActiveTab] = useState<PrinterViewerTab | null>(null);
const [plates, setPlates] = useState<PlateMetadata[]>([]);
const [platesLoading, setPlatesLoading] = useState(false);
const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
const ext = filename.toLowerCase().split('.').pop() || '';
const hasModel = ext === '3mf' || ext === 'stl';
const hasGcode = ext === 'gcode' || ext === '3mf';
useEffect(() => {
setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
}, [hasModel, hasGcode]);
useEffect(() => {
setPlates([]);
setSelectedPlateId(null);
if (!hasModel) return;
setPlatesLoading(true);
api.getPrinterFilePlates(printerId, filePath)
.then((data) => setPlates(data.plates || []))
.catch(() => setPlates([]))
.finally(() => setPlatesLoading(false));
}, [filePath, hasModel, printerId]);
const hasMultiplePlates = plates.length > 1;
const selectedPlate = selectedPlateId == null
? null
: plates.find((plate) => plate.index === selectedPlateId) ?? null;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-6" onClick={onClose}>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{filename}</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex border-b border-bambu-dark-tertiary">
<button
onClick={() => hasModel && setActiveTab('3d')}
disabled={!hasModel}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === '3d'
? 'text-bambu-green border-b-2 border-bambu-green'
: hasModel
? 'text-bambu-gray hover:text-white'
: 'text-bambu-gray/30 cursor-not-allowed'
}`}
>
<Box className="w-4 h-4" />
3D Model
{!hasModel && <span className="text-xs">(not available)</span>}
</button>
<button
onClick={() => hasGcode && setActiveTab('gcode')}
disabled={!hasGcode}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === 'gcode'
? 'text-bambu-green border-b-2 border-bambu-green'
: hasGcode
? 'text-bambu-gray hover:text-white'
: 'text-bambu-gray/30 cursor-not-allowed'
}`}
>
<FileText className="w-4 h-4" />
G-code Preview
{!hasGcode && <span className="text-xs">(not sliced)</span>}
</button>
</div>
<div className="flex-1 overflow-hidden p-4">
{activeTab === '3d' && hasModel ? (
<div className="w-full h-full flex flex-col gap-3">
{hasMultiplePlates && (
<div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
<Box className="w-4 h-4" />
Plates
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSelectedPlateId(null)}
className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
selectedPlateId == null
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
}`}
>
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Box className="w-5 h-5 text-bambu-gray" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">All Plates</p>
<p className="text-xs text-bambu-gray truncate">
{plates.length} plate{plates.length !== 1 ? 's' : ''}
</p>
</div>
{selectedPlateId == null && (
<CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
{plates.map((plate) => (
<button
key={plate.index}
type="button"
onClick={() => setSelectedPlateId(plate.index)}
className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
selectedPlateId === plate.index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
}`}
>
{plate.has_thumbnail ? (
<img
src={api.getPrinterFilePlateThumbnail(printerId, plate.index, filePath)}
alt={`Plate ${plate.index}`}
className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
/>
) : (
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Box className="w-5 h-5 text-bambu-gray" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">
{plate.name || `Plate ${plate.index}`}
</p>
<p className="text-xs text-bambu-gray truncate">
{plate.objects.length > 0
? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
</p>
</div>
{selectedPlateId === plate.index && (
<CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
))}
</div>
{selectedPlate && (
<div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
<span>Plate {selectedPlate.index}</span>
{selectedPlate.print_time_seconds != null && (
<span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
)}
{selectedPlate.filament_used_grams != null && (
<span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
)}
{selectedPlate.filaments.length > 0 && (
<span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
)}
</div>
)}
</div>
)}
<div className="flex-1">
<ModelViewer
url={api.getPrinterFileDownloadUrl(printerId, filePath)}
fileType={ext}
selectedPlateId={selectedPlateId}
className="w-full h-full"
/>
</div>
</div>
) : activeTab === 'gcode' && hasGcode ? (
<GcodeViewer
gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}
className="w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
No preview available for this file
</div>
)}
</div>
</div>
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -92,6 +295,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('name-asc');
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null);
// Close on Escape key
useEffect(() => {
@@ -253,6 +457,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
title="Close file manager"
aria-label="Close file manager"
>
<X className="w-5 h-5" />
</button>
@@ -294,6 +500,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer"
title="Sort files"
aria-label="Sort files"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
@@ -318,6 +526,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
onClick={navigateUp}
disabled={currentPath === '/'}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
title="Go to parent folder"
aria-label="Go to parent folder"
>
<ChevronLeft className="w-4 h-4" />
</button>
@@ -408,9 +618,23 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
/>
<span className="flex-1 text-white truncate">{file.name}</span>
{!file.is_directory && (
<span className="text-sm text-bambu-gray">
{formatFileSize(file.size)}
</span>
<div className="flex items-center gap-3">
<span className="text-sm text-bambu-gray">
{formatFileSize(file.size)}
</span>
{(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && (
<button
onClick={(e) => {
e.stopPropagation();
setViewerFile({ path: file.path, name: file.name });
}}
className="p-1 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green"
title="3D View"
>
<Box className="w-4 h-4" />
</button>
)}
</div>
)}
{file.is_directory && (
<ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
@@ -508,6 +732,15 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
onCancel={() => setFilesToDelete([])}
/>
)}
{viewerFile && (
<PrinterFileViewerModal
printerId={printerId}
filePath={viewerFile.path}
filename={viewerFile.name}
onClose={() => setViewerFile(null)}
/>
)}
</div>
);
}
+399 -144
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import JSZip from 'jszip';
import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from './Button';
@@ -14,8 +15,10 @@ interface BuildVolume {
interface ModelViewerProps {
url: string;
fileType?: string;
buildVolume?: BuildVolume;
filamentColors?: string[];
selectedPlateId?: number | null;
className?: string;
}
@@ -29,12 +32,20 @@ interface ObjectData {
id: string;
meshes: MeshData[];
defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
plateId?: number | null;
}
interface BuildItem {
objectId: string;
transform: THREE.Matrix4;
extruder?: number; // Can override object's extruder
plateId?: number | null;
}
interface Parsed3MFData {
objects: Map<string, ObjectData>;
buildItems: BuildItem[];
plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
}
// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
@@ -101,10 +112,34 @@ async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Pro
return meshes;
}
async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
const zip = await JSZip.loadAsync(arrayBuffer);
function parsePlateIdFromAttributes(element: Element): number | null {
const plateAttribute = Array.from(element.attributes).find((attr) => {
const name = attr.name.toLowerCase();
return (
name === 'plate_id' ||
name === 'plater_id' ||
name === 'plateid' ||
name === 'platerid' ||
name.endsWith(':plate_id') ||
name.endsWith(':plater_id')
);
});
if (!plateAttribute?.value) return null;
const parsed = Number.parseInt(plateAttribute.value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
let zip: JSZip;
try {
zip = await JSZip.loadAsync(arrayBuffer);
} catch {
throw new Error('Unsupported file format');
}
const objects = new Map<string, ObjectData>();
const buildItems: BuildItem[] = [];
const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
const parser = new DOMParser();
// Helper to load and parse a model file from the zip
@@ -121,6 +156,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
// Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
const extruderMapById = new Map<string, number>();
const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
const objectNameById = new Map<string, string>();
const plateAssignmentsByObjectId = new Map<string, number>();
const modelSettingsFile = zip.files['Metadata/model_settings.config'];
if (modelSettingsFile) {
try {
@@ -132,7 +169,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
const objectId = objEl.getAttribute('id');
if (!objectId) continue;
// Find object-level extruder
// Find object-level extruder + name
const directMetadata = Array.from(objEl.children).filter(
(el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
);
@@ -143,6 +180,14 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
}
}
const nameMetadata = Array.from(objEl.children).find(
(el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'
);
const objectName = nameMetadata?.getAttribute('value');
if (objectName) {
objectNameById.set(objectId, objectName);
}
// Find part-level extruders
const partElements = objEl.getElementsByTagName('part');
for (let j = 0; j < partElements.length; j++) {
@@ -162,11 +207,78 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
}
}
}
// Parse plate -> object assignments
const plateElements = doc.getElementsByTagName('plate');
for (let i = 0; i < plateElements.length; i++) {
const plateEl = plateElements[i];
let plateId: number | null = null;
const metadataElements = plateEl.getElementsByTagName('metadata');
for (let j = 0; j < metadataElements.length; j++) {
const metaEl = metadataElements[j];
const key = metaEl.getAttribute('key');
if (key === 'plater_id' || key === 'plate_id') {
const value = metaEl.getAttribute('value');
if (value) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
plateId = parsed;
}
}
}
}
if (plateId == null) continue;
const modelInstances = plateEl.getElementsByTagName('model_instance');
for (let j = 0; j < modelInstances.length; j++) {
const instanceEl = modelInstances[j];
const instanceMetadata = instanceEl.getElementsByTagName('metadata');
for (let k = 0; k < instanceMetadata.length; k++) {
const metaEl = instanceMetadata[k];
if (metaEl.getAttribute('key') === 'object_id') {
const value = metaEl.getAttribute('value');
if (value) {
plateAssignmentsByObjectId.set(value, plateId);
}
}
}
}
}
} catch {
// Silently ignore model_settings.config parsing errors
}
}
// Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
const plateAssignmentsByName = new Map<string, number>();
const plateJsonNames = Object.keys(zip.files).filter(
(name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
);
for (const name of plateJsonNames) {
const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
if (!match) continue;
const plateIndex = Number.parseInt(match[1], 10);
if (!Number.isFinite(plateIndex)) continue;
try {
const payload = await zip.files[name].async('string');
const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
const objectsList = json.bbox_objects ?? [];
for (const entry of objectsList) {
if (entry?.name) {
plateAssignmentsByName.set(entry.name, plateIndex);
}
}
if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
const [minX, minY, maxX, maxY] = json.bbox_all;
if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
}
}
} catch {
// Ignore plate json parsing errors
}
}
// Find the main 3D model file
const mainModelPath = Object.keys(zip.files).find(
(name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
@@ -184,11 +296,11 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
}
}
}
return { objects, buildItems };
return { objects, buildItems, plateBounds };
}
const mainDoc = await loadModelFile(mainModelPath);
if (!mainDoc) return { objects, buildItems };
if (!mainDoc) return { objects, buildItems, plateBounds };
// Parse objects - Bambu Studio uses components to reference external files
const objectElements = mainDoc.getElementsByTagName('object');
@@ -197,6 +309,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
const objectId = objEl.getAttribute('id');
if (!objectId) continue;
const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
// Get default extruder from model_settings.config map, falling back to attribute or default
let defaultExtruder = extruderMapById.get(objectId) ?? -1;
if (defaultExtruder < 0) {
@@ -279,7 +393,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
}
if (meshes.length > 0) {
objects.set(objectId, { id: objectId, meshes, defaultExtruder });
objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
}
}
@@ -293,11 +407,15 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
if (!objectId) continue;
const transform = parseTransform(itemEl.getAttribute('transform'));
buildItems.push({ objectId, transform });
const itemPlateId = parsePlateIdFromAttributes(itemEl);
const objectPlateId = objects.get(objectId)?.plateId ?? null;
const objectName = objectNameById.get(objectId);
const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
}
}
return { objects, buildItems };
return { objects, buildItems, plateBounds };
}
function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
@@ -321,14 +439,145 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
return geometry;
}
export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
function disposeGroup(group: THREE.Group) {
group.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
for (const material of child.material) {
material.dispose();
}
} else {
child.material.dispose();
}
}
});
}
function buildModelGroup(
parsedData: Parsed3MFData,
selectedPlateId: number | null,
filamentColors?: string[],
): THREE.Group {
const { objects, buildItems } = parsedData;
const group = new THREE.Group();
// Create materials for each extruder color
const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
const defaultColor = '#00ae42';
const colorStr = filamentColors?.[extruder] || defaultColor;
// Convert hex color string to THREE.js color
const color = new THREE.Color(colorStr);
return new THREE.MeshPhongMaterial({
color,
shininess: 30,
flatShading: false,
});
};
// Group geometries by extruder index (using per-mesh extruder)
const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
? buildItems
: buildItems.filter((item) => item.plateId === selectedPlateId);
const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
// If we have build items, use them for positioning
if (activeBuildItems.length > 0) {
for (const item of activeBuildItems) {
const objectData = objects.get(item.objectId);
if (!objectData) continue;
for (const meshData of objectData.meshes) {
// Use mesh's extruder, or item override, or object default
const extruder = item.extruder ?? meshData.extruder;
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
const transformedVertices: number[] = [];
for (let k = 0; k < meshData.vertices.length; k += 3) {
const v = new THREE.Vector3(
meshData.vertices[k],
meshData.vertices[k + 1],
meshData.vertices[k + 2]
);
v.applyMatrix4(item.transform);
transformedVertices.push(v.x, v.y, v.z);
}
// Now create geometry with coordinate conversion
const geometry = createGeometryFromMesh({
vertices: transformedVertices,
triangles: meshData.triangles,
extruder: extruder,
});
if (!geometriesByExtruder.has(extruder)) {
geometriesByExtruder.set(extruder, []);
}
geometriesByExtruder.get(extruder)!.push(geometry);
}
}
} else {
// Fallback: just add all objects without transforms
for (const objectData of objects.values()) {
for (const meshData of objectData.meshes) {
// Use per-mesh extruder
const extruder = meshData.extruder;
const geometry = createGeometryFromMesh(meshData);
if (!geometriesByExtruder.has(extruder)) {
geometriesByExtruder.set(extruder, []);
}
geometriesByExtruder.get(extruder)!.push(geometry);
}
}
}
// Create meshes for each extruder group
for (const [extruder, geometries] of geometriesByExtruder) {
if (geometries.length === 0) continue;
const mergedGeometry = geometries.length === 1
? geometries[0]
: mergeGeometries(geometries, false);
if (mergedGeometry) {
const material = getMaterial(extruder);
const mesh = new THREE.Mesh(mergedGeometry, material);
group.add(mesh);
}
// Dispose individual geometries if merged
if (geometries.length > 1) {
for (const geom of geometries) {
geom.dispose();
}
}
}
return group;
}
export function ModelViewer({
url,
fileType,
buildVolume = { x: 256, y: 256, z: 256 },
filamentColors,
selectedPlateId = null,
className = '',
}: ModelViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const modelGroupRef = useRef<THREE.Group | null>(null);
const plateRef = useRef<THREE.Mesh | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
useEffect(() => {
if (!containerRef.current) return;
@@ -377,6 +626,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
const gridDivisions = Math.ceil(gridSize / 16);
const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
scene.add(gridHelper);
gridRef.current = gridHelper;
// Build plate indicator
const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
@@ -390,6 +640,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
plate.rotation.x = -Math.PI / 2;
plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
scene.add(plate);
plateRef.current = plate;
// Animation loop - keep it simple for reliability
let animationId: number;
@@ -400,143 +651,51 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
};
animate();
// Load 3MF
fetch(url)
.then((res) => {
if (!res.ok) throw new Error('Failed to load file');
return res.arrayBuffer();
})
.then(parse3MF)
.then(({ objects, buildItems }) => {
if (objects.size === 0) {
throw new Error('No meshes found in 3MF file');
}
setLoading(true);
setError(null);
setParsedData(null);
setStlGeometry(null);
// Create materials for each extruder color
const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
const defaultColor = '#00ae42';
const colorStr = filamentColors?.[extruder] || defaultColor;
// Convert hex color string to THREE.js color
const color = new THREE.Color(colorStr);
return new THREE.MeshPhongMaterial({
color,
shininess: 30,
flatShading: false,
});
};
const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
const group = new THREE.Group();
// Group geometries by extruder index (using per-mesh extruder)
const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
// If we have build items, use them for positioning
if (buildItems.length > 0) {
for (const item of buildItems) {
const objectData = objects.get(item.objectId);
if (!objectData) continue;
for (const meshData of objectData.meshes) {
// Use mesh's extruder, or item override, or object default
const extruder = item.extruder ?? meshData.extruder;
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
const transformedVertices: number[] = [];
for (let k = 0; k < meshData.vertices.length; k += 3) {
const v = new THREE.Vector3(
meshData.vertices[k],
meshData.vertices[k + 1],
meshData.vertices[k + 2]
);
v.applyMatrix4(item.transform);
transformedVertices.push(v.x, v.y, v.z);
}
// Now create geometry with coordinate conversion
const geometry = createGeometryFromMesh({
vertices: transformedVertices,
triangles: meshData.triangles,
extruder: extruder,
});
if (!geometriesByExtruder.has(extruder)) {
geometriesByExtruder.set(extruder, []);
}
geometriesByExtruder.get(extruder)!.push(geometry);
}
if (normalizedType === 'stl') {
fetch(url)
.then((res) => {
if (!res.ok) throw new Error('Failed to load file');
return res.arrayBuffer();
})
.then((buffer) => {
const loader = new STLLoader();
const geometry = loader.parse(buffer);
geometry.computeVertexNormals();
geometry.rotateX(-Math.PI / 2);
setStlGeometry(geometry);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
} else if (normalizedType === '3mf') {
fetch(url)
.then((res) => {
if (!res.ok) throw new Error('Failed to load file');
return res.arrayBuffer();
})
.then(parse3MF)
.then((parsed) => {
if (parsed.objects.size === 0) {
throw new Error('No meshes found in 3MF file');
}
} else {
// Fallback: just add all objects without transforms
for (const objectData of objects.values()) {
for (const meshData of objectData.meshes) {
// Use per-mesh extruder
const extruder = meshData.extruder;
const geometry = createGeometryFromMesh(meshData);
if (!geometriesByExtruder.has(extruder)) {
geometriesByExtruder.set(extruder, []);
}
geometriesByExtruder.get(extruder)!.push(geometry);
}
}
}
// Create meshes for each extruder group
for (const [extruder, geometries] of geometriesByExtruder) {
if (geometries.length === 0) continue;
const mergedGeometry = geometries.length === 1
? geometries[0]
: mergeGeometries(geometries, false);
if (mergedGeometry) {
const material = getMaterial(extruder);
const mesh = new THREE.Mesh(mergedGeometry, material);
group.add(mesh);
}
// Dispose individual geometries if merged
if (geometries.length > 1) {
for (const geom of geometries) {
geom.dispose();
}
}
}
// Get bounding box to position model
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
// Always place models on the build plate (Y=0)
group.position.y = -box.min.y;
// For models without build transforms, also center X/Z
if (buildItems.length === 0) {
group.position.x = -center.x;
group.position.z = -center.z;
}
scene.add(group);
// Recalculate bounding box after positioning
const finalBox = new THREE.Box3().setFromObject(group);
const finalCenter = finalBox.getCenter(new THREE.Vector3());
const finalSize = finalBox.getSize(new THREE.Vector3());
// Adjust camera to fit model
const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
const cameraDistance = maxDim * 1.8;
camera.position.set(
finalCenter.x + cameraDistance * 0.7,
finalCenter.y + cameraDistance * 0.5,
finalCenter.z + cameraDistance * 0.7
);
controls.target.copy(finalCenter);
controls.update();
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
setParsedData(parsed);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
} else {
setError('Unsupported file format');
setLoading(false);
}
// Handle resize
const handleResize = () => {
@@ -555,8 +714,104 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
controls.dispose();
renderer.dispose();
container.removeChild(renderer.domElement);
modelGroupRef.current = null;
plateRef.current = null;
gridRef.current = null;
};
}, [url, buildVolume, filamentColors]);
}, [url, buildVolume, fileType]);
useEffect(() => {
if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
if (!parsedData && !stlGeometry) return;
if (modelGroupRef.current) {
sceneRef.current.remove(modelGroupRef.current);
disposeGroup(modelGroupRef.current);
}
const isStlModel = !!stlGeometry;
const group = isStlModel
? (() => {
const materialColor = filamentColors?.[0] || '#00ae42';
const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
const mesh = new THREE.Mesh(stlGeometry!, material);
const stlGroup = new THREE.Group();
stlGroup.add(mesh);
return stlGroup;
})()
: buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
modelGroupRef.current = group;
sceneRef.current.add(group);
// Get bounding box to position model
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
// Always place models on the build plate (Y=0)
group.position.y = -box.min.y;
// For a selected plate, center the plate contents on the build plate
const shouldRecenter = isStlModel || parsedData!.buildItems.length === 0;
const centerOffsetX = shouldRecenter ? -center.x : 0;
const centerOffsetZ = shouldRecenter ? -center.z : 0;
let plateOffsetX = 0;
let plateOffsetZ = 0;
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
const plateBox = new THREE.Box3().setFromObject(group);
const bounds = parsedData!.plateBounds.get(selectedPlateId);
if (bounds) {
plateOffsetX = plateBox.min.x - bounds.minX;
plateOffsetZ = plateBox.min.z - bounds.minY;
} else {
const epsilon = 1e-6;
plateOffsetX = Math.floor((plateBox.min.x + epsilon) / buildVolume.x) * buildVolume.x;
plateOffsetZ = Math.floor((plateBox.min.z + epsilon) / buildVolume.y) * buildVolume.y;
}
}
const plateCenterX = buildVolume.x / 2;
const plateCenterZ = buildVolume.y / 2;
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
group.position.x = centerOffsetX - plateOffsetX;
group.position.z = centerOffsetZ - plateOffsetZ;
} else if (isStlModel) {
group.position.x = centerOffsetX + plateCenterX;
group.position.z = centerOffsetZ + plateCenterZ;
} else {
group.position.x = centerOffsetX;
group.position.z = centerOffsetZ;
}
if (plateRef.current) {
plateRef.current.position.x = plateCenterX;
plateRef.current.position.z = plateCenterZ;
}
if (gridRef.current) {
gridRef.current.position.x = plateCenterX;
gridRef.current.position.z = plateCenterZ;
}
// Recalculate bounding box after positioning
const finalBox = new THREE.Box3().setFromObject(group);
const finalCenter = finalBox.getCenter(new THREE.Vector3());
const finalSize = finalBox.getSize(new THREE.Vector3());
// Adjust camera to fit model
const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
const cameraDistance = maxDim * 1.8;
cameraRef.current.position.set(
finalCenter.x + cameraDistance * 0.7,
finalCenter.y + cameraDistance * 0.5,
finalCenter.z + cameraDistance * 0.7
);
controlsRef.current.target.copy(finalCenter);
controlsRef.current.update();
setLoading(false);
}, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
const resetView = () => {
if (cameraRef.current && controlsRef.current) {
+183 -15
View File
@@ -1,16 +1,19 @@
import { useState, useEffect } from 'react';
import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
import { X, ExternalLink, Box, Code2, Loader2, Layers, Check } from 'lucide-react';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import { Button } from './Button';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
type ViewTab = '3d' | 'gcode';
interface ModelViewerModalProps {
archiveId: number;
archiveId?: number;
libraryFileId?: number;
title: string;
fileType?: string;
onClose: () => void;
}
@@ -22,10 +25,14 @@ interface Capabilities {
filament_colors: string[];
}
export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
const isLibrary = libraryFileId != null;
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
const [loading, setLoading] = useState(true);
const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
const [platesLoading, setPlatesLoading] = useState(false);
const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
// Close on Escape key
useEffect(() => {
@@ -37,6 +44,31 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
}, [onClose]);
useEffect(() => {
setLoading(true);
if (isLibrary) {
const normalizedType = (fileType || '').toLowerCase();
const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
setCapabilities({
has_model: hasModel,
has_gcode: hasGcode,
has_source: false,
build_volume: { x: 256, y: 256, z: 256 },
filament_colors: [],
});
setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
setLoading(false);
return;
}
if (!archiveId) {
setCapabilities(null);
setActiveTab(null);
setLoading(false);
return;
}
api.getArchiveCapabilities(archiveId)
.then(caps => {
setCapabilities(caps);
@@ -54,12 +86,56 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
setActiveTab('3d');
setLoading(false);
});
}, [archiveId]);
}, [archiveId, fileType, isLibrary]);
useEffect(() => {
setPlatesLoading(true);
setSelectedPlateId(null);
if (isLibrary) {
const normalizedType = (fileType || '').toLowerCase();
if (!libraryFileId || normalizedType !== '3mf') {
setPlatesData(null);
setPlatesLoading(false);
return;
}
api.getLibraryFilePlates(libraryFileId)
.then((data) => setPlatesData(data))
.catch(() => setPlatesData(null))
.finally(() => setPlatesLoading(false));
return;
}
if (!archiveId) {
setPlatesData(null);
setPlatesLoading(false);
return;
}
api.getArchivePlates(archiveId)
.then((data) => setPlatesData(data))
.catch(() => setPlatesData(null))
.finally(() => setPlatesLoading(false));
}, [archiveId, fileType, isLibrary, libraryFileId]);
const plates = platesData?.plates ?? [];
const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
const selectedPlate: PlateMetadata | null = selectedPlateId == null
? null
: plates.find((plate) => plate.index === selectedPlateId) ?? null;
const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
const handleOpenInSlicer = () => {
if (!canOpenInSlicer) return;
// URL must include .3mf filename for Bambu Studio to recognize the format
const filename = title || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
if (isLibrary) {
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
openInSlicer(downloadUrl);
return;
}
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
openInSlicer(downloadUrl);
};
@@ -76,7 +152,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
<ExternalLink className="w-4 h-4" />
Open in Slicer
</Button>
@@ -129,17 +205,109 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
) : activeTab === '3d' && capabilities ? (
<ModelViewer
url={capabilities.has_source
? api.getSource3mfDownloadUrl(archiveId)
: api.getArchiveDownload(archiveId)}
buildVolume={capabilities.build_volume}
filamentColors={capabilities.filament_colors}
className="w-full h-full"
/>
<div className="w-full h-full flex flex-col gap-3">
{hasMultiplePlates && (
<div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
<Layers className="w-4 h-4" />
Plates
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSelectedPlateId(null)}
className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
selectedPlateId == null
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
}`}
>
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Layers className="w-5 h-5 text-bambu-gray" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">All Plates</p>
<p className="text-xs text-bambu-gray truncate">
{plates.length} plate{plates.length !== 1 ? 's' : ''}
</p>
</div>
{selectedPlateId == null && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
{plates.map((plate) => (
<button
key={plate.index}
type="button"
onClick={() => setSelectedPlateId(plate.index)}
className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
selectedPlateId === plate.index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
}`}
>
{plate.has_thumbnail && plate.thumbnail_url ? (
<img
src={plate.thumbnail_url}
alt={`Plate ${plate.index}`}
className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
/>
) : (
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Layers className="w-5 h-5 text-bambu-gray" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">
{plate.name || `Plate ${plate.index}`}
</p>
<p className="text-xs text-bambu-gray truncate">
{plate.objects.length > 0
? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
</p>
</div>
{selectedPlateId === plate.index && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
))}
</div>
{selectedPlate && (
<div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
<span>Plate {selectedPlate.index}</span>
{selectedPlate.print_time_seconds != null && (
<span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
)}
{selectedPlate.filament_used_grams != null && (
<span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
)}
{selectedPlate.filaments.length > 0 && (
<span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
)}
</div>
)}
</div>
)}
<div className="flex-1">
<ModelViewer
url={isLibrary
? api.getLibraryFileDownloadUrl(libraryFileId!)
: (capabilities.has_source
? api.getSource3mfDownloadUrl(archiveId!)
: api.getArchiveDownload(archiveId!))}
fileType={fileType}
buildVolume={capabilities.build_volume}
filamentColors={capabilities.filament_colors}
selectedPlateId={selectedPlateId}
className="w-full h-full"
/>
</div>
</div>
) : activeTab === 'gcode' && capabilities ? (
<GcodeViewer
gcodeUrl={api.getArchiveGcode(archiveId)}
gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
filamentColors={capabilities.filament_colors}
className="w-full h-full"
/>
+3 -1
View File
@@ -10,6 +10,7 @@ import { useToast } from '../../contexts/ToastContext';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import { isPlaceholderDate } from '../../utils/amsHelpers';
import { toDateTimeLocalValue } from '../../utils/date';
import { PrinterSelector } from './PrinterSelector';
import { PlateSelector } from './PlateSelector';
import { FilamentMapping } from './FilamentMapping';
@@ -90,7 +91,8 @@ export function PrintModal({
let scheduledTime = '';
if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
const date = new Date(queueItem.scheduled_time);
scheduledTime = date.toISOString().slice(0, 16);
// Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
scheduledTime = toDateTimeLocalValue(date);
}
return {
@@ -19,8 +19,9 @@ export function VirtualPrinterSettings() {
const [localMode, setLocalMode] = useState<LocalMode>('immediate');
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
const [showAccessCode, setShowAccessCode] = useState(false);
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);
// Fetch current settings
const { data: settings, isLoading } = useQuery({
@@ -41,6 +42,13 @@ export function VirtualPrinterSettings() {
queryFn: api.getPrinters,
});
// Fetch network interfaces for SSDP proxy (only in proxy mode)
const { data: networkInterfaces } = useQuery({
queryKey: ['network-interfaces'],
queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
enabled: localMode === 'proxy',
});
// Initialize local state from settings
useEffect(() => {
if (settings) {
@@ -53,12 +61,13 @@ export function VirtualPrinterSettings() {
setLocalMode(mode);
setLocalModel(settings.model);
setLocalTargetPrinterId(settings.target_printer_id);
setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
}
}, [settings]);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>
virtualPrinterApi.updateSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -148,6 +157,12 @@ export function VirtualPrinterSettings() {
updateMutation.mutate({ model });
};
const handleRemoteInterfaceChange = (ip: string) => {
setLocalRemoteInterfaceIp(ip);
setPendingAction('remoteInterface');
updateMutation.mutate({ remote_interface_ip: ip });
};
if (isLoading) {
return (
<Card>
@@ -350,6 +365,45 @@ export function VirtualPrinterSettings() {
</div>
)}
{/* Remote Interface - only for proxy mode (SSDP proxy) */}
{localMode === 'proxy' && (
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">{t('virtualPrinter.remoteInterface.title')}</div>
<div className="text-sm text-bambu-gray mb-3">
{localRemoteInterfaceIp ? (
<span className="flex items-center gap-1 text-green-400">
<Check className="w-4 h-4" />
{t('virtualPrinter.remoteInterface.configured')}
</span>
) : (
<span className="flex items-center gap-1 text-bambu-gray">
<Info className="w-4 h-4" />
{t('virtualPrinter.remoteInterface.optional')}
</span>
)}
</div>
<div className="relative">
<select
value={localRemoteInterfaceIp}
onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
disabled={pendingAction === 'remoteInterface'}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
>
<option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
{networkInterfaces?.map((iface) => (
<option key={iface.ip} value={iface.ip}>
{iface.name} ({iface.ip}) - {iface.subnet}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-2">
{t('virtualPrinter.remoteInterface.hint')}
</p>
</div>
)}
{/* Mode */}
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
@@ -504,11 +558,11 @@ export function VirtualPrinterSettings() {
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
<div className="text-white">{status.proxy.ftp_connections}</div>
<div className="text-white">{status.proxy.ftp_connections ?? 0}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
<div className="text-white">{status.proxy.mqtt_connections}</div>
<div className="text-white">{status.proxy.mqtt_connections ?? 0}</div>
</div>
</div>
) : (
+136 -45
View File
@@ -32,6 +32,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
trayInfoIdx: tray.tray_info_idx || '',
});
}
});
@@ -50,6 +51,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
isExternal: true,
label: 'External',
globalTrayId: 254,
trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
});
}
@@ -60,6 +62,14 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
* Compute AMS mapping for a printer given filament requirements and printer status.
* This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
*
* Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
*
* The tray_info_idx is a filament type identifier stored in the 3MF file when the user
* slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
* tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
* have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
* matching among those trays.
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @returns AMS mapping array or undefined if no mapping needed
@@ -77,30 +87,66 @@ export function computeAmsMapping(
const usedTrayIds = new Set<number>();
const comparisons = filamentReqs.filaments.map((req) => {
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
const reqTrayInfoIdx = req.tray_info_idx || '';
// Get available trays (not already used)
const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
@@ -143,6 +189,8 @@ export interface LoadedFilament {
isExternal: boolean;
label: string;
globalTrayId: number;
/** Unique spool identifier (e.g., "GFA00", "P4d64437") */
trayInfoIdx?: string;
}
/**
@@ -153,6 +201,8 @@ export interface FilamentRequirement {
type: string;
color: string;
used_grams: number;
/** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
tray_info_idx?: string;
}
/**
@@ -215,6 +265,7 @@ export function useLoadedFilaments(
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
trayInfoIdx: tray.tray_info_idx || '',
});
}
});
@@ -233,6 +284,7 @@ export function useLoadedFilaments(
isExternal: true,
label: 'External',
globalTrayId: 254,
trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
});
}
@@ -297,31 +349,69 @@ export function useFilamentMapping(
}
}
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
// Auto-match: Find a loaded filament
// Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
const reqTrayInfoIdx = req.tray_info_idx || '';
// Get available trays (not already used)
const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
@@ -330,11 +420,12 @@ export function useFilamentMapping(
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
// idxMatch is always considered a color match (same spool = same color)
const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
// Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
// Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
let status: FilamentStatus;
if (exactMatch || similarMatch) {
if (idxMatch || exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
+14 -5
View File
@@ -687,6 +687,7 @@ export default {
pending: 'Ausstehend',
waiting: 'Wartend',
printing: 'Druckt',
paused: 'Pausiert',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
skipped: 'Übersprungen',
@@ -2477,6 +2478,13 @@ export default {
hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
},
remoteInterface: {
title: 'Slicer-Netzwerkschnittstelle',
configured: 'SSDP-Proxy aktiviert',
optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
},
mode: {
title: 'Modus',
archive: 'Archivieren',
@@ -2502,11 +2510,12 @@ export default {
step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
},
status: {
title: 'Status-Details',
+14 -5
View File
@@ -687,6 +687,7 @@ export default {
pending: 'Pending',
waiting: 'Waiting',
printing: 'Printing',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
skipped: 'Skipped',
@@ -2477,6 +2478,13 @@ export default {
hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
},
remoteInterface: {
title: 'Slicer Network Interface',
configured: 'SSDP proxy enabled',
optional: 'Optional - for SSDP discovery across networks',
placeholder: 'Select interface for slicer network...',
hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
},
mode: {
title: 'Mode',
archive: 'Archive',
@@ -2502,11 +2510,12 @@ export default {
step4: 'The "Bambuddy" printer should appear in the discovery list',
step5: 'Connect using the access code you set',
step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
proxyStep1: 'Set the target printer IP (must be on your LAN)',
proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
proxyStep3: 'In your slicer, manually add a network printer',
proxyStep4: "Enter Bambuddy's external address and printer's access code",
proxyStep5: 'Print as normal - traffic is relayed to the real printer',
proxyStep1: 'Select the target printer (must be in LAN mode)',
proxyStep2: 'For cross-network: select the slicer network interface',
proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
proxyStep4: 'Connect using the printer\'s access code',
proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
},
status: {
title: 'Status Details',
+30
View File
@@ -549,6 +549,7 @@ export default {
status: {
pending: '待機中',
printing: '印刷中',
paused: '一時停止',
completed: '完了',
failed: '失敗',
skipped: 'スキップ',
@@ -1473,6 +1474,35 @@ export default {
model: 'モデル',
serialNumber: 'シリアルナンバー',
pendingFiles: '保留中のファイル',
modeProxy: 'プロキシ',
modeProxyDesc: '実際のプリンターに中継',
descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
proxyingTo: '{{name}}にプロキシ中',
notActive: '非アクティブ',
targetPrinterTitle: 'ターゲットプリンター',
targetPrinterConfigured: 'プロキシターゲット設定済み',
targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
targetPrinterPlaceholder: 'プリンターを選択...',
targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
remoteInterfaceTitle: 'スライサーネットワークインターフェース',
remoteInterfaceConfigured: 'SSDPプロキシ有効',
remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
targetPrinterRequired: '先にターゲットプリンターを選択してください',
ftpPort: 'FTPポート',
mqttPort: 'MQTTポート',
ftpConnections: 'FTP接続数',
mqttConnections: 'MQTT接続数',
targetPrinterStatus: 'ターゲットプリンター',
howItWorksProxy: '仕組み(プロキシモード)',
proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
proxyStep4: 'プリンターのアクセスコードで接続',
proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
},
enterNewCodeToChange: '新しいコードを入力して変更',
enter8CharCode: '8文字のコードを入力',
+13 -2
View File
@@ -99,6 +99,15 @@ function isSlicedFile(filename: string | null | undefined): boolean {
return lower.endsWith('.gcode') || lower.includes('.gcode.');
}
function getArchiveFileType(filename: string | null | undefined): string | undefined {
if (!filename) return undefined;
const lower = filename.toLowerCase();
if (lower.endsWith('.3mf')) return '3mf';
if (lower.endsWith('.stl')) return 'stl';
if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';
return lower.split('.').pop();
}
// formatDate imported from '../utils/date' - handles UTC conversion
function ArchiveCard({
@@ -1040,6 +1049,7 @@ function ArchiveCard({
<ModelViewerModal
archiveId={archive.id}
title={archive.print_name || archive.filename}
fileType={getArchiveFileType(archive.filename)}
onClose={() => setShowViewer(false)}
/>
)}
@@ -1833,6 +1843,7 @@ function ArchiveListRow({
<ModelViewerModal
archiveId={archive.id}
title={archive.print_name || archive.filename}
fileType={getArchiveFileType(archive.filename)}
onClose={() => setShowViewer(false)}
/>
)}
@@ -2522,7 +2533,7 @@ export function ArchivesPage() {
</div>
)}
<div className="flex items-center justify-between mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">Archives</h1>
@@ -2542,7 +2553,7 @@ export function ArchivesPage() {
{filteredArchives?.length || 0} of {archives?.length || 0} prints
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{/* Export dropdown */}
<div className="relative">
<Button
+42 -1
View File
@@ -37,6 +37,7 @@ import {
Pencil,
Play,
Image,
Box,
} from 'lucide-react';
import { api } from '../api/client';
import type {
@@ -51,6 +52,7 @@ import type {
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { ModelViewerModal } from '../components/ModelViewerModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
@@ -887,6 +889,7 @@ interface FileCardProps {
onDownload: (id: number) => void;
onAddToQueue?: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
onPreview3d?: (file: LibraryFileListItem) => void;
onRename?: (file: LibraryFileListItem) => void;
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
thumbnailVersion?: number;
@@ -895,7 +898,7 @@ interface FileCardProps {
t: TFunction;
}
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, t }: FileCardProps) {
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, t }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -993,6 +996,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{t('fileManager.addToQueue')}
</button>
)}
{onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:read')) { onPreview3d(file); setShowActions(false); } }}
disabled={!hasPermission('library:read')}
title={!hasPermission('library:read') ? 'You do not have permission to preview files' : undefined}
>
<Box className="w-3.5 h-3.5" />
3D Preview
</button>
)}
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -1081,6 +1097,7 @@ export function FileManagerPage() {
const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
@@ -1940,6 +1957,7 @@ export function FileManagerPage() {
onDownload={handleDownload}
onAddToQueue={(id) => addToQueueMutation.mutate([id])}
onPrint={setPrintFile}
onPreview3d={setViewerFile}
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
thumbnailVersion={thumbnailVersions[file.id]}
@@ -2056,6 +2074,20 @@ export function FileManagerPage() {
</button>
</>
)}
{(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
<button
onClick={() => hasPermission('library:read') && setViewerFile(file)}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:read')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
disabled={!hasPermission('library:read')}
>
<Box className="w-4 h-4" />
</button>
)}
<button
onClick={() => hasPermission('library:read') && handleDownload(file.id)}
className={`p-1.5 rounded transition-colors ${
@@ -2211,6 +2243,15 @@ export function FileManagerPage() {
/>
)}
{viewerFile && (
<ModelViewerModal
libraryFileId={viewerFile.id}
title={viewerFile.print_name || viewerFile.filename}
fileType={viewerFile.file_type}
onClose={() => setViewerFile(null)}
/>
)}
{renameItem && (
<RenameModal
type={renameItem.type}
+47 -3
View File
@@ -1,6 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
DndContext,
@@ -46,6 +46,7 @@ import {
CheckSquare,
Square,
User,
Pause,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -80,7 +81,7 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
return formatDateTime(dateString, timeFormat);
}
function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
// Special case: pending with waiting_reason shows as "Waiting"
if (status === 'pending' && waitingReason) {
return (
@@ -91,6 +92,16 @@ function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['sta
);
}
// Special case: printing but printer is paused
if (status === 'printing' && (printerState === 'PAUSE' || printerState === 'PAUSED')) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20">
<Pause className="w-3.5 h-3.5" />
{t('queue.status.paused')}
</span>
);
}
const config = {
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
@@ -286,6 +297,7 @@ function SortableQueueItem({
onToggleSelect,
hasPermission,
canModify,
printerState,
t,
}: {
item: PrintQueueItem;
@@ -301,6 +313,7 @@ function SortableQueueItem({
onToggleSelect?: () => void;
hasPermission: (permission: Permission) => boolean;
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
printerState?: string | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const canReorder = hasPermission('queue:reorder');
@@ -500,7 +513,7 @@ function SortableQueueItem({
</div>
{/* Status badge */}
<StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
<StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
{/* Actions */}
<div className="flex items-center gap-1">
@@ -825,6 +838,36 @@ export function QueuePage() {
return items;
}, [queue, filterLocation, matchesLocationFilter]);
// Get unique printer IDs from active items to fetch their statuses
const activePrinterIds = useMemo(() => {
const ids = new Set<number>();
activeItems.forEach(item => {
if (item.printer_id) ids.add(item.printer_id);
});
return Array.from(ids);
}, [activeItems]);
// Fetch printer statuses for printers with active jobs
const printerStatusQueries = useQueries({
queries: activePrinterIds.map(printerId => ({
queryKey: ['printerStatus', printerId],
queryFn: () => api.getPrinterStatus(printerId),
refetchInterval: 5000,
})),
});
// Build a map of printer_id -> state for quick lookup
const printerStateMap = useMemo(() => {
const map: Record<number, string | null> = {};
activePrinterIds.forEach((printerId, index) => {
const result = printerStatusQueries[index];
if (result?.data?.state) {
map[printerId] = result.data.state;
}
});
return map;
}, [activePrinterIds, printerStatusQueries]);
const historyItems = useMemo(() => {
let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
if (filterLocation) {
@@ -1035,6 +1078,7 @@ export function QueuePage() {
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
t={t}
/>
))}
+2 -2
View File
@@ -672,12 +672,12 @@ export function StatsPage() {
return (
<div className="p-4 md:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
<p className="text-bambu-gray">{t('stats.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{/* Hidden widgets button - toggles panel in Dashboard */}
{hiddenCount > 0 && (
<Button
+41
View File
@@ -0,0 +1,41 @@
export interface PlateFilament {
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}
export interface PlateMetadata {
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: PlateFilament[];
}
export interface ArchivePlatesResponse {
archive_id: number;
filename: string;
plates: PlateMetadata[];
is_multi_plate: boolean;
}
export interface LibraryFilePlatesResponse {
file_id: number;
filename: string;
plates: PlateMetadata[];
is_multi_plate: boolean;
}
export interface ViewerPlateSelectionState {
selected_plate_id: number | null;
}
export interface PlateAssignment {
object_id: string;
plate_id: number | null;
}
-7
View File
@@ -7,13 +7,6 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
pool: 'threads',
poolOptions: {
threads: {
maxThreads: 14,
minThreads: 4,
},
},
environmentOptions: {
jsdom: {
url: 'http://localhost:3000',
@@ -0,0 +1,13 @@
# Plate Management Verification Checklist
## Manual Verification
- Open an archive with a multi-plate 3MF and verify the plate selector shows All Plates plus each plate entry.
- Select each plate and confirm the 3D viewer updates to show only that plate’s objects.
- Switch back to All Plates and confirm all geometry renders again.
- Verify the plate thumbnail and plate name are shown in the selector for plates with metadata.
- Open a single-plate 3MF and confirm the selector does not appear.
- Open an STL or non-3MF archive and confirm the viewer shows a friendly unsupported format error (no crash).
## Test Targets
- MSW handler for /api/v1/archives/:id/plates should return a structured response.
- ArchivesPage plate hover handlers should not throw when plates data is empty.
+206
View File
@@ -0,0 +1,206 @@
# 3MF Plate Management Feature Plan
## Overview
This plan outlines the implementation of plate management features for the bambuddy 3D viewer. This will allow users to view, select, and manage individual plates within 3MF files, similar to how Bambu Studio handles multi-plate projects.
## Background
### Current 3MF File Structure
3MF files use a hierarchical XML structure to manage multiple build plates:
```
3dmodel.model (root)
├── Metadata/
│ └── model_settings.config # Plate configuration manager
├── 3dmodel.model (main model data)
│ ├── <object> definitions (unique geometry)
│ └── <build> items (plate assignments)
└── Metadata/
└── plate_1.png, plate_2.png, etc. (plate thumbnails)
```
### Key Concepts
- **Build Items**: Objects assigned to specific plates via `bambu:plate_id` attribute
- **Plate Configuration**: `Metadata/model_settings.config` manages plate definitions, names, bed types
- **Object Instances**: Each unique object defined once, referenced by multiple build items
- **Transformation Matrix**: Each build item has position, rotation, scale relative to its plate's center
- **Plate Thumbnails**: PNG images showing each plate's layout for preview
## Feature Requirements
### 1. Plate Metadata Parsing
**Goal**: Read and parse `Metadata/model_settings.config` from 3MF files
**Technical Details**:
- Parse XML structure to extract:
- Plate count
- Plate names (custom or default)
- Plate dimensions
- Bed type per plate (Textured PE, Cool Plate, etc.)
- Printer type per plate (X1C, A1 mini, etc.)
- Extract plate-to-object mappings from `<build>` items
- Parse object-to-plate assignments from `<object>` `bambu:plate_id` attributes
**Implementation Approach**:
- Use JSZip to extract `Metadata/model_settings.config` from 3MF ZIP
- Parse XML using DOMParser or similar library
- Create TypeScript interfaces for plate metadata
### 2. UI/UX Design
**Goal**: Intuitive plate selection and object management interface
**Components Needed**:
#### A. Plate Selector/Tab System
- **Plate Tabs**: Horizontal tabs showing each plate (Plate 1, Plate 2, etc.)
- **Plate Dropdown**: Dropdown to select active plate
- **Plate Info Panel**: Display plate name, dimensions, bed type
- **Thumbnail Preview**: Show plate thumbnail when selected
#### B. Object Filtering
- **Filter by Plate**: When a plate is selected, show only objects on that plate
- **Object List**: Display objects with their positions on current plate
- **Object Selection**: Allow selecting individual objects (checkbox, multi-select)
- **Object Info**: Show object name, dimensions, material color
#### C. 3D Viewer Enhancements
- **Plate-Specific Rendering**: Render only objects for selected plate
- **Object Highlighting**: Highlight selected objects in 3D view
- **Plate Grid Overlay**: Show build plate boundaries when viewing specific plate
- **Plate Indicator**: Visual indicator of which plate is currently active
#### D. Object Manipulation (Optional - Future Enhancement)
- **Drag and Drop**: Move objects between plates
- **Object Repositioning**: Adjust X/Y/Z position on current plate
- **Object Rotation**: Rotate objects on build plate
- **Object Scaling**: Resize objects
- **Delete from Plate**: Remove object from current plate
- **Add to Plate**: Copy object to different plate
### 3. Data Model Design
**Goal**: Track plate assignments and configurations
**State Structure**:
```typescript
interface Plate {
id: string;
name: string;
width: number;
depth: number;
bedType: string;
printerType: string;
objectIds: string[];
}
interface ObjectAssignment {
objectId: string;
plateId: string;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
interface ViewerState {
selectedPlateId: string | null;
selectedObjectIds: string[];
filterMode: 'all' | 'plate';
}
```
**Storage Considerations**:
- In-memory state for current session
- Optional: Save plate configurations to localStorage
- Optional: Save to backend as user preferences
### 4. Backend API Requirements
**Goal**: API endpoints for saving/loading plate configurations
**Required Endpoints**:
#### A. Get Plate Metadata
```
GET /api/v1/library/files/{id}/plates
```
Returns plate metadata from 3MF file
#### B. Save Plate Configuration (Optional)
```
POST /api/v1/library/files/{id}/plates/config
Body: {
plates: Plate[];
defaultPlateId?: string;
}
```
Save custom plate names, assignments to user preferences
#### C. Get Plate Thumbnail
```
GET /api/v1/library/files/{id}/plates/{plateId}/thumbnail
```
Returns PNG image of specific plate
### 5. Implementation Steps
#### Step 1: 3MF Metadata Parser
- Create `PlateMetadataParser` class
- Implement `parse3MFPlateMetadata(file: File)` method
- Extract `Metadata/model_settings.config` from ZIP
- Parse XML structure
- Return typed plate metadata
#### Step 2: Update ModelViewer Component
- Add `plates` prop to ModelViewerProps
- Add `selectedPlateId` prop to ModelViewerProps
- Add `selectedObjectIds` prop to ModelViewerProps
- Add `filterMode` prop to ModelViewerProps
- Modify `useEffect` to filter objects by selected plate
- Add plate selection state management
- Implement object highlighting for selected objects
- Add plate grid overlay visualization
#### Step 3: Update ModelViewerModal
- Add plate selector UI (tabs or dropdown)
- Display plate information panel
- Show plate thumbnail preview
- Add "All Plates" view option
- Pass selected plate and objects to ModelViewer
#### Step 4: Update FileManagerPage
- Add plate management state
- Add plate selector to file cards (optional)
- Display plate badge on file cards
- Add plate count indicator in file list
#### Step 5: API Client Updates
- Add `getLibraryFilePlates(fileId: number)` method
- Add `saveLibraryFilePlateConfig(fileId: number, config: PlateConfig)` method
- Add `getLibraryFilePlateThumbnail(fileId: number, plateId: string)` method
#### Step 6: Testing
- Test with multi-plate 3MF files
- Test plate selection and object filtering
- Test plate switching
- Test object highlighting
- Verify thumbnail generation
### 6. Technical Considerations
#### A. Coordinate Systems
- **3MF**: Uses local (0,0,0) origin per plate
- **Viewer**: Uses Three.js Y-up coordinate system
- **Challenge**: Need to convert between coordinate systems when rendering specific plates
**Solution**: Store plate origin offset, apply when rendering specific plate
#### B. Performance
- **Lazy Loading**: Load plate metadata on-demand, not entire file upfront
- **Object Culling**: Don't render objects not on current plate
- **Thumbnail Caching**: Cache plate thumbnails
#### C. Backward Compatibility
- **Single Plate Files**: Continue working as-is (show all plates)
- **No Plate Metadata**: Gracefully degrade to full model view
- **STL Files**: Plate management not applicable (no plate structure)
### 7. UI/UX Flow
```mermaid
flowchart TD
A[User opens 3MF file] --> B{3D Viewer loads file}
B --> C{Parse plate metadata}
C --> D{Display plate selector}
D --> E{User selects plate}
E --> F{Filter objects by plate}
F --> G{Highlight selected objects}
G --> H{Render plate-specific view}
H --> I{User clicks Print}
```
### 8. Limitations
- **Complexity**: 3MF plate structure is complex, requires careful XML parsing
- **File Size**: Large 3MF files with many plates may have performance impact
- **STL Files**: No plate structure, feature not applicable
- **Testing**: Extensive testing needed for various plate configurations
- **Backward Compatibility**: Must maintain existing single-plate view behavior
### 9. Future Enhancements (Out of Scope)
- **Object Manipulation**: Drag-and-drop, rotation, scaling
- **Plate Creation**: Add new plates, duplicate objects between plates
- **Plate Templates**: Save and reuse plate configurations
- **Batch Operations**: Apply settings to multiple plates at once
- **Visual Plate Editor**: Graphical plate layout designer
## Success Criteria
- [ ] Users can view all plates in a 3MF file
- [ ] Users can select individual plates
- [ ] Objects are filtered by selected plate
- [ ] Selected objects are highlighted in 3D view
- [ ] Plate information is displayed (name, dimensions, bed type)
- [ ] Plate thumbnails are shown
- [ ] Works with both 3MF and STL files
- [ ] Backward compatible with single-plate files
- [ ] Performance is acceptable with large files
- [ ] All existing features continue to work
## Implementation Priority
1. **High**: Core plate parsing and rendering
2. **Medium**: UI components and state management
3. **Low**: Backend API and advanced features
4. **Future**: Object manipulation and plate creation
+520
View File
@@ -0,0 +1,520 @@
@echo off
chcp 65001 >nul 2>&1
setlocal enabledelayedexpansion
title Bambuddy
REM ============================================
REM Bambuddy Portable Launcher for Windows
REM
REM Double-click to start. First run downloads
REM Python and Node.js automatically (portable,
REM no system changes). Everything is stored in
REM the .portable\ folder.
REM
REM Usage:
REM start_bambuddy.bat Launch
REM start_bambuddy.bat update Update deps & rebuild frontend
REM start_bambuddy.bat reset Clean all & fresh start
REM set PORT=9000 & start_bambuddy.bat Change port
REM ============================================
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
set "PORTABLE=%ROOT%\.portable"
set "PYTHON_DIR=%PORTABLE%\python"
set "NODE_DIR=%PORTABLE%\node"
set "FFMPEG_DIR=%PORTABLE%\ffmpeg"
REM NOTE: Python version is intentionally pinned to a specific portable build.
REM If you upgrade the bundled Python runtime, update PYTHON_VER here
REM and make sure it matches the version used in download/installation logic.
if not defined PYTHON_VER set "PYTHON_VER=3.13.1"
REM Default Node.js version for the portable runtime. Override by setting NODE_VER before running this script.
if not defined NODE_VER set "NODE_VER=22.12.0"
REM NOTE: FFmpeg is not downloaded automatically.
REM Install from the official site and add it to PATH:
REM https://ffmpeg.org/download.html
REM Pinned SHA256 hashes for downloads (update when bumping versions)
set "GET_PIP_SHA256=dffc3658baada4ef383f31c3c672d4e5e306a6e376cee8bee5dbdf1385525104"
set "PYTHON_ZIP_HASH_AMD64=7b7923ff0183a8b8fca90f6047184b419b108cb437f75fc1c002f9d2f8bcec16"
set "PYTHON_ZIP_HASH_ARM64=ae8561bf958f77c68cb6c44ced983e5267fe965a7e4168f41ec2291350b81d55"
set "NODE_ZIP_HASH_X64=2b8f2256382f97ad51e29ff71f702961af466c4616393f767455501e6aece9b8"
set "NODE_ZIP_HASH_ARM64=17401720af48976e3f67c41e8968a135fb49ca1f88103a92e0e8c70605763854"
REM Detect system architecture (amd64 or arm64)
set "PYTHON_ARCH=amd64"
set "NODE_ARCH=x64"
if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "PYTHON_ARCH=arm64"
set "NODE_ARCH=arm64"
)
if defined PROCESSOR_ARCHITEW6432 (
if /I "%PROCESSOR_ARCHITEW6432%"=="ARM64" (
set "PYTHON_ARCH=arm64"
set "NODE_ARCH=arm64"
)
)
set "PYTHON_ZIP_HASH_EXPECTED=%PYTHON_ZIP_HASH_AMD64%"
if /I "%PYTHON_ARCH%"=="arm64" set "PYTHON_ZIP_HASH_EXPECTED=%PYTHON_ZIP_HASH_ARM64%"
set "NODE_ZIP_HASH_EXPECTED=%NODE_ZIP_HASH_X64%"
if /I "%NODE_ARCH%"=="arm64" set "NODE_ZIP_HASH_EXPECTED=%NODE_ZIP_HASH_ARM64%"
if not defined PORT set "PORT=8000"
REM Validate PORT is a number in the range 1-65535
echo(!PORT!| findstr /R "^[0-9][0-9]*$" >nul
if errorlevel 1 (
echo Invalid PORT value "%PORT%". PORT must be an integer between 1 and 65535.
exit /b 1
)
if %PORT% LSS 1 (
echo Invalid PORT value "%PORT%". PORT must be between 1 and 65535.
exit /b 1
)
if %PORT% GTR 65535 (
echo Invalid PORT value "%PORT%". PORT must be between 1 and 65535.
exit /b 1
)
REM ---- Handle arguments ----
if /i "%~1"=="reset" (
echo Cleaning up portable environment...
call :safe_rmdir "%PORTABLE%" ".portable"
if errorlevel 1 exit /b 1
call :safe_rmdir "%ROOT%\static" "static"
if errorlevel 1 exit /b 1
echo Done. Run again without arguments to set up fresh.
pause
exit /b 0
)
if /i "%~1"=="update" (
echo Forcing dependency update and frontend rebuild...
if exist "%PORTABLE%\.deps-installed" del "%PORTABLE%\.deps-installed"
call :safe_rmdir "%ROOT%\static" "static"
if errorlevel 1 exit /b 1
)
REM ---- Check prerequisites ----
where curl >nul 2>&1
if errorlevel 1 (
echo.
echo [ERROR] curl.exe is not available.
echo Windows 10 version 1803 or later is required.
echo.
pause
exit /b 1
)
where tar >nul 2>&1
if errorlevel 1 (
echo.
echo [ERROR] tar.exe is not available.
echo Windows 10 version 1803 or later is required.
echo.
pause
exit /b 1
)
REM ---- Verify project structure ----
if not exist "%ROOT%\backend\app\main.py" (
echo.
echo [ERROR] backend\app\main.py not found.
echo This script must be in the Bambuddy project root.
echo.
pause
exit /b 1
)
echo.
echo ____ _ _ _
echo ^| __ ) __ _ _ __ ___ ^| ^|__ _ _ __^| ^| __^| ^|_ _
echo ^| _ \ / _` ^| '_ ` _ \^| '_ \^| ^| ^| ^|/ _` ^|/ _` ^| ^| ^| ^|
echo ^| ^|_) ^| (_^| ^| ^| ^| ^| ^| ^| ^|_) ^| ^|_^| ^| (_^| ^| (_^| ^| ^|_^| ^|
echo ^|____/ \__,_^|_^| ^|_^| ^|_^|_.__/ \__,_^|\__,_^|\__,_^|\__, ^|
echo ^|___/
echo.
REM ============================================
REM Step 1: Setup Portable Python
REM ============================================
if exist "%PYTHON_DIR%\python.exe" (
echo [OK] Python %PYTHON_VER% found.
goto :python_ready
)
echo [1/6] Downloading Python %PYTHON_VER% (portable)...
if not exist "%PORTABLE%" mkdir "%PORTABLE%"
if not exist "%PYTHON_DIR%" mkdir "%PYTHON_DIR%"
curl -L --fail --show-error --progress-bar -o "%PORTABLE%\python.zip" ^
"https://www.python.org/ftp/python/%PYTHON_VER%/python-%PYTHON_VER%-embed-%PYTHON_ARCH%.zip"
if errorlevel 1 (
echo [ERROR] Failed to download Python.
pause
exit /b 1
)
call :verify_sha256 "%PORTABLE%\python.zip" "%PYTHON_ZIP_HASH_EXPECTED%" "Python"
if errorlevel 1 (
echo [ERROR] Failed to download Python archive.
pause
exit /b 1
)
REM Download official SHA256 checksum for the Python archive
curl -L --progress-bar -o "%PORTABLE%\python.zip.sha256" ^
"https://www.python.org/ftp/python/%PYTHON_VER%/python-%PYTHON_VER%-embed-amd64.zip.sha256"
if errorlevel 1 (
echo [ERROR] Failed to download Python checksum file.
del "%PORTABLE%\python.zip" >nul 2>&1
pause
exit /b 1
)
REM Compute SHA256 hash of the downloaded archive
set "PYTHON_ZIP_HASH="
for /f "tokens=1 usebackq" %%H in (`
certutil -hashfile "%PORTABLE%\python.zip" SHA256 ^| findstr /R /I "^[0-9A-F][0-9A-F]"
`) do (
set "PYTHON_ZIP_HASH=%%H"
goto :python_hash_done
)
:python_hash_done
if not defined PYTHON_ZIP_HASH (
echo [ERROR] Failed to compute SHA256 hash for Python archive.
del "%PORTABLE%\python.zip" >nul 2>&1
del "%PORTABLE%\python.zip.sha256" >nul 2>&1
pause
exit /b 1
)
REM Read expected SHA256 hash from the checksum file
set "PYTHON_ZIP_HASH_EXPECTED="
for /f "tokens=1" %%H in ('type "%PORTABLE%\python.zip.sha256"') do (
set "PYTHON_ZIP_HASH_EXPECTED=%%H"
goto :python_expected_hash_done
)
:python_expected_hash_done
if not defined PYTHON_ZIP_HASH_EXPECTED (
echo [ERROR] Failed to read expected SHA256 hash for Python archive.
del "%PORTABLE%\python.zip" >nul 2>&1
del "%PORTABLE%\python.zip.sha256" >nul 2>&1
pause
exit /b 1
)
REM Compare actual and expected hashes (case-insensitive)
if /I not "%PYTHON_ZIP_HASH%"=="%PYTHON_ZIP_HASH_EXPECTED%" (
echo [ERROR] SHA256 checksum verification for Python archive failed.
echo [INFO] Expected: %PYTHON_ZIP_HASH_EXPECTED%
echo [INFO] Actual: %PYTHON_ZIP_HASH%
del "%PORTABLE%\python.zip" >nul 2>&1
del "%PORTABLE%\python.zip.sha256" >nul 2>&1
pause
exit /b 1
)
del "%PORTABLE%\python.zip.sha256" >nul 2>&1
echo Extracting Python...
tar -xf "%PORTABLE%\python.zip" -C "%PYTHON_DIR%"
if errorlevel 1 (
echo [ERROR] Failed to extract Python archive.
del "%PORTABLE%\python.zip" >nul 2>&1
pause
exit /b 1
)
del "%PORTABLE%\python.zip"
if not exist "%PYTHON_DIR%\python.exe" (
echo [ERROR] Python executable not found after extraction.
pause
exit /b 1
)
REM Enable site-packages by rewriting the ._pth file
REM Derive python tag (e.g., 3.13.x -> 313) from %PYTHON_VER%
for /f "tokens=1,2 delims=." %%A in ("%PYTHON_VER%") do (
set "PY_MAJOR=%%A"
set "PY_MINOR=%%B"
)
set "PYTHON_TAG=%PY_MAJOR%%PY_MINOR%"
(
echo python!PYTHON_TAG!.zip
echo .
echo import site
) > "%PYTHON_DIR%\python!PYTHON_TAG!._pth"
REM ============================================
REM Step 2: Install pip
REM ============================================
echo.
echo [2/6] Installing pip...
curl -L --fail -sS -o "%PORTABLE%\get-pip.py" "https://bootstrap.pypa.io/get-pip.py"
if errorlevel 1 (
echo [ERROR] Failed to download get-pip.py.
pause
exit /b 1
)
call :verify_sha256 "%PORTABLE%\get-pip.py" "%GET_PIP_SHA256%" "get-pip.py"
if errorlevel 1 (
del "%PORTABLE%\get-pip.py" >nul 2>&1
pause
exit /b 1
)
"%PYTHON_DIR%\python.exe" "%PORTABLE%\get-pip.py" --no-warn-script-location -q
if errorlevel 1 (
echo [ERROR] Failed to install pip.
pause
exit /b 1
)
del "%PORTABLE%\get-pip.py"
echo [OK] Python %PYTHON_VER% ready.
:python_ready
REM ============================================
REM Step 2.5: Create Virtual Environment (best effort)
REM ============================================
set "VENV_DIR=%PORTABLE%\venv"
set "PYTHON_EXE=%PYTHON_DIR%\python.exe"
if not exist "%VENV_DIR%\Scripts\python.exe" (
echo.
echo Creating virtual environment [optional]...
"%PYTHON_DIR%\python.exe" -m venv "%VENV_DIR%"
if errorlevel 1 (
echo [WARN] Failed to create virtual environment. Continuing without venv.
)
)
if exist "%VENV_DIR%\Scripts\python.exe" (
set "PYTHON_EXE=%VENV_DIR%\Scripts\python.exe"
)
REM ============================================
REM Step 3: Install Python Dependencies
REM ============================================
if exist "%PORTABLE%\.deps-installed" (
echo [OK] Python packages found.
goto :deps_ready
)
echo.
echo [3/6] Installing Python packages (this may take a few minutes)...
if exist "%ROOT%\requirements.lock" (
"%PYTHON_EXE%" -m pip install -r "%ROOT%\requirements.lock" --require-hashes --no-warn-script-location -q
) else (
echo [WARN] requirements.lock not found. Falling back to requirements.txt - no hash enforcement.
"%PYTHON_EXE%" -m pip install -r "%ROOT%\requirements.txt" --no-warn-script-location -q
)
if errorlevel 1 (
echo [ERROR] Failed to install Python packages.
pause
exit /b 1
)
REM Create marker file
echo %date% %time% > "%PORTABLE%\.deps-installed"
echo [OK] Packages installed.
:deps_ready
REM ============================================
REM Step 4-6: Build Frontend (if needed)
REM ============================================
if exist "%ROOT%\static\index.html" (
echo [OK] Frontend found.
goto :frontend_ready
)
REM ---- Download Node.js if needed ----
if exist "%NODE_DIR%\node.exe" goto :node_ready
echo.
echo [4/6] Downloading Node.js %NODE_VER% (portable)...
curl -L --fail --show-error --progress-bar -o "%PORTABLE%\node.zip" ^
"https://nodejs.org/dist/v%NODE_VER%/node-v%NODE_VER%-win-%NODE_ARCH%.zip"
if errorlevel 1 (
echo [ERROR] Failed to download Node.js.
pause
exit /b 1
)
call :verify_sha256 "%PORTABLE%\node.zip" "%NODE_ZIP_HASH_EXPECTED%" "Node.js"
if errorlevel 1 (
del "%PORTABLE%\node.zip" >nul 2>&1
pause
exit /b 1
)
echo Extracting Node.js...
tar -xf "%PORTABLE%\node.zip" -C "%PORTABLE%"
if errorlevel 1 (
echo [ERROR] Failed to extract Node.js archive.
del "%PORTABLE%\node.zip" >nul 2>&1
pause
exit /b 1
)
if exist "%PORTABLE%\node-v%NODE_VER%-win-%NODE_ARCH%" (
ren "%PORTABLE%\node-v%NODE_VER%-win-%NODE_ARCH%" node
)
del "%PORTABLE%\node.zip"
echo [OK] Node.js %NODE_VER% ready.
:node_ready
REM ---- Build frontend ----
echo.
echo [5/6] Building frontend (this may take a while)...
set "PATH=%NODE_DIR%;%PATH%"
pushd "%ROOT%\frontend"
if exist "%ROOT%\frontend\package-lock.json" (
call "%NODE_DIR%\npm.cmd" ci
) else (
call "%NODE_DIR%\npm.cmd" install
)
if errorlevel 1 (
echo [ERROR] npm install failed.
popd
pause
exit /b 1
)
call "%NODE_DIR%\npm.cmd" run build
if errorlevel 1 (
echo [ERROR] Frontend build failed.
popd
pause
exit /b 1
)
popd
if not exist "%ROOT%\frontend\static\index.html" (
echo [ERROR] Frontend build did not produce static\index.html.
echo Expected: "%ROOT%\frontend\static\index.html"
pause
exit /b 1
)
if not exist "%ROOT%\static\index.html" (
echo [ERROR] Frontend build did not produce static\index.html.
echo Expected: "%ROOT%\static\index.html"
pause
exit /b 1
)
echo [OK] Frontend built.
:frontend_ready
REM ============================================
REM Step 6: Setup Portable FFmpeg (if needed)
REM ============================================
where ffmpeg >nul 2>&1
if not errorlevel 1 (
echo [OK] FFmpeg found in system PATH.
goto :ffmpeg_ready
)
if exist "%FFMPEG_DIR%\bin\ffmpeg.exe" (
echo [OK] FFmpeg found.
goto :ffmpeg_ready
)
echo.
echo [6/6] FFmpeg not found.
echo [INFO] Install FFmpeg from the official site and add it to PATH:
echo https://ffmpeg.org/download.html
echo [INFO] Timelapse features will be unavailable until FFmpeg is installed.
:ffmpeg_ready
REM ============================================
REM Launch Bambuddy
REM ============================================
echo.
echo ================================================
echo Bambuddy is starting on port %PORT%
echo Open: http://localhost:%PORT%
echo.
echo Press Ctrl+C to stop
echo ================================================
echo.
REM Set PYTHONPATH so "backend.app.main" module is found
set "PYTHONPATH=%ROOT%"
REM Add portable FFmpeg to PATH if available
if exist "%FFMPEG_DIR%\bin\ffmpeg.exe" set "PATH=%FFMPEG_DIR%\bin;%PATH%"
REM Open browser after server is ready (poll localhost)
start /b cmd /c "for /l %%i in (1,1,30) do (curl -s -f -o nul http://localhost:%PORT% && (start http://localhost:%PORT% & exit /b 0) & timeout /t 1 /nobreak >nul)"
REM Launch the application
"%PYTHON_EXE%" -m uvicorn backend.app.main:app --host 0.0.0.0 --port %PORT% --loop asyncio
echo.
echo Bambuddy has stopped.
pause
endlocal
goto :eof
REM ============================================
REM Helpers
REM ============================================
:safe_rmdir
set "TARGET=%~1"
set "LABEL=%~2"
if "%TARGET%"=="" (
echo [ERROR] %LABEL% path is empty. Aborting.
exit /b 1
)
if /I "%TARGET%"=="\" (
echo [ERROR] %LABEL% path resolved to root. Aborting.
exit /b 1
)
if not exist "%TARGET%" exit /b 0
echo Deleting "%TARGET%"
rmdir /s /q "%TARGET%"
if errorlevel 1 (
echo [ERROR] Failed to delete "%TARGET%".
exit /b 1
)
exit /b 0
:verify_sha256
set "FILE=%~1"
set "EXPECTED=%~2"
set "LABEL=%~3"
if "%EXPECTED%"=="" (
echo [ERROR] %LABEL% checksum not found.
exit /b 1
)
set "ACTUAL="
for /f "tokens=1" %%H in ('certutil -hashfile "%FILE%" SHA256 ^| findstr /R /I "^[0-9A-F][0-9A-F]"') do (
set "ACTUAL=%%H"
goto :hash_done
)
:hash_done
if not defined ACTUAL (
echo [ERROR] Failed to compute SHA256 for %LABEL%.
exit /b 1
)
if /I not "%ACTUAL%"=="%EXPECTED%" (
echo [ERROR] SHA256 verification failed for %LABEL%.
echo [INFO] Expected: %EXPECTED%
echo [INFO] Actual: %ACTUAL%
exit /b 1
)
exit /b 0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-B5vyvTs3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DME4t7XG.css">
<script type="module" crossorigin src="/assets/index-3wkUCoeq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-fdAEMOwp.css">
</head>
<body>
<div id="root"></div>
+28
View File
@@ -0,0 +1,28 @@
# What's New: 3D File Preview Updates
## Overview
- Expanded 3D previews in the Library File Manager and Printer File Manager.
- Added STL support (interactive 3D view) alongside existing 3MF/G-code previews.
- Improved multi-plate handling for 3MF files in the printer file manager.
## Library File Manager (Files Page)
- 3D preview now supports `.3mf`, `.gcode`, and `.stl` files.
- STL files open in the same viewer modal used by 3MF files.
- Plate-aware selection continues to work for 3MF files with multiple plates.
## Printer File Manager (Printers → File Manager)
- Added a 3D View action for `.3mf`, `.gcode`, and `.stl` files on the printer.
- New printer-side 3MF plate endpoints:
- Plate list and metadata retrieval.
- Plate thumbnail retrieval.
- Added a printer-side gcode preview endpoint for 3MF and `.gcode` files.
- STL models are centered on the build sheet in the 3D viewer.
## Model Viewer Enhancements
- Added STL rendering support using `STLLoader`.
- Viewer now selects the correct rendering pipeline based on file type.
- STL models auto-center on the build plate for a consistent viewing experience.
## Affected Areas
- Frontend: File Manager modals and 3D viewer.
- Backend: Printer file preview endpoints for plates, plate thumbnails, and gcode.