diff --git a/.python-bin/python b/.python-bin/python new file mode 120000 index 000000000..c9b234cf2 --- /dev/null +++ b/.python-bin/python @@ -0,0 +1 @@ +/Users/wreaves/projects/MisterBeardy/bambuddy/.venv/bin/python \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7c9971e..2291fe946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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): diff --git a/README.md b/README.md index d64b2f50c..3320a799c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

Release - + License Stars Issues @@ -444,7 +444,26 @@ services: -#### 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 diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 2c96b6cc9..f5d04270c 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -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, } ) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 62334e8c1..4b93d2dc8 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -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, } ) diff --git a/backend/app/api/routes/metrics.py b/backend/app/api/routes/metrics.py index 757f531dc..3080ed83b 100644 --- a/backend/app/api/routes/metrics.py +++ b/backend/app/api/routes/metrics.py @@ -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}") diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 1db304b0d..132092ed3 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -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 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, diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 96780a15f..b0b933407 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -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}") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5a1105683..a9ef0a368 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index cdc303b12..be0d8db90 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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})") diff --git a/backend/app/services/network_utils.py b/backend/app/services/network_utils.py new file mode 100644 index 000000000..b6dd90238 --- /dev/null +++ b/backend/app/services/network_utils.py @@ -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] diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index e60e7efa7..aff3818ac 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -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() diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index ef303ca81..40ef4e152 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -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 diff --git a/backend/app/services/virtual_printer/ssdp_server.py b/backend/app/services/virtual_printer/ssdp_server.py index 2f033b688..221d1e194 100644 --- a/backend/app/services/virtual_printer/ssdp_server.py +++ b/backend/app/services/virtual_printer/ssdp_server.py @@ -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}") diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py index 143ed7f83..536056244 100644 --- a/backend/app/services/virtual_printer/tcp_proxy.py +++ b/backend/app/services/virtual_printer/tcp_proxy.py @@ -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__( diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 670098b86..ebcff3726 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -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() diff --git a/backend/tests/unit/test_scheduler_ams_mapping.py b/backend/tests/unit/test_scheduler_ams_mapping.py index 82db6cfec..919c658f9 100644 --- a/backend/tests/unit/test_scheduler_ams_mapping.py +++ b/backend/tests/unit/test_scheduler_ams_mapping.py @@ -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 diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index bff112632..11958eb0f 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -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', () => { diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx index ebf91860d..af8d0abe4 100644 --- a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx +++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx @@ -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, diff --git a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts new file mode 100644 index 000000000..39a5f7e3b --- /dev/null +++ b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts @@ -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 + }); +}); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 8d0158981..5ce009b2a 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -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', () => { diff --git a/frontend/src/__tests__/pages/ArchivesPage.test.tsx b/frontend/src/__tests__/pages/ArchivesPage.test.tsx index 0b19a0fd1..7f10618be 100644 --- a/frontend/src/__tests__/pages/ArchivesPage.test.tsx +++ b/frontend/src/__tests__/pages/ArchivesPage.test.tsx @@ -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([]); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aeeb2218f..9cb026835 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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( }); 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 => { const headers: Record = {}; 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(`/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('/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('/smart-plugs/discover/scan', { method: 'POST' }), getTasmotaScanStatus: () => request('/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('/smart-plugs/discover/stop', { method: 'POST' }), getDiscoveredTasmotaDevices: () => request('/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(`/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; // 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(`/settings/virtual-printer?${params.toString()}`, { method: 'PUT', diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index 6b43f45d3..1d9155e45 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -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 { diff --git a/frontend/src/components/FilamentTrends.tsx b/frontend/src/components/FilamentTrends.tsx index 594ef61c7..98cfd071b 100644 --- a/frontend/src/components/FilamentTrends.tsx +++ b/frontend/src/components/FilamentTrends.tsx @@ -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); diff --git a/frontend/src/components/FileManagerModal.tsx b/frontend/src/components/FileManagerModal.tsx index 0b3abd30a..de6802b3e 100644 --- a/frontend/src/components/FileManagerModal.tsx +++ b/frontend/src/components/FileManagerModal.tsx @@ -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(null); + const [plates, setPlates] = useState([]); + const [platesLoading, setPlatesLoading] = useState(false); + const [selectedPlateId, setSelectedPlateId] = useState(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 ( +

+
e.stopPropagation()} + > +
+

{filename}

+ +
+ +
+ + +
+ +
+ {activeTab === '3d' && hasModel ? ( +
+ {hasMultiplePlates && ( +
+
+ + Plates + {platesLoading && } +
+
+ + {plates.map((plate) => ( + + ))} +
+ {selectedPlate && ( +
+ Plate {selectedPlate.index} + {selectedPlate.print_time_seconds != null && ( + ETA {Math.round(selectedPlate.print_time_seconds / 60)} min + )} + {selectedPlate.filament_used_grams != null && ( + {selectedPlate.filament_used_grams.toFixed(1)} g + )} + {selectedPlate.filaments.length > 0 && ( + {selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''} + )} +
+ )} +
+ )} +
+ +
+
+ ) : activeTab === 'gcode' && hasGcode ? ( + + ) : ( +
+ No preview available for this file +
+ )} +
+
+
+ ); +} + 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([]); const [sortBy, setSortBy] = useState('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 @@ -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) => (