Parcourir la source

Merge branch '0.1.8b' into cadtoolbox/248

MartinNYHC il y a 3 mois
Parent
commit
cf286407fb
48 fichiers modifiés avec 4052 ajouts et 417 suppressions
  1. 46 0
      .github/workflows/codeql.yml
  2. 1 0
      .python-bin/python
  3. 56 1
      CHANGELOG.md
  4. 21 2
      README.md
  5. 88 19
      backend/app/api/routes/archives.py
  6. 79 13
      backend/app/api/routes/library.py
  7. 2 4
      backend/app/api/routes/metrics.py
  8. 316 1
      backend/app/api/routes/printers.py
  9. 25 1
      backend/app/api/routes/settings.py
  10. 1 1
      backend/app/core/config.py
  11. 7 3
      backend/app/main.py
  12. 119 0
      backend/app/services/network_utils.py
  13. 119 27
      backend/app/services/print_scheduler.py
  14. 116 19
      backend/app/services/virtual_printer/manager.py
  15. 245 20
      backend/app/services/virtual_printer/ssdp_server.py
  16. 3 2
      backend/app/services/virtual_printer/tcp_proxy.py
  17. 163 2
      backend/tests/unit/services/test_virtual_printer.py
  18. 202 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  19. 25 2
      frontend/src/__tests__/api/client.test.ts
  20. 2 1
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  21. 349 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  22. 8 2
      frontend/src/__tests__/mocks/handlers.ts
  23. 8 2
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  24. 61 51
      frontend/src/api/client.ts
  25. 4 2
      frontend/src/components/FilamentHoverCard.tsx
  26. 2 2
      frontend/src/components/FilamentTrends.tsx
  27. 236 3
      frontend/src/components/FileManagerModal.tsx
  28. 401 146
      frontend/src/components/ModelViewer.tsx
  29. 183 15
      frontend/src/components/ModelViewerModal.tsx
  30. 3 1
      frontend/src/components/PrintModal/index.tsx
  31. 58 4
      frontend/src/components/VirtualPrinterSettings.tsx
  32. 136 45
      frontend/src/hooks/useFilamentMapping.ts
  33. 14 5
      frontend/src/i18n/locales/de.ts
  34. 14 5
      frontend/src/i18n/locales/en.ts
  35. 30 0
      frontend/src/i18n/locales/ja.ts
  36. 13 2
      frontend/src/pages/ArchivesPage.tsx
  37. 37 0
      frontend/src/pages/FileManagerPage.tsx
  38. 47 3
      frontend/src/pages/QueuePage.tsx
  39. 2 2
      frontend/src/pages/StatsPage.tsx
  40. 41 0
      frontend/src/types/plates.ts
  41. 0 7
      frontend/vitest.config.ts
  42. 13 0
      plans/3mf-plate-management-verification.md
  43. 206 0
      plans/3mf-plate-management.md
  44. 520 0
      start_bambuddy.bat
  45. 0 0
      static/assets/index-3wkUCoeq.js
  46. 0 0
      static/assets/index-fdAEMOwp.css
  47. 2 2
      static/index.html
  48. 28 0
      whats_new.md

+ 46 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,46 @@
+name: CodeQL
+
+on:
+  push:
+    branches: ['**']
+  pull_request:
+    branches: ['**']
+  schedule:
+    # Run weekly on Sunday at 3:00 UTC
+    - cron: '0 3 * * 0'
+
+# Cancel in-progress runs for the same branch
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+  security-events: write
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        language: ['javascript-typescript', 'python']
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v4
+        with:
+          languages: ${{ matrix.language }}
+          # Use default queries plus security-extended
+          queries: security-extended
+
+      - name: Autobuild
+        uses: github/codeql-action/autobuild@v4
+
+      - name: Perform CodeQL Analysis
+        uses: github/codeql-action/analyze@v4
+        with:
+          category: '/language:${{ matrix.language }}'

+ 1 - 0
.python-bin/python

@@ -0,0 +1 @@
+/Users/wreaves/projects/MisterBeardy/bambuddy/.venv/bin/python

+ 56 - 1
CHANGELOG.md

@@ -2,7 +2,62 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 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
 ### Security
 - **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
 - **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):

+ 21 - 2
README.md

@@ -11,7 +11,7 @@
 <p align="center">
 <p align="center">
   <a href="https://github.com/maziggy/bambuddy/releases"><img src="https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue" alt="Release"></a>
   <a href="https://github.com/maziggy/bambuddy/releases"><img src="https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue" alt="Release"></a>
   <img src="https://github.com/maziggy/bambuddy/actions/workflows/github-code-scanning/codeql/badge.svg">
   <img src="https://github.com/maziggy/bambuddy/actions/workflows/github-code-scanning/codeql/badge.svg">
-  <img src="https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main">  
+  <img src="https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main">
   <a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square" alt="License"></a>
   <a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square" alt="License"></a>
   <a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
   <a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
   <a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
   <a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
@@ -444,7 +444,26 @@ services:
 
 
 </details>
 </details>
 
 
-#### Manual Installation
+#### Windows (Portable Launcher)
+
+The easiest way to run Bambuddy on Windows - no installation required:
+
+```batch
+git clone https://github.com/maziggy/bambuddy.git
+cd bambuddy
+start_bambuddy.bat
+```
+
+Double-click `start_bambuddy.bat` and it will:
+- Download Python and Node.js automatically (portable, no system changes)
+- Install dependencies and build the frontend
+- Open your browser to http://localhost:8000
+
+Everything is stored in the `.portable\` folder. Use `start_bambuddy.bat reset` to clean up.
+
+> **Custom port:** `set PORT=9000 & start_bambuddy.bat`
+
+#### Manual Installation (Linux/macOS)
 
 
 ```bash
 ```bash
 # Clone and setup
 # Clone and setup

+ 88 - 19
backend/app/api/routes/archives.py

@@ -466,10 +466,8 @@ async def get_archive_stats(
             total_seconds += print_time_seconds
             total_seconds += print_time_seconds
     total_time = total_seconds / 3600  # Convert to hours
     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
     total_filament = filament_result.scalar() or 0
 
 
     cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
     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,
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     and filament requirements. For single-plate exports, returns a single plate.
     """
     """
+    import json
+    import re
     import xml.etree.ElementTree as ET
     import xml.etree.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -2274,19 +2274,50 @@ async def get_archive_plates(
             # Find all plate gcode files to determine available 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")]
             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": []}
-
-            # 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 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)
+
+            if not plate_indices:
+                # No plate metadata found
+                return {
+                    "archive_id": archive_id,
+                    "filename": archive.filename,
+                    "plates": [],
+                    "is_multi_plate": False,
+                }
 
 
             plate_indices.sort()
             plate_indices.sort()
 
 
@@ -2391,16 +2422,48 @@ async def get_archive_plates(
 
 
                         plate_metadata[plate_index] = plate_info
                         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
             # Build plate list
             for idx in plate_indices:
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
                 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(
                 plates.append(
                     {
                     {
                         "index": idx,
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
                         "has_thumbnail": has_thumbnail,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
                         if has_thumbnail
@@ -2511,6 +2574,8 @@ async def get_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                 try:
                                     used_grams = float(used_g)
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
                                 except (ValueError, TypeError):
@@ -2524,6 +2589,7 @@ async def get_filament_requirements(
                                             "color": filament_color,
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                         }
                                     )
                                     )
                             break
                             break
@@ -2537,6 +2603,8 @@ async def get_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "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
                         # Only include filaments that are actually used
                         try:
                         try:
                             used_grams = float(used_g)
                             used_grams = float(used_g)
@@ -2551,6 +2619,7 @@ async def get_filament_requirements(
                                     "color": filament_color,
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                                 }
                             )
                             )
 
 

+ 79 - 13
backend/app/api/routes/library.py

@@ -1310,6 +1310,8 @@ async def get_library_file_plates(
     Returns a list of plates with their index, name, thumbnail availability,
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     and filament requirements. For single-plate exports, returns a single plate.
     """
     """
+    import json
+    import re
     import xml.etree.ElementTree as ET
     import xml.etree.ElementTree as ET
     import zipfile
     import zipfile
 
 
@@ -1337,19 +1339,45 @@ async def get_library_file_plates(
             # Find all plate gcode files to determine available 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")]
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
 
 
-            if not gcode_files:
-                # No sliced plates found
+            # 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)
+
+            if not plate_indices:
+                # No plate metadata found
                 return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
                 return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
 
 
-            # 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
-
             plate_indices.sort()
             plate_indices.sort()
 
 
             # Parse model_settings.config for plate names
             # Parse model_settings.config for plate names
@@ -1446,16 +1474,48 @@ async def get_library_file_plates(
                             plate_info["name"] = plate_info["objects"][0]
                             plate_info["name"] = plate_info["objects"][0]
                         plate_metadata[plate_index] = plate_info
                         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
             # Build plate list
             for idx in plate_indices:
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
                 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(
                 plates.append(
                     {
                     {
                         "index": idx,
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
                         "has_thumbnail": has_thumbnail,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
                         if has_thumbnail
@@ -1577,6 +1637,8 @@ async def get_library_file_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                 try:
                                     used_grams = float(used_g)
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
                                 except (ValueError, TypeError):
@@ -1590,6 +1652,7 @@ async def get_library_file_filament_requirements(
                                             "color": filament_color,
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                         }
                                     )
                                     )
                             break
                             break
@@ -1602,6 +1665,8 @@ async def get_library_file_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
                         used_m = filament_elem.get("used_m", "0")
 
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         try:
                         try:
                             used_grams = float(used_g)
                             used_grams = float(used_g)
                         except (ValueError, TypeError):
                         except (ValueError, TypeError):
@@ -1615,6 +1680,7 @@ async def get_library_file_filament_requirements(
                                     "color": filament_color,
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                                 }
                             )
                             )
 
 

+ 2 - 4
backend/app/api/routes/metrics.py

@@ -362,13 +362,11 @@ async def get_metrics(
             )
             )
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
             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("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
     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
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 
 

+ 316 - 1
backend/app/api/routes/printers.py

@@ -543,9 +543,9 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 async def get_printer_cover(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
     view: str | None = None,
     view: str | None = None,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
+    # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
 
 
     Args:
     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")
 @router.post("/{printer_id}/files/download-zip")
 async def download_printer_files_as_zip(
 async def download_printer_files_as_zip(
     printer_id: int,
     printer_id: int,

+ 25 - 1
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")
 @router.get("/virtual-printer/models")
 async def get_virtual_printer_models(
 async def get_virtual_printer_models(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -466,6 +477,7 @@ async def get_virtual_printer_settings(
     mode = await get_setting(db, "virtual_printer_mode")
     mode = await get_setting(db, "virtual_printer_mode")
     model = await get_setting(db, "virtual_printer_model")
     model = await get_setting(db, "virtual_printer_model")
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
     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 {
     return {
         "enabled": enabled == "true" if enabled else False,
         "enabled": enabled == "true" if enabled else False,
@@ -473,6 +485,7 @@ async def get_virtual_printer_settings(
         "mode": mode or "immediate",
         "mode": mode or "immediate",
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "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(),
         "status": virtual_printer_manager.get_status(),
     }
     }
 
 
@@ -484,10 +497,16 @@ async def update_virtual_printer_settings(
     mode: str = None,
     mode: str = None,
     model: str = None,
     model: str = None,
     target_printer_id: int = None,
     target_printer_id: int = None,
+    remote_interface_ip: str = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: 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 sqlalchemy import select
 
 
     from backend.app.models.printer import Printer
     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_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_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_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
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
     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_mode = mode if mode is not None else current_mode
     new_model = model if model is not None else current_model
     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_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
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
     # "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)
         await set_setting(db, "virtual_printer_model", model)
     if target_printer_id is not None:
     if target_printer_id is not None:
         await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
         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()
     await db.commit()
     db.expire_all()
     db.expire_all()
 
 
@@ -599,6 +622,7 @@ async def update_virtual_printer_settings(
             model=new_model,
             model=new_model,
             target_printer_ip=target_printer_ip,
             target_printer_ip=target_printer_ip,
             target_printer_serial=target_printer_serial,
             target_printer_serial=target_printer_serial,
+            remote_interface_ip=new_remote_iface,
         )
         )
     except ValueError as e:
     except ValueError as e:
         logger.warning(f"Virtual printer configuration validation error: {e}")
         logger.warning(f"Virtual printer configuration validation error: {e}")

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.7"
+APP_VERSION = "0.1.8b"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

+ 7 - 3
backend/app/main.py

@@ -727,7 +727,11 @@ async def on_print_start(printer_id: int, data: dict):
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
 
 
         # Plate detection check - pause if objects detected on build plate
         # 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:
         if printer and printer.plate_detection_enabled:
+            logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}")
             try:
             try:
                 from backend.app.services.plate_detection import check_plate_empty
                 from backend.app.services.plate_detection import check_plate_empty
 
 
@@ -2423,8 +2427,6 @@ async def lifespan(app: FastAPI):
 
 
         # Restore MQTT smart plug subscriptions
         # Restore MQTT smart plug subscriptions
         if mqtt_settings.get("mqtt_enabled"):
         if mqtt_settings.get("mqtt_enabled"):
-            from sqlalchemy import select
-
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
 
 
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
@@ -2501,6 +2503,7 @@ async def lifespan(app: FastAPI):
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_model = await get_setting(db, "virtual_printer_model") or ""
             vp_model = await get_setting(db, "virtual_printer_model") or ""
             vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
             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
             # Look up printer IP and serial if in proxy mode
             vp_target_ip = ""
             vp_target_ip = ""
@@ -2526,6 +2529,7 @@ async def lifespan(app: FastAPI):
                         model=vp_model,
                         model=vp_model,
                         target_printer_ip=vp_target_ip,
                         target_printer_ip=vp_target_ip,
                         target_printer_serial=vp_target_serial,
                         target_printer_serial=vp_target_serial,
+                        remote_interface_ip=vp_remote_iface,
                     )
                     )
                     if vp_mode == "proxy":
                     if vp_mode == "proxy":
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
@@ -2662,8 +2666,8 @@ async def auth_middleware(request, call_next):
         )
         )
 
 
     # Validate JWT token
     # Validate JWT token
+    import jwt
     try:
     try:
-        import jwt
 
 
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
 
 

+ 119 - 0
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]

+ 119 - 27
backend/app/services/print_scheduler.py

@@ -5,7 +5,7 @@ import json
 import logging
 import logging
 import xml.etree.ElementTree as ET
 import xml.etree.ElementTree as ET
 import zipfile
 import zipfile
-from datetime import datetime
+from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 
 
 from sqlalchemy import func, select
 from sqlalchemy import func, select
@@ -76,6 +76,18 @@ class PrintScheduler:
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
                     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
                 # Skip items that require manual start
                 if item.manual_start:
                 if item.manual_start:
                     continue
                     continue
@@ -434,6 +446,8 @@ class PrintScheduler:
                                 filament_id = filament_elem.get("id")
                                 filament_id = filament_elem.get("id")
                                 filament_type = filament_elem.get("type", "")
                                 filament_type = filament_elem.get("type", "")
                                 filament_color = filament_elem.get("color", "")
                                 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")
                                 used_g = filament_elem.get("used_g", "0")
                                 try:
                                 try:
                                     used_grams = float(used_g)
                                     used_grams = float(used_g)
@@ -443,6 +457,7 @@ class PrintScheduler:
                                                 "slot_id": int(filament_id),
                                                 "slot_id": int(filament_id),
                                                 "type": filament_type,
                                                 "type": filament_type,
                                                 "color": filament_color,
                                                 "color": filament_color,
+                                                "tray_info_idx": tray_info_idx,
                                                 "used_grams": round(used_grams, 1),
                                                 "used_grams": round(used_grams, 1),
                                             }
                                             }
                                         )
                                         )
@@ -455,6 +470,8 @@ class PrintScheduler:
                         filament_id = filament_elem.get("id")
                         filament_id = filament_elem.get("id")
                         filament_type = filament_elem.get("type", "")
                         filament_type = filament_elem.get("type", "")
                         filament_color = filament_elem.get("color", "")
                         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")
                         used_g = filament_elem.get("used_g", "0")
                         try:
                         try:
                             used_grams = float(used_g)
                             used_grams = float(used_g)
@@ -464,6 +481,7 @@ class PrintScheduler:
                                         "slot_id": int(filament_id),
                                         "slot_id": int(filament_id),
                                         "type": filament_type,
                                         "type": filament_type,
                                         "color": filament_color,
                                         "color": filament_color,
+                                        "tray_info_idx": tray_info_idx,
                                         "used_grams": round(used_grams, 1),
                                         "used_grams": round(used_grams, 1),
                                     }
                                     }
                                 )
                                 )
@@ -500,6 +518,8 @@ class PrintScheduler:
                 if tray_type:
                 if tray_type:
                     tray_id = tray.get("id", 0)
                     tray_id = tray.get("id", 0)
                     tray_color = tray.get("tray_color", "")
                     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
                     # Normalize color: remove alpha, add hash
                     color = self._normalize_color(tray_color)
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
                     # Calculate global tray ID
@@ -509,6 +529,7 @@ class PrintScheduler:
                         {
                         {
                             "type": tray_type,
                             "type": tray_type,
                             "color": color,
                             "color": color,
+                            "tray_info_idx": tray_info_idx,
                             "ams_id": ams_id,
                             "ams_id": ams_id,
                             "tray_id": tray_id,
                             "tray_id": tray_id,
                             "is_ht": is_ht,
                             "is_ht": is_ht,
@@ -525,6 +546,7 @@ class PrintScheduler:
                 {
                 {
                     "type": vt_tray["tray_type"],
                     "type": vt_tray["tray_type"],
                     "color": color,
                     "color": color,
+                    "tray_info_idx": vt_tray.get("tray_info_idx", ""),
                     "ams_id": -1,
                     "ams_id": -1,
                     "tray_id": 0,
                     "tray_id": 0,
                     "is_ht": False,
                     "is_ht": False,
@@ -569,11 +591,17 @@ class PrintScheduler:
     def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
     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.
         """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:
         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:
         Returns:
             AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
             AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
@@ -588,31 +616,64 @@ class PrintScheduler:
         for req in required:
         for req in required:
             req_type = (req.get("type") or "").upper()
             req_type = (req.get("type") or "").upper()
             req_color = req.get("color", "")
             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
             exact_match = None
             similar_match = None
             similar_match = None
             type_only_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]
+
+            # 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
+
+            # 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):
-                    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
-
-            match = exact_match or similar_match or type_only_match
+                    # 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:
             if match:
                 used_tray_ids.add(match["global_tray_id"])
                 used_tray_ids.add(match["global_tray_id"])
                 comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": 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")
                 logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 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
             file_path = settings.base_dir / archive.file_path
             filename = archive.filename
             filename = archive.filename
 
 
@@ -953,6 +1036,17 @@ class PrintScheduler:
             except json.JSONDecodeError:
             except json.JSONDecodeError:
                 logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
                 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
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
         started = printer_manager.start_print(
             item.printer_id,
             item.printer_id,
@@ -968,10 +1062,7 @@ class PrintScheduler:
         )
         )
 
 
         if started:
         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
             # Get estimated time for notification
             estimated_time = None
             estimated_time = None
@@ -1003,6 +1094,7 @@ class PrintScheduler:
             except Exception:
             except Exception:
                 pass  # Don't fail if MQTT fails
                 pass  # Don't fail if MQTT fails
         else:
         else:
+            # Print command failed - revert status
             item.status = "failed"
             item.status = "failed"
             item.error_message = "Failed to send print command to printer"
             item.error_message = "Failed to send print command to printer"
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()

+ 116 - 19
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.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 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.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
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -93,9 +93,11 @@ class VirtualPrinterManager:
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_serial = ""  # For proxy mode (real printer's serial)
         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
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
@@ -108,12 +110,54 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         self._upload_dir = self._base_dir / "uploads"
         self._cert_dir = self._base_dir / "certs"
         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
         # Certificate service
         self._cert_service = CertificateService(self._cert_dir)
         self._cert_service = CertificateService(self._cert_dir)
 
 
         # Track pending uploads for MQTT correlation
         # Track pending uploads for MQTT correlation
         self._pending_files: dict[str, Path] = {}
         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:
     def _get_serial_for_model(self, model: str) -> str:
         """Get appropriate serial number for the given model.
         """Get appropriate serial number for the given model.
 
 
@@ -157,6 +201,7 @@ class VirtualPrinterManager:
         model: str = "",
         model: str = "",
         target_printer_ip: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",
         target_printer_serial: str = "",
+        remote_interface_ip: str = "",
     ) -> None:
     ) -> None:
         """Configure and start/stop virtual printer.
         """Configure and start/stop virtual printer.
 
 
@@ -167,6 +212,7 @@ class VirtualPrinterManager:
             model: SSDP model code (e.g., 'BL-P001' for X1C)
             model: SSDP model code (e.g., 'BL-P001' for X1C)
             target_printer_ip: Target printer IP for proxy mode
             target_printer_ip: Target printer IP for proxy mode
             target_printer_serial: Target printer serial 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
         # Proxy mode has different requirements
         if mode == "proxy":
         if mode == "proxy":
@@ -183,12 +229,14 @@ class VirtualPrinterManager:
         mode_changed = mode != self._mode
         mode_changed = mode != self._mode
         target_changed = target_printer_ip != self._target_printer_ip
         target_changed = target_printer_ip != self._target_printer_ip
         serial_changed = target_printer_serial != self._target_printer_serial
         serial_changed = target_printer_serial != self._target_printer_serial
+        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
         old_mode = self._mode
         old_mode = self._mode
 
 
         logger.debug(
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
             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
         self._access_code = access_code
@@ -196,8 +244,13 @@ class VirtualPrinterManager:
         self._model = new_model
         self._model = new_model
         self._target_printer_ip = target_printer_ip
         self._target_printer_ip = target_printer_ip
         self._target_printer_serial = target_printer_serial
         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:
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
             logger.info("Starting virtual printer (was disabled)")
@@ -247,13 +300,6 @@ class VirtualPrinterManager:
         cert_path, key_path = self._cert_service.generate_certificates()
         cert_path, key_path = self._cert_service.generate_certificates()
         logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
         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
         # Initialize TLS proxy with our certificates
         self._proxy = SlicerProxyManager(
         self._proxy = SlicerProxyManager(
             target_host=self._target_printer_ip,
             target_host=self._target_printer_ip,
@@ -269,21 +315,68 @@ class VirtualPrinterManager:
             except Exception as e:
             except Exception as e:
                 logger.error(f"Virtual printer {name} failed: {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(
             asyncio.create_task(
                 run_with_logging(self._proxy.start(), "Proxy"),
                 run_with_logging(self._proxy.start(), "Proxy"),
                 name="virtual_printer_proxy",
                 name="virtual_printer_proxy",
-            ),
-        ]
+            )
+        )
 
 
         logger.info(
         logger.info(
             f"Virtual printer proxy started: "
             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:
     async def _start_server_mode(self) -> None:
@@ -361,6 +454,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             await self._ssdp.stop()
             self._ssdp = None
             self._ssdp = None
 
 
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+
         if self._proxy:
         if self._proxy:
             await self._proxy.stop()
             await self._proxy.stop()
             self._proxy = None
             self._proxy = None

+ 245 - 20
backend/app/services/virtual_printer/ssdp_server.py

@@ -2,18 +2,23 @@
 
 
 Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
 Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
 announcements so the virtual printer appears as a discoverable Bambu printer.
 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 asyncio
 import logging
 import logging
+import re
 import socket
 import socket
 import struct
 import struct
-from datetime import datetime
 
 
 logger = logging.getLogger(__name__)
 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
 SSDP_PORT = 2021
 
 
 # Bambu service target
 # Bambu service target
@@ -60,44 +65,49 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
             return "127.0.0.1"
 
 
     def _build_notify_message(self) -> bytes:
     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()
         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
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
         message = (
             "NOTIFY * HTTP/1.1\r\n"
             "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"Location: {ip}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:alive\r\n"
             "NTS: ssdp:alive\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             f"USN: {self.serial}\r\n"
+            "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
             "\r\n"
         )
         )
         return message.encode()
         return message.encode()
 
 
     def _build_response_message(self) -> bytes:
     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()
         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
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
-        # Added: Devseclink, DevVersion, DevCap for better compatibility
         message = (
         message = (
             "HTTP/1.1 200 OK\r\n"
             "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"Location: {ip}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             f"USN: {self.serial}\r\n"
             "Cache-Control: max-age=1800\r\n"
             "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
@@ -106,7 +116,9 @@ class VirtualPrinterSSDPServer:
             "DevConnect.bambu.com: lan\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
             "\r\n"
         )
         )
         return message.encode()
         return message.encode()
@@ -137,7 +149,7 @@ class VirtualPrinterSSDPServer:
             self._socket.bind(("", SSDP_PORT))
             self._socket.bind(("", SSDP_PORT))
 
 
             # Join multicast group
             # 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)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
 
             # Enable broadcast
             # Enable broadcast
@@ -212,13 +224,14 @@ class VirtualPrinterSSDPServer:
             self._socket = None
             self._socket = None
 
 
     async def _send_notify(self) -> 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:
         if not self._socket:
             return
             return
 
 
         try:
         try:
             msg = self._build_notify_message()
             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}")
             logger.debug(f"Sent SSDP NOTIFY for {self.name}")
         except Exception as e:
         except Exception as e:
             logger.debug(f"Failed to send NOTIFY: {e}")
             logger.debug(f"Failed to send NOTIFY: {e}")
@@ -230,7 +243,7 @@ class VirtualPrinterSSDPServer:
 
 
         message = (
         message = (
             "NOTIFY * HTTP/1.1\r\n"
             "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"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:byebye\r\n"
             "NTS: ssdp:byebye\r\n"
             f"USN: {self.serial}\r\n"
             f"USN: {self.serial}\r\n"
@@ -238,7 +251,7 @@ class VirtualPrinterSSDPServer:
         )
         )
 
 
         try:
         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")
             logger.debug("Sent SSDP byebye")
         except Exception:
         except Exception:
             pass
             pass
@@ -268,3 +281,215 @@ class VirtualPrinterSSDPServer:
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
             except Exception as e:
             except Exception as e:
                 logger.debug(f"Failed to send SSDP response: {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}")

+ 3 - 2
backend/app/services/virtual_printer/tcp_proxy.py

@@ -289,8 +289,9 @@ class SlicerProxyManager:
     PRINTER_FTP_PORT = 990
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
     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
     LOCAL_MQTT_PORT = 8883
 
 
     def __init__(
     def __init__(

+ 163 - 2
backend/tests/unit/services/test_virtual_printer.py

@@ -464,7 +464,8 @@ class TestSlicerProxyManager:
 
 
     def test_proxy_manager_initializes_ports(self, proxy_manager):
     def test_proxy_manager_initializes_ports(self, proxy_manager):
         """Verify proxy manager has correct port constants."""
         """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.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
@@ -482,6 +483,125 @@ class TestSlicerProxyManager:
         assert status["mqtt_connections"] == 0
         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:
 class TestVirtualPrinterManagerProxyMode:
     """Tests for VirtualPrinterManager proxy mode."""
     """Tests for VirtualPrinterManager proxy mode."""
 
 
@@ -528,7 +648,7 @@ class TestVirtualPrinterManagerProxyMode:
         mock_proxy = MagicMock()
         mock_proxy = MagicMock()
         mock_proxy.get_status.return_value = {
         mock_proxy.get_status.return_value = {
             "running": True,
             "running": True,
-            "ftp_port": 9990,
+            "ftp_port": 990,  # Privileged port for Bambu Studio compatibility
             "mqtt_port": 8883,
             "mqtt_port": 8883,
             "ftp_connections": 1,
             "ftp_connections": 1,
             "mqtt_connections": 2,
             "mqtt_connections": 2,
@@ -541,5 +661,46 @@ class TestVirtualPrinterManagerProxyMode:
         assert status["mode"] == "proxy"
         assert status["mode"] == "proxy"
         assert status["target_printer_ip"] == "192.168.1.100"
         assert status["target_printer_ip"] == "192.168.1.100"
         assert "proxy" in status
         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"]["ftp_connections"] == 1
         assert status["proxy"]["mqtt_connections"] == 2
         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()

+ 202 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -265,3 +265,205 @@ class TestMatchFilamentsToSlots:
 
 
         result = scheduler._match_filaments_to_slots(required, loaded)
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [254]
         assert result == [254]
+
+    def test_match_by_tray_info_idx_priority(self, scheduler):
+        """tray_info_idx match should have highest priority over color match."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 0,
+                "tray_info_idx": "GFB00",
+            },  # Same color, different spool
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 1,
+                "tray_info_idx": "GFA00",
+            },  # Same color, exact spool
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 2,
+                "tray_info_idx": "GFC00",
+            },  # Same color, different spool
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (exact tray_info_idx match)
+
+    def test_match_by_tray_info_idx_with_different_colors(self, scheduler):
+        """tray_info_idx match should work even if colors differ slightly."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "P4d64437"}]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": ""},  # No idx
+            {
+                "type": "PLA",
+                "color": "#000010",
+                "global_tray_id": 3,
+                "tray_info_idx": "P4d64437",
+            },  # Exact spool (slightly different color reported)
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [3]  # Should pick tray 3 (exact tray_info_idx match)
+
+    def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler):
+        """Should fall back to color matching when tray_info_idx is empty."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "tray_info_idx": "GFB00"},  # Color match
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (color match)
+
+    def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler):
+        """Should fall back to color when tray_info_idx doesn't match any loaded spool."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "OLD_SPOOL"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "global_tray_id": 0,
+                "tray_info_idx": "NEW_SPOOL",
+            },  # Different idx but same color
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]  # Should fall back to color match
+
+    def test_match_multiple_same_color_with_tray_info_idx(self, scheduler):
+        """Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario)."""
+        # This is the exact scenario from issue #245 - 3 black PLA spools
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA03"},  # Wants tray 3
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},  # Tray 0
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},  # Tray 1
+            {"type": "PLA", "color": "#000000", "global_tray_id": 2, "tray_info_idx": "GFA02"},  # Tray 2
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 3,
+                "tray_info_idx": "GFA03",
+            },  # Tray 3 - the one we want
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [3]  # Should pick tray 3, not tray 0
+
+    def test_match_tray_info_idx_not_reused(self, scheduler):
+        """tray_info_idx matched trays should not be reused for other slots."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
+            {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 1]  # Each slot gets its specific tray
+
+    def test_match_non_unique_tray_info_idx_uses_color(self, scheduler):
+        """Non-unique tray_info_idx should fall back to color matching.
+
+        This is the scenario where multiple trays have the same tray_info_idx
+        (e.g., two spools of generic PLA both have GFA00). The color should
+        be used as tiebreaker instead of just picking the first match.
+        """
+        # User sliced with green PLA (tray_info_idx=GFA00)
+        # Two trays have GFA00: tray 3 (white) and tray 4 (green)
+        # Should pick tray 4 because the color matches
+        required = [
+            {"slot_id": 2, "type": "PLA", "color": "#00FF00", "tray_info_idx": "GFA00"},  # Green PLA
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"},  # White PLA
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "tray_info_idx": "GFA00"},  # Green PLA
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [-1, 4]  # Should pick tray 4 (color match), not tray 3 (first match)
+
+    def test_match_non_unique_tray_info_idx_same_color(self, scheduler):
+        """Non-unique tray_info_idx with identical colors picks first match.
+
+        When multiple trays have the same tray_info_idx AND same color,
+        there's no way to differentiate, so first match is used.
+        """
+        required = [
+            {"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA00"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 3, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 4, "tray_info_idx": "GFA00"},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Both have same color, so first is used
+        assert result == [-1, 3]
+
+
+class TestBuildLoadedFilamentsTrayInfoIdx:
+    """Test tray_info_idx extraction in _build_loaded_filaments."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler):
+        """Should extract tray_info_idx from AMS trays."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA00"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA01"},
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+        assert result[0]["tray_info_idx"] == "GFA00"
+        assert result[1]["tray_info_idx"] == "GFA01"
+
+    def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler):
+        """Missing tray_info_idx should default to empty string."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},  # No tray_info_idx
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["tray_info_idx"] == ""
+
+    def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler):
+        """Should extract tray_info_idx from external spool."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["tray_info_idx"] == "P4d64437"
+        assert result[0]["is_external"] is True

+ 25 - 2
frontend/src/__tests__/api/client.test.ts

@@ -95,11 +95,11 @@ describe('API Client Auth Header', () => {
     expect(capturedHeaders!.get('Authorization')).toBeNull();
     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(
     server.use(
       http.get('/api/v1/settings/spoolman', () => {
       http.get('/api/v1/settings/spoolman', () => {
         return HttpResponse.json(
         return HttpResponse.json(
-          { detail: 'Not authenticated' },
+          { detail: 'Could not validate credentials' },
           { status: 401 }
           { status: 401 }
         );
         );
       })
       })
@@ -117,6 +117,29 @@ describe('API Client Auth Header', () => {
     expect(getAuthToken()).toBeNull();
     expect(getAuthToken()).toBeNull();
     expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
     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', () => {
 describe('FormData requests include auth header', () => {

+ 2 - 1
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -43,6 +43,7 @@ const createMockSettings = (overrides = {}) => ({
   mode: 'immediate' as const,
   mode: 'immediate' as const,
   model: '3DPrinter-X1-Carbon',
   model: '3DPrinter-X1-Carbon',
   target_printer_id: null as number | null,
   target_printer_id: null as number | null,
+  remote_interface_ip: null as string | null,
   status: {
   status: {
     enabled: false,
     enabled: false,
     running: false,
     running: false,
@@ -515,7 +516,7 @@ describe('VirtualPrinterSettings', () => {
             proxy: {
             proxy: {
               running: true,
               running: true,
               target_host: '192.168.1.100',
               target_host: '192.168.1.100',
-              ftp_port: 9990,
+              ftp_port: 990,  // Privileged port for Bambu Studio compatibility
               mqtt_port: 8883,
               mqtt_port: 8883,
               ftp_connections: 1,
               ftp_connections: 1,
               mqtt_connections: 2,
               mqtt_connections: 2,

+ 349 - 0
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
+  });
+});

+ 8 - 2
frontend/src/__tests__/mocks/handlers.ts

@@ -383,8 +383,14 @@ export const handlers = [
   // Archives
   // 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', () => {
   http.get('/api/v1/archives/:id/filament-requirements', () => {

+ 8 - 2
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -84,8 +84,14 @@ describe('ArchivesPage', () => {
       http.get('/api/v1/archives/tags', () => {
       http.get('/api/v1/archives/tags', () => {
         return HttpResponse.json(['test', 'calibration', 'functional']);
         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', () => {
       http.get('/api/v1/archives/:id/filament-requirements', () => {
         return HttpResponse.json([]);
         return HttpResponse.json([]);

+ 61 - 51
frontend/src/api/client.ts

@@ -1,3 +1,5 @@
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
+
 const API_BASE = '/api/v1';
 const API_BASE = '/api/v1';
 
 
 // Auth token storage
 // Auth token storage
@@ -37,16 +39,27 @@ async function request<T>(
   });
   });
 
 
   if (!response.ok) {
   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 error = await response.json().catch(() => ({}));
     const detail = error.detail;
     const detail = error.detail;
     const message = typeof detail === 'string'
     const message = typeof detail === 'string'
       ? detail
       ? detail
       : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
       : (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);
     throw new Error(message);
   }
   }
 
 
@@ -2063,6 +2076,33 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  getPrinterFileGcodeUrl: (printerId: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
+  getPrinterFilePlates: (printerId: number, path: string) =>
+    request<{
+      printer_id: number;
+      path: string;
+      filename: string;
+      plates: Array<{
+        index: number;
+        name: string | null;
+        objects: string[];
+        has_thumbnail: boolean;
+        thumbnail_url: string | null;
+        print_time_seconds: number | null;
+        filament_used_grams: number | null;
+        filaments: Array<{
+          slot_id: number;
+          type: string;
+          color: string;
+          used_grams: number;
+          used_meters: number;
+        }>;
+      }>;
+      is_multi_plate: boolean;
+    }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
+  getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
   downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
   downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
     const headers: Record<string, string> = {};
     const headers: Record<string, string> = {};
     if (authToken) {
     if (authToken) {
@@ -2557,27 +2597,7 @@ export const api = {
   getArchiveForSlicer: (id: number, filename: string) =>
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
   getArchivePlates: (archiveId: number) =>
-    request<{
-      archive_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        has_thumbnail: boolean;
-        thumbnail_url: string | null;
-        print_time_seconds: number | null;
-        filament_used_grams: number | null;
-        filaments: Array<{
-          slot_id: number;
-          type: string;
-          color: string;
-          used_grams: number;
-          used_meters: number;
-        }>;
-      }>;
-      is_multi_plate: boolean;
-    }>(`/archives/${archiveId}/plates`),
+    request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
     request<{
     request<{
       archive_id: number;
       archive_id: number;
@@ -2713,6 +2733,8 @@ export const api = {
   },
   },
   checkFfmpeg: () =>
   checkFfmpeg: () =>
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
+  getNetworkInterfaces: () =>
+    request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
 
 
   // Cloud
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -2794,13 +2816,11 @@ export const api = {
 
 
   // Tasmota Discovery (auto-detects network)
   // Tasmota Discovery (auto-detects network)
   startTasmotaScan: () =>
   startTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
   getTasmotaScanStatus: () =>
   getTasmotaScanStatus: () =>
     request<TasmotaScanStatus>('/smart-plugs/discover/status'),
     request<TasmotaScanStatus>('/smart-plugs/discover/status'),
   stopTasmotaScan: () =>
   stopTasmotaScan: () =>
-    fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
-      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+    request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
   getDiscoveredTasmotaDevices: () =>
   getDiscoveredTasmotaDevices: () =>
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
 
 
@@ -3565,27 +3585,7 @@ export const api = {
       }
       }
     ),
     ),
   getLibraryFilePlates: (fileId: number) =>
   getLibraryFilePlates: (fileId: number) =>
-    request<{
-      file_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        has_thumbnail: boolean;
-        thumbnail_url: string | null;
-        print_time_seconds: number | null;
-        filament_used_grams: number | null;
-        filaments: Array<{
-          slot_id: number;
-          type: string;
-          color: string;
-          used_grams: number;
-          used_meters: number;
-        }>;
-      }>;
-      is_multi_plate: boolean;
-    }>(`/library/files/${fileId}/plates`),
+    request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
     request<{
     request<{
       file_id: number;
       file_id: number;
@@ -3992,9 +3992,17 @@ export interface VirtualPrinterSettings {
   mode: VirtualPrinterMode;
   mode: VirtualPrinterMode;
   model: string;
   model: string;
   target_printer_id: number | null;  // For proxy mode
   target_printer_id: number | null;  // For proxy mode
+  remote_interface_ip: string | null;  // For SSDP proxy across networks
   status: VirtualPrinterStatus;
   status: VirtualPrinterStatus;
 }
 }
 
 
+export interface NetworkInterface {
+  name: string;
+  ip: string;
+  netmask: string;
+  subnet: string;
+}
+
 export interface VirtualPrinterModels {
 export interface VirtualPrinterModels {
   models: Record<string, string>;  // SSDP code -> display name
   models: Record<string, string>;  // SSDP code -> display name
   default: string;
   default: string;
@@ -4024,6 +4032,7 @@ export const virtualPrinterApi = {
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
     model?: string;
     target_printer_id?: number;
     target_printer_id?: number;
+    remote_interface_ip?: string;
   }) => {
   }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4031,6 +4040,7 @@ export const virtualPrinterApi = {
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.model !== undefined) params.set('model', data.model);
     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.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
+    if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
 
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',
       method: 'PUT',

+ 4 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -87,10 +87,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     if (isVisible && triggerRef.current && cardRef.current) {
     if (isVisible && triggerRef.current && cardRef.current) {
       const triggerRect = triggerRef.current.getBoundingClientRect();
       const triggerRect = triggerRef.current.getBoundingClientRect();
       const cardHeight = cardRef.current.offsetHeight;
       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;
       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) {
       if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
         setPosition('bottom');
         setPosition('bottom');
       } else {
       } else {

+ 2 - 2
frontend/src/components/FilamentTrends.tsx

@@ -143,7 +143,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
 
 
       months.push({
       months.push({
         month: monthStr,
         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),
         cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
         prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
         prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
       });
       });
@@ -153,7 +153,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   }, [archives]);
   }, [archives]);
 
 
   const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
   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 totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
   const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
   const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
 
 

+ 236 - 3
frontend/src/components/FileManagerModal.tsx

@@ -20,10 +20,14 @@ import {
   CheckSquare,
   CheckSquare,
   Square,
   Square,
   MinusSquare,
   MinusSquare,
+  Box,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { ModelViewer } from './ModelViewer';
+import { GcodeViewer } from './GcodeViewer';
+import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 interface FileManagerModalProps {
 interface FileManagerModalProps {
@@ -32,6 +36,205 @@ interface FileManagerModalProps {
   onClose: () => void;
   onClose: () => void;
 }
 }
 
 
+type PrinterViewerTab = '3d' | 'gcode';
+
+interface PrinterFileViewerModalProps {
+  printerId: number;
+  filePath: string;
+  filename: string;
+  onClose: () => void;
+}
+
+function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) {
+  const [activeTab, setActiveTab] = useState<PrinterViewerTab | null>(null);
+  const [plates, setPlates] = useState<PlateMetadata[]>([]);
+  const [platesLoading, setPlatesLoading] = useState(false);
+  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
+
+  const ext = filename.toLowerCase().split('.').pop() || '';
+  const hasModel = ext === '3mf' || ext === 'stl';
+  const hasGcode = ext === 'gcode' || ext === '3mf';
+
+  useEffect(() => {
+    setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+  }, [hasModel, hasGcode]);
+
+  useEffect(() => {
+    setPlates([]);
+    setSelectedPlateId(null);
+
+    if (!hasModel) return;
+
+    setPlatesLoading(true);
+    api.getPrinterFilePlates(printerId, filePath)
+      .then((data) => setPlates(data.plates || []))
+      .catch(() => setPlates([]))
+      .finally(() => setPlatesLoading(false));
+  }, [filePath, hasModel, printerId]);
+
+  const hasMultiplePlates = plates.length > 1;
+  const selectedPlate = selectedPlateId == null
+    ? null
+    : plates.find((plate) => plate.index === selectedPlateId) ?? null;
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-6" onClick={onClose}>
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{filename}</h2>
+          <Button variant="ghost" size="sm" onClick={onClose}>
+            <X className="w-5 h-5" />
+          </Button>
+        </div>
+
+        <div className="flex border-b border-bambu-dark-tertiary">
+          <button
+            onClick={() => hasModel && setActiveTab('3d')}
+            disabled={!hasModel}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === '3d'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : hasModel
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <Box className="w-4 h-4" />
+            3D Model
+            {!hasModel && <span className="text-xs">(not available)</span>}
+          </button>
+          <button
+            onClick={() => hasGcode && setActiveTab('gcode')}
+            disabled={!hasGcode}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === 'gcode'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : hasGcode
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <FileText className="w-4 h-4" />
+            G-code Preview
+            {!hasGcode && <span className="text-xs">(not sliced)</span>}
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-hidden p-4">
+          {activeTab === '3d' && hasModel ? (
+            <div className="w-full h-full flex flex-col gap-3">
+              {hasMultiplePlates && (
+                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                    <Box className="w-4 h-4" />
+                    Plates
+                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                  </div>
+                  <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
+                    <button
+                      type="button"
+                      onClick={() => setSelectedPlateId(null)}
+                      className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                        selectedPlateId == null
+                          ? 'border-bambu-green bg-bambu-green/10'
+                          : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                      }`}
+                    >
+                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                        <Box className="w-5 h-5 text-bambu-gray" />
+                      </div>
+                      <div className="min-w-0 flex-1">
+                        <p className="text-sm text-white font-medium truncate">All Plates</p>
+                        <p className="text-xs text-bambu-gray truncate">
+                          {plates.length} plate{plates.length !== 1 ? 's' : ''}
+                        </p>
+                      </div>
+                      {selectedPlateId == null && (
+                        <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </button>
+                    {plates.map((plate) => (
+                      <button
+                        key={plate.index}
+                        type="button"
+                        onClick={() => setSelectedPlateId(plate.index)}
+                        className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                          selectedPlateId === plate.index
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                        }`}
+                      >
+                        {plate.has_thumbnail ? (
+                          <img
+                            src={api.getPrinterFilePlateThumbnail(printerId, plate.index, filePath)}
+                            alt={`Plate ${plate.index}`}
+                            className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+                          />
+                        ) : (
+                          <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                            <Box className="w-5 h-5 text-bambu-gray" />
+                          </div>
+                        )}
+                        <div className="min-w-0 flex-1">
+                          <p className="text-sm text-white font-medium truncate">
+                            {plate.name || `Plate ${plate.index}`}
+                          </p>
+                          <p className="text-xs text-bambu-gray truncate">
+                            {plate.objects.length > 0
+                              ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
+                              : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                          </p>
+                        </div>
+                        {selectedPlateId === plate.index && (
+                          <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                        )}
+                      </button>
+                    ))}
+                  </div>
+                  {selectedPlate && (
+                    <div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
+                      <span>Plate {selectedPlate.index}</span>
+                      {selectedPlate.print_time_seconds != null && (
+                        <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
+                      )}
+                      {selectedPlate.filament_used_grams != null && (
+                        <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                      )}
+                      {selectedPlate.filaments.length > 0 && (
+                        <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
+                      )}
+                    </div>
+                  )}
+                </div>
+              )}
+              <div className="flex-1">
+                <ModelViewer
+                  url={api.getPrinterFileDownloadUrl(printerId, filePath)}
+                  fileType={ext}
+                  selectedPlateId={selectedPlateId}
+                  className="w-full h-full"
+                />
+              </div>
+            </div>
+          ) : activeTab === 'gcode' && hasGcode ? (
+            <GcodeViewer
+              gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}
+              className="w-full h-full"
+            />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              No preview available for this file
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
   if (bytes === 0) return '0 B';
   if (bytes === 0) return '0 B';
   const k = 1024;
   const k = 1024;
@@ -92,6 +295,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
   const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
   const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
   const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
+  const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null);
 
 
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
@@ -253,6 +457,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               <button
               <button
                 onClick={onClose}
                 onClick={onClose}
                 className="text-bambu-gray hover:text-white transition-colors"
                 className="text-bambu-gray hover:text-white transition-colors"
+                title="Close file manager"
+                aria-label="Close file manager"
               >
               >
                 <X className="w-5 h-5" />
                 <X className="w-5 h-5" />
               </button>
               </button>
@@ -294,6 +500,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               value={sortBy}
               value={sortBy}
               onChange={(e) => setSortBy(e.target.value as SortOption)}
               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"
               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) => (
               {SORT_OPTIONS.map((option) => (
                 <option key={option.value} value={option.value}>
                 <option key={option.value} value={option.value}>
@@ -318,6 +526,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               onClick={navigateUp}
               onClick={navigateUp}
               disabled={currentPath === '/'}
               disabled={currentPath === '/'}
               className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
               className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+              title="Go to parent folder"
+              aria-label="Go to parent folder"
             >
             >
               <ChevronLeft className="w-4 h-4" />
               <ChevronLeft className="w-4 h-4" />
             </button>
             </button>
@@ -408,9 +618,23 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                         />
                         />
                         <span className="flex-1 text-white truncate">{file.name}</span>
                         <span className="flex-1 text-white truncate">{file.name}</span>
                         {!file.is_directory && (
                         {!file.is_directory && (
-                          <span className="text-sm text-bambu-gray">
-                            {formatFileSize(file.size)}
-                          </span>
+                          <div className="flex items-center gap-3">
+                            <span className="text-sm text-bambu-gray">
+                              {formatFileSize(file.size)}
+                            </span>
+                            {(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && (
+                              <button
+                                onClick={(e) => {
+                                  e.stopPropagation();
+                                  setViewerFile({ path: file.path, name: file.name });
+                                }}
+                                className="p-1 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green"
+                                title="3D View"
+                              >
+                                <Box className="w-4 h-4" />
+                              </button>
+                            )}
+                          </div>
                         )}
                         )}
                         {file.is_directory && (
                         {file.is_directory && (
                           <ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
                           <ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
@@ -508,6 +732,15 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
           onCancel={() => setFilesToDelete([])}
           onCancel={() => setFilesToDelete([])}
         />
         />
       )}
       )}
+
+      {viewerFile && (
+        <PrinterFileViewerModal
+          printerId={printerId}
+          filePath={viewerFile.path}
+          filename={viewerFile.name}
+          onClose={() => setViewerFile(null)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 401 - 146
frontend/src/components/ModelViewer.tsx

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import * as THREE from 'three';
 import * as THREE from 'three';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
 import JSZip from 'jszip';
 import JSZip from 'jszip';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -14,8 +15,10 @@ interface BuildVolume {
 
 
 interface ModelViewerProps {
 interface ModelViewerProps {
   url: string;
   url: string;
+  fileType?: string;
   buildVolume?: BuildVolume;
   buildVolume?: BuildVolume;
   filamentColors?: string[];
   filamentColors?: string[];
+  selectedPlateId?: number | null;
   className?: string;
   className?: string;
 }
 }
 
 
@@ -29,12 +32,20 @@ interface ObjectData {
   id: string;
   id: string;
   meshes: MeshData[];
   meshes: MeshData[];
   defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
   defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
+  plateId?: number | null;
 }
 }
 
 
 interface BuildItem {
 interface BuildItem {
   objectId: string;
   objectId: string;
   transform: THREE.Matrix4;
   transform: THREE.Matrix4;
   extruder?: number; // Can override object's extruder
   extruder?: number; // Can override object's extruder
+  plateId?: number | null;
+}
+
+interface Parsed3MFData {
+  objects: Map<string, ObjectData>;
+  buildItems: BuildItem[];
+  plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
 }
 }
 
 
 // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
 // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
@@ -101,10 +112,34 @@ async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Pro
   return meshes;
   return meshes;
 }
 }
 
 
-async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
-  const zip = await JSZip.loadAsync(arrayBuffer);
+function parsePlateIdFromAttributes(element: Element): number | null {
+  const plateAttribute = Array.from(element.attributes).find((attr) => {
+    const name = attr.name.toLowerCase();
+    return (
+      name === 'plate_id' ||
+      name === 'plater_id' ||
+      name === 'plateid' ||
+      name === 'platerid' ||
+      name.endsWith(':plate_id') ||
+      name.endsWith(':plater_id')
+    );
+  });
+
+  if (!plateAttribute?.value) return null;
+  const parsed = Number.parseInt(plateAttribute.value, 10);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
+async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
+  let zip: JSZip;
+  try {
+    zip = await JSZip.loadAsync(arrayBuffer);
+  } catch {
+    throw new Error('Unsupported file format');
+  }
   const objects = new Map<string, ObjectData>();
   const objects = new Map<string, ObjectData>();
   const buildItems: BuildItem[] = [];
   const buildItems: BuildItem[] = [];
+  const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
   const parser = new DOMParser();
   const parser = new DOMParser();
 
 
   // Helper to load and parse a model file from the zip
   // Helper to load and parse a model file from the zip
@@ -121,6 +156,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
   // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
   // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
   const extruderMapById = new Map<string, number>();
   const extruderMapById = new Map<string, number>();
   const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
   const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
+  const objectNameById = new Map<string, string>();
+  const plateAssignmentsByObjectId = new Map<string, number>();
   const modelSettingsFile = zip.files['Metadata/model_settings.config'];
   const modelSettingsFile = zip.files['Metadata/model_settings.config'];
   if (modelSettingsFile) {
   if (modelSettingsFile) {
     try {
     try {
@@ -132,7 +169,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
         const objectId = objEl.getAttribute('id');
         const objectId = objEl.getAttribute('id');
         if (!objectId) continue;
         if (!objectId) continue;
 
 
-        // Find object-level extruder
+        // Find object-level extruder + name
         const directMetadata = Array.from(objEl.children).filter(
         const directMetadata = Array.from(objEl.children).filter(
           (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
           (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
         );
         );
@@ -143,6 +180,14 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
           }
           }
         }
         }
 
 
+        const nameMetadata = Array.from(objEl.children).find(
+          (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'
+        );
+        const objectName = nameMetadata?.getAttribute('value');
+        if (objectName) {
+          objectNameById.set(objectId, objectName);
+        }
+
         // Find part-level extruders
         // Find part-level extruders
         const partElements = objEl.getElementsByTagName('part');
         const partElements = objEl.getElementsByTagName('part');
         for (let j = 0; j < partElements.length; j++) {
         for (let j = 0; j < partElements.length; j++) {
@@ -162,11 +207,78 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
           }
           }
         }
         }
       }
       }
+
+      // Parse plate -> object assignments
+      const plateElements = doc.getElementsByTagName('plate');
+      for (let i = 0; i < plateElements.length; i++) {
+        const plateEl = plateElements[i];
+        let plateId: number | null = null;
+        const metadataElements = plateEl.getElementsByTagName('metadata');
+        for (let j = 0; j < metadataElements.length; j++) {
+          const metaEl = metadataElements[j];
+          const key = metaEl.getAttribute('key');
+          if (key === 'plater_id' || key === 'plate_id') {
+            const value = metaEl.getAttribute('value');
+            if (value) {
+              const parsed = Number.parseInt(value, 10);
+              if (Number.isFinite(parsed)) {
+                plateId = parsed;
+              }
+            }
+          }
+        }
+        if (plateId == null) continue;
+
+        const modelInstances = plateEl.getElementsByTagName('model_instance');
+        for (let j = 0; j < modelInstances.length; j++) {
+          const instanceEl = modelInstances[j];
+          const instanceMetadata = instanceEl.getElementsByTagName('metadata');
+          for (let k = 0; k < instanceMetadata.length; k++) {
+            const metaEl = instanceMetadata[k];
+            if (metaEl.getAttribute('key') === 'object_id') {
+              const value = metaEl.getAttribute('value');
+              if (value) {
+                plateAssignmentsByObjectId.set(value, plateId);
+              }
+            }
+          }
+        }
+      }
     } catch {
     } catch {
       // Silently ignore model_settings.config parsing errors
       // Silently ignore model_settings.config parsing errors
     }
     }
   }
   }
 
 
+  // Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
+  const plateAssignmentsByName = new Map<string, number>();
+  const plateJsonNames = Object.keys(zip.files).filter(
+    (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
+  );
+  for (const name of plateJsonNames) {
+    const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
+    if (!match) continue;
+    const plateIndex = Number.parseInt(match[1], 10);
+    if (!Number.isFinite(plateIndex)) continue;
+    try {
+      const payload = await zip.files[name].async('string');
+      const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
+      const objectsList = json.bbox_objects ?? [];
+      for (const entry of objectsList) {
+        if (entry?.name) {
+          plateAssignmentsByName.set(entry.name, plateIndex);
+        }
+      }
+      if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
+        const [minX, minY, maxX, maxY] = json.bbox_all;
+        if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
+          plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
+        }
+      }
+    } catch {
+      // Ignore plate json parsing errors
+    }
+  }
+
   // Find the main 3D model file
   // Find the main 3D model file
   const mainModelPath = Object.keys(zip.files).find(
   const mainModelPath = Object.keys(zip.files).find(
     (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
     (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
@@ -184,11 +296,11 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
         }
         }
       }
       }
     }
     }
-    return { objects, buildItems };
+    return { objects, buildItems, plateBounds };
   }
   }
 
 
   const mainDoc = await loadModelFile(mainModelPath);
   const mainDoc = await loadModelFile(mainModelPath);
-  if (!mainDoc) return { objects, buildItems };
+  if (!mainDoc) return { objects, buildItems, plateBounds };
 
 
   // Parse objects - Bambu Studio uses components to reference external files
   // Parse objects - Bambu Studio uses components to reference external files
   const objectElements = mainDoc.getElementsByTagName('object');
   const objectElements = mainDoc.getElementsByTagName('object');
@@ -197,6 +309,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     const objectId = objEl.getAttribute('id');
     const objectId = objEl.getAttribute('id');
     if (!objectId) continue;
     if (!objectId) continue;
 
 
+    const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
+
     // Get default extruder from model_settings.config map, falling back to attribute or default
     // Get default extruder from model_settings.config map, falling back to attribute or default
     let defaultExtruder = extruderMapById.get(objectId) ?? -1;
     let defaultExtruder = extruderMapById.get(objectId) ?? -1;
     if (defaultExtruder < 0) {
     if (defaultExtruder < 0) {
@@ -279,7 +393,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     }
     }
 
 
     if (meshes.length > 0) {
     if (meshes.length > 0) {
-      objects.set(objectId, { id: objectId, meshes, defaultExtruder });
+      objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
     }
     }
   }
   }
 
 
@@ -293,11 +407,15 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
       if (!objectId) continue;
       if (!objectId) continue;
 
 
       const transform = parseTransform(itemEl.getAttribute('transform'));
       const transform = parseTransform(itemEl.getAttribute('transform'));
-      buildItems.push({ objectId, transform });
+      const itemPlateId = parsePlateIdFromAttributes(itemEl);
+      const objectPlateId = objects.get(objectId)?.plateId ?? null;
+      const objectName = objectNameById.get(objectId);
+      const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
+      buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
     }
     }
   }
   }
 
 
-  return { objects, buildItems };
+  return { objects, buildItems, plateBounds };
 }
 }
 
 
 function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
 function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
@@ -321,14 +439,145 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
   return geometry;
   return geometry;
 }
 }
 
 
-export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
+function disposeGroup(group: THREE.Group) {
+  group.traverse((child) => {
+    if (child instanceof THREE.Mesh) {
+      child.geometry.dispose();
+      if (Array.isArray(child.material)) {
+        for (const material of child.material) {
+          material.dispose();
+        }
+      } else {
+        child.material.dispose();
+      }
+    }
+  });
+}
+
+function buildModelGroup(
+  parsedData: Parsed3MFData,
+  selectedPlateId: number | null,
+  filamentColors?: string[],
+): THREE.Group {
+  const { objects, buildItems } = parsedData;
+  const group = new THREE.Group();
+
+  // Create materials for each extruder color
+  const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
+    const defaultColor = '#00ae42';
+    const colorStr = filamentColors?.[extruder] || defaultColor;
+    // Convert hex color string to THREE.js color
+    const color = new THREE.Color(colorStr);
+    return new THREE.MeshPhongMaterial({
+      color,
+      shininess: 30,
+      flatShading: false,
+    });
+  };
+
+  // Group geometries by extruder index (using per-mesh extruder)
+  const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
+
+  const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
+  const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
+    ? buildItems
+    : buildItems.filter((item) => item.plateId === selectedPlateId);
+  const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
+
+  // If we have build items, use them for positioning
+  if (activeBuildItems.length > 0) {
+    for (const item of activeBuildItems) {
+      const objectData = objects.get(item.objectId);
+      if (!objectData) continue;
+
+      for (const meshData of objectData.meshes) {
+        // Use mesh's extruder, or item override, or object default
+        const extruder = item.extruder ?? meshData.extruder;
+
+        // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
+        const transformedVertices: number[] = [];
+        for (let k = 0; k < meshData.vertices.length; k += 3) {
+          const v = new THREE.Vector3(
+            meshData.vertices[k],
+            meshData.vertices[k + 1],
+            meshData.vertices[k + 2]
+          );
+          v.applyMatrix4(item.transform);
+          transformedVertices.push(v.x, v.y, v.z);
+        }
+        // Now create geometry with coordinate conversion
+        const geometry = createGeometryFromMesh({
+          vertices: transformedVertices,
+          triangles: meshData.triangles,
+          extruder: extruder,
+        });
+
+        if (!geometriesByExtruder.has(extruder)) {
+          geometriesByExtruder.set(extruder, []);
+        }
+        geometriesByExtruder.get(extruder)!.push(geometry);
+      }
+    }
+  } else {
+    // Fallback: just add all objects without transforms
+    for (const objectData of objects.values()) {
+      for (const meshData of objectData.meshes) {
+        // Use per-mesh extruder
+        const extruder = meshData.extruder;
+        const geometry = createGeometryFromMesh(meshData);
+        if (!geometriesByExtruder.has(extruder)) {
+          geometriesByExtruder.set(extruder, []);
+        }
+        geometriesByExtruder.get(extruder)!.push(geometry);
+      }
+    }
+  }
+
+  // Create meshes for each extruder group
+  for (const [extruder, geometries] of geometriesByExtruder) {
+    if (geometries.length === 0) continue;
+
+    const mergedGeometry = geometries.length === 1
+      ? geometries[0]
+      : mergeGeometries(geometries, false);
+
+    if (mergedGeometry) {
+      const material = getMaterial(extruder);
+      const mesh = new THREE.Mesh(mergedGeometry, material);
+      group.add(mesh);
+    }
+
+    // Dispose individual geometries if merged
+    if (geometries.length > 1) {
+      for (const geom of geometries) {
+        geom.dispose();
+      }
+    }
+  }
+
+  return group;
+}
+
+export function ModelViewer({
+  url,
+  fileType,
+  buildVolume = { x: 256, y: 256, z: 256 },
+  filamentColors,
+  selectedPlateId = null,
+  className = '',
+}: ModelViewerProps) {
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
+  const modelGroupRef = useRef<THREE.Group | null>(null);
+  const plateRef = useRef<THREE.Mesh | null>(null);
+  const gridRef = useRef<THREE.GridHelper | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
+  const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!containerRef.current) return;
     if (!containerRef.current) return;
@@ -377,6 +626,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     const gridDivisions = Math.ceil(gridSize / 16);
     const gridDivisions = Math.ceil(gridSize / 16);
     const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
     const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
     scene.add(gridHelper);
     scene.add(gridHelper);
+    gridRef.current = gridHelper;
 
 
     // Build plate indicator
     // Build plate indicator
     const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
     const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
@@ -390,6 +640,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     plate.rotation.x = -Math.PI / 2;
     plate.rotation.x = -Math.PI / 2;
     plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
     plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
     scene.add(plate);
     scene.add(plate);
+    plateRef.current = plate;
 
 
     // Animation loop - keep it simple for reliability
     // Animation loop - keep it simple for reliability
     let animationId: number;
     let animationId: number;
@@ -400,143 +651,51 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     };
     };
     animate();
     animate();
 
 
-    // Load 3MF
-    fetch(url)
-      .then((res) => {
-        if (!res.ok) throw new Error('Failed to load file');
-        return res.arrayBuffer();
-      })
-      .then(parse3MF)
-      .then(({ objects, buildItems }) => {
-        if (objects.size === 0) {
-          throw new Error('No meshes found in 3MF file');
-        }
-
-        // Create materials for each extruder color
-        const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
-          const defaultColor = '#00ae42';
-          const colorStr = filamentColors?.[extruder] || defaultColor;
-          // Convert hex color string to THREE.js color
-          const color = new THREE.Color(colorStr);
-          return new THREE.MeshPhongMaterial({
-            color,
-            shininess: 30,
-            flatShading: false,
-          });
-        };
-
-        const group = new THREE.Group();
-        // Group geometries by extruder index (using per-mesh extruder)
-        const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
-
-        // If we have build items, use them for positioning
-        if (buildItems.length > 0) {
-          for (const item of buildItems) {
-            const objectData = objects.get(item.objectId);
-            if (!objectData) continue;
-
-            for (const meshData of objectData.meshes) {
-              // Use mesh's extruder, or item override, or object default
-              const extruder = item.extruder ?? meshData.extruder;
-
-              // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
-              const transformedVertices: number[] = [];
-              for (let k = 0; k < meshData.vertices.length; k += 3) {
-                const v = new THREE.Vector3(
-                  meshData.vertices[k],
-                  meshData.vertices[k + 1],
-                  meshData.vertices[k + 2]
-                );
-                v.applyMatrix4(item.transform);
-                transformedVertices.push(v.x, v.y, v.z);
-              }
-              // Now create geometry with coordinate conversion
-              const geometry = createGeometryFromMesh({
-                vertices: transformedVertices,
-                triangles: meshData.triangles,
-                extruder: extruder,
-              });
-
-              if (!geometriesByExtruder.has(extruder)) {
-                geometriesByExtruder.set(extruder, []);
-              }
-              geometriesByExtruder.get(extruder)!.push(geometry);
-            }
-          }
-        } else {
-          // Fallback: just add all objects without transforms
-          for (const objectData of objects.values()) {
-            for (const meshData of objectData.meshes) {
-              // Use per-mesh extruder
-              const extruder = meshData.extruder;
-              const geometry = createGeometryFromMesh(meshData);
-              if (!geometriesByExtruder.has(extruder)) {
-                geometriesByExtruder.set(extruder, []);
-              }
-              geometriesByExtruder.get(extruder)!.push(geometry);
-            }
-          }
-        }
-
-        // Create meshes for each extruder group
-        for (const [extruder, geometries] of geometriesByExtruder) {
-          if (geometries.length === 0) continue;
-
-          const mergedGeometry = geometries.length === 1
-            ? geometries[0]
-            : mergeGeometries(geometries, false);
-
-          if (mergedGeometry) {
-            const material = getMaterial(extruder);
-            const mesh = new THREE.Mesh(mergedGeometry, material);
-            group.add(mesh);
-          }
-
-          // Dispose individual geometries if merged
-          if (geometries.length > 1) {
-            for (const geom of geometries) {
-              geom.dispose();
-            }
+    setLoading(true);
+    setError(null);
+    setParsedData(null);
+    setStlGeometry(null);
+
+    const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
+
+    if (normalizedType === 'stl') {
+      fetch(url)
+        .then((res) => {
+          if (!res.ok) throw new Error('Failed to load file');
+          return res.arrayBuffer();
+        })
+        .then((buffer) => {
+          const loader = new STLLoader();
+          const geometry = loader.parse(buffer);
+          geometry.computeVertexNormals();
+          geometry.rotateX(-Math.PI / 2);
+          setStlGeometry(geometry);
+        })
+        .catch((err) => {
+          setError(err.message);
+          setLoading(false);
+        });
+    } else if (normalizedType === '3mf') {
+      fetch(url)
+        .then((res) => {
+          if (!res.ok) throw new Error('Failed to load file');
+          return res.arrayBuffer();
+        })
+        .then(parse3MF)
+        .then((parsed) => {
+          if (parsed.objects.size === 0) {
+            throw new Error('No meshes found in 3MF file');
           }
           }
-        }
-
-        // Get bounding box to position model
-        const box = new THREE.Box3().setFromObject(group);
-        const center = box.getCenter(new THREE.Vector3());
-
-        // Always place models on the build plate (Y=0)
-        group.position.y = -box.min.y;
-
-        // For models without build transforms, also center X/Z
-        if (buildItems.length === 0) {
-          group.position.x = -center.x;
-          group.position.z = -center.z;
-        }
-
-        scene.add(group);
-
-        // Recalculate bounding box after positioning
-        const finalBox = new THREE.Box3().setFromObject(group);
-        const finalCenter = finalBox.getCenter(new THREE.Vector3());
-        const finalSize = finalBox.getSize(new THREE.Vector3());
-
-        // Adjust camera to fit model
-        const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
-        const cameraDistance = maxDim * 1.8;
-        camera.position.set(
-          finalCenter.x + cameraDistance * 0.7,
-          finalCenter.y + cameraDistance * 0.5,
-          finalCenter.z + cameraDistance * 0.7
-        );
-        controls.target.copy(finalCenter);
-        controls.update();
-
-        setLoading(false);
-      })
-      .catch((err) => {
-        setError(err.message);
-        setLoading(false);
-      });
+          setParsedData(parsed);
+        })
+        .catch((err) => {
+          setError(err.message);
+          setLoading(false);
+        });
+    } else {
+      setError('Unsupported file format');
+      setLoading(false);
+    }
 
 
     // Handle resize
     // Handle resize
     const handleResize = () => {
     const handleResize = () => {
@@ -555,8 +714,104 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
       controls.dispose();
       controls.dispose();
       renderer.dispose();
       renderer.dispose();
       container.removeChild(renderer.domElement);
       container.removeChild(renderer.domElement);
+      modelGroupRef.current = null;
+      plateRef.current = null;
+      gridRef.current = null;
     };
     };
-  }, [url, buildVolume, filamentColors]);
+  }, [url, buildVolume, fileType]);
+
+  useEffect(() => {
+    if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
+    if (!parsedData && !stlGeometry) return;
+
+    if (modelGroupRef.current) {
+      sceneRef.current.remove(modelGroupRef.current);
+      disposeGroup(modelGroupRef.current);
+    }
+
+    const isStlModel = !!stlGeometry;
+    const group = isStlModel
+      ? (() => {
+          const materialColor = filamentColors?.[0] || '#00ae42';
+          const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
+          const mesh = new THREE.Mesh(stlGeometry!, material);
+          const stlGroup = new THREE.Group();
+          stlGroup.add(mesh);
+          return stlGroup;
+        })()
+      : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
+    modelGroupRef.current = group;
+    sceneRef.current.add(group);
+
+    // Get bounding box to position model
+    const box = new THREE.Box3().setFromObject(group);
+    const center = box.getCenter(new THREE.Vector3());
+
+    // Always place models on the build plate (Y=0)
+    group.position.y = -box.min.y;
+
+    // For a selected plate, center the plate contents on the build plate
+    const shouldRecenter = isStlModel || parsedData!.buildItems.length === 0;
+    const centerOffsetX = shouldRecenter ? -center.x : 0;
+    const centerOffsetZ = shouldRecenter ? -center.z : 0;
+
+    let plateOffsetX = 0;
+    let plateOffsetZ = 0;
+    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
+      const plateBox = new THREE.Box3().setFromObject(group);
+      const bounds = parsedData!.plateBounds.get(selectedPlateId);
+      if (bounds) {
+        plateOffsetX = plateBox.min.x - bounds.minX;
+        plateOffsetZ = plateBox.min.z - bounds.minY;
+      } else {
+        const epsilon = 1e-6;
+        plateOffsetX = Math.floor((plateBox.min.x + epsilon) / buildVolume.x) * buildVolume.x;
+        plateOffsetZ = Math.floor((plateBox.min.z + epsilon) / buildVolume.y) * buildVolume.y;
+      }
+    }
+
+    const plateCenterX = buildVolume.x / 2;
+    const plateCenterZ = buildVolume.y / 2;
+
+    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
+      group.position.x = centerOffsetX - plateOffsetX;
+      group.position.z = centerOffsetZ - plateOffsetZ;
+    } else if (isStlModel) {
+      group.position.x = centerOffsetX + plateCenterX;
+      group.position.z = centerOffsetZ + plateCenterZ;
+    } else {
+      group.position.x = centerOffsetX;
+      group.position.z = centerOffsetZ;
+    }
+
+    if (plateRef.current) {
+      plateRef.current.position.x = plateCenterX;
+      plateRef.current.position.z = plateCenterZ;
+    }
+
+    if (gridRef.current) {
+      gridRef.current.position.x = plateCenterX;
+      gridRef.current.position.z = plateCenterZ;
+    }
+
+    // Recalculate bounding box after positioning
+    const finalBox = new THREE.Box3().setFromObject(group);
+    const finalCenter = finalBox.getCenter(new THREE.Vector3());
+    const finalSize = finalBox.getSize(new THREE.Vector3());
+
+    // Adjust camera to fit model
+    const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
+    const cameraDistance = maxDim * 1.8;
+    cameraRef.current.position.set(
+      finalCenter.x + cameraDistance * 0.7,
+      finalCenter.y + cameraDistance * 0.5,
+      finalCenter.z + cameraDistance * 0.7
+    );
+    controlsRef.current.target.copy(finalCenter);
+    controlsRef.current.update();
+
+    setLoading(false);
+  }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
 
 
   const resetView = () => {
   const resetView = () => {
     if (cameraRef.current && controlsRef.current) {
     if (cameraRef.current && controlsRef.current) {

+ 183 - 15
frontend/src/components/ModelViewerModal.tsx

@@ -1,16 +1,19 @@
 import { useState, useEffect } from 'react';
 import { useState, useEffect } from 'react';
-import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
+import { X, ExternalLink, Box, Code2, Loader2, Layers, Check } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
 type ViewTab = '3d' | 'gcode';
 type ViewTab = '3d' | 'gcode';
 
 
 interface ModelViewerModalProps {
 interface ModelViewerModalProps {
-  archiveId: number;
+  archiveId?: number;
+  libraryFileId?: number;
   title: string;
   title: string;
+  fileType?: string;
   onClose: () => void;
   onClose: () => void;
 }
 }
 
 
@@ -22,10 +25,14 @@ interface Capabilities {
   filament_colors: string[];
   filament_colors: string[];
 }
 }
 
 
-export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
+export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
+  const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
+  const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
+  const [platesLoading, setPlatesLoading] = useState(false);
+  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
 
 
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
@@ -37,6 +44,31 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
   }, [onClose]);
   }, [onClose]);
 
 
   useEffect(() => {
   useEffect(() => {
+    setLoading(true);
+
+    if (isLibrary) {
+      const normalizedType = (fileType || '').toLowerCase();
+      const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
+      const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
+      setCapabilities({
+        has_model: hasModel,
+        has_gcode: hasGcode,
+        has_source: false,
+        build_volume: { x: 256, y: 256, z: 256 },
+        filament_colors: [],
+      });
+      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+      setLoading(false);
+      return;
+    }
+
+    if (!archiveId) {
+      setCapabilities(null);
+      setActiveTab(null);
+      setLoading(false);
+      return;
+    }
+
     api.getArchiveCapabilities(archiveId)
     api.getArchiveCapabilities(archiveId)
       .then(caps => {
       .then(caps => {
         setCapabilities(caps);
         setCapabilities(caps);
@@ -54,12 +86,56 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
         setActiveTab('3d');
         setActiveTab('3d');
         setLoading(false);
         setLoading(false);
       });
       });
-  }, [archiveId]);
+  }, [archiveId, fileType, isLibrary]);
+
+  useEffect(() => {
+    setPlatesLoading(true);
+    setSelectedPlateId(null);
+
+    if (isLibrary) {
+      const normalizedType = (fileType || '').toLowerCase();
+      if (!libraryFileId || normalizedType !== '3mf') {
+        setPlatesData(null);
+        setPlatesLoading(false);
+        return;
+      }
+      api.getLibraryFilePlates(libraryFileId)
+        .then((data) => setPlatesData(data))
+        .catch(() => setPlatesData(null))
+        .finally(() => setPlatesLoading(false));
+      return;
+    }
+
+    if (!archiveId) {
+      setPlatesData(null);
+      setPlatesLoading(false);
+      return;
+    }
+
+    api.getArchivePlates(archiveId)
+      .then((data) => setPlatesData(data))
+      .catch(() => setPlatesData(null))
+      .finally(() => setPlatesLoading(false));
+  }, [archiveId, fileType, isLibrary, libraryFileId]);
+
+  const plates = platesData?.plates ?? [];
+  const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
+  const selectedPlate: PlateMetadata | null = selectedPlateId == null
+    ? null
+    : plates.find((plate) => plate.index === selectedPlateId) ?? null;
+
+  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
 
   const handleOpenInSlicer = () => {
   const handleOpenInSlicer = () => {
+    if (!canOpenInSlicer) return;
     // URL must include .3mf filename for Bambu Studio to recognize the format
     // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
     const filename = title || 'model';
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
+    if (isLibrary) {
+      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+      openInSlicer(downloadUrl);
+      return;
+    }
+    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
     openInSlicer(downloadUrl);
     openInSlicer(downloadUrl);
   };
   };
 
 
@@ -76,7 +152,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
           <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
           <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
           <div className="flex items-center gap-2">
           <div className="flex items-center gap-2">
-            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
+            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
               <ExternalLink className="w-4 h-4" />
               <ExternalLink className="w-4 h-4" />
               Open in Slicer
               Open in Slicer
             </Button>
             </Button>
@@ -129,17 +205,109 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
             </div>
             </div>
           ) : activeTab === '3d' && capabilities ? (
           ) : activeTab === '3d' && capabilities ? (
-            <ModelViewer
-              url={capabilities.has_source
-                ? api.getSource3mfDownloadUrl(archiveId)
-                : api.getArchiveDownload(archiveId)}
-              buildVolume={capabilities.build_volume}
-              filamentColors={capabilities.filament_colors}
-              className="w-full h-full"
-            />
+            <div className="w-full h-full flex flex-col gap-3">
+              {hasMultiplePlates && (
+                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                    <Layers className="w-4 h-4" />
+                    Plates
+                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                  </div>
+                  <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
+                    <button
+                      type="button"
+                      onClick={() => setSelectedPlateId(null)}
+                      className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                        selectedPlateId == null
+                          ? 'border-bambu-green bg-bambu-green/10'
+                          : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                      }`}
+                    >
+                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                        <Layers className="w-5 h-5 text-bambu-gray" />
+                      </div>
+                      <div className="min-w-0 flex-1">
+                        <p className="text-sm text-white font-medium truncate">All Plates</p>
+                        <p className="text-xs text-bambu-gray truncate">
+                          {plates.length} plate{plates.length !== 1 ? 's' : ''}
+                        </p>
+                      </div>
+                      {selectedPlateId == null && (
+                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </button>
+                    {plates.map((plate) => (
+                      <button
+                        key={plate.index}
+                        type="button"
+                        onClick={() => setSelectedPlateId(plate.index)}
+                        className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                          selectedPlateId === plate.index
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                        }`}
+                      >
+                        {plate.has_thumbnail && plate.thumbnail_url ? (
+                          <img
+                            src={plate.thumbnail_url}
+                            alt={`Plate ${plate.index}`}
+                            className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+                          />
+                        ) : (
+                          <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                            <Layers className="w-5 h-5 text-bambu-gray" />
+                          </div>
+                        )}
+                        <div className="min-w-0 flex-1">
+                          <p className="text-sm text-white font-medium truncate">
+                            {plate.name || `Plate ${plate.index}`}
+                          </p>
+                          <p className="text-xs text-bambu-gray truncate">
+                            {plate.objects.length > 0
+                              ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
+                              : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                          </p>
+                        </div>
+                        {selectedPlateId === plate.index && (
+                          <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                        )}
+                      </button>
+                    ))}
+                  </div>
+                  {selectedPlate && (
+                    <div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
+                      <span>Plate {selectedPlate.index}</span>
+                      {selectedPlate.print_time_seconds != null && (
+                        <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
+                      )}
+                      {selectedPlate.filament_used_grams != null && (
+                        <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                      )}
+                      {selectedPlate.filaments.length > 0 && (
+                        <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
+                      )}
+                    </div>
+                  )}
+                </div>
+              )}
+              <div className="flex-1">
+                  <ModelViewer
+                    url={isLibrary
+                      ? api.getLibraryFileDownloadUrl(libraryFileId!)
+                      : (capabilities.has_source
+                        ? api.getSource3mfDownloadUrl(archiveId!)
+                        : api.getArchiveDownload(archiveId!))}
+                    fileType={fileType}
+                    buildVolume={capabilities.build_volume}
+                    filamentColors={capabilities.filament_colors}
+                    selectedPlateId={selectedPlateId}
+                    className="w-full h-full"
+                  />
+              </div>
+            </div>
           ) : activeTab === 'gcode' && capabilities ? (
           ) : activeTab === 'gcode' && capabilities ? (
             <GcodeViewer
             <GcodeViewer
-              gcodeUrl={api.getArchiveGcode(archiveId)}
+              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
               filamentColors={capabilities.filament_colors}
               filamentColors={capabilities.filament_colors}
               className="w-full h-full"
               className="w-full h-full"
             />
             />

+ 3 - 1
frontend/src/components/PrintModal/index.tsx

@@ -10,6 +10,7 @@ import { useToast } from '../../contexts/ToastContext';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
+import { toDateTimeLocalValue } from '../../utils/date';
 import { PrinterSelector } from './PrinterSelector';
 import { PrinterSelector } from './PrinterSelector';
 import { PlateSelector } from './PlateSelector';
 import { PlateSelector } from './PlateSelector';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentMapping } from './FilamentMapping';
@@ -90,7 +91,8 @@ export function PrintModal({
       let scheduledTime = '';
       let scheduledTime = '';
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
         const date = new Date(queueItem.scheduled_time);
         const date = new Date(queueItem.scheduled_time);
-        scheduledTime = date.toISOString().slice(0, 16);
+        // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
+        scheduledTime = toDateTimeLocalValue(date);
       }
       }
 
 
       return {
       return {

+ 58 - 4
frontend/src/components/VirtualPrinterSettings.tsx

@@ -19,8 +19,9 @@ export function VirtualPrinterSettings() {
   const [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
+  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
-  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
+  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);
 
 
   // Fetch current settings
   // Fetch current settings
   const { data: settings, isLoading } = useQuery({
   const { data: settings, isLoading } = useQuery({
@@ -41,6 +42,13 @@ export function VirtualPrinterSettings() {
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  // Fetch network interfaces for SSDP proxy (only in proxy mode)
+  const { data: networkInterfaces } = useQuery({
+    queryKey: ['network-interfaces'],
+    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
+    enabled: localMode === 'proxy',
+  });
+
   // Initialize local state from settings
   // Initialize local state from settings
   useEffect(() => {
   useEffect(() => {
     if (settings) {
     if (settings) {
@@ -53,12 +61,13 @@ export function VirtualPrinterSettings() {
       setLocalMode(mode);
       setLocalMode(mode);
       setLocalModel(settings.model);
       setLocalModel(settings.model);
       setLocalTargetPrinterId(settings.target_printer_id);
       setLocalTargetPrinterId(settings.target_printer_id);
+      setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
     }
     }
   }, [settings]);
   }, [settings]);
 
 
   // Update mutation
   // Update mutation
   const updateMutation = useMutation({
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>
       virtualPrinterApi.updateSettings(data),
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -148,6 +157,12 @@ export function VirtualPrinterSettings() {
     updateMutation.mutate({ model });
     updateMutation.mutate({ model });
   };
   };
 
 
+  const handleRemoteInterfaceChange = (ip: string) => {
+    setLocalRemoteInterfaceIp(ip);
+    setPendingAction('remoteInterface');
+    updateMutation.mutate({ remote_interface_ip: ip });
+  };
+
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <Card>
       <Card>
@@ -350,6 +365,45 @@ export function VirtualPrinterSettings() {
             </div>
             </div>
           )}
           )}
 
 
+          {/* Remote Interface - only for proxy mode (SSDP proxy) */}
+          {localMode === 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.remoteInterface.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {localRemoteInterfaceIp ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.configured')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-bambu-gray">
+                    <Info className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.optional')}
+                  </span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localRemoteInterfaceIp}
+                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
+                  disabled={pendingAction === 'remoteInterface'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
+                >
+                  <option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
+                  {networkInterfaces?.map((iface) => (
+                    <option key={iface.ip} value={iface.ip}>
+                      {iface.name} ({iface.ip}) - {iface.subnet}
+                    </option>
+                  ))}
+                </select>
+                <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+              </div>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.remoteInterface.hint')}
+              </p>
+            </div>
+          )}
+
           {/* Mode */}
           {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
           <div className="py-3 border-t border-bambu-dark-tertiary">
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
@@ -504,11 +558,11 @@ export function VirtualPrinterSettings() {
                   </div>
                   </div>
                   <div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
-                    <div className="text-white">{status.proxy.ftp_connections}</div>
+                    <div className="text-white">{status.proxy.ftp_connections ?? 0}</div>
                   </div>
                   </div>
                   <div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
-                    <div className="text-white">{status.proxy.mqtt_connections}</div>
+                    <div className="text-white">{status.proxy.mqtt_connections ?? 0}</div>
                   </div>
                   </div>
                 </div>
                 </div>
               ) : (
               ) : (

+ 136 - 45
frontend/src/hooks/useFilamentMapping.ts

@@ -32,6 +32,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           isExternal: false,
           isExternal: false,
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          trayInfoIdx: tray.tray_info_idx || '',
         });
         });
       }
       }
     });
     });
@@ -50,6 +51,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       isExternal: true,
       isExternal: true,
       label: 'External',
       label: 'External',
       globalTrayId: 254,
       globalTrayId: 254,
+      trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
     });
     });
   }
   }
 
 
@@ -60,6 +62,14 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
  * Compute AMS mapping for a printer given filament requirements and printer status.
  * Compute AMS mapping for a printer given filament requirements and printer status.
  * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
  * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
  *
  *
+ * Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
+ *
+ * The tray_info_idx is a filament type identifier stored in the 3MF file when the user
+ * slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
+ * tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
+ * have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
+ * matching among those trays.
+ *
  * @param filamentReqs - Required filaments from the 3MF file
  * @param filamentReqs - Required filaments from the 3MF file
  * @param printerStatus - Current printer status with AMS information
  * @param printerStatus - Current printer status with AMS information
  * @returns AMS mapping array or undefined if no mapping needed
  * @returns AMS mapping array or undefined if no mapping needed
@@ -77,30 +87,66 @@ export function computeAmsMapping(
   const usedTrayIds = new Set<number>();
   const usedTrayIds = new Set<number>();
 
 
   const comparisons = filamentReqs.filaments.map((req) => {
   const comparisons = filamentReqs.filaments.map((req) => {
-    // Auto-match: Find a loaded filament that matches by TYPE
-    // Priority: exact color match > similar color match > type-only match
-    const exactMatch = loadedFilaments.find(
-      (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
-        f.type?.toUpperCase() === req.type?.toUpperCase() &&
-        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-    );
-    const similarMatch =
-      !exactMatch &&
-      loadedFilaments.find(
+    const reqTrayInfoIdx = req.tray_info_idx || '';
+
+    // Get available trays (not already used)
+    const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+    let idxMatch: LoadedFilament | undefined;
+    let exactMatch: LoadedFilament | undefined;
+    let similarMatch: LoadedFilament | undefined;
+    let typeOnlyMatch: LoadedFilament | undefined;
+
+    // Check if tray_info_idx is unique among available trays
+    if (reqTrayInfoIdx) {
+      const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
+      if (idxMatches.length === 1) {
+        // Unique tray_info_idx - use it as definitive match
+        idxMatch = idxMatches[0];
+      } else if (idxMatches.length > 1) {
+        // Multiple trays with same tray_info_idx - use color matching among them
+        exactMatch = idxMatches.find(
+          (f) =>
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+        );
+        if (!exactMatch) {
+          similarMatch = idxMatches.find(
+            (f) =>
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              colorsAreSimilar(f.color, req.color)
+          );
+        }
+        if (!exactMatch && !similarMatch) {
+          typeOnlyMatch = idxMatches.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+        }
+      }
+    }
+
+    // If no idx match, do standard type/color matching on all available trays
+    if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
+      exactMatch = available.find(
         (f) =>
         (f) =>
-          !usedTrayIds.has(f.globalTrayId) &&
           f.type?.toUpperCase() === req.type?.toUpperCase() &&
           f.type?.toUpperCase() === req.type?.toUpperCase() &&
-          colorsAreSimilar(f.color, req.color)
-      );
-    const typeOnlyMatch =
-      !exactMatch &&
-      !similarMatch &&
-      loadedFilaments.find(
-        (f) =>
-          !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
       );
       );
-    const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+      if (!exactMatch) {
+        similarMatch = available.find(
+          (f) =>
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+      }
+      if (!exactMatch && !similarMatch) {
+        typeOnlyMatch = available.find(
+          (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+        );
+      }
+    }
+
+    const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
 
 
     // Mark this tray as used so it won't be assigned to another slot
     // Mark this tray as used so it won't be assigned to another slot
     if (loaded) {
     if (loaded) {
@@ -143,6 +189,8 @@ export interface LoadedFilament {
   isExternal: boolean;
   isExternal: boolean;
   label: string;
   label: string;
   globalTrayId: number;
   globalTrayId: number;
+  /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
+  trayInfoIdx?: string;
 }
 }
 
 
 /**
 /**
@@ -153,6 +201,8 @@ export interface FilamentRequirement {
   type: string;
   type: string;
   color: string;
   color: string;
   used_grams: number;
   used_grams: number;
+  /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
+  tray_info_idx?: string;
 }
 }
 
 
 /**
 /**
@@ -215,6 +265,7 @@ export function useLoadedFilaments(
             isExternal: false,
             isExternal: false,
             label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
             label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
             globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
             globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+            trayInfoIdx: tray.tray_info_idx || '',
           });
           });
         }
         }
       });
       });
@@ -233,6 +284,7 @@ export function useLoadedFilaments(
         isExternal: true,
         isExternal: true,
         label: 'External',
         label: 'External',
         globalTrayId: 254,
         globalTrayId: 254,
+        trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
       });
       });
     }
     }
 
 
@@ -297,31 +349,69 @@ export function useFilamentMapping(
         }
         }
       }
       }
 
 
-      // Auto-match: Find a loaded filament that matches by TYPE
-      // Priority: exact color match > similar color match > type-only match
+      // Auto-match: Find a loaded filament
+      // Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
       // IMPORTANT: Exclude trays that are already assigned (manually or auto)
       // IMPORTANT: Exclude trays that are already assigned (manually or auto)
-      const exactMatch = loadedFilaments.find(
-        (f) =>
-          !usedTrayIds.has(f.globalTrayId) &&
-          f.type?.toUpperCase() === req.type?.toUpperCase() &&
-          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch =
-        !exactMatch &&
-        loadedFilaments.find(
+      const reqTrayInfoIdx = req.tray_info_idx || '';
+
+      // Get available trays (not already used)
+      const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+      let idxMatch: LoadedFilament | undefined;
+      let exactMatch: LoadedFilament | undefined;
+      let similarMatch: LoadedFilament | undefined;
+      let typeOnlyMatch: LoadedFilament | undefined;
+
+      // Check if tray_info_idx is unique among available trays
+      if (reqTrayInfoIdx) {
+        const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
+        if (idxMatches.length === 1) {
+          // Unique tray_info_idx - use it as definitive match
+          idxMatch = idxMatches[0];
+        } else if (idxMatches.length > 1) {
+          // Multiple trays with same tray_info_idx - use color matching among them
+          exactMatch = idxMatches.find(
+            (f) =>
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+          );
+          if (!exactMatch) {
+            similarMatch = idxMatches.find(
+              (f) =>
+                f.type?.toUpperCase() === req.type?.toUpperCase() &&
+                colorsAreSimilar(f.color, req.color)
+            );
+          }
+          if (!exactMatch && !similarMatch) {
+            typeOnlyMatch = idxMatches.find(
+              (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+            );
+          }
+        }
+      }
+
+      // If no idx match, do standard type/color matching on all available trays
+      if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
+        exactMatch = available.find(
           (f) =>
           (f) =>
-            !usedTrayIds.has(f.globalTrayId) &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
-            colorsAreSimilar(f.color, req.color)
-        );
-      const typeOnlyMatch =
-        !exactMatch &&
-        !similarMatch &&
-        loadedFilaments.find(
-          (f) =>
-            !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
         );
         );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+        if (!exactMatch) {
+          similarMatch = available.find(
+            (f) =>
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              colorsAreSimilar(f.color, req.color)
+          );
+        }
+        if (!exactMatch && !similarMatch) {
+          typeOnlyMatch = available.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+        }
+      }
+
+      const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
 
 
       // Mark this tray as used so it won't be assigned to another slot
       // Mark this tray as used so it won't be assigned to another slot
       if (loaded) {
       if (loaded) {
@@ -330,11 +420,12 @@ export function useFilamentMapping(
 
 
       const hasFilament = !!loaded;
       const hasFilament = !!loaded;
       const typeMatch = hasFilament;
       const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
+      // idxMatch is always considered a color match (same spool = same color)
+      const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
 
 
-      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
+      // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
       let status: FilamentStatus;
       let status: FilamentStatus;
-      if (exactMatch || similarMatch) {
+      if (idxMatch || exactMatch || similarMatch) {
         status = 'match';
         status = 'match';
       } else if (typeOnlyMatch) {
       } else if (typeOnlyMatch) {
         status = 'type_only';
         status = 'type_only';

+ 14 - 5
frontend/src/i18n/locales/de.ts

@@ -687,6 +687,7 @@ export default {
       pending: 'Ausstehend',
       pending: 'Ausstehend',
       waiting: 'Wartend',
       waiting: 'Wartend',
       printing: 'Druckt',
       printing: 'Druckt',
+      paused: 'Pausiert',
       completed: 'Abgeschlossen',
       completed: 'Abgeschlossen',
       failed: 'Fehlgeschlagen',
       failed: 'Fehlgeschlagen',
       skipped: 'Übersprungen',
       skipped: 'Übersprungen',
@@ -2480,6 +2481,13 @@ export default {
       hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
       hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
       noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
       noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
     },
     },
+    remoteInterface: {
+      title: 'Slicer-Netzwerkschnittstelle',
+      configured: 'SSDP-Proxy aktiviert',
+      optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
+      placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
+      hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
+    },
     mode: {
     mode: {
       title: 'Modus',
       title: 'Modus',
       archive: 'Archivieren',
       archive: 'Archivieren',
@@ -2505,11 +2513,12 @@ export default {
       step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
       step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
       step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
       step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
       step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
       step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
-      proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
-      proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
-      proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
-      proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
-      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
+      proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
+      proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
+      proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
+      proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
+      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
+      proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
     },
     },
     status: {
     status: {
       title: 'Status-Details',
       title: 'Status-Details',

+ 14 - 5
frontend/src/i18n/locales/en.ts

@@ -687,6 +687,7 @@ export default {
       pending: 'Pending',
       pending: 'Pending',
       waiting: 'Waiting',
       waiting: 'Waiting',
       printing: 'Printing',
       printing: 'Printing',
+      paused: 'Paused',
       completed: 'Completed',
       completed: 'Completed',
       failed: 'Failed',
       failed: 'Failed',
       skipped: 'Skipped',
       skipped: 'Skipped',
@@ -2480,6 +2481,13 @@ export default {
       hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
       hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
       noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
       noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
     },
     },
+    remoteInterface: {
+      title: 'Slicer Network Interface',
+      configured: 'SSDP proxy enabled',
+      optional: 'Optional - for SSDP discovery across networks',
+      placeholder: 'Select interface for slicer network...',
+      hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
+    },
     mode: {
     mode: {
       title: 'Mode',
       title: 'Mode',
       archive: 'Archive',
       archive: 'Archive',
@@ -2505,11 +2513,12 @@ export default {
       step4: 'The "Bambuddy" printer should appear in the discovery list',
       step4: 'The "Bambuddy" printer should appear in the discovery list',
       step5: 'Connect using the access code you set',
       step5: 'Connect using the access code you set',
       step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
       step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
-      proxyStep1: 'Set the target printer IP (must be on your LAN)',
-      proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
-      proxyStep3: 'In your slicer, manually add a network printer',
-      proxyStep4: "Enter Bambuddy's external address and printer's access code",
-      proxyStep5: 'Print as normal - traffic is relayed to the real printer',
+      proxyStep1: 'Select the target printer (must be in LAN mode)',
+      proxyStep2: 'For cross-network: select the slicer network interface',
+      proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
+      proxyStep4: 'Connect using the printer\'s access code',
+      proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
+      proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
     },
     },
     status: {
     status: {
       title: 'Status Details',
       title: 'Status Details',

+ 30 - 0
frontend/src/i18n/locales/ja.ts

@@ -549,6 +549,7 @@ export default {
     status: {
     status: {
       pending: '待機中',
       pending: '待機中',
       printing: '印刷中',
       printing: '印刷中',
+      paused: '一時停止',
       completed: '完了',
       completed: '完了',
       failed: '失敗',
       failed: '失敗',
       skipped: 'スキップ',
       skipped: 'スキップ',
@@ -1473,6 +1474,35 @@ export default {
       model: 'モデル',
       model: 'モデル',
       serialNumber: 'シリアルナンバー',
       serialNumber: 'シリアルナンバー',
       pendingFiles: '保留中のファイル',
       pendingFiles: '保留中のファイル',
+      modeProxy: 'プロキシ',
+      modeProxyDesc: '実際のプリンターに中継',
+      descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
+      proxyingTo: '{{name}}にプロキシ中',
+      notActive: '非アクティブ',
+      targetPrinterTitle: 'ターゲットプリンター',
+      targetPrinterConfigured: 'プロキシターゲット設定済み',
+      targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
+      targetPrinterPlaceholder: 'プリンターを選択...',
+      targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
+      targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
+      remoteInterfaceTitle: 'スライサーネットワークインターフェース',
+      remoteInterfaceConfigured: 'SSDPプロキシ有効',
+      remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
+      remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
+      remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
+      targetPrinterRequired: '先にターゲットプリンターを選択してください',
+      ftpPort: 'FTPポート',
+      mqttPort: 'MQTTポート',
+      ftpConnections: 'FTP接続数',
+      mqttConnections: 'MQTT接続数',
+      targetPrinterStatus: 'ターゲットプリンター',
+      howItWorksProxy: '仕組み(プロキシモード)',
+      proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
+      proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
+      proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
+      proxyStep4: 'プリンターのアクセスコードで接続',
+      proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
+      proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
     },
     },
     enterNewCodeToChange: '新しいコードを入力して変更',
     enterNewCodeToChange: '新しいコードを入力して変更',
     enter8CharCode: '8文字のコードを入力',
     enter8CharCode: '8文字のコードを入力',

+ 13 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -99,6 +99,15 @@ function isSlicedFile(filename: string | null | undefined): boolean {
   return lower.endsWith('.gcode') || lower.includes('.gcode.');
   return lower.endsWith('.gcode') || lower.includes('.gcode.');
 }
 }
 
 
+function getArchiveFileType(filename: string | null | undefined): string | undefined {
+  if (!filename) return undefined;
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.3mf')) return '3mf';
+  if (lower.endsWith('.stl')) return 'stl';
+  if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';
+  return lower.split('.').pop();
+}
+
 // formatDate imported from '../utils/date' - handles UTC conversion
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 
 function ArchiveCard({
 function ArchiveCard({
@@ -1040,6 +1049,7 @@ function ArchiveCard({
         <ModelViewerModal
         <ModelViewerModal
           archiveId={archive.id}
           archiveId={archive.id}
           title={archive.print_name || archive.filename}
           title={archive.print_name || archive.filename}
+          fileType={getArchiveFileType(archive.filename)}
           onClose={() => setShowViewer(false)}
           onClose={() => setShowViewer(false)}
         />
         />
       )}
       )}
@@ -1833,6 +1843,7 @@ function ArchiveListRow({
         <ModelViewerModal
         <ModelViewerModal
           archiveId={archive.id}
           archiveId={archive.id}
           title={archive.print_name || archive.filename}
           title={archive.print_name || archive.filename}
+          fileType={getArchiveFileType(archive.filename)}
           onClose={() => setShowViewer(false)}
           onClose={() => setShowViewer(false)}
         />
         />
       )}
       )}
@@ -2522,7 +2533,7 @@ export function ArchivesPage() {
         </div>
         </div>
       )}
       )}
 
 
-      <div className="flex items-center justify-between mb-8">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
         <div>
         <div>
           <div className="flex items-center gap-3">
           <div className="flex items-center gap-3">
             <h1 className="text-2xl font-bold text-white">Archives</h1>
             <h1 className="text-2xl font-bold text-white">Archives</h1>
@@ -2542,7 +2553,7 @@ export function ArchivesPage() {
             {filteredArchives?.length || 0} of {archives?.length || 0} prints
             {filteredArchives?.length || 0} of {archives?.length || 0} prints
           </p>
           </p>
         </div>
         </div>
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Export dropdown */}
           {/* Export dropdown */}
           <div className="relative">
           <div className="relative">
             <Button
             <Button

+ 37 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -52,6 +52,7 @@ import type {
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
+import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -907,6 +908,7 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
+  onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
   thumbnailVersion?: number;
@@ -1021,6 +1023,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('fileManager.schedulePrint')}
                   {t('fileManager.schedulePrint')}
                 </button>
                 </button>
               )}
               )}
+              {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
+                <button
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:read')) { onPreview3d(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:read')}
+                  title={!hasPermission('library:read') ? 'You do not have permission to preview files' : undefined}
+                >
+                  <Box className="w-3.5 h-3.5" />
+                  3D Preview
+                </button>
+              )}
               <button
               <button
                 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -1110,6 +1125,7 @@ export function FileManagerPage() {
   const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
   const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
+  const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1993,6 +2009,7 @@ export function FileManagerPage() {
                       if (file) setScheduleFile(file);
                       if (file) setScheduleFile(file);
                     }}
                     }}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
+                    onPreview3d={setViewerFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     thumbnailVersion={thumbnailVersions[file.id]}
@@ -2128,6 +2145,20 @@ export function FileManagerPage() {
                           </button>
                           </button>
                         </>
                         </>
                       )}
                       )}
+                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
+                        <button
+                          onClick={() => hasPermission('library:read') && setViewerFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:read')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
+                          disabled={!hasPermission('library:read')}
+                        >
+                          <Box className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                       <button
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         className={`p-1.5 rounded transition-colors ${
                         className={`p-1.5 rounded transition-colors ${
@@ -2296,6 +2327,12 @@ export function FileManagerPage() {
             queryClient.invalidateQueries({ queryKey: ['queue'] });
             queryClient.invalidateQueries({ queryKey: ['queue'] });
             queryClient.invalidateQueries({ queryKey: ['archives'] });
             queryClient.invalidateQueries({ queryKey: ['archives'] });
           }}
           }}
+      {viewerFile && (
+        <ModelViewerModal
+          libraryFileId={viewerFile.id}
+          title={viewerFile.print_name || viewerFile.filename}
+          fileType={viewerFile.file_type}
+          onClose={() => setViewerFile(null)}
         />
         />
       )}
       )}
 
 

+ 47 - 3
frontend/src/pages/QueuePage.tsx

@@ -1,6 +1,6 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import { Link } from 'react-router-dom';
 import {
 import {
   DndContext,
   DndContext,
@@ -46,6 +46,7 @@ import {
   CheckSquare,
   CheckSquare,
   Square,
   Square,
   User,
   User,
+  Pause,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -80,7 +81,7 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
   return formatDateTime(dateString, timeFormat);
   return formatDateTime(dateString, timeFormat);
 }
 }
 
 
-function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
+function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
   // Special case: pending with waiting_reason shows as "Waiting"
   // Special case: pending with waiting_reason shows as "Waiting"
   if (status === 'pending' && waitingReason) {
   if (status === 'pending' && waitingReason) {
     return (
     return (
@@ -91,6 +92,16 @@ function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['sta
     );
     );
   }
   }
 
 
+  // Special case: printing but printer is paused
+  if (status === 'printing' && (printerState === 'PAUSE' || printerState === 'PAUSED')) {
+    return (
+      <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20">
+        <Pause className="w-3.5 h-3.5" />
+        {t('queue.status.paused')}
+      </span>
+    );
+  }
+
   const config = {
   const config = {
     pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
     pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
     printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
     printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
@@ -286,6 +297,7 @@ function SortableQueueItem({
   onToggleSelect,
   onToggleSelect,
   hasPermission,
   hasPermission,
   canModify,
   canModify,
+  printerState,
   t,
   t,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
@@ -301,6 +313,7 @@ function SortableQueueItem({
   onToggleSelect?: () => void;
   onToggleSelect?: () => void;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
+  printerState?: string | null;
   t: (key: string, options?: Record<string, unknown>) => string;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
 }) {
   const canReorder = hasPermission('queue:reorder');
   const canReorder = hasPermission('queue:reorder');
@@ -500,7 +513,7 @@ function SortableQueueItem({
         </div>
         </div>
 
 
         {/* Status badge */}
         {/* Status badge */}
-        <StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
+        <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex items-center gap-1">
         <div className="flex items-center gap-1">
@@ -825,6 +838,36 @@ export function QueuePage() {
     return items;
     return items;
   }, [queue, filterLocation, matchesLocationFilter]);
   }, [queue, filterLocation, matchesLocationFilter]);
 
 
+  // Get unique printer IDs from active items to fetch their statuses
+  const activePrinterIds = useMemo(() => {
+    const ids = new Set<number>();
+    activeItems.forEach(item => {
+      if (item.printer_id) ids.add(item.printer_id);
+    });
+    return Array.from(ids);
+  }, [activeItems]);
+
+  // Fetch printer statuses for printers with active jobs
+  const printerStatusQueries = useQueries({
+    queries: activePrinterIds.map(printerId => ({
+      queryKey: ['printerStatus', printerId],
+      queryFn: () => api.getPrinterStatus(printerId),
+      refetchInterval: 5000,
+    })),
+  });
+
+  // Build a map of printer_id -> state for quick lookup
+  const printerStateMap = useMemo(() => {
+    const map: Record<number, string | null> = {};
+    activePrinterIds.forEach((printerId, index) => {
+      const result = printerStatusQueries[index];
+      if (result?.data?.state) {
+        map[printerId] = result.data.state;
+      }
+    });
+    return map;
+  }, [activePrinterIds, printerStatusQueries]);
+
   const historyItems = useMemo(() => {
   const historyItems = useMemo(() => {
     let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
     let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
     if (filterLocation) {
     if (filterLocation) {
@@ -1035,6 +1078,7 @@ export function QueuePage() {
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
                     canModify={canModify}
                     canModify={canModify}
+                    printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
                     t={t}
                     t={t}
                   />
                   />
                 ))}
                 ))}

+ 2 - 2
frontend/src/pages/StatsPage.tsx

@@ -672,12 +672,12 @@ export function StatsPage() {
 
 
   return (
   return (
     <div className="p-4 md:p-8">
     <div className="p-4 md:p-8">
-      <div className="flex items-center justify-between mb-6">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
         <div>
         <div>
           <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
           <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
           <p className="text-bambu-gray">{t('stats.subtitle')}</p>
           <p className="text-bambu-gray">{t('stats.subtitle')}</p>
         </div>
         </div>
-        <div className="flex items-center gap-2">
+        <div className="flex items-center gap-2 flex-wrap">
           {/* Hidden widgets button - toggles panel in Dashboard */}
           {/* Hidden widgets button - toggles panel in Dashboard */}
           {hiddenCount > 0 && (
           {hiddenCount > 0 && (
             <Button
             <Button

+ 41 - 0
frontend/src/types/plates.ts

@@ -0,0 +1,41 @@
+export interface PlateFilament {
+  slot_id: number;
+  type: string;
+  color: string;
+  used_grams: number;
+  used_meters: number;
+}
+
+export interface PlateMetadata {
+  index: number;
+  name: string | null;
+  objects: string[];
+  has_thumbnail: boolean;
+  thumbnail_url: string | null;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+  filaments: PlateFilament[];
+}
+
+export interface ArchivePlatesResponse {
+  archive_id: number;
+  filename: string;
+  plates: PlateMetadata[];
+  is_multi_plate: boolean;
+}
+
+export interface LibraryFilePlatesResponse {
+  file_id: number;
+  filename: string;
+  plates: PlateMetadata[];
+  is_multi_plate: boolean;
+}
+
+export interface ViewerPlateSelectionState {
+  selected_plate_id: number | null;
+}
+
+export interface PlateAssignment {
+  object_id: string;
+  plate_id: number | null;
+}

+ 0 - 7
frontend/vitest.config.ts

@@ -7,13 +7,6 @@ export default defineConfig({
   test: {
   test: {
     globals: true,
     globals: true,
     environment: 'jsdom',
     environment: 'jsdom',
-    pool: 'threads',
-    poolOptions: {
-      threads: {
-        maxThreads: 14,
-        minThreads: 4,
-      },
-    },
     environmentOptions: {
     environmentOptions: {
       jsdom: {
       jsdom: {
         url: 'http://localhost:3000',
         url: 'http://localhost:3000',

+ 13 - 0
plans/3mf-plate-management-verification.md

@@ -0,0 +1,13 @@
+# Plate Management Verification Checklist
+
+## Manual Verification
+- Open an archive with a multi-plate 3MF and verify the plate selector shows All Plates plus each plate entry.
+- Select each plate and confirm the 3D viewer updates to show only that plate’s objects.
+- Switch back to All Plates and confirm all geometry renders again.
+- Verify the plate thumbnail and plate name are shown in the selector for plates with metadata.
+- Open a single-plate 3MF and confirm the selector does not appear.
+- Open an STL or non-3MF archive and confirm the viewer shows a friendly unsupported format error (no crash).
+
+## Test Targets
+- MSW handler for /api/v1/archives/:id/plates should return a structured response.
+- ArchivesPage plate hover handlers should not throw when plates data is empty.

+ 206 - 0
plans/3mf-plate-management.md

@@ -0,0 +1,206 @@
+# 3MF Plate Management Feature Plan
+## Overview
+This plan outlines the implementation of plate management features for the bambuddy 3D viewer. This will allow users to view, select, and manage individual plates within 3MF files, similar to how Bambu Studio handles multi-plate projects.
+## Background
+### Current 3MF File Structure
+3MF files use a hierarchical XML structure to manage multiple build plates:
+```
+3dmodel.model (root)
+├── Metadata/
+│   └── model_settings.config  # Plate configuration manager
+├── 3dmodel.model (main model data)
+│   ├── <object> definitions (unique geometry)
+│   └── <build> items (plate assignments)
+└── Metadata/
+    └── plate_1.png, plate_2.png, etc. (plate thumbnails)
+```
+### Key Concepts
+- **Build Items**: Objects assigned to specific plates via `bambu:plate_id` attribute
+- **Plate Configuration**: `Metadata/model_settings.config` manages plate definitions, names, bed types
+- **Object Instances**: Each unique object defined once, referenced by multiple build items
+- **Transformation Matrix**: Each build item has position, rotation, scale relative to its plate's center
+- **Plate Thumbnails**: PNG images showing each plate's layout for preview
+## Feature Requirements
+### 1. Plate Metadata Parsing
+**Goal**: Read and parse `Metadata/model_settings.config` from 3MF files
+**Technical Details**:
+- Parse XML structure to extract:
+  - Plate count
+  - Plate names (custom or default)
+  - Plate dimensions
+  - Bed type per plate (Textured PE, Cool Plate, etc.)
+  - Printer type per plate (X1C, A1 mini, etc.)
+- Extract plate-to-object mappings from `<build>` items
+- Parse object-to-plate assignments from `<object>` `bambu:plate_id` attributes
+**Implementation Approach**:
+- Use JSZip to extract `Metadata/model_settings.config` from 3MF ZIP
+- Parse XML using DOMParser or similar library
+- Create TypeScript interfaces for plate metadata
+### 2. UI/UX Design
+**Goal**: Intuitive plate selection and object management interface
+**Components Needed**:
+#### A. Plate Selector/Tab System
+- **Plate Tabs**: Horizontal tabs showing each plate (Plate 1, Plate 2, etc.)
+- **Plate Dropdown**: Dropdown to select active plate
+- **Plate Info Panel**: Display plate name, dimensions, bed type
+- **Thumbnail Preview**: Show plate thumbnail when selected
+#### B. Object Filtering
+- **Filter by Plate**: When a plate is selected, show only objects on that plate
+- **Object List**: Display objects with their positions on current plate
+- **Object Selection**: Allow selecting individual objects (checkbox, multi-select)
+- **Object Info**: Show object name, dimensions, material color
+#### C. 3D Viewer Enhancements
+- **Plate-Specific Rendering**: Render only objects for selected plate
+- **Object Highlighting**: Highlight selected objects in 3D view
+- **Plate Grid Overlay**: Show build plate boundaries when viewing specific plate
+- **Plate Indicator**: Visual indicator of which plate is currently active
+#### D. Object Manipulation (Optional - Future Enhancement)
+- **Drag and Drop**: Move objects between plates
+- **Object Repositioning**: Adjust X/Y/Z position on current plate
+- **Object Rotation**: Rotate objects on build plate
+- **Object Scaling**: Resize objects
+- **Delete from Plate**: Remove object from current plate
+- **Add to Plate**: Copy object to different plate
+### 3. Data Model Design
+**Goal**: Track plate assignments and configurations
+**State Structure**:
+```typescript
+interface Plate {
+  id: string;
+  name: string;
+  width: number;
+  depth: number;
+  bedType: string;
+  printerType: string;
+  objectIds: string[];
+}
+interface ObjectAssignment {
+  objectId: string;
+  plateId: string;
+  position: { x: number; y: number; z: number };
+  rotation: { x: number; y: number; z: number };
+  scale: { x: number; y: number; z: number };
+}
+interface ViewerState {
+  selectedPlateId: string | null;
+  selectedObjectIds: string[];
+  filterMode: 'all' | 'plate';
+}
+```
+**Storage Considerations**:
+- In-memory state for current session
+- Optional: Save plate configurations to localStorage
+- Optional: Save to backend as user preferences
+### 4. Backend API Requirements
+**Goal**: API endpoints for saving/loading plate configurations
+**Required Endpoints**:
+#### A. Get Plate Metadata
+```
+GET /api/v1/library/files/{id}/plates
+```
+Returns plate metadata from 3MF file
+#### B. Save Plate Configuration (Optional)
+```
+POST /api/v1/library/files/{id}/plates/config
+Body: {
+  plates: Plate[];
+  defaultPlateId?: string;
+}
+```
+Save custom plate names, assignments to user preferences
+#### C. Get Plate Thumbnail
+```
+GET /api/v1/library/files/{id}/plates/{plateId}/thumbnail
+```
+Returns PNG image of specific plate
+### 5. Implementation Steps
+#### Step 1: 3MF Metadata Parser
+- Create `PlateMetadataParser` class
+- Implement `parse3MFPlateMetadata(file: File)` method
+- Extract `Metadata/model_settings.config` from ZIP
+- Parse XML structure
+- Return typed plate metadata
+#### Step 2: Update ModelViewer Component
+- Add `plates` prop to ModelViewerProps
+- Add `selectedPlateId` prop to ModelViewerProps
+- Add `selectedObjectIds` prop to ModelViewerProps
+- Add `filterMode` prop to ModelViewerProps
+- Modify `useEffect` to filter objects by selected plate
+- Add plate selection state management
+- Implement object highlighting for selected objects
+- Add plate grid overlay visualization
+#### Step 3: Update ModelViewerModal
+- Add plate selector UI (tabs or dropdown)
+- Display plate information panel
+- Show plate thumbnail preview
+- Add "All Plates" view option
+- Pass selected plate and objects to ModelViewer
+#### Step 4: Update FileManagerPage
+- Add plate management state
+- Add plate selector to file cards (optional)
+- Display plate badge on file cards
+- Add plate count indicator in file list
+#### Step 5: API Client Updates
+- Add `getLibraryFilePlates(fileId: number)` method
+- Add `saveLibraryFilePlateConfig(fileId: number, config: PlateConfig)` method
+- Add `getLibraryFilePlateThumbnail(fileId: number, plateId: string)` method
+#### Step 6: Testing
+- Test with multi-plate 3MF files
+- Test plate selection and object filtering
+- Test plate switching
+- Test object highlighting
+- Verify thumbnail generation
+### 6. Technical Considerations
+#### A. Coordinate Systems
+- **3MF**: Uses local (0,0,0) origin per plate
+- **Viewer**: Uses Three.js Y-up coordinate system
+- **Challenge**: Need to convert between coordinate systems when rendering specific plates
+**Solution**: Store plate origin offset, apply when rendering specific plate
+#### B. Performance
+- **Lazy Loading**: Load plate metadata on-demand, not entire file upfront
+- **Object Culling**: Don't render objects not on current plate
+- **Thumbnail Caching**: Cache plate thumbnails
+#### C. Backward Compatibility
+- **Single Plate Files**: Continue working as-is (show all plates)
+- **No Plate Metadata**: Gracefully degrade to full model view
+- **STL Files**: Plate management not applicable (no plate structure)
+### 7. UI/UX Flow
+```mermaid
+flowchart TD
+    A[User opens 3MF file] --> B{3D Viewer loads file}
+    B --> C{Parse plate metadata}
+    C --> D{Display plate selector}
+    D --> E{User selects plate}
+    E --> F{Filter objects by plate}
+    F --> G{Highlight selected objects}
+    G --> H{Render plate-specific view}
+    H --> I{User clicks Print}
+```
+### 8. Limitations
+- **Complexity**: 3MF plate structure is complex, requires careful XML parsing
+- **File Size**: Large 3MF files with many plates may have performance impact
+- **STL Files**: No plate structure, feature not applicable
+- **Testing**: Extensive testing needed for various plate configurations
+- **Backward Compatibility**: Must maintain existing single-plate view behavior
+### 9. Future Enhancements (Out of Scope)
+- **Object Manipulation**: Drag-and-drop, rotation, scaling
+- **Plate Creation**: Add new plates, duplicate objects between plates
+- **Plate Templates**: Save and reuse plate configurations
+- **Batch Operations**: Apply settings to multiple plates at once
+- **Visual Plate Editor**: Graphical plate layout designer
+## Success Criteria
+- [ ] Users can view all plates in a 3MF file
+- [ ] Users can select individual plates
+- [ ] Objects are filtered by selected plate
+- [ ] Selected objects are highlighted in 3D view
+- [ ] Plate information is displayed (name, dimensions, bed type)
+- [ ] Plate thumbnails are shown
+- [ ] Works with both 3MF and STL files
+- [ ] Backward compatible with single-plate files
+- [ ] Performance is acceptable with large files
+- [ ] All existing features continue to work
+## Implementation Priority
+1. **High**: Core plate parsing and rendering
+2. **Medium**: UI components and state management
+3. **Low**: Backend API and advanced features
+4. **Future**: Object manipulation and plate creation

+ 520 - 0
start_bambuddy.bat

@@ -0,0 +1,520 @@
+@echo off
+
+chcp 65001 >nul 2>&1
+setlocal enabledelayedexpansion
+
+title Bambuddy
+
+REM ============================================
+REM  Bambuddy Portable Launcher for Windows
+REM
+REM  Double-click to start. First run downloads
+REM  Python and Node.js automatically (portable,
+REM  no system changes). Everything is stored in
+REM  the .portable\ folder.
+REM
+REM  Usage:
+REM    start_bambuddy.bat            Launch
+REM    start_bambuddy.bat update     Update deps & rebuild frontend
+REM    start_bambuddy.bat reset      Clean all & fresh start
+REM    set PORT=9000 & start_bambuddy.bat   Change port
+REM ============================================
+
+set "ROOT=%~dp0"
+if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
+
+set "PORTABLE=%ROOT%\.portable"
+set "PYTHON_DIR=%PORTABLE%\python"
+set "NODE_DIR=%PORTABLE%\node"
+set "FFMPEG_DIR=%PORTABLE%\ffmpeg"
+REM NOTE: Python version is intentionally pinned to a specific portable build.
+REM       If you upgrade the bundled Python runtime, update PYTHON_VER here
+REM       and make sure it matches the version used in download/installation logic.
+if not defined PYTHON_VER set "PYTHON_VER=3.13.1"
+REM Default Node.js version for the portable runtime. Override by setting NODE_VER before running this script.
+if not defined NODE_VER set "NODE_VER=22.12.0"
+REM NOTE: FFmpeg is not downloaded automatically.
+REM       Install from the official site and add it to PATH:
+REM       https://ffmpeg.org/download.html
+
+REM Pinned SHA256 hashes for downloads (update when bumping versions)
+set "GET_PIP_SHA256=dffc3658baada4ef383f31c3c672d4e5e306a6e376cee8bee5dbdf1385525104"
+set "PYTHON_ZIP_HASH_AMD64=7b7923ff0183a8b8fca90f6047184b419b108cb437f75fc1c002f9d2f8bcec16"
+set "PYTHON_ZIP_HASH_ARM64=ae8561bf958f77c68cb6c44ced983e5267fe965a7e4168f41ec2291350b81d55"
+set "NODE_ZIP_HASH_X64=2b8f2256382f97ad51e29ff71f702961af466c4616393f767455501e6aece9b8"
+set "NODE_ZIP_HASH_ARM64=17401720af48976e3f67c41e8968a135fb49ca1f88103a92e0e8c70605763854"
+
+REM Detect system architecture (amd64 or arm64)
+set "PYTHON_ARCH=amd64"
+set "NODE_ARCH=x64"
+if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
+    set "PYTHON_ARCH=arm64"
+    set "NODE_ARCH=arm64"
+)
+if defined PROCESSOR_ARCHITEW6432 (
+    if /I "%PROCESSOR_ARCHITEW6432%"=="ARM64" (
+        set "PYTHON_ARCH=arm64"
+        set "NODE_ARCH=arm64"
+    )
+)
+
+set "PYTHON_ZIP_HASH_EXPECTED=%PYTHON_ZIP_HASH_AMD64%"
+if /I "%PYTHON_ARCH%"=="arm64" set "PYTHON_ZIP_HASH_EXPECTED=%PYTHON_ZIP_HASH_ARM64%"
+
+set "NODE_ZIP_HASH_EXPECTED=%NODE_ZIP_HASH_X64%"
+if /I "%NODE_ARCH%"=="arm64" set "NODE_ZIP_HASH_EXPECTED=%NODE_ZIP_HASH_ARM64%"
+
+if not defined PORT set "PORT=8000"
+
+REM Validate PORT is a number in the range 1-65535
+echo(!PORT!| findstr /R "^[0-9][0-9]*$" >nul
+if errorlevel 1 (
+    echo Invalid PORT value "%PORT%". PORT must be an integer between 1 and 65535.
+    exit /b 1
+)
+
+if %PORT% LSS 1 (
+    echo Invalid PORT value "%PORT%". PORT must be between 1 and 65535.
+    exit /b 1
+)
+if %PORT% GTR 65535 (
+    echo Invalid PORT value "%PORT%". PORT must be between 1 and 65535.
+    exit /b 1
+)
+
+REM ---- Handle arguments ----
+if /i "%~1"=="reset" (
+    echo Cleaning up portable environment...
+    call :safe_rmdir "%PORTABLE%" ".portable"
+    if errorlevel 1 exit /b 1
+    call :safe_rmdir "%ROOT%\static" "static"
+    if errorlevel 1 exit /b 1
+    echo Done. Run again without arguments to set up fresh.
+    pause
+    exit /b 0
+)
+
+if /i "%~1"=="update" (
+    echo Forcing dependency update and frontend rebuild...
+    if exist "%PORTABLE%\.deps-installed" del "%PORTABLE%\.deps-installed"
+    call :safe_rmdir "%ROOT%\static" "static"
+    if errorlevel 1 exit /b 1
+)
+
+REM ---- Check prerequisites ----
+where curl >nul 2>&1
+if errorlevel 1 (
+    echo.
+    echo [ERROR] curl.exe is not available.
+    echo         Windows 10 version 1803 or later is required.
+    echo.
+    pause
+    exit /b 1
+)
+where tar >nul 2>&1
+if errorlevel 1 (
+    echo.
+    echo [ERROR] tar.exe is not available.
+    echo         Windows 10 version 1803 or later is required.
+    echo.
+    pause
+    exit /b 1
+)
+
+REM ---- Verify project structure ----
+if not exist "%ROOT%\backend\app\main.py" (
+    echo.
+    echo [ERROR] backend\app\main.py not found.
+    echo         This script must be in the Bambuddy project root.
+    echo.
+    pause
+    exit /b 1
+)
+
+echo.
+echo  ____                  _               _     _
+echo ^| __ )  __ _ _ __ ___ ^| ^|__  _   _  __^| ^| __^| ^|_   _
+echo ^|  _ \ / _` ^| '_ ` _ \^| '_ \^| ^| ^| ^|/ _` ^|/ _` ^| ^| ^| ^|
+echo ^| ^|_) ^| (_^| ^| ^| ^| ^| ^| ^| ^|_) ^| ^|_^| ^| (_^| ^| (_^| ^| ^|_^| ^|
+echo ^|____/ \__,_^|_^| ^|_^| ^|_^|_.__/ \__,_^|\__,_^|\__,_^|\__, ^|
+echo                                                ^|___/
+echo.
+
+REM ============================================
+REM  Step 1: Setup Portable Python
+REM ============================================
+if exist "%PYTHON_DIR%\python.exe" (
+    echo [OK] Python %PYTHON_VER% found.
+    goto :python_ready
+)
+
+echo [1/6] Downloading Python %PYTHON_VER% (portable)...
+
+if not exist "%PORTABLE%" mkdir "%PORTABLE%"
+if not exist "%PYTHON_DIR%" mkdir "%PYTHON_DIR%"
+
+curl -L --fail --show-error --progress-bar -o "%PORTABLE%\python.zip" ^
+    "https://www.python.org/ftp/python/%PYTHON_VER%/python-%PYTHON_VER%-embed-%PYTHON_ARCH%.zip"
+if errorlevel 1 (
+    echo [ERROR] Failed to download Python.
+    pause
+    exit /b 1
+)
+call :verify_sha256 "%PORTABLE%\python.zip" "%PYTHON_ZIP_HASH_EXPECTED%" "Python"
+if errorlevel 1 (
+    echo [ERROR] Failed to download Python archive.
+    pause
+    exit /b 1
+)
+
+REM Download official SHA256 checksum for the Python archive
+curl -L --progress-bar -o "%PORTABLE%\python.zip.sha256" ^
+    "https://www.python.org/ftp/python/%PYTHON_VER%/python-%PYTHON_VER%-embed-amd64.zip.sha256"
+if errorlevel 1 (
+    echo [ERROR] Failed to download Python checksum file.
+    del "%PORTABLE%\python.zip" >nul 2>&1
+    pause
+    exit /b 1
+)
+
+REM Compute SHA256 hash of the downloaded archive
+set "PYTHON_ZIP_HASH="
+for /f "tokens=1 usebackq" %%H in (`
+    certutil -hashfile "%PORTABLE%\python.zip" SHA256 ^| findstr /R /I "^[0-9A-F][0-9A-F]"
+`) do (
+    set "PYTHON_ZIP_HASH=%%H"
+    goto :python_hash_done
+)
+
+:python_hash_done
+if not defined PYTHON_ZIP_HASH (
+    echo [ERROR] Failed to compute SHA256 hash for Python archive.
+    del "%PORTABLE%\python.zip" >nul 2>&1
+    del "%PORTABLE%\python.zip.sha256" >nul 2>&1
+    pause
+    exit /b 1
+)
+
+REM Read expected SHA256 hash from the checksum file
+set "PYTHON_ZIP_HASH_EXPECTED="
+for /f "tokens=1" %%H in ('type "%PORTABLE%\python.zip.sha256"') do (
+    set "PYTHON_ZIP_HASH_EXPECTED=%%H"
+    goto :python_expected_hash_done
+)
+
+:python_expected_hash_done
+if not defined PYTHON_ZIP_HASH_EXPECTED (
+    echo [ERROR] Failed to read expected SHA256 hash for Python archive.
+    del "%PORTABLE%\python.zip" >nul 2>&1
+    del "%PORTABLE%\python.zip.sha256" >nul 2>&1
+    pause
+    exit /b 1
+)
+
+REM Compare actual and expected hashes (case-insensitive)
+if /I not "%PYTHON_ZIP_HASH%"=="%PYTHON_ZIP_HASH_EXPECTED%" (
+    echo [ERROR] SHA256 checksum verification for Python archive failed.
+    echo [INFO] Expected: %PYTHON_ZIP_HASH_EXPECTED%
+    echo [INFO] Actual:   %PYTHON_ZIP_HASH%
+    del "%PORTABLE%\python.zip" >nul 2>&1
+    del "%PORTABLE%\python.zip.sha256" >nul 2>&1
+    pause
+    exit /b 1
+)
+
+del "%PORTABLE%\python.zip.sha256" >nul 2>&1
+echo Extracting Python...
+tar -xf "%PORTABLE%\python.zip" -C "%PYTHON_DIR%"
+if errorlevel 1 (
+    echo [ERROR] Failed to extract Python archive.
+    del "%PORTABLE%\python.zip" >nul 2>&1
+    pause
+    exit /b 1
+)
+del "%PORTABLE%\python.zip"
+if not exist "%PYTHON_DIR%\python.exe" (
+    echo [ERROR] Python executable not found after extraction.
+    pause
+    exit /b 1
+)
+
+REM Enable site-packages by rewriting the ._pth file
+REM Derive python tag (e.g., 3.13.x -> 313) from %PYTHON_VER%
+for /f "tokens=1,2 delims=." %%A in ("%PYTHON_VER%") do (
+    set "PY_MAJOR=%%A"
+    set "PY_MINOR=%%B"
+)
+set "PYTHON_TAG=%PY_MAJOR%%PY_MINOR%"
+(
+    echo python!PYTHON_TAG!.zip
+    echo .
+    echo import site
+) > "%PYTHON_DIR%\python!PYTHON_TAG!._pth"
+
+REM ============================================
+REM  Step 2: Install pip
+REM ============================================
+echo.
+echo [2/6] Installing pip...
+
+curl -L --fail -sS -o "%PORTABLE%\get-pip.py" "https://bootstrap.pypa.io/get-pip.py"
+if errorlevel 1 (
+    echo [ERROR] Failed to download get-pip.py.
+    pause
+    exit /b 1
+)
+call :verify_sha256 "%PORTABLE%\get-pip.py" "%GET_PIP_SHA256%" "get-pip.py"
+if errorlevel 1 (
+    del "%PORTABLE%\get-pip.py" >nul 2>&1
+    pause
+    exit /b 1
+)
+
+"%PYTHON_DIR%\python.exe" "%PORTABLE%\get-pip.py" --no-warn-script-location -q
+if errorlevel 1 (
+    echo [ERROR] Failed to install pip.
+    pause
+    exit /b 1
+)
+del "%PORTABLE%\get-pip.py"
+
+echo [OK] Python %PYTHON_VER% ready.
+
+:python_ready
+
+REM ============================================
+REM  Step 2.5: Create Virtual Environment (best effort)
+REM ============================================
+set "VENV_DIR=%PORTABLE%\venv"
+set "PYTHON_EXE=%PYTHON_DIR%\python.exe"
+if not exist "%VENV_DIR%\Scripts\python.exe" (
+    echo.
+    echo Creating virtual environment [optional]...
+    "%PYTHON_DIR%\python.exe" -m venv "%VENV_DIR%"
+    if errorlevel 1 (
+        echo [WARN] Failed to create virtual environment. Continuing without venv.
+    )
+)
+if exist "%VENV_DIR%\Scripts\python.exe" (
+    set "PYTHON_EXE=%VENV_DIR%\Scripts\python.exe"
+)
+
+REM ============================================
+REM  Step 3: Install Python Dependencies
+REM ============================================
+if exist "%PORTABLE%\.deps-installed" (
+    echo [OK] Python packages found.
+    goto :deps_ready
+)
+
+echo.
+echo [3/6] Installing Python packages (this may take a few minutes)...
+if exist "%ROOT%\requirements.lock" (
+    "%PYTHON_EXE%" -m pip install -r "%ROOT%\requirements.lock" --require-hashes --no-warn-script-location -q
+) else (
+    echo [WARN] requirements.lock not found. Falling back to requirements.txt - no hash enforcement.
+    "%PYTHON_EXE%" -m pip install -r "%ROOT%\requirements.txt" --no-warn-script-location -q
+)
+if errorlevel 1 (
+    echo [ERROR] Failed to install Python packages.
+    pause
+    exit /b 1
+)
+
+REM Create marker file
+echo %date% %time% > "%PORTABLE%\.deps-installed"
+echo [OK] Packages installed.
+
+:deps_ready
+
+REM ============================================
+REM  Step 4-6: Build Frontend (if needed)
+REM ============================================
+if exist "%ROOT%\static\index.html" (
+    echo [OK] Frontend found.
+    goto :frontend_ready
+)
+
+REM ---- Download Node.js if needed ----
+if exist "%NODE_DIR%\node.exe" goto :node_ready
+
+echo.
+echo [4/6] Downloading Node.js %NODE_VER% (portable)...
+
+curl -L --fail --show-error --progress-bar -o "%PORTABLE%\node.zip" ^
+    "https://nodejs.org/dist/v%NODE_VER%/node-v%NODE_VER%-win-%NODE_ARCH%.zip"
+if errorlevel 1 (
+    echo [ERROR] Failed to download Node.js.
+    pause
+    exit /b 1
+)
+call :verify_sha256 "%PORTABLE%\node.zip" "%NODE_ZIP_HASH_EXPECTED%" "Node.js"
+if errorlevel 1 (
+    del "%PORTABLE%\node.zip" >nul 2>&1
+    pause
+    exit /b 1
+)
+
+echo Extracting Node.js...
+tar -xf "%PORTABLE%\node.zip" -C "%PORTABLE%"
+if errorlevel 1 (
+    echo [ERROR] Failed to extract Node.js archive.
+    del "%PORTABLE%\node.zip" >nul 2>&1
+    pause
+    exit /b 1
+)
+if exist "%PORTABLE%\node-v%NODE_VER%-win-%NODE_ARCH%" (
+    ren "%PORTABLE%\node-v%NODE_VER%-win-%NODE_ARCH%" node
+)
+del "%PORTABLE%\node.zip"
+echo [OK] Node.js %NODE_VER% ready.
+
+:node_ready
+
+REM ---- Build frontend ----
+echo.
+echo [5/6] Building frontend (this may take a while)...
+
+set "PATH=%NODE_DIR%;%PATH%"
+
+pushd "%ROOT%\frontend"
+
+if exist "%ROOT%\frontend\package-lock.json" (
+    call "%NODE_DIR%\npm.cmd" ci
+) else (
+    call "%NODE_DIR%\npm.cmd" install
+)
+if errorlevel 1 (
+    echo [ERROR] npm install failed.
+    popd
+    pause
+    exit /b 1
+)
+
+call "%NODE_DIR%\npm.cmd" run build
+if errorlevel 1 (
+    echo [ERROR] Frontend build failed.
+    popd
+    pause
+    exit /b 1
+)
+
+popd
+if not exist "%ROOT%\frontend\static\index.html" (
+    echo [ERROR] Frontend build did not produce static\index.html.
+    echo        Expected: "%ROOT%\frontend\static\index.html"
+    pause
+    exit /b 1
+)
+if not exist "%ROOT%\static\index.html" (
+    echo [ERROR] Frontend build did not produce static\index.html.
+    echo        Expected: "%ROOT%\static\index.html"
+    pause
+    exit /b 1
+)
+echo [OK] Frontend built.
+
+:frontend_ready
+
+REM ============================================
+REM  Step 6: Setup Portable FFmpeg (if needed)
+REM ============================================
+where ffmpeg >nul 2>&1
+if not errorlevel 1 (
+    echo [OK] FFmpeg found in system PATH.
+    goto :ffmpeg_ready
+)
+
+if exist "%FFMPEG_DIR%\bin\ffmpeg.exe" (
+    echo [OK] FFmpeg found.
+    goto :ffmpeg_ready
+)
+
+echo.
+echo [6/6] FFmpeg not found.
+echo [INFO] Install FFmpeg from the official site and add it to PATH:
+echo        https://ffmpeg.org/download.html
+echo [INFO] Timelapse features will be unavailable until FFmpeg is installed.
+
+:ffmpeg_ready
+
+REM ============================================
+REM  Launch Bambuddy
+REM ============================================
+echo.
+echo ================================================
+echo   Bambuddy is starting on port %PORT%
+echo   Open: http://localhost:%PORT%
+echo.
+echo   Press Ctrl+C to stop
+echo ================================================
+echo.
+
+REM Set PYTHONPATH so "backend.app.main" module is found
+set "PYTHONPATH=%ROOT%"
+
+REM Add portable FFmpeg to PATH if available
+if exist "%FFMPEG_DIR%\bin\ffmpeg.exe" set "PATH=%FFMPEG_DIR%\bin;%PATH%"
+
+REM Open browser after server is ready (poll localhost)
+start /b cmd /c "for /l %%i in (1,1,30) do (curl -s -f -o nul http://localhost:%PORT% && (start http://localhost:%PORT% & exit /b 0) & timeout /t 1 /nobreak >nul)"
+
+REM Launch the application
+"%PYTHON_EXE%" -m uvicorn backend.app.main:app --host 0.0.0.0 --port %PORT% --loop asyncio
+
+echo.
+echo Bambuddy has stopped.
+pause
+
+endlocal
+goto :eof
+
+
+REM ============================================
+REM  Helpers
+REM ============================================
+:safe_rmdir
+set "TARGET=%~1"
+set "LABEL=%~2"
+if "%TARGET%"=="" (
+    echo [ERROR] %LABEL% path is empty. Aborting.
+    exit /b 1
+)
+if /I "%TARGET%"=="\" (
+    echo [ERROR] %LABEL% path resolved to root. Aborting.
+    exit /b 1
+)
+if not exist "%TARGET%" exit /b 0
+echo Deleting "%TARGET%"
+rmdir /s /q "%TARGET%"
+if errorlevel 1 (
+    echo [ERROR] Failed to delete "%TARGET%".
+    exit /b 1
+)
+exit /b 0
+
+:verify_sha256
+set "FILE=%~1"
+set "EXPECTED=%~2"
+set "LABEL=%~3"
+if "%EXPECTED%"=="" (
+    echo [ERROR] %LABEL% checksum not found.
+    exit /b 1
+)
+set "ACTUAL="
+for /f "tokens=1" %%H in ('certutil -hashfile "%FILE%" SHA256 ^| findstr /R /I "^[0-9A-F][0-9A-F]"') do (
+    set "ACTUAL=%%H"
+    goto :hash_done
+)
+:hash_done
+if not defined ACTUAL (
+    echo [ERROR] Failed to compute SHA256 for %LABEL%.
+    exit /b 1
+)
+if /I not "%ACTUAL%"=="%EXPECTED%" (
+    echo [ERROR] SHA256 verification failed for %LABEL%.
+    echo [INFO] Expected: %EXPECTED%
+    echo [INFO] Actual:   %ACTUAL%
+    exit /b 1
+)
+exit /b 0

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-3wkUCoeq.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-fdAEMOwp.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BfIGXFHd.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B7d6gcUW.css">
+    <script type="module" crossorigin src="/assets/index-3wkUCoeq.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-fdAEMOwp.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 28 - 0
whats_new.md

@@ -0,0 +1,28 @@
+# What's New: 3D File Preview Updates
+
+## Overview
+- Expanded 3D previews in the Library File Manager and Printer File Manager.
+- Added STL support (interactive 3D view) alongside existing 3MF/G-code previews.
+- Improved multi-plate handling for 3MF files in the printer file manager.
+
+## Library File Manager (Files Page)
+- 3D preview now supports `.3mf`, `.gcode`, and `.stl` files.
+- STL files open in the same viewer modal used by 3MF files.
+- Plate-aware selection continues to work for 3MF files with multiple plates.
+
+## Printer File Manager (Printers → File Manager)
+- Added a 3D View action for `.3mf`, `.gcode`, and `.stl` files on the printer.
+- New printer-side 3MF plate endpoints:
+  - Plate list and metadata retrieval.
+  - Plate thumbnail retrieval.
+- Added a printer-side gcode preview endpoint for 3MF and `.gcode` files.
+- STL models are centered on the build sheet in the 3D viewer.
+
+## Model Viewer Enhancements
+- Added STL rendering support using `STLLoader`.
+- Viewer now selects the correct rendering pipeline based on file type.
+- STL models auto-center on the build plate for a consistent viewing experience.
+
+## Affected Areas
+- Frontend: File Manager modals and 3D viewer.
+- Backend: Printer file preview endpoints for plates, plate thumbnails, and gcode.

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff